diff --git a/portals/admin/src/main/webapp/site/public/locales/en.json b/portals/admin/src/main/webapp/site/public/locales/en.json index ea335635123..e32951b0806 100644 --- a/portals/admin/src/main/webapp/site/public/locales/en.json +++ b/portals/admin/src/main/webapp/site/public/locales/en.json @@ -574,6 +574,7 @@ "GatewayEnvironments.AddEditGWEnvironment.general.details.description.platform": "Provide display name and description of the Gateway Environment", "GatewayEnvironments.AddEditGWEnvironment.mode": "Gateway Mode", "GatewayEnvironments.AddEditGWEnvironment.mode.description": "Deployability or discoverabilty of APIs", + "GatewayEnvironments.AddEditGWEnvironment.save.failed": "Save failed", "GatewayEnvironments.AddEditGWEnvironment.type.description": "Key type supported by the Gateway Environment", "GatewayEnvironments.AddEditGWEnvironment.visibility.add.description": "Configure vhosts", "GatewayEnvironments.AddEditVhost.add.vhost.btn": "New VHost", @@ -591,6 +592,15 @@ "GatewayEnvironments.AddEditVhost.httpsPort": "HTTPS Port", "GatewayEnvironments.AddEditVhost.wsPort": "WS Port", "GatewayEnvironments.AddEditVhost.wssPort": "WSS Port", + "GatewayEnvironments.GatewayConfiguration.advancedSettings": "Advanced Settings", + "GatewayEnvironments.PlanMapping.action": "Action", + "GatewayEnvironments.PlanMapping.addValidationError": "Provide a local subscription policy and remote plan name.", + "GatewayEnvironments.PlanMapping.alreadyExists": "Plan Mapping already exists.", + "GatewayEnvironments.PlanMapping.loadingPolicies": "Loading local subscription plans...", + "GatewayEnvironments.PlanMapping.localPolicyRequired": "Select a local subscription policy.", + "GatewayEnvironments.PlanMapping.noCompatibleLocalPlans": "No local subscription plans match the supported API types of this gateway.", + "GatewayEnvironments.PlanMapping.noPoliciesLeft": "No local subscription policies left to map", + "GatewayEnvironments.PlanMapping.remotePlanRequired": "Enter a remote plan name.", "Gateways.AddEditGateway.back.to.gateways": "Back to Gateways", "Gateways.AddEditGateway.loading.gateway.details": "Loading gateway details...", "Gateways.AddEditGateway.loading.platform.gateway.details": "Loading platform gateway details...", diff --git a/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/AddEditGWEnvironment.jsx b/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/AddEditGWEnvironment.jsx index 47263e6d154..49b7aea363f 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/AddEditGWEnvironment.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/AddEditGWEnvironment.jsx @@ -80,6 +80,7 @@ import { } from './PlatformGatewayUtils'; const PREFIX = 'AddEditGWEnvironment'; +const FEDERATED_GATEWAY_VALIDATION_ERROR_CODE = 900520; const classes = { pageContent: `${PREFIX}-pageContent`, @@ -365,6 +366,23 @@ const getGatewayConfigValidationError = (value, formatMessage) => { return false; }; +const extractPlanMappingErrors = (responseBody) => { + const rowErrors = {}; + if (!responseBody + || responseBody.code !== FEDERATED_GATEWAY_VALIDATION_ERROR_CODE + || !Array.isArray(responseBody.error)) { + return rowErrors; + } + + responseBody.error.forEach((errorItem) => { + const fieldKey = errorItem?.message; + if (fieldKey && fieldKey.startsWith('plan_mapping.')) { + rowErrors[fieldKey] = errorItem.description || 'Invalid external plan assignment'; + } + }); + return rowErrors; +}; + /** * Reducer * @param {JSON} state State @@ -411,9 +429,11 @@ function AddEditGWEnvironment(props) { const [invalidRoles, setInvalidRoles] = useState([]); const [roleValidity, setRoleValidity] = useState(true); const [gatewayConfigurations, setGatewayConfiguration] = useState([]); + const [selectedGatewaySupportedApiTypes, setSelectedGatewaySupportedApiTypes] = useState([]); const [supportedModes, setSupportedModes] = useState([]); const [validating, setValidating] = useState(false); const [saving, setSaving] = useState(false); + const [planMappingErrors, setPlanMappingErrors] = useState({}); const { gatewayTypes } = settings; const gatewayVersions = useMemo(() => getPlatformGatewayVersions(settings), [settings]); const { @@ -638,6 +658,10 @@ function AddEditGWEnvironment(props) { } }, [permissions]); + useEffect(() => { + setPlanMappingErrors({}); + }, [gatewayType]); + useEffect(() => { if (!platformHeaderEditMode) { setPlatformDisplayNameDraft( @@ -657,7 +681,7 @@ function AddEditGWEnvironment(props) { ]); useEffect(() => { - const config = settings.gatewayConfiguration.filter( + const config = (settings.gatewayConfiguration || []).filter( (t) => t.type === gatewayType, )[0]; if ( @@ -667,11 +691,13 @@ function AddEditGWEnvironment(props) { ) { setGatewayConfiguration([]); setSupportedModes([]); + setSelectedGatewaySupportedApiTypes([]); } else { setGatewayConfiguration(config.configurations || []); setSupportedModes(config.supportedModes || []); + setSelectedGatewaySupportedApiTypes(config.supportedApiTypes || []); } - }, [gatewayType]); + }, [gatewayType, settings.gatewayConfiguration]); let permissionType = ''; if (permissions) { @@ -881,6 +907,13 @@ function AddEditGWEnvironment(props) { }; const setAdditionalProperties = (key, value) => { + if (key && planMappingErrors[key]) { + setPlanMappingErrors((prevErrors) => { + const updatedErrors = { ...prevErrors }; + delete updatedErrors[key]; + return updatedErrors; + }); + } const clonedAdditionalProperties = cloneDeep(additionalProperties); if (value === undefined) { delete clonedAdditionalProperties[key]; @@ -1189,6 +1222,7 @@ function AddEditGWEnvironment(props) { promiseAPICall .then((result) => { + setPlanMappingErrors({}); if (id) { Alert.success( `${name} ${intl.formatMessage({ @@ -1226,8 +1260,28 @@ function AddEditGWEnvironment(props) { }) .catch((error) => { const { response } = error; - if (response.body) { - Alert.error(response.body.description); + if (response?.body) { + setPlanMappingErrors(extractPlanMappingErrors(response.body)); + Alert.error( + response.body.description + || error?.message + || intl.formatMessage({ + id: 'GatewayEnvironments.AddEditGWEnvironment.save.failed', + defaultMessage: 'Save failed', + }), + ); + } else { + setPlanMappingErrors({}); + const statusSuffix = response?.status + ? ` (${response.status}${response?.statusText ? ` ${response.statusText}` : ''})` + : ''; + Alert.error( + error?.message + || `${intl.formatMessage({ + id: 'GatewayEnvironments.AddEditGWEnvironment.save.failed', + defaultMessage: 'Save failed', + })}${statusSuffix}`, + ); } setSaving(false); }); @@ -2371,6 +2425,7 @@ function AddEditGWEnvironment(props) { gatewayConfigurations={ gatewayConfigurations } + supportedApiTypes={selectedGatewaySupportedApiTypes} additionalProperties={cloneDeep( additionalProperties, )} @@ -2383,6 +2438,9 @@ function AddEditGWEnvironment(props) { validating={ validating } + planMappingErrors={ + planMappingErrors + } gatewayId={cloneDeep( id, )} diff --git a/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/GatewayConfiguration.jsx b/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/GatewayConfiguration.jsx index c91244a14cf..90d50c159d6 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/GatewayConfiguration.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/GatewayConfiguration.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { styled } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; import FormControlLabel from '@mui/material/FormControlLabel'; @@ -10,9 +10,15 @@ import RadioGroup from '@mui/material/RadioGroup'; import { FormattedMessage } from 'react-intl'; import InputLabel from '@mui/material/InputLabel'; import FormHelperText from '@mui/material/FormHelperText'; +import Accordion from '@mui/material/Accordion'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import CustomGatewayInputField from 'AppComponents/GatewayEnvironments/CustomGatewayInputField'; +import GatewayPlanMapping from './GatewayPlanMapping'; const StyledSpan = styled('span')(({ theme }) => ({ color: theme.palette.error.dark })); +const PLAN_MAPPING_CONFIG_TYPE = 'plan_mapping'; // Styled wrapper to mimic TextField's outlined style const StyledFormControl = styled(FormControl)(({ theme }) => ({ @@ -42,9 +48,15 @@ const StyledFormControl = styled(FormControl)(({ theme }) => ({ export default function GatewayConfiguration(props) { const { gatewayConfigurations, additionalProperties = {}, setAdditionalProperties = () => {}, gatewayId, - hasErrors, validating, + hasErrors, validating, planMappingErrors = {}, supportedApiTypes = [], } = props; + const mappingConfigurationNames = useMemo(() => { + return gatewayConfigurations + .filter((config) => config.type === PLAN_MAPPING_CONFIG_TYPE) + .map((config) => config.name); + }, [gatewayConfigurations]); + const getAllNestedGatewayConfigPropertyNames = (connectorConfigurations, parentKey = '') => { const gatewayConfigPropertyNames = []; @@ -167,7 +179,13 @@ export default function GatewayConfiguration(props) { // Clear any properties in additionalProperties that are not in the current valid set Object.keys(additionalProperties).forEach((propName) => { - if (!currentValidProperties.includes(propName)) { + const isMappingProperty = mappingConfigurationNames.some((configName) => ( + propName.startsWith(`${configName}.`) + )); + if ( + !currentValidProperties.includes(propName) + && !isMappingProperty + ) { setAdditionalProperties(propName, undefined); } }); @@ -207,7 +225,17 @@ export default function GatewayConfiguration(props) { }); } } - if (gatewayConfiguration.type === 'input') { + if (gatewayConfiguration.type === PLAN_MAPPING_CONFIG_TYPE) { + return ( + + ); + } else if (gatewayConfiguration.type === 'input') { if (gatewayConfiguration.mask) { return ( @@ -333,9 +361,29 @@ export default function GatewayConfiguration(props) { }); }; + const regularConfigurations = gatewayConfigurations.filter( + (config) => config.type !== PLAN_MAPPING_CONFIG_TYPE, + ); + const mappingConfigurations = gatewayConfigurations.filter( + (config) => config.type === PLAN_MAPPING_CONFIG_TYPE, + ); + return (
- {renderConnectorConfigurations(gatewayConfigurations)} + {renderConnectorConfigurations(regularConfigurations)} + {mappingConfigurations.length > 0 && ( + + }> + + + + {renderConnectorConfigurations(mappingConfigurations)} + + + )}
); } @@ -350,4 +398,6 @@ GatewayConfiguration.defaultProps = { />, hasErrors: () => {}, validating: false, + planMappingErrors: {}, + supportedApiTypes: [], }; diff --git a/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/GatewayPlanMapping.jsx b/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/GatewayPlanMapping.jsx new file mode 100644 index 00000000000..8d009e53e1b --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/GatewayEnvironments/GatewayPlanMapping.jsx @@ -0,0 +1,413 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import API from 'AppData/api'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import IconButton from '@mui/material/IconButton'; +import MenuItem from '@mui/material/MenuItem'; +import Stack from '@mui/material/Stack'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import TableContainer from '@mui/material/TableContainer'; +import TextField from '@mui/material/TextField'; +import FormLabel from '@mui/material/FormLabel'; +import FormHelperText from '@mui/material/FormHelperText'; +import Typography from '@mui/material/Typography'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AddCircleIcon from '@mui/icons-material/AddCircle'; +import ClearIcon from '@mui/icons-material/Clear'; +import Alert from 'AppComponents/Shared/Alert'; +import { FormattedMessage } from 'react-intl'; + +const DEFAULT_LEFT_COLUMN_LABEL = 'WSO2 Subscription Policy'; +const PLAN_MAPPING_TABLE_MAX_HEIGHT = 400; +const NON_MAPPABLE_SUBSCRIPTION_POLICIES = [ + 'Unauthenticated', + 'DefaultSubscriptionless', + 'AsyncDefaultSubscriptionless', +]; +const PLAN_MAPPING_API_TYPES = { + EVENTCOUNTLIMIT: 'async', + AIAPIQUOTALIMIT: 'ai-api', +}; + +const resolveSubscriptionPolicyApiType = (policy) => { + const quotaType = String(policy?.defaultLimit?.type || '').toUpperCase(); + return PLAN_MAPPING_API_TYPES[quotaType] || 'rest'; +}; + +const buildPlanMappingValues = (subscriptionPolicies, supportedApiTypes) => { + const supportedApiTypeSet = new Set(supportedApiTypes || []); + return (subscriptionPolicies || []).reduce((values, policy) => { + const policyName = policy?.policyName || policy?.name || policy?.displayName; + if (!policyName || NON_MAPPABLE_SUBSCRIPTION_POLICIES.includes(policyName)) { + return values; + } + const apiType = resolveSubscriptionPolicyApiType(policy); + if (!supportedApiTypeSet.has(apiType)) { + return values; + } + return [ + ...values, + { + policyName, + label: policy.displayName || policyName, + apiType, + }, + ]; + }, []); +}; + +/** + * Gateway plan mapping configuration. + * @param {Object} props component props. + * @returns {JSX.Element} rendered gateway plan mapping section. + */ +export default function GatewayPlanMapping(props) { + const { + gatewayConfiguration, + supportedApiTypes = [], + additionalProperties = {}, + setAdditionalProperties = () => {}, + planMappingErrors = {}, + } = props; + const restApi = useMemo(() => new API(), []); + const [subscriptionPolicies, setSubscriptionPolicies] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(''); + const [selectedPolicyName, setSelectedPolicyName] = useState(''); + const [newRemotePlanName, setNewRemotePlanName] = useState(''); + const [validationError, setValidationError] = useState({}); + + useEffect(() => { + let isActive = true; + setIsLoading(true); + setLoadError(''); + restApi.getSubscritionPolicyList() + .then((result) => { + if (isActive) { + setSubscriptionPolicies(result.body?.list || []); + } + }) + .catch((error) => { + if (isActive) { + setSubscriptionPolicies([]); + setLoadError(error?.response?.body?.description || error.message); + } + }) + .finally(() => { + if (isActive) { + setIsLoading(false); + } + }); + return () => { + isActive = false; + }; + }, [restApi]); + + const leftLabel = DEFAULT_LEFT_COLUMN_LABEL; + const rightLabel = gatewayConfiguration?.default || gatewayConfiguration.label || 'Value'; + const planMappingPropertyPrefix = `${gatewayConfiguration.name}.`; + const values = useMemo(() => { + return buildPlanMappingValues(subscriptionPolicies, supportedApiTypes); + }, [subscriptionPolicies, supportedApiTypes]); + const compatibleValues = useMemo(() => { + return values.filter((mappingValue) => ( + mappingValue && typeof mappingValue === 'object' && mappingValue.policyName + )); + }, [values]); + + const getPlanMappingError = (localPolicyName) => { + return planMappingErrors[`${planMappingPropertyPrefix}${localPolicyName}`] || ''; + }; + const getPlanMappingPropertyKey = (localPolicyName) => `${planMappingPropertyPrefix}${localPolicyName}`; + const mappedValues = compatibleValues.filter((mappingValue) => { + const propertyKey = getPlanMappingPropertyKey(mappingValue.policyName); + return Boolean((additionalProperties[propertyKey] || '').trim()); + }); + const availableValues = compatibleValues.filter((mappingValue) => { + const propertyKey = getPlanMappingPropertyKey(mappingValue.policyName); + return !(additionalProperties[propertyKey] || '').trim(); + }); + + const clearValues = () => { + setSelectedPolicyName(''); + setNewRemotePlanName(''); + setValidationError({}); + }; + const isAddDisabled = availableValues.length === 0 || !selectedPolicyName || !newRemotePlanName.trim(); + const validateNewMapping = () => { + const nextValidationError = {}; + if (!selectedPolicyName) { + nextValidationError.selectedPolicyName = ( + + ); + } + if (!newRemotePlanName.trim()) { + nextValidationError.newRemotePlanName = ( + + ); + } + if (selectedPolicyName + && !availableValues.some((mappingValue) => mappingValue.policyName === selectedPolicyName)) { + nextValidationError.selectedPolicyName = ( + + ); + } + setValidationError(nextValidationError); + return Object.keys(nextValidationError).length === 0; + }; + const handleAddToList = () => { + if (!validateNewMapping()) { + Alert.error( + , + ); + return; + } + setAdditionalProperties(getPlanMappingPropertyKey(selectedPolicyName), newRemotePlanName.trim()); + clearValues(); + }; + const onDelete = (localPolicyName) => { + setAdditionalProperties(getPlanMappingPropertyKey(localPolicyName), undefined); + }; + let planMappingContent; + if (isLoading) { + planMappingContent = ( + + + + + + + ); + } else if (loadError) { + planMappingContent = {loadError}; + } else if (compatibleValues.length === 0) { + planMappingContent = ( + + + + ); + } else { + planMappingContent = ( + + + + + {leftLabel} + {rightLabel} + + + + + + + + + { + setSelectedPolicyName(event.target.value); + setValidationError((currentErrors) => ({ + ...currentErrors, + selectedPolicyName: '', + })); + }} + error={Boolean(validationError.selectedPolicyName)} + helperText={validationError.selectedPolicyName || undefined} + fullWidth + > + {availableValues.length === 0 && ( + + + + )} + {availableValues.map((mappingValue) => ( + + {mappingValue.label || mappingValue.policyName} + + ))} + + + + { + setNewRemotePlanName(event.target.value); + setValidationError((currentErrors) => ({ + ...currentErrors, + newRemotePlanName: '', + })); + }} + error={Boolean(validationError.newRemotePlanName)} + helperText={validationError.newRemotePlanName || undefined} + variant='outlined' + margin='dense' + fullWidth + /> + + + + + + + + + + + + + {mappedValues.map((mappingValue) => { + const propertyKey = getPlanMappingPropertyKey(mappingValue.policyName); + const displayValue = (additionalProperties[propertyKey] || '').trim(); + return ( + + + + {mappingValue.label || mappingValue.policyName} + + + + + {displayValue} + + {getPlanMappingError(mappingValue.policyName) && ( + + {getPlanMappingError(mappingValue.policyName)} + + )} + + + { onDelete(mappingValue.policyName); }} + size='large' + > + + + + + ); + })} + +
+
+ ); + } + + return ( + + + {gatewayConfiguration.label && ( + {gatewayConfiguration.label} + )} + + {gatewayConfiguration.tooltip && ( + + {gatewayConfiguration.tooltip} + + )} + {planMappingContent} + + ); +} + +GatewayPlanMapping.propTypes = { + gatewayConfiguration: PropTypes.shape({ + name: PropTypes.string, + label: PropTypes.string, + tooltip: PropTypes.string, + default: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + values: PropTypes.arrayOf(PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + id: PropTypes.string, + label: PropTypes.string, + apiType: PropTypes.string, + }), + ])), + }).isRequired, + supportedApiTypes: PropTypes.arrayOf(PropTypes.string), + additionalProperties: PropTypes.objectOf(PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ])), + setAdditionalProperties: PropTypes.func, + planMappingErrors: PropTypes.objectOf(PropTypes.string), +}; + +GatewayPlanMapping.defaultProps = { + supportedApiTypes: [], + additionalProperties: {}, + setAdditionalProperties: () => {}, + planMappingErrors: {}, +}; diff --git a/portals/devportal/src/main/webapp/site/public/locales/en.json b/portals/devportal/src/main/webapp/site/public/locales/en.json index c09b8e24a33..0d43b52f6cd 100644 --- a/portals/devportal/src/main/webapp/site/public/locales/en.json +++ b/portals/devportal/src/main/webapp/site/public/locales/en.json @@ -32,6 +32,7 @@ "Apis.Details.APIKeys.ApiKeyAssociation.associateSuccess.message": "API key has been successfully associated with the application.", "Apis.Details.APIKeys.ApiKeyAssociation.associateSuccess.title": "Association Successful", "Apis.Details.APIKeys.ApiKeyAssociation.button.associate": "Associate", + "Apis.Details.APIKeys.ApiKeyAssociation.button.associating": "Associating...", "Apis.Details.APIKeys.ApiKeyAssociation.button.cancel": "Cancel", "Apis.Details.APIKeys.ApiKeyAssociation.button.ok": "OK", "Apis.Details.APIKeys.ApiKeyAssociation.button.removeAssociation": "Remove Association", @@ -51,6 +52,7 @@ "Apis.Details.APIKeys.ApiKeyGenerate.alert.regenerateFailed": "Failed to regenerate API Key. Please try again.", "Apis.Details.APIKeys.ApiKeyGenerate.button.close": "Close", "Apis.Details.APIKeys.ApiKeyGenerate.button.regenerate": "Regenerate", + "Apis.Details.APIKeys.ApiKeyGenerate.button.regenerating": "Regenerating...", "Apis.Details.APIKeys.ApiKeyGenerate.copyAlert.title": "Please Copy the API Key", "Apis.Details.APIKeys.ApiKeyGenerate.keyLabel": "API Key", "Apis.Details.APIKeys.ApiKeyGenerate.regenerateKey.alert.message": "Please copy this regenerated API Key value as it will be displayed only for the current browser session. (The API Key will not be visible in the UI after the page is refreshed.)", @@ -80,7 +82,9 @@ "Apis.Details.APIKeys.ApiKeyListing.button.generating": "Generating...", "Apis.Details.APIKeys.ApiKeyListing.button.ok": "OK", "Apis.Details.APIKeys.ApiKeyListing.button.removeAssociation": "Remove Association", + "Apis.Details.APIKeys.ApiKeyListing.button.removingAssociation": "Removing...", "Apis.Details.APIKeys.ApiKeyListing.button.revoke": "Revoke", + "Apis.Details.APIKeys.ApiKeyListing.button.revoking": "Revoking...", "Apis.Details.APIKeys.ApiKeyListing.column.actions": "Actions", "Apis.Details.APIKeys.ApiKeyListing.column.apiKey": "API Key", "Apis.Details.APIKeys.ApiKeyListing.column.application": "Application", diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyAssociation.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyAssociation.jsx index 731bd86b4eb..5bdbb90e1f8 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyAssociation.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyAssociation.jsx @@ -20,6 +20,7 @@ import React from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import { Button, + CircularProgress, Dialog, DialogActions, DialogContent, @@ -225,8 +226,16 @@ export default function ApiKeyAssociation(apiUUID, refreshApiKeys, subscribedApp onClick={handleAssociateKey} variant='contained' disabled={!selectedAppForAssociation || isAssociating} + startIcon={isAssociating ? : null} > - + {isAssociating ? ( + + ) : ( + + )} @@ -391,6 +400,9 @@ export default function ApiKeyAssociation(apiUUID, refreshApiKeys, subscribedApp ); return { + isAssociating, + isDissociating, + selectedKeyForDissociate, // Handlers handleOpenAssociationModal, handleRemoveAssociation, diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyGenerate.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyGenerate.jsx index f7f4a523e14..d9c002cbe51 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyGenerate.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyGenerate.jsx @@ -22,6 +22,7 @@ import { Alert as MuiAlert, Box, Button, + CircularProgress, Dialog, DialogActions, DialogContent, @@ -64,6 +65,8 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) { // Regenerate modal state const [regenerateModalOpen, setRegenerateModalOpen] = React.useState(false); const [regeneratedApiKey, setRegeneratedApiKey] = React.useState(null); + const [isRegenerating, setIsRegenerating] = React.useState(false); + const [regeneratingKeyUUID, setRegeneratingKeyUUID] = React.useState(null); // Validity period options const validityOptions = [ @@ -233,6 +236,11 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) { // Regenerate handlers const handleRegenerateKey = (keyData) => { + if (isRegenerating) { + return; + } + setIsRegenerating(true); + setRegeneratingKeyUUID(keyData.keyUUID); const restApi = new API(); restApi.regenerateApiApiKey(apiUUID, keyData.keyUUID) .then((response) => { @@ -243,12 +251,16 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) { }; setRegeneratedApiKey(regeneratedKey); setRegenerateModalOpen(true); + setIsRegenerating(false); + setRegeneratingKeyUUID(null); setTimeout(() => { refreshApiKeys(); }, 500); }) .catch((error) => { console.error('Error regenerating key:', error); + setIsRegenerating(false); + setRegeneratingKeyUUID(null); Alert.error(intl.formatMessage({ id: 'Apis.Details.APIKeys.ApiKeyGenerate.alert.regenerateFailed', defaultMessage: 'Failed to regenerate API Key. Please try again.', @@ -263,16 +275,24 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) { }; // Render regenerate button - const renderRegenerateButton = (keyData) => ( - - ); + const renderRegenerateButton = (keyData) => { + const isThisKeyRegenerating = isRegenerating && regeneratingKeyUUID === keyData.keyUUID; + return ( + + ); + }; // Render dialogs const renderDialogs = () => ( @@ -438,6 +458,8 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) { setRestrictionValue, generationModalOpen, isGenerating, + isRegenerating, + regeneratingKeyUUID, apikey, showToken, validityOptions, diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyListing.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyListing.jsx index a40e0193043..1f2f49cf6b4 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyListing.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyListing.jsx @@ -22,6 +22,7 @@ import { Box, Button, Chip, + CircularProgress, Dialog, DialogActions, DialogContent, @@ -78,6 +79,7 @@ export default function ApiKeyListing() { const [revokeErrorOpen, setRevokeErrorOpen] = React.useState(false); const [revokeErrorMessage, setRevokeErrorMessage] = React.useState(''); const [selectedKeyForRevoke, setSelectedKeyForRevoke] = React.useState(null); + const [isRevoking, setIsRevoking] = React.useState(false); // Subscribed applications state const [subscribedApps, setSubscribedApps] = React.useState([]); @@ -198,6 +200,9 @@ export default function ApiKeyListing() { const { handleOpenAssociationModal, handleRemoveAssociation, + isAssociating, + isDissociating, + selectedKeyForDissociate, renderDialogs: renderAssociationDialogs, } = ApiKeyAssociation(apiUUID, refreshApiKeys, subscribedApps); @@ -235,9 +240,11 @@ export default function ApiKeyListing() { const handleConfirmRevoke = () => { setRevokeConfirmOpen(false); + setIsRevoking(true); const restApi = new API(); restApi.revokeAPIBoundAPIKey(apiUUID, selectedKeyForRevoke.keyUUID) .then(() => { + setIsRevoking(false); setRevokeSuccessOpen(true); // Refresh the API keys list return restApi.getApiApiKeys(apiUUID); @@ -250,6 +257,7 @@ export default function ApiKeyListing() { if (process.env.NODE_ENV !== 'production') { console.log(error); } + setIsRevoking(false); setRevokeErrorMessage( error.message || intl.formatMessage({ id: 'Apis.Details.APIKeys.ApiKeyListing.error.revokeFailed', @@ -434,6 +442,7 @@ export default function ApiKeyListing() { size='small' startIcon={} onClick={() => handleOpenAssociationModal(keyData)} + disabled={isAssociating} > @@ -442,13 +451,23 @@ export default function ApiKeyListing() { variant='outlined' size='small' color='error' - startIcon={} + startIcon={isDissociating && selectedKeyForDissociate?.keyUUID === keyData.keyUUID + ? + : } onClick={() => handleRemoveAssociation(keyData)} + disabled={isDissociating && selectedKeyForDissociate?.keyUUID === keyData.keyUUID} > - + {isDissociating && selectedKeyForDissociate?.keyUUID === keyData.keyUUID ? ( + + ) : ( + + )} )} {renderRegenerateButton(keyData)} @@ -936,6 +966,7 @@ export default function ApiKeyListing() { onClick={handleGenerateKey} variant='contained' disabled={!displayName.trim() || isGenerating} + startIcon={isGenerating ? : null} > {isGenerating ? ( diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/ApiConsole/ApiConsole.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/ApiConsole/ApiConsole.jsx index adff672f837..b68bdb5ac22 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/ApiConsole/ApiConsole.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/ApiConsole/ApiConsole.jsx @@ -455,7 +455,10 @@ class ApiConsole extends React.Component { securitySchemeType, username, password, productionAccessToken, sandboxAccessToken, selectedKeyType, productionApiKey, sandboxApiKey, api, advAuthHeaderValue, } = this.state; - if ((api.advertiseInfo && api.advertiseInfo.advertised) || (api.gatewayVendor && api.gatewayVendor !== 'wso2')) { + if (api.advertiseInfo && api.advertiseInfo.advertised) { + return advAuthHeaderValue; + } + if (api.gatewayVendor && api.gatewayVendor !== 'wso2' && securitySchemeType !== 'API-KEY') { return advAuthHeaderValue; } if (securitySchemeType === 'BASIC') { diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/index.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/index.jsx index 231834baacf..9fc82cc330e 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/index.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/index.jsx @@ -632,8 +632,7 @@ class DetailsLegacy extends React.Component { open={open} id='left-menu-overview' /> - {user && showCredentials && !isSubValidationDisabled - && (api.gatewayVendor === 'wso2' || !api.gatewayVendor || api.gatewayType === 'solace') && ( + {user && showCredentials && !isSubValidationDisabled && ( {securitySchemeType !== 'TEST' && (!api.advertiseInfo || !api.advertiseInfo.advertised) - && (api.gatewayVendor === 'wso2' || !api.gatewayVendor) && ( + && isWSO2Gateway && ( <> @@ -709,7 +712,7 @@ function TryOutController(props) { )} {((isApiKeyEnabled || isBasicAuthEnabled || isOAuthEnabled) && showSecurityType) && (!api.advertiseInfo || !api.advertiseInfo.advertised) - && (api.gatewayVendor === 'wso2' || !api.gatewayVendor) && ( + && shouldShowIntegratedAuthSection && ( <> )} {((!api.advertiseInfo || !api.advertiseInfo.advertised) - && (api.gatewayVendor === 'wso2' || !api.gatewayVendor)) ? ( + && shouldShowIntegratedAuthSection) ? ( {securitySchemeType === 'BASIC' && ( @@ -1100,7 +1103,7 @@ function TryOutController(props) { /> )} {(!api.advertiseInfo || !api.advertiseInfo.advertised) - && (api.gatewayVendor === 'wso2' || !api.gatewayVendor) && ( + && shouldShowIntegratedAuthSection && ( {(environments && environments.length > 0) && ( diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Subscriptions/Subscriptions.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Subscriptions/Subscriptions.jsx index 8600ed9e990..041c5e49e87 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Subscriptions/Subscriptions.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Subscriptions/Subscriptions.jsx @@ -87,6 +87,19 @@ function Subscriptions(props) { && api.policies[0].includes(CONSTS.DEFAULT_SUBSCRIPTIONLESS_PLAN); const typeToDisplay = getTypeToDisplay(api.apiType); + const isSubscriptionManagementSupported = () => { + if (api.gatewayVendor === 'wso2' || api.gatewayType === 'solace') { + return true; + } + const gatewayType = api.gatewayType || 'wso2/synapse'; + const gatewayFeatures = settings?.gatewayFeatureCatalog?.gatewayFeatures?.[gatewayType]; + if (!gatewayFeatures) { + return false; + } + const subscriptionsConfig = gatewayFeatures.subscriptions || []; + return subscriptionsConfig.includes('subscriptions'); + }; + const getAllowedScopes = () => { if (api.apiType && api.apiType.toUpperCase() === 'MCP') { return ['apim:mcp_server_create', 'apim:mcp_server_manage', 'apim:mcp_server_publish']; @@ -171,7 +184,7 @@ function Subscriptions(props) { } return ( ( - {(api.gatewayVendor === 'wso2' || api.gatewayType === 'solace') && + {isSubscriptionManagementSupported() && ( )} - {(api.gatewayVendor === 'wso2' || api.gatewayType === 'solace') && ( + {isSubscriptionManagementSupported() && (