From 244b616ef3554e9b563f474a91a0b7c5f12196ef Mon Sep 17 00:00:00 2001 From: ashiduDissanayake Date: Mon, 27 Apr 2026 14:19:50 +0530 Subject: [PATCH 1/3] feat: complete admin template creation workflow and devportal template selector --- .../app/components/Base/RouteMenuMapping.jsx | 30 + .../Governance/Templates/DeleteTemplate.jsx | 114 +++ .../Governance/Templates/ListTemplates.jsx | 259 ++++++ .../Governance/Templates/TemplateWizard.jsx | 294 +++++++ .../components/Governance/Templates/index.jsx | 37 + .../Templates/steps/FormBuilderStep.jsx | 702 ++++++++++++++++ .../Templates/steps/GeneralDetailsStep.jsx | 235 ++++++ .../Templates/steps/ReviewPublishStep.jsx | 573 +++++++++++++ .../Templates/steps/RulesetBindingsStep.jsx | 771 ++++++++++++++++++ .../source/src/app/data/GovernanceAPI.js | 86 ++ .../Applications/ApplicationFormHandler.jsx | 67 +- .../Applications/Create/TemplateSelector.jsx | 167 ++++ .../AppsAndKeys/ApplicationCreateForm.jsx | 83 +- .../main/webapp/source/src/app/data/api.jsx | 51 ++ 14 files changed, 3425 insertions(+), 44 deletions(-) create mode 100644 portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/DeleteTemplate.jsx create mode 100644 portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/ListTemplates.jsx create mode 100644 portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/TemplateWizard.jsx create mode 100644 portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/index.jsx create mode 100644 portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/FormBuilderStep.jsx create mode 100644 portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/GeneralDetailsStep.jsx create mode 100644 portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx create mode 100644 portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx create mode 100644 portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplateSelector.jsx diff --git a/portals/admin/src/main/webapp/source/src/app/components/Base/RouteMenuMapping.jsx b/portals/admin/src/main/webapp/source/src/app/components/Base/RouteMenuMapping.jsx index 79c883c3bb3..af918442529 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/Base/RouteMenuMapping.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/Base/RouteMenuMapping.jsx @@ -42,6 +42,7 @@ import ListRoles from 'AppComponents//RolePermissions/ListRoles.jsx'; import TenantConfSave from 'AppComponents/AdvancedSettings/TenantConfSave'; import Policies from 'AppComponents/Governance/Policies'; import RulesetCatalog from 'AppComponents/Governance/RulesetCatalog'; +import ListTemplates from 'AppComponents/Governance/Templates'; import BusinessIcon from '@mui/icons-material/Business'; import Organizations from 'AppComponents/Organizations/ListOrganizations'; @@ -50,6 +51,7 @@ import CategoryIcon from '@mui/icons-material/Category'; import BookmarksIcon from '@mui/icons-material/Bookmarks'; import PolicyIcon from '@mui/icons-material/Policy'; import RuleIcon from '@mui/icons-material/Rule'; +import LayersIcon from '@mui/icons-material/Layers'; import BlockIcon from '@mui/icons-material/Block'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import AssignmentIcon from '@mui/icons-material/Assignment'; @@ -370,6 +372,34 @@ const RouteMenuMapping = (intl) => [ component: RulesetCatalog, icon: , }, + { + id: 'Templates', + displayText: intl.formatMessage({ + id: 'Base.RouteMenuMapping.governance.templates', + defaultMessage: 'Templates', + }), + path: '/governance/templates', + component: ListTemplates, + icon: , + addEditPageDetails: [ + { + id: 'Create Template', + displayText: intl.formatMessage({ + id: 'Base.RouteMenuMapping.governance.templates.create', + defaultMessage: 'Create Template', + }), + path: '/governance/templates/create', + }, + { + id: 'Edit Template', + displayText: intl.formatMessage({ + id: 'Base.RouteMenuMapping.governance.templates.edit', + defaultMessage: 'Edit Template', + }), + path: '/governance/templates/(.*?)$', + }, + ], + }, ], }, { diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/DeleteTemplate.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/DeleteTemplate.jsx new file mode 100644 index 00000000000..7cfb573661d --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/DeleteTemplate.jsx @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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 React from 'react'; +import GovernanceAPI from 'AppData/GovernanceAPI'; +import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from 'react-intl'; +import DialogContentText from '@mui/material/DialogContentText'; +import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import FormDialogBase from 'AppComponents/AdminPages/Addons/FormDialogBase'; +import Alert from 'AppComponents/Shared/Alert'; + +/** + * Renders delete dialog or a disabled icon for read-only (global) templates. + * @param {Object} props component properties + * @returns {JSX} Delete control + */ +function DeleteTemplate({ updateList, dataRow }) { + const { id, isReadOnly } = dataRow; + const intl = useIntl(); + + if (isReadOnly) { + return ( + + + + + + + + ); + } + + const formSaveCallback = () => { + return new GovernanceAPI() + .deleteDevportalGovernanceTemplate(id) + .then(() => ( + + )) + .catch((error) => { + const { response, message } = error; + if (response && response.body) { + Alert.error(response.body.message); + } else if (message) { + Alert.error(message); + } else { + Alert.error(intl.formatMessage({ + id: 'Governance.Templates.Delete.error', + defaultMessage: 'Something went wrong while deleting the Template', + })); + } + }) + .finally(() => { + updateList(); + }); + }; + + return ( + } + formSaveCallback={formSaveCallback} + > + + + + + ); +} + +DeleteTemplate.propTypes = { + updateList: PropTypes.func.isRequired, + dataRow: PropTypes.shape({ + id: PropTypes.string.isRequired, + isReadOnly: PropTypes.bool, + }).isRequired, +}; + +export default DeleteTemplate; diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/ListTemplates.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/ListTemplates.jsx new file mode 100644 index 00000000000..abb700d6a3d --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/ListTemplates.jsx @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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 React from 'react'; +import { useIntl, FormattedMessage } from 'react-intl'; +import Typography from '@mui/material/Typography'; +import { + Chip, Tooltip, Button, List, ListItemButton, ListItemIcon, Link, ListItemText, +} from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; +import EditIcon from '@mui/icons-material/Edit'; +import DescriptionIcon from '@mui/icons-material/Description'; +import ListBase from 'AppComponents/AdminPages/Addons/ListBase'; +import HelpBase from 'AppComponents/AdminPages/Addons/HelpBase'; +import GovernanceAPI from 'AppData/GovernanceAPI'; +import Configurations from 'Config'; +import { useAppContext } from 'AppComponents/Shared/AppContext'; +import DeleteTemplate from './DeleteTemplate'; + +/** + * Render a list of Devportal Governance Templates. + * @returns {JSX} List component + */ +export default function ListTemplates() { + const intl = useIntl(); + const { isSuperTenant } = useAppContext(); + + // apiCall defined as closure to capture isSuperTenant for isReadOnly computation + function apiCall() { + return new GovernanceAPI() + .getDevportalGovernanceTemplates({ limit: 100, offset: 0 }) + .then((result) => result.body.list.map((t) => ({ + ...t, + isReadOnly: !!t.isGlobal && !isSuperTenant, + }))) + .catch((error) => { + throw error; + }); + } + + // IMPORTANT: id must be the LAST column — ListBase reads rowData[rowData.length - 2] for routing + const columProps = [ + { + name: 'name', + label: intl.formatMessage({ + id: 'Governance.Templates.List.column.template', + defaultMessage: 'Template', + }), + options: { + sort: true, + customBodyRender: (value, tableMeta) => { + const desc = tableMeta.rowData[1]; + return ( + +
+ {value} + + {desc} + +
+
+ ); + }, + setCellProps: () => ({ style: { width: '35%' } }), + }, + }, + { + name: 'description', + options: { display: false }, + }, + { + name: 'status', + label: intl.formatMessage({ + id: 'Governance.Templates.List.column.status', + defaultMessage: 'Status', + }), + options: { + sort: false, + customBodyRender: (value) => ( + + ), + setCellProps: () => ({ style: { width: '12%', textAlign: 'center' } }), + setCellHeaderProps: () => ({ style: { textAlign: 'center' } }), + }, + }, + { + name: 'isGlobal', + label: intl.formatMessage({ + id: 'Governance.Templates.List.column.scope', + defaultMessage: 'Scope', + }), + options: { + sort: false, + customBodyRender: (value) => ( + value ? ( + + ) : ( + + ) + ), + setCellProps: () => ({ style: { width: '12%', textAlign: 'center' } }), + setCellHeaderProps: () => ({ style: { textAlign: 'center' } }), + }, + }, + { + name: 'isDefault', + label: intl.formatMessage({ + id: 'Governance.Templates.List.column.default', + defaultMessage: 'Default', + }), + options: { + sort: false, + customBodyRender: (value) => ( + value ? : null + ), + setCellProps: () => ({ style: { width: '12%', textAlign: 'center' } }), + setCellHeaderProps: () => ({ style: { textAlign: 'center' } }), + }, + }, + { + name: 'id', + options: { display: false }, // Must remain last — used by ListBase for edit routing + }, + ]; + + const pageProps = { + pageStyle: 'paperLess', + title: intl.formatMessage({ + id: 'Governance.Templates.List.title', + defaultMessage: 'Devportal Governance Templates', + }), + pageDescription: intl.formatMessage({ + id: 'Governance.Templates.List.description', + defaultMessage: 'Create and manage templates that configure the Devportal application creation' + + ' workflow and bind governance rulesets to enforce developer policies.', + }), + help: ( + + + + + + + + + )} + /> + + + + + ), + }; + + const emptyBoxProps = { + content: ( + + + + ), + title: ( + + + + ), + }; + + const addButtonOverride = ( + + + + ); + + return ( + , + title: intl.formatMessage({ + id: 'Governance.Templates.List.edit.title', + defaultMessage: 'Edit Template', + }), + routeTo: '/governance/templates/', + }} + addButtonOverride={addButtonOverride} + /> + ); +} diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/TemplateWizard.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/TemplateWizard.jsx new file mode 100644 index 00000000000..06cde117975 --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/TemplateWizard.jsx @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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 React, { useReducer, useState, useEffect } from 'react'; +import { useParams, Link as RouterLink } from 'react-router-dom'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + Box, + Button, + CircularProgress, + Paper, + Step, + StepLabel, + Stepper, + Typography, +} from '@mui/material'; +import ContentBase from 'AppComponents/AdminPages/Addons/ContentBase'; +import Alert from 'AppComponents/Shared/Alert'; +import GovernanceAPI from 'AppData/GovernanceAPI'; +import GeneralDetailsStep from './steps/GeneralDetailsStep'; +import FormBuilderStep from './steps/FormBuilderStep'; +import RulesetBindingsStep from './steps/RulesetBindingsStep'; +import ReviewPublishStep from './steps/ReviewPublishStep'; + +/** + * Initial form config structure. + * Sections map directly to the three phases of the Devportal application wizard. + * Step 2 (Form Builder) reads and writes this structure. + */ +const INITIAL_FORM_CONFIG = { + application: { + throttlingPolicy: { hidden: false, defaultValue: '' }, + tokenType: { hidden: false, defaultValue: 'JWT' }, + callbackUrl: { hidden: false, defaultValue: '' }, + }, + subscription: { + throttlingPolicy: { hidden: false, defaultValue: '' }, + }, + keyGeneration: { + keyType: { hidden: false, defaultValue: 'PRODUCTION' }, + grantTypes: { hidden: false, defaultValue: [] }, + validityPeriod: { hidden: false, defaultValue: -1 }, + additionalProperties: { hidden: false, defaultValue: {} }, + }, +}; + +const INITIAL_STATE = { + name: '', + description: '', + status: 'DRAFT', + isDefault: false, + isGlobal: false, + formConfig: INITIAL_FORM_CONFIG, + rulesetBindings: [], +}; + +function templateReducer(state, { field, value }) { + return { ...state, [field]: value }; +} + +const STEPS = [ + { + id: 'Governance.Templates.Wizard.step.general', + defaultMessage: 'General Details', + }, + { + id: 'Governance.Templates.Wizard.step.formBuilder', + defaultMessage: 'Form Builder', + }, + { + id: 'Governance.Templates.Wizard.step.rulesets', + defaultMessage: 'Ruleset Bindings', + }, + { + id: 'Governance.Templates.Wizard.step.review', + defaultMessage: 'Review & Publish', + }, +]; + +/** + * Multi-step wizard for creating and editing Devportal Governance Templates. + * Holds all template state; child step components receive templateState + dispatch. + * @returns {JSX} TemplateWizard component + */ +export default function TemplateWizard() { + const { id: templateId } = useParams(); + const intl = useIntl(); + const isEditMode = !!templateId; + + const [templateState, dispatch] = useReducer(templateReducer, INITIAL_STATE); + const [activeStep, setActiveStep] = useState(0); + const [loading, setLoading] = useState(isEditMode); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!isEditMode) return; + new GovernanceAPI() + .getDevportalGovernanceTemplateById(templateId) + .then((res) => { + const t = res.body; + dispatch({ field: 'name', value: t.name || '' }); + dispatch({ field: 'description', value: t.description || '' }); + dispatch({ field: 'status', value: t.status || 'DRAFT' }); + dispatch({ field: 'isDefault', value: !!t.isDefault }); + dispatch({ field: 'isGlobal', value: !!t.isGlobal }); + dispatch({ field: 'formConfig', value: t.formConfig || INITIAL_FORM_CONFIG }); + dispatch({ field: 'rulesetBindings', value: t.rulesetBindings || [] }); + }) + .catch((error) => { + const msg = error?.response?.body?.message + || intl.formatMessage({ + id: 'Governance.Templates.Wizard.load.error', + defaultMessage: 'Failed to load template', + }); + Alert.error(msg); + }) + .finally(() => setLoading(false)); + }, [templateId]); + + const handleSave = () => { + setSaving(true); + const payload = { + name: templateState.name, + description: templateState.description, + status: templateState.status, + isDefault: templateState.isDefault, + isGlobal: templateState.isGlobal, + formConfig: templateState.formConfig, + rulesetBindings: templateState.rulesetBindings, + }; + + const apiCall = isEditMode + ? new GovernanceAPI().updateDevportalGovernanceTemplateById(templateId, payload) + : new GovernanceAPI().createDevportalGovernanceTemplate(payload); + + apiCall + .then(() => { + Alert.success(intl.formatMessage({ + id: 'Governance.Templates.Wizard.save.success', + defaultMessage: isEditMode + ? 'Template updated successfully' + : 'Template created successfully', + })); + }) + .catch((error) => { + const msg = error?.response?.body?.message + || intl.formatMessage({ + id: 'Governance.Templates.Wizard.save.error', + defaultMessage: 'Failed to save template', + }); + Alert.error(msg); + }) + .finally(() => setSaving(false)); + }; + + const pageTitle = isEditMode + ? intl.formatMessage({ + id: 'Governance.Templates.Wizard.title.edit', + defaultMessage: 'Edit Template', + }) + : intl.formatMessage({ + id: 'Governance.Templates.Wizard.title.create', + defaultMessage: 'Create Template', + }); + + if (loading) { + return ( + + + + + + ); + } + + const isNameValid = templateState.name.trim().length > 0; + const isLastStep = activeStep === STEPS.length - 1; + + // Placeholder content for steps not yet implemented + const stepContent = [ + , + , + , + , + ]; + + return ( + + {/* Stepper header */} + + + {STEPS.map((step) => ( + + + + + + ))} + + + + {/* Active step content */} + + {stepContent[activeStep]} + + + {/* Navigation */} + + + + + + {activeStep > 0 && ( + + )} + + + {isLastStep ? ( + + ) : ( + + )} + + + + ); +} diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/index.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/index.jsx new file mode 100644 index 00000000000..5644062823e --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/index.jsx @@ -0,0 +1,37 @@ +/* eslint-disable */ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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 React from 'react'; +import { Route, Switch, withRouter } from 'react-router-dom'; +import ResourceNotFound from 'AppComponents/Base/Errors/ResourceNotFound'; +import ListTemplates from './ListTemplates'; +import TemplateWizard from './TemplateWizard'; + +function Templates() { + return ( + + + + + + + ); +} + +export default withRouter(Templates); diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/FormBuilderStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/FormBuilderStep.jsx new file mode 100644 index 00000000000..b6fc77585aa --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/FormBuilderStep.jsx @@ -0,0 +1,702 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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 React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from 'react-intl'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import { + Box, + Chip, + CircularProgress, + FormControlLabel, + Grid, + MenuItem, + OutlinedInput, + Select, + Switch, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import Alert from 'AppComponents/Shared/Alert'; +import API from 'AppData/api'; + +// ─── Static option lists ────────────────────────────────────────────────────── + +const GRANT_TYPES = [ + { value: 'client_credentials', label: 'Client Credentials' }, + { value: 'password', label: 'Resource Owner Password' }, + { value: 'authorization_code', label: 'Authorization Code' }, + { value: 'refresh_token', label: 'Refresh Token' }, + { value: 'implicit', label: 'Implicit' }, + { value: 'urn:ietf:params:oauth:grant-type:token-exchange', label: 'Token Exchange' }, +]; + +const TOKEN_TYPES = [ + { value: 'JWT', label: 'JWT' }, + { value: 'OAUTH', label: 'Opaque (OAuth)' }, +]; + +const KEY_TYPES = [ + { value: 'PRODUCTION', label: 'Production' }, + { value: 'SANDBOX', label: 'Sandbox' }, +]; + +// ─── Reusable field-row component ──────────────────────────────────────────── + +/** + * Renders one configurable form field row. + * + * Layout: [Label + description | Hide switch | Default value input] + * When hidden=true the helper text under the default input reminds the admin + * that this value will be silently applied on the developer's behalf. + */ +function FieldRow({ + label, + description, + hidden, + onToggleHidden, + defaultInput, // JSX for the default-value control + noDefault, // true = no default editor exists (e.g. additionalProperties) +}) { + const intl = useIntl(); + return ( + + {/* Field label */} + + + {label} + + {description && ( + + {description} + + )} + + + {/* Hide toggle */} + + onToggleHidden(e.target.checked)} + size='small' + color='warning' + /> + )} + label={( + + + + )} + sx={{ ml: 0 }} + /> + + + {/* Default value */} + + {noDefault ? ( + + + + + + + + + ) : ( + + {defaultInput} + {hidden && ( + + + + )} + + )} + + + ); +} + +FieldRow.propTypes = { + label: PropTypes.node.isRequired, + description: PropTypes.node, + hidden: PropTypes.bool.isRequired, + onToggleHidden: PropTypes.func.isRequired, + defaultInput: PropTypes.node, + noDefault: PropTypes.bool, +}; + +FieldRow.defaultProps = { + description: null, + defaultInput: null, + noDefault: false, +}; + +// ─── Section heading inside AccordionDetails ───────────────────────────────── + +function SectionHeader() { + return ( + + + + + + + + + + + + + + + + + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +/** + * Step 2 of the TemplateWizard. + * Renders three Accordion sections (Application Metadata, Subscriptions, Key Generation). + * Each field row has a hide-toggle and a default-value input. + * Dispatches the fully updated formConfig on every change. + * + * @param {Object} props + * @param {Object} props.templateState - wizard state from TemplateWizard reducer + * @param {Function} props.dispatch - reducer dispatch + */ +export default function FormBuilderStep({ templateState, dispatch }) { + const intl = useIntl(); + const [appPolicies, setAppPolicies] = useState([]); + const [subPolicies, setSubPolicies] = useState([]); + const [loadingPolicies, setLoadingPolicies] = useState(true); + + // ── Data fetch ────────────────────────────────────────────────────────── + useEffect(() => { + const api = new API(); + Promise.all([ + api.applicationThrottlingPoliciesGet(), + api.getSubscritionPolicyList(), // note: typo is in the source API class + ]) + .then(([appRes, subRes]) => { + setAppPolicies(appRes.body.list.map((p) => p.policyName)); + setSubPolicies(subRes.body.list.map((p) => p.policyName)); + }) + .catch(() => { + Alert.error(intl.formatMessage({ + id: 'Governance.Templates.FormBuilder.policiesFetch.error', + defaultMessage: 'Failed to load throttling policies. Dropdowns may be empty.', + })); + }) + .finally(() => setLoadingPolicies(false)); + }, []); + + // ── Helpers ───────────────────────────────────────────────────────────── + + /** + * Returns the config entry for a single field, falling back gracefully if + * formConfig came from an older schema that omits this field. + */ + const getField = (section, fieldKey, emptyDefault = '') => { + return templateState.formConfig?.[section]?.[fieldKey] + ?? { hidden: false, defaultValue: emptyDefault }; + }; + + /** + * Immutably updates one config key (hidden | defaultValue) on a single field + * and dispatches the entire updated formConfig. + */ + const updateField = (section, fieldKey, configKey, value) => { + dispatch({ + field: 'formConfig', + value: { + ...templateState.formConfig, + [section]: { + ...templateState.formConfig[section], + [fieldKey]: { + ...(templateState.formConfig[section]?.[fieldKey] ?? {}), + [configKey]: value, + }, + }, + }, + }); + }; + + // ── Shorthand aliases for current config values ────────────────────────── + const appThrottling = getField('application', 'throttlingPolicy'); + const appTokenType = getField('application', 'tokenType', 'JWT'); + const appCallbackUrl = getField('application', 'callbackUrl'); + const subThrottling = getField('subscription', 'throttlingPolicy'); + const keyGenKeyType = getField('keyGeneration', 'keyType', 'PRODUCTION'); + const keyGenGrantTypes = getField('keyGeneration', 'grantTypes', []); + const keyGenValidity = getField('keyGeneration', 'validityPeriod', -1); + const keyGenAdditional = getField('keyGeneration', 'additionalProperties', {}); + + // Ensure array type for multi-select value (guard against stale string value) + const grantTypesValue = Array.isArray(keyGenGrantTypes.defaultValue) + ? keyGenGrantTypes.defaultValue + : []; + + if (loadingPolicies) { + return ( + + + + ); + } + + return ( + + + + + + + + + {/* ── Section 1: Application Metadata ── */} + + }> + + + + + + + + + + + + + {/* Throttling Policy */} + + )} + description={( + + )} + hidden={appThrottling.hidden} + onToggleHidden={(v) => updateField('application', 'throttlingPolicy', 'hidden', v)} + defaultInput={( + + )} + /> + + {/* Token Type */} + + )} + description={( + + )} + hidden={appTokenType.hidden} + onToggleHidden={(v) => updateField('application', 'tokenType', 'hidden', v)} + defaultInput={( + + )} + /> + + {/* Callback URL */} + + )} + description={( + + )} + hidden={appCallbackUrl.hidden} + onToggleHidden={(v) => updateField('application', 'callbackUrl', 'hidden', v)} + defaultInput={( + updateField('application', 'callbackUrl', 'defaultValue', e.target.value)} + /> + )} + /> + + + + {/* ── Section 2: Subscriptions ── */} + + }> + + + + + + + + + + + + + {/* Subscription Throttling Policy */} + + )} + description={( + + )} + hidden={subThrottling.hidden} + onToggleHidden={(v) => updateField('subscription', 'throttlingPolicy', 'hidden', v)} + defaultInput={( + + )} + /> + + + + {/* ── Section 3: Key Generation ── */} + + }> + + + + + + + + + + + + + {/* Key Type */} + + )} + description={( + + )} + hidden={keyGenKeyType.hidden} + onToggleHidden={(v) => updateField('keyGeneration', 'keyType', 'hidden', v)} + defaultInput={( + + )} + /> + + {/* Grant Types */} + + )} + description={( + + )} + hidden={keyGenGrantTypes.hidden} + onToggleHidden={(v) => updateField('keyGeneration', 'grantTypes', 'hidden', v)} + defaultInput={( + + )} + /> + + {/* Validity Period */} + + )} + description={( + + )} + hidden={keyGenValidity.hidden} + onToggleHidden={(v) => updateField('keyGeneration', 'validityPeriod', 'hidden', v)} + defaultInput={( + updateField( + 'keyGeneration', + 'validityPeriod', + 'defaultValue', + Number(e.target.value), + )} + inputProps={{ min: -1 }} + helperText={keyGenValidity.defaultValue === -1 + ? intl.formatMessage({ + id: 'Governance.Templates.FormBuilder.keyGen.validity.unlimited', + defaultMessage: 'Token will not expire', + }) + : ''} + /> + )} + /> + + {/* Additional Properties (PKCE) — hide toggle only */} + + )} + description={( + + )} + hidden={keyGenAdditional.hidden} + onToggleHidden={(v) => updateField('keyGeneration', 'additionalProperties', 'hidden', v)} + noDefault + /> + + + + ); +} + +FormBuilderStep.propTypes = { + templateState: PropTypes.shape({ + formConfig: PropTypes.object.isRequired, + }).isRequired, + dispatch: PropTypes.func.isRequired, +}; diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/GeneralDetailsStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/GeneralDetailsStep.jsx new file mode 100644 index 00000000000..1a97327286d --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/GeneralDetailsStep.jsx @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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 React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + Box, + FormControlLabel, + Grid, + MenuItem, + Switch, + TextField, + Typography, + Divider, +} from '@mui/material'; +import { useAppContext } from 'AppComponents/Shared/AppContext'; + +/** + * Step 1 of the TemplateWizard: name, description, status, isDefault, isGlobal (super tenant only). + * @param {Object} props + * @param {Object} props.templateState - wizard state slice for this step + * @param {Function} props.dispatch - reducer dispatch from TemplateWizard + * @returns {JSX} + */ +export default function GeneralDetailsStep({ templateState, dispatch }) { + const intl = useIntl(); + const { isSuperTenant } = useAppContext(); + const { + name, description, status, isDefault, isGlobal, + } = templateState; + + // Track blur to avoid showing validation errors before the user touches the field + const [nameTouched, setNameTouched] = useState(false); + + const nameError = nameTouched && !name.trim(); + + return ( + + + + + + + + + + {/* Name */} + + dispatch({ field: 'name', value: e.target.value })} + onBlur={() => setNameTouched(true)} + error={nameError} + helperText={nameError + ? intl.formatMessage({ + id: 'Governance.Templates.Wizard.GeneralDetails.name.required', + defaultMessage: 'Template name is required', + }) + : intl.formatMessage({ + id: 'Governance.Templates.Wizard.GeneralDetails.name.helper', + defaultMessage: 'A unique, human-readable name for this template', + })} + inputProps={{ maxLength: 256 }} + variant='outlined' + /> + + + {/* Status */} + + dispatch({ field: 'status', value: e.target.value })} + helperText={intl.formatMessage({ + id: 'Governance.Templates.Wizard.GeneralDetails.status.helper', + defaultMessage: 'Only PUBLISHED templates are visible to Devportal users', + })} + variant='outlined' + > + + + + + + + + + + {/* Description */} + + dispatch({ field: 'description', value: e.target.value })} + inputProps={{ maxLength: 1024 }} + helperText={intl.formatMessage({ + id: 'Governance.Templates.Wizard.GeneralDetails.description.helper', + defaultMessage: 'Briefly describe the purpose and intended audience of this template', + })} + variant='outlined' + /> + + + {/* Toggles section */} + + + + + + + + {/* isDefault toggle */} + + dispatch({ field: 'isDefault', value: e.target.checked })} + color='primary' + /> + )} + label={( + + + + + + + + + )} + sx={{ alignItems: 'flex-start', ml: 0 }} + /> + + + {/* isGlobal toggle — super tenant only */} + {isSuperTenant && ( + + dispatch({ field: 'isGlobal', value: e.target.checked })} + color='secondary' + /> + )} + label={( + + + + + + + + + )} + sx={{ alignItems: 'flex-start', ml: 0 }} + /> + + )} + + + ); +} + +GeneralDetailsStep.propTypes = { + templateState: PropTypes.shape({ + name: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + isDefault: PropTypes.bool.isRequired, + isGlobal: PropTypes.bool.isRequired, + }).isRequired, + dispatch: PropTypes.func.isRequired, +}; diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx new file mode 100644 index 00000000000..69f927f7f0b --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx @@ -0,0 +1,573 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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 React, { useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Chip, + CircularProgress, + Grid, + Paper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import GovernanceAPI from 'AppData/GovernanceAPI'; + +// ── Static label maps (mirrors FormBuilderStep; extracted here to avoid cross-import) ── + +const GRANT_TYPE_LABELS = { + authorization_code: 'Authorization Code', + implicit: 'Implicit', + password: 'Password', + client_credentials: 'Client Credentials', + refresh_token: 'Refresh Token', + 'urn:ietf:params:oauth:grant-type:device_code': 'Device Code', +}; + +const TOKEN_TYPE_LABELS = { + JWT: 'JWT', + OAUTH: 'OAuth (Opaque)', +}; + +const KEY_TYPE_LABELS = { + PRODUCTION: 'Production', + SANDBOX: 'Sandbox', +}; + +/** + * Metadata describing every formConfig field for human-readable rendering. + * Shape per entry: { label, section, fieldKey, valueMap?, isArray? } + */ +const FORM_CONFIG_META = [ + { + sectionKey: 'application', + sectionLabel: 'Application Details', + fields: [ + { fieldKey: 'throttlingPolicy', label: 'Throttling Policy' }, + { fieldKey: 'tokenType', label: 'Token Type', valueMap: TOKEN_TYPE_LABELS }, + { fieldKey: 'callbackUrl', label: 'Callback URL' }, + ], + }, + { + sectionKey: 'subscription', + sectionLabel: 'Subscription', + fields: [ + { fieldKey: 'throttlingPolicy', label: 'Throttling Policy' }, + ], + }, + { + sectionKey: 'keyGeneration', + sectionLabel: 'Key Generation', + fields: [ + { fieldKey: 'keyType', label: 'Key Type', valueMap: KEY_TYPE_LABELS }, + { + fieldKey: 'grantTypes', label: 'Grant Types', valueMap: GRANT_TYPE_LABELS, isArray: true, + }, + { fieldKey: 'validityPeriod', label: 'Validity Period (seconds)' }, + { fieldKey: 'additionalProperties', label: 'Additional Properties', noDefault: true }, + ], + }, +]; + +// ── File-scope sub-components (stable references, no remounting) ────────────────────── + +/** + * Titled section card with a colored left-border accent. + */ +function SummarySection({ title, children, accentColor = 'primary.main' }) { + return ( + + + + {title} + + + + {children} + + + ); +} + +SummarySection.propTypes = { + title: PropTypes.node.isRequired, + children: PropTypes.node.isRequired, + accentColor: PropTypes.string, +}; + +SummarySection.defaultProps = { + accentColor: 'primary.main', +}; + +/** + * Two-column label / value row. + */ +function SummaryRow({ label, children }) { + return ( + + + + {label} + + + + {children} + + + ); +} + +SummaryRow.propTypes = { + label: PropTypes.node.isRequired, + children: PropTypes.node.isRequired, +}; + +// ── Helper to render a field's default value in a human-readable way ────────────────── + +function renderDefaultValue(fieldMeta, fieldConfig) { + const { valueMap, isArray, noDefault } = fieldMeta; + const raw = fieldConfig?.defaultValue; + + if (noDefault) { + return ( + + + + ); + } + + if (raw === undefined || raw === null || raw === '') { + return ( + + + + ); + } + + if (raw === -1) { + return Unlimited; + } + + if (isArray && Array.isArray(raw)) { + if (raw.length === 0) { + return ( + + + + ); + } + return ( + + {raw.map((v) => ( + + ))} + + ); + } + + const displayValue = valueMap?.[raw] ?? String(raw); + return {displayValue}; +} + +// ── Main component ───────────────────────────────────────────────────────────────────── + +/** + * Step 4 of the TemplateWizard: read-only summary of all configured values before save. + * @param {Object} props + * @param {Object} props.templateState - full wizard state + * @returns {JSX} + */ +export default function ReviewPublishStep({ templateState }) { + const intl = useIntl(); + const { + name, description, status, isDefault, isGlobal, + formConfig, rulesetBindings, + } = templateState; + + const [rulesetMap, setRulesetMap] = useState({}); + const [loadingRulesets, setLoadingRulesets] = useState(false); + + useEffect(() => { + if (rulesetBindings.length === 0) return; + setLoadingRulesets(true); + new GovernanceAPI() + .getRulesets({ limit: 200, offset: 0 }) + .then((res) => { + const map = {}; + (res.body?.list ?? []).forEach((r) => { map[r.id] = r.name; }); + setRulesetMap(map); + }) + .catch(() => {}) + .finally(() => setLoadingRulesets(false)); + }, []); + + // DTO payload for the raw JSON accordion + const dtoPayload = useMemo(() => ({ + name, + description, + status, + isDefault, + isGlobal, + formConfig, + rulesetBindings, + }), [name, description, status, isDefault, isGlobal, formConfig, rulesetBindings]); + + return ( + + + + + + + + + {/* ── Section 1: General Details ── */} + + + {name || '—'} + + + + + {description || intl.formatMessage({ + id: 'Governance.Templates.ReviewPublish.label.noDescription', + defaultMessage: 'No description provided', + })} + + + + + + + + + + {isDefault && ( + + )} + {isGlobal && ( + + )} + {!isDefault && !isGlobal && ( + + + + )} + + + + + {/* ── Section 2: Form Configuration ── */} + + {FORM_CONFIG_META.map(({ sectionKey, sectionLabel, fields }) => ( + + + {sectionLabel} + + {fields.map((fieldMeta) => { + const fieldConfig = formConfig?.[sectionKey]?.[fieldMeta.fieldKey] + ?? { hidden: false, defaultValue: fieldMeta.isArray ? [] : '' }; + const isHidden = !!fieldConfig.hidden; + return ( + + + + {fieldMeta.label} + + + + + + + {isHidden ? ( + + + + ) : ( + renderDefaultValue(fieldMeta, fieldConfig) + )} + + + ); + })} + + ))} + + + {/* ── Section 3: Ruleset Bindings ── */} + + {rulesetBindings.length === 0 ? ( + + + + ) : ( + <> + {loadingRulesets && ( + + + + + + + )} + + + + + + + + + + + + + + + + {[...rulesetBindings] + .sort((a, b) => a.bindingOrder - b.bindingOrder) + .map((binding) => { + const rulesetName = rulesetMap[binding.rulesetId] + ?? binding.rulesetId; + const scopeLabel = binding.keyManagerScopes.length === 0 + ? intl.formatMessage({ + id: 'Governance.Templates.ReviewPublish.rulesets.scope.all', + defaultMessage: 'All Key Managers', + }) + : intl.formatMessage( + { + id: 'Governance.Templates.ReviewPublish.rulesets.scope.count', + defaultMessage: '{count} Key Manager(s)', + }, + { count: binding.keyManagerScopes.length }, + ); + return ( + + {binding.bindingOrder + 1} + + {rulesetName} + + + + {scopeLabel} + + + + ); + })} + +
+ + )} +
+ + {/* ── Section 4: Raw Payload (collapsible) ── */} + + }> + + + + + + + {JSON.stringify(dtoPayload, null, 2)} + + + +
+ ); +} + +ReviewPublishStep.propTypes = { + templateState: PropTypes.shape({ + name: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + isDefault: PropTypes.bool.isRequired, + isGlobal: PropTypes.bool.isRequired, + formConfig: PropTypes.object.isRequired, + rulesetBindings: PropTypes.array.isRequired, + }).isRequired, +}; diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx new file mode 100644 index 00000000000..89a36ed8a79 --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx @@ -0,0 +1,771 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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 React, { useState, useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + Box, + Button, + Chip, + CircularProgress, + Divider, + Grid, + IconButton, + InputAdornment, + MenuItem, + OutlinedInput, + Paper, + Select, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import SearchIcon from '@mui/icons-material/Search'; +import PublicIcon from '@mui/icons-material/Public'; +import VpnKeyIcon from '@mui/icons-material/VpnKey'; +import GovernanceAPI from 'AppData/GovernanceAPI'; +import API from 'AppData/api'; +import Alert from 'AppComponents/Shared/Alert'; +import Utils from 'AppData/Utils'; + +// ─── Sentinel value for "All Key Managers" option in the multi-select ───────── +// An empty keyManagerScopes array on the DTO means global scope. +// Internally we represent that as this sentinel so MUI Select has a value to display. +const ALL_KM_VALUE = '__all_key_managers__'; + +// ─── Available-ruleset row (left pane) ──────────────────────────────────────── + +/** + * One row in the "Available Rulesets" left pane. + * Shows name, ruleType chip, artifactType chip, and an Add button. + * The Add button is replaced with a "Bound" chip when already added. + */ +function AvailableRulesetRow({ ruleset, isBound, onAdd }) { + const intl = useIntl(); + return ( + + {/* Ruleset info */} + + + {ruleset.name} + + + + + + + + {/* Action */} + {isBound ? ( + + ) : ( + + + + + + )} + + ); +} + +AvailableRulesetRow.propTypes = { + ruleset: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + ruleType: PropTypes.string.isRequired, + artifactType: PropTypes.string.isRequired, + }).isRequired, + isBound: PropTypes.bool.isRequired, + onAdd: PropTypes.func.isRequired, +}; + +// ─── Bound-ruleset card (right pane) ───────────────────────────────────────── + +/** + * Configuration card for a single bound ruleset. + * + * Controls: + * - Binding order: integer TextField + * - Key Manager scope: multi-Select with sentinel ALL_KM_VALUE for global scope + * - Remove button + * + * KM scope behaviour: + * - Value `[ALL_KM_VALUE]` → payload `keyManagerScopes: []` (all key managers) + * - Value `['km-uuid-1', ...]` → payload `keyManagerScopes: [{ keyManagerUuid: ... }]` + * + * The sentinel is mapped in/out only at this component boundary; the DTO never + * sees it. + */ +function BoundRulesetCard({ + binding, + ruleset, + keyManagers, + onOrderChange, + onScopeChange, + onRemove, +}) { + const intl = useIntl(); + + // Map DTO → Select value: empty scopes array becomes [ALL_KM_VALUE] + const selectValue = binding.keyManagerScopes.length === 0 + ? [ALL_KM_VALUE] + : binding.keyManagerScopes.map((s) => s.keyManagerUuid); + + const handleKmChange = (event) => { + const raw = event.target.value; // string[] from MUI multi-select + const lastPicked = raw[raw.length - 1]; + + if (lastPicked === ALL_KM_VALUE) { + // User explicitly clicked "All Key Managers" → revert to global scope + onScopeChange([]); + return; + } + + // Remove the sentinel from the selection (handles the case where + // the user adds a specific KM while the sentinel is still present) + const specificIds = raw.filter((v) => v !== ALL_KM_VALUE); + + if (specificIds.length === 0) { + // All specific KMs were deselected → fall back to global + onScopeChange([]); + } else { + onScopeChange(specificIds.map((id) => ({ keyManagerUuid: id }))); + } + }; + + const rulesetName = ruleset?.name || binding.rulesetId; + const isGlobalScope = binding.keyManagerScopes.length === 0; + + return ( + + {/* Header row: name + remove */} + + + + {rulesetName} + + {ruleset && ( + + )} + + + + + + + + + + + {/* Controls */} + + {/* Binding order */} + + + + + onOrderChange(Math.max(0, Number(e.target.value)))} + inputProps={{ min: 0, step: 1 }} + helperText={intl.formatMessage({ + id: 'Governance.Templates.RulesetBindings.order.helper', + defaultMessage: 'Lower = evaluated first', + })} + /> + + + {/* Key Manager scope */} + + + + + + + {isGlobalScope && ( + + + + )} + + + + ); +} + +BoundRulesetCard.propTypes = { + binding: PropTypes.shape({ + rulesetId: PropTypes.string.isRequired, + bindingOrder: PropTypes.number.isRequired, + keyManagerScopes: PropTypes.arrayOf(PropTypes.shape({ + keyManagerUuid: PropTypes.string.isRequired, + })).isRequired, + }).isRequired, + ruleset: PropTypes.shape({ + name: PropTypes.string.isRequired, + ruleType: PropTypes.string.isRequired, + }), + keyManagers: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + isGlobal: PropTypes.bool, + })).isRequired, + onOrderChange: PropTypes.func.isRequired, + onScopeChange: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, +}; + +BoundRulesetCard.defaultProps = { + ruleset: null, +}; + +// ─── Main component ─────────────────────────────────────────────────────────── + +/** + * Step 3 of the TemplateWizard. + * + * Dual-pane layout: + * Left — Searchable list of all available Spectral rulesets. "Add" button + * creates a new binding entry (disabled once already bound). + * Right — List of current bindings, each configurable with evaluation order + * and Key Manager scope. + * + * Dispatches to templateState.rulesetBindings on every change. + * + * @param {Object} props + * @param {Object} props.templateState - wizard state slice + * @param {Function} props.dispatch - reducer dispatch + */ +export default function RulesetBindingsStep({ templateState, dispatch }) { + const intl = useIntl(); + + const [allRulesets, setAllRulesets] = useState([]); + const [keyManagers, setKeyManagers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + + // ── Data fetch ──────────────────────────────────────────────────────────── + useEffect(() => { + const govApi = new GovernanceAPI(); + const adminApi = new API(); + + Promise.all([ + govApi.getRulesets({ limit: 200, offset: 0 }), + adminApi.getKeyManagersList(), + // Global KMs are visible to all tenant admins; gracefully ignore 403s + adminApi.getGlobalKeyManagersList().catch(() => ({ body: { list: [] } })), + ]) + .then(([rulesetRes, localKmRes, globalKmRes]) => { + setAllRulesets(rulesetRes.body.list || []); + + const localKms = (localKmRes.body.list || []).map((km) => ({ + ...km, + isGlobal: false, + })); + const globalKms = (globalKmRes.body.list || []).map((km) => ({ + ...km, + isGlobal: true, + })); + setKeyManagers([...localKms, ...globalKms]); + }) + .catch(() => { + Alert.error(intl.formatMessage({ + id: 'Governance.Templates.RulesetBindings.fetch.error', + defaultMessage: 'Failed to load rulesets or Key Managers', + })); + }) + .finally(() => setLoading(false)); + }, []); + + // ── Derived data ────────────────────────────────────────────────────────── + + // Fast lookup: rulesetId → RulesetInfo + const rulesetMap = useMemo( + () => Object.fromEntries(allRulesets.map((r) => [r.id, r])), + [allRulesets], + ); + + // Set of already-bound ruleset IDs for O(1) "is this bound?" check + const boundIds = useMemo( + () => new Set(templateState.rulesetBindings.map((b) => b.rulesetId)), + [templateState.rulesetBindings], + ); + + const filteredRulesets = useMemo( + () => allRulesets.filter((r) => r.name.toLowerCase().includes(search.toLowerCase())), + [allRulesets, search], + ); + + // ── Binding mutations — always return a new array to preserve immutability ─ + + const addBinding = (ruleset) => { + const newBinding = { + rulesetId: ruleset.id, + // Auto-assign next order; admin can reorder manually + bindingOrder: templateState.rulesetBindings.length, + keyManagerScopes: [], + }; + dispatch({ + field: 'rulesetBindings', + value: [...templateState.rulesetBindings, newBinding], + }); + }; + + const removeBinding = (rulesetId) => { + dispatch({ + field: 'rulesetBindings', + value: templateState.rulesetBindings + .filter((b) => b.rulesetId !== rulesetId) + // Re-compact binding orders after removal so there are no gaps + .map((b, idx) => ({ ...b, bindingOrder: idx })), + }); + }; + + const updateBinding = (rulesetId, patch) => { + dispatch({ + field: 'rulesetBindings', + value: templateState.rulesetBindings.map((b) => ( + b.rulesetId === rulesetId ? { ...b, ...patch } : b + )), + }); + }; + + // ── Render ──────────────────────────────────────────────────────────────── + + if (loading) { + return ( + + + + ); + } + + const { rulesetBindings } = templateState; + const sortedBindings = [...rulesetBindings].sort((a, b) => a.bindingOrder - b.bindingOrder); + + return ( + + + + + + + + + + + {/* ── Left pane: Available Rulesets ── */} + + + {/* Panel header */} + + + + + setSearch(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {/* Scrollable ruleset list */} + + {filteredRulesets.length === 0 ? ( + + + {search ? ( + + ) : ( + + )} + + + ) : ( + filteredRulesets.map((ruleset) => ( + addBinding(ruleset)} + /> + )) + )} + + + + + {/* ── Right pane: Bound Rulesets ── */} + + + {/* Panel header */} + + + + + 0 ? 'primary' : 'default'} + variant='outlined' + /> + + + {/* Scrollable bound list */} + + {rulesetBindings.length === 0 ? ( + + + + + + + + + + ) : ( + sortedBindings.map((binding) => ( + updateBinding(binding.rulesetId, { bindingOrder: order })} + onScopeChange={(scopes) => updateBinding(binding.rulesetId, { keyManagerScopes: scopes })} + onRemove={() => removeBinding(binding.rulesetId)} + /> + )) + )} + + + {/* Bound count summary footer */} + {rulesetBindings.length > 0 && ( + + + + + + + )} + + + + + ); +} + +RulesetBindingsStep.propTypes = { + templateState: PropTypes.shape({ + rulesetBindings: PropTypes.arrayOf(PropTypes.shape({ + rulesetId: PropTypes.string.isRequired, + bindingOrder: PropTypes.number.isRequired, + keyManagerScopes: PropTypes.arrayOf(PropTypes.shape({ + keyManagerUuid: PropTypes.string.isRequired, + })).isRequired, + })).isRequired, + }).isRequired, + dispatch: PropTypes.func.isRequired, +}; diff --git a/portals/admin/src/main/webapp/source/src/app/data/GovernanceAPI.js b/portals/admin/src/main/webapp/source/src/app/data/GovernanceAPI.js index b36e9ca8649..3514398dbd4 100644 --- a/portals/admin/src/main/webapp/source/src/app/data/GovernanceAPI.js +++ b/portals/admin/src/main/webapp/source/src/app/data/GovernanceAPI.js @@ -334,6 +334,92 @@ class GovernanceAPI extends Resource { ); }); } + + /** + * Get list of Devportal Governance templates + * @param {Object} [params] Optional query parameters (limit, offset) + * @returns {Promise} Promised templates list response + */ + getDevportalGovernanceTemplates(params = {}) { + return this.client.then((client) => { + return client.apis['Devportal Governance Templates'].getDevportalGovernanceTemplates( + params, + this._requestMetaData(), + ); + }); + } + + /** + * Get the default Devportal Governance template for the organization + * @returns {Promise} Promised default template response + */ + getDefaultDevportalGovernanceTemplate() { + return this.client.then((client) => { + return client.apis['Devportal Governance Templates'].getDefaultDevportalGovernanceTemplate( + {}, + this._requestMetaData(), + ); + }); + } + + /** + * Get a Devportal Governance template by id + * @param {string} templateId Template id + * @returns {Promise} Promised template response + */ + getDevportalGovernanceTemplateById(templateId) { + return this.client.then((client) => { + return client.apis['Devportal Governance Templates'].getDevportalGovernanceTemplateById( + { templateId }, + this._requestMetaData(), + ); + }); + } + + /** + * Create a new Devportal Governance template + * @param {Object} template Template object + * @returns {Promise} Promised created template response + */ + createDevportalGovernanceTemplate(template) { + return this.client.then((client) => { + return client.apis['Devportal Governance Templates'].createDevportalGovernanceTemplate( + { 'Content-Type': 'application/json' }, + { requestBody: template }, + this._requestMetaData(), + ); + }); + } + + /** + * Update a Devportal Governance template by id + * @param {string} templateId Template id + * @param {Object} template Updated template object + * @returns {Promise} Promised updated template response + */ + updateDevportalGovernanceTemplateById(templateId, template) { + return this.client.then((client) => { + return client.apis['Devportal Governance Templates'].updateDevportalGovernanceTemplateById( + { templateId, 'Content-Type': 'application/json' }, + { requestBody: template }, + this._requestMetaData(), + ); + }); + } + + /** + * Delete a Devportal Governance template by id + * @param {string} templateId Template id + * @returns {Promise} Promised delete response + */ + deleteDevportalGovernanceTemplate(templateId) { + return this.client.then((client) => { + return client.apis['Devportal Governance Templates'].deleteDevportalGovernanceTemplate( + { templateId }, + this._requestMetaData(), + ); + }); + } } export default GovernanceAPI; diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/ApplicationFormHandler.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/ApplicationFormHandler.jsx index 5aa396fac58..5cbd89302af 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Applications/ApplicationFormHandler.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/ApplicationFormHandler.jsx @@ -35,6 +35,7 @@ import Progress from 'AppComponents/Shared/Progress'; import { app } from 'Settings'; import isEqual from 'lodash.isequal'; import ApplicationCreateBase from './Create/ApplicationCreateBase'; +import TemplateSelector from './Create/TemplateSelector'; const PREFIX = 'ApplicationFormHandler'; @@ -84,6 +85,8 @@ class ApplicationFormHandler extends React.Component { isOrgAccessControlEnabled: false, applicationOwner: '', isOrgWideAppUpdateEnabled: false, + // null = template selector shown; false = skipped; object = template chosen + selectedTemplate: null, }; this.handleAddChip = this.handleAddChip.bind(this); this.handleDeleteChip = this.handleDeleteChip.bind(this); @@ -323,11 +326,17 @@ class ApplicationFormHandler extends React.Component { .catch((error) => { const { response } = error; if (response && response.body) { - const message = response.body.description || intl.formatMessage({ - defaultMessage: 'Error while creating the application', - id: 'Applications.Create.ApplicationFormHandler.error.while.creating.the.application', - }); - Alert.error(message); + const { error: violations } = response.body; + if (Array.isArray(violations) && violations.length > 0) { + // Governance backend returns Spectral rule violations in error[] + violations.forEach((v) => Alert.error(v.message || v.description)); + } else { + const message = response.body.description || intl.formatMessage({ + defaultMessage: 'Error while creating the application', + id: 'Applications.Create.ApplicationFormHandler.error.while.creating.the.application', + }); + Alert.error(message); + } } else { Alert.error(error.message); } @@ -453,6 +462,26 @@ class ApplicationFormHandler extends React.Component { this.setState({ isOrgAccessControlEnabled: enabled }); } + /** + * Called when the developer selects a template in TemplateSelector. + * Applies the template's application-section defaults into applicationRequest, + * then advances past the selector to show the create form. + * @param {Object} template - The selected governance template object + */ + handleTemplateSelect = (template) => { + const appConfig = template?.formConfig?.application ?? {}; + this.setState((prevState) => { + const newRequest = { ...prevState.applicationRequest }; + if (appConfig.throttlingPolicy?.defaultValue) { + newRequest.throttlingPolicy = appConfig.throttlingPolicy.defaultValue; + } + if (appConfig.tokenType?.defaultValue) { + newRequest.tokenType = appConfig.tokenType.defaultValue; + } + return { selectedTemplate: template, applicationRequest: newRequest }; + }); + } + /** * Check whether there are any changes in the form */ @@ -469,10 +498,35 @@ class ApplicationFormHandler extends React.Component { render() { const { throttlingPolicyList, applicationRequest, isNameValid, allAppAttributes, isApplicationSharingEnabled, - isEdit, applicationOwner, isOrgWideAppUpdateEnabled, isOrgAccessControlEnabled, + isEdit, applicationOwner, isOrgWideAppUpdateEnabled, isOrgAccessControlEnabled, selectedTemplate, } = this.state; const { match: { params } } = this.props; + // Template selection gate: only for new applications, not edits. + // selectedTemplate===null means not yet decided; false means skipped; object means chosen. + if (!isEdit && selectedTemplate === null) { + return ( + + + + )} + > + + + this.setState({ selectedTemplate: false })} + /> + + + + ); + } + const CreatePageTitle = ( <> @@ -545,6 +599,7 @@ class ApplicationFormHandler extends React.Component { isOrgAccessControlEnabled={isOrgAccessControlEnabled} handleDeleteChip={this.handleDeleteChip} handleAddChip={this.handleAddChip} + formConfig={selectedTemplate?.formConfig ?? {}} enable /> diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplateSelector.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplateSelector.jsx new file mode 100644 index 00000000000..2ad4ba928d9 --- /dev/null +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplateSelector.jsx @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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 React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + Chip, + CircularProgress, + Grid, + Typography, +} from '@mui/material'; +import API from 'AppData/api'; + +/** + * Intercept the "Add Application" flow and let the developer choose a Governance Template + * before the create form is shown. + * + * Fail-open contract: if no published templates are available (empty list or fetch error), + * onSkip() is called automatically so the standard un-governed form is presented. + * + * @param {Object} props + * @param {Function} props.onSelect - Called with the full template object when the user picks one + * @param {Function} props.onSkip - Called when no templates exist or the fetch fails + */ +export default function TemplateSelector({ onSelect, onSkip }) { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + new API() + .getDevportalGovernanceTemplates({ limit: 100, offset: 0 }) + .then((res) => { + const list = res.body?.list ?? []; + if (list.length === 0) { + onSkip(); + } else { + setTemplates(list); + } + }) + .catch(() => { + // Fail open — governance unavailable should never block app creation + onSkip(); + }) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( + + + + ); + } + + // If templates is empty after load, onSkip was already called — render nothing + if (templates.length === 0) { + return null; + } + + return ( + + + + + + + + + + {templates.map((template) => ( + + + + {template.name} + + )} + action={template.isGlobal ? ( + + )} + size='small' + color='secondary' + variant='outlined' + sx={{ mt: 1, mr: 1 }} + /> + ) : null} + sx={{ pb: 0 }} + /> + + + {template.description || ( + + )} + + + + + + + + ))} + + + ); +} + +TemplateSelector.propTypes = { + onSelect: PropTypes.func.isRequired, + onSkip: PropTypes.func.isRequired, +}; diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/ApplicationCreateForm.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/ApplicationCreateForm.jsx index 87a4d20fc1d..4596412381b 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/ApplicationCreateForm.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/ApplicationCreateForm.jsx @@ -137,7 +137,10 @@ const ApplicationCreate = (props) => { isOrgAccessControlEnabled, handleAddChip, handleDeleteChip, + formConfig, } = props; + + const isHidden = (fieldKey) => formConfig?.application?.[fieldKey]?.hidden === true; const description = applicationRequest.description || ''; const showDescError = () => { const descLength = description.length; @@ -186,46 +189,48 @@ const ApplicationCreate = (props) => { }) }} /> - - )} - value={applicationRequest.throttlingPolicy} - name='throttlingPolicy' - onChange={handleChange} - helperText={( - + )} + value={applicationRequest.throttlingPolicy} + name='throttlingPolicy' + onChange={handleChange} + helperText={( + - )} - margin='normal' - variant='outlined' - inputProps={{ - alt: intl.formatMessage({ - defaultMessage: 'Required', - id: 'Shared.AppsAndKeys.ApplicationCreateForm.required.alt', - }) - }} - > - {throttlingPolicyList.map((policy) => ( - - {policy} - - ))} - + id='Shared.AppsAndKeys.ApplicationCreateForm.assign.api.request' + /> + )} + margin='normal' + variant='outlined' + inputProps={{ + alt: intl.formatMessage({ + defaultMessage: 'Required', + id: 'Shared.AppsAndKeys.ApplicationCreateForm.required.alt', + }) + }} + > + {throttlingPolicyList.map((policy) => ( + + {policy} + + ))} + + )} { }; ApplicationCreate.defaultProps = { ApplicationCreate: null, + formConfig: {}, }; ApplicationCreate.propTypes = { classes: PropTypes.shape({}).isRequired, + formConfig: PropTypes.shape({}), applicationRequest: PropTypes.shape({}).isRequired, intl: PropTypes.shape({}).isRequired, isNameValid: PropTypes.bool.isRequired, diff --git a/portals/devportal/src/main/webapp/source/src/app/data/api.jsx b/portals/devportal/src/main/webapp/source/src/app/data/api.jsx index fd18d76770c..509813fdd3d 100644 --- a/portals/devportal/src/main/webapp/source/src/app/data/api.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/data/api.jsx @@ -18,6 +18,7 @@ import APIClientFactory from './APIClientFactory'; import Resource from './Resource'; import Wsdl from './Wsdl'; import Utils from './Utils'; +import AuthManager from './AuthManager'; /** * An abstract representation of an API @@ -1286,4 +1287,54 @@ export default class API extends Resource { return client.apis.Users.organizationInformation(this._requestMetaData()); }); } + + /** + * Fetch published Devportal Governance Templates from the governance REST API. + * The governance API lives outside the devportal swagger spec, so this method + * uses a direct fetch() with the same auth headers the swagger-client interceptor uses. + * Only PUBLISHED templates are returned; the list is empty on any error so callers + * can fail open to the un-governed form. + * + * @param {Object} params - Optional query params: { limit, offset } + * @returns {Promise<{body: {list: Array}}>} Resolves to swagger-client-shaped response + */ + getDevportalGovernanceTemplates(params = {}) { + const user = AuthManager.getUser(Utils.getEnvironment().label); + const token = user ? user.getPartialToken() : ''; + + const queryParams = new URLSearchParams({ + limit: params.limit ?? 25, + offset: params.offset ?? 0, + }); + + // Mirror the tenant header logic from APIClient._getRequestInterceptor + const headers = { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + if (window.location) { + const search = new URLSearchParams(window.location.search); + const tenant = search.get('tenant'); + if (tenant) headers['X-WSO2-Tenant'] = tenant; + } + + return fetch(`/api/am/governance/v1/templates?${queryParams}`, { + credentials: 'include', + headers, + }) + .then((res) => { + if (!res.ok) { + const err = new Error(res.statusText); + err.status = res.status; + throw err; + } + return res.json(); + }) + .then((json) => ({ + body: { + list: (json.list ?? []).filter((t) => t.status === 'PUBLISHED'), + count: json.count ?? 0, + }, + })); + } } From d18ea158348ac69f240d4373ca0b031ada59b336 Mon Sep 17 00:00:00 2001 From: ashiduDissanayake Date: Mon, 27 Apr 2026 21:28:30 +0530 Subject: [PATCH 2/3] feat: implement devportal dynamic form rendering for subscriptions and key generation --- .../main/webapp/site/public/locales/en.json | 130 ++++++++++++++++++ .../Governance/Templates/TemplateWizard.jsx | 1 - .../Templates/steps/FormBuilderStep.jsx | 46 +++++-- .../Templates/steps/GeneralDetailsStep.jsx | 10 +- .../Templates/steps/ReviewPublishStep.jsx | 10 +- .../Templates/steps/RulesetBindingsStep.jsx | 15 +- .../main/webapp/site/public/locales/en.json | 5 + .../Details/SubscriptionSection.jsx | 4 + .../Details/SubscriptionTableData.jsx | 30 +++- .../Applications/Details/Subscriptions.jsx | 8 ++ .../components/Applications/Details/index.jsx | 27 +++- .../Shared/AppsAndKeys/KeyConfiguration.jsx | 97 +++++++------ .../Shared/AppsAndKeys/TokenManager.jsx | 4 + .../main/webapp/source/src/app/data/api.jsx | 36 +++++ 14 files changed, 354 insertions(+), 69 deletions(-) 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..105acb071f6 100644 --- a/portals/admin/src/main/webapp/site/public/locales/en.json +++ b/portals/admin/src/main/webapp/site/public/locales/en.json @@ -442,6 +442,9 @@ "Base.RouteMenuMapping.gateways.items.Editing": "Edit Gateway Environment", "Base.RouteMenuMapping.governance": "Governance", "Base.RouteMenuMapping.governance.policies": "Policies", + "Base.RouteMenuMapping.governance.templates": "Templates", + "Base.RouteMenuMapping.governance.templates.create": "Create Template", + "Base.RouteMenuMapping.governance.templates.edit": "Edit Template", "Base.RouteMenuMapping.keymanagers": "Key Managers", "Base.RouteMenuMapping.keymanagers.items.Adding": "Add Key Manager", "Base.RouteMenuMapping.keymanagers.items.Editing": "Edit Key Manager", @@ -832,6 +835,133 @@ "Governance.Rulesets.List.help.link": "Create and Manage Rulesets", "Governance.Rulesets.List.search.placeholder": "Search rulesets by name or type", "Governance.Rulesets.List.title": "Ruleset Catalog", + "Governance.Templates.Delete.confirmation": "Are you sure you want to delete this Template? This action cannot be undone.", + "Governance.Templates.Delete.dialog.btn": "Delete", + "Governance.Templates.Delete.dialog.title": "Delete Template?", + "Governance.Templates.Delete.error": "Something went wrong while deleting the Template", + "Governance.Templates.Delete.readOnly.tooltip": "Global templates can only be deleted by Super Tenant admins", + "Governance.Templates.Delete.success": "Template deleted successfully", + "Governance.Templates.FormBuilder.app.callbackUrl.desc": "OAuth redirect URI; required for Authorization Code / Implicit flows", + "Governance.Templates.FormBuilder.app.callbackUrl.label": "Callback URL", + "Governance.Templates.FormBuilder.app.throttlingPolicy.desc": "Rate limit tier applied to the application", + "Governance.Templates.FormBuilder.app.throttlingPolicy.label": "Throttling Policy", + "Governance.Templates.FormBuilder.app.tokenType.desc": "JWT for self-contained tokens; Opaque for reference tokens", + "Governance.Templates.FormBuilder.app.tokenType.label": "Token Type", + "Governance.Templates.FormBuilder.header.default": "Default Value", + "Governance.Templates.FormBuilder.header.field": "Field", + "Governance.Templates.FormBuilder.header.visibility": "Visibility", + "Governance.Templates.FormBuilder.heading": "Form Builder", + "Governance.Templates.FormBuilder.hiddenAutoApply": "Applied automatically — developer will not see this field", + "Governance.Templates.FormBuilder.hide.label": "Hide from Developer", + "Governance.Templates.FormBuilder.keyGen.additionalProps.desc": "PKCE and other Key Manager-specific settings; defaults are managed by the Key Manager configuration", + "Governance.Templates.FormBuilder.keyGen.additionalProps.label": "Additional Properties (PKCE)", + "Governance.Templates.FormBuilder.keyGen.grantTypes.desc": "Allowed OAuth2 grant flows; hiding locks developers to the defaults selected here", + "Governance.Templates.FormBuilder.keyGen.grantTypes.label": "Grant Types", + "Governance.Templates.FormBuilder.keyGen.grantTypes.placeholder": "Select grant types…", + "Governance.Templates.FormBuilder.keyGen.keyType.desc": "Production keys are used for live traffic; Sandbox for testing", + "Governance.Templates.FormBuilder.keyGen.keyType.label": "Key Type", + "Governance.Templates.FormBuilder.keyGen.validity.desc": "Token expiry in seconds; enter -1 for unlimited", + "Governance.Templates.FormBuilder.keyGen.validity.label": "Token Validity Period", + "Governance.Templates.FormBuilder.keyGen.validity.unlimited": "Token will not expire", + "Governance.Templates.FormBuilder.noDefault.text": "Managed by Key Manager", + "Governance.Templates.FormBuilder.noDefault.tooltip": "Default value is managed by the Key Manager configuration", + "Governance.Templates.FormBuilder.policiesFetch.error": "Failed to load throttling policies. Dropdowns may be empty.", + "Governance.Templates.FormBuilder.section.application": "Application Metadata", + "Governance.Templates.FormBuilder.section.application.desc": "Fields shown on the \"Create Application\" form", + "Governance.Templates.FormBuilder.section.keyGen": "Key Generation", + "Governance.Templates.FormBuilder.section.keyGen.desc": "Fields shown on the \"Generate Keys\" panel", + "Governance.Templates.FormBuilder.section.subscription": "Subscriptions", + "Governance.Templates.FormBuilder.section.subscription.desc": "Fields shown on the \"Subscribe to API\" dialog", + "Governance.Templates.FormBuilder.select.noDefault": "— No default —", + "Governance.Templates.FormBuilder.sub.throttlingPolicy.desc": "Rate limit tier applied per API subscription", + "Governance.Templates.FormBuilder.sub.throttlingPolicy.label": "Subscription Throttling Policy", + "Governance.Templates.FormBuilder.subheading": "Configure which fields developers see in the application creation wizard and set organization-wide defaults for hidden fields.", + "Governance.Templates.List.add.title": "Create Template", + "Governance.Templates.List.add.triggerButtonText": "Create Template", + "Governance.Templates.List.btn.create": "Create Template", + "Governance.Templates.List.column.default": "Default", + "Governance.Templates.List.column.scope": "Scope", + "Governance.Templates.List.column.status": "Status", + "Governance.Templates.List.column.template": "Template", + "Governance.Templates.List.description": "Create and manage templates that configure the Devportal application creation workflow and bind governance rulesets to enforce developer policies.", + "Governance.Templates.List.edit.title": "Edit Template", + "Governance.Templates.List.empty.content": "Templates configure the Devportal application wizard and enforce ruleset bindings on developers. Click Create Template to get started.", + "Governance.Templates.List.empty.title": "Devportal Governance Templates", + "Governance.Templates.List.help.link": "Create and Manage Templates", + "Governance.Templates.List.search.placeholder": "Search templates by name", + "Governance.Templates.List.title": "Devportal Governance Templates", + "Governance.Templates.ReviewPublish.chip.default": "Default", + "Governance.Templates.ReviewPublish.chip.global": "Global", + "Governance.Templates.ReviewPublish.field.hidden": "Hidden", + "Governance.Templates.ReviewPublish.field.hiddenFromUser": "Field hidden from user", + "Governance.Templates.ReviewPublish.field.managedByKM": "Managed by Key Manager", + "Governance.Templates.ReviewPublish.field.noDefault": "No default", + "Governance.Templates.ReviewPublish.field.visible": "Visible", + "Governance.Templates.ReviewPublish.flags.none": "None", + "Governance.Templates.ReviewPublish.heading": "Review & Publish", + "Governance.Templates.ReviewPublish.label.description": "Description", + "Governance.Templates.ReviewPublish.label.flags": "Flags", + "Governance.Templates.ReviewPublish.label.name": "Template Name", + "Governance.Templates.ReviewPublish.label.noDescription": "No description provided", + "Governance.Templates.ReviewPublish.label.status": "Status", + "Governance.Templates.ReviewPublish.rulesets.col.name": "Ruleset", + "Governance.Templates.ReviewPublish.rulesets.col.order": "Order", + "Governance.Templates.ReviewPublish.rulesets.col.scope": "Key Manager Scope", + "Governance.Templates.ReviewPublish.rulesets.loading": "Resolving ruleset names…", + "Governance.Templates.ReviewPublish.rulesets.none": "No rulesets bound to this template", + "Governance.Templates.ReviewPublish.rulesets.scope.all": "All Key Managers", + "Governance.Templates.ReviewPublish.rulesets.scope.count": "{count} Key Manager(s)", + "Governance.Templates.ReviewPublish.section.formConfig": "Form Configuration", + "Governance.Templates.ReviewPublish.section.general": "General Details", + "Governance.Templates.ReviewPublish.section.payload": "Raw JSON Payload", + "Governance.Templates.ReviewPublish.section.rulesets": "Ruleset Bindings", + "Governance.Templates.ReviewPublish.subheading": "Review the template configuration below. Use the Back button to make changes.", + "Governance.Templates.RulesetBindings.add.tooltip": "Add to template", + "Governance.Templates.RulesetBindings.available.empty": "No rulesets found. Create rulesets in the Ruleset Catalog first.", + "Governance.Templates.RulesetBindings.available.heading": "Available Rulesets", + "Governance.Templates.RulesetBindings.bound.chip": "Bound", + "Governance.Templates.RulesetBindings.bound.empty.hint": "Click the + button next to a ruleset on the left to add it", + "Governance.Templates.RulesetBindings.bound.empty.title": "No rulesets bound yet", + "Governance.Templates.RulesetBindings.bound.heading": "Bound Rulesets", + "Governance.Templates.RulesetBindings.clearAll.btn": "Clear all", + "Governance.Templates.RulesetBindings.fetch.error": "Failed to load rulesets or Key Managers", + "Governance.Templates.RulesetBindings.footer.summary": "{count} ruleset(s) will be snapshotted at application creation", + "Governance.Templates.RulesetBindings.heading": "Ruleset Bindings", + "Governance.Templates.RulesetBindings.order.helper": "Lower = evaluated first", + "Governance.Templates.RulesetBindings.order.label": "Evaluation Order", + "Governance.Templates.RulesetBindings.remove.tooltip": "Remove binding", + "Governance.Templates.RulesetBindings.scope.allKm": "All Key Managers", + "Governance.Templates.RulesetBindings.scope.allKm.desc": "Ruleset applies regardless of which Key Manager issues the token", + "Governance.Templates.RulesetBindings.scope.allKm.option": "All Key Managers (Global)", + "Governance.Templates.RulesetBindings.scope.global.hint": "Select specific Key Managers to restrict when this ruleset is enforced", + "Governance.Templates.RulesetBindings.scope.label": "Key Manager Scope", + "Governance.Templates.RulesetBindings.scope.noKm": "No Key Managers configured", + "Governance.Templates.RulesetBindings.search.empty": "No rulesets match your search", + "Governance.Templates.RulesetBindings.search.placeholder": "Search rulesets…", + "Governance.Templates.RulesetBindings.subheading": "Bind Spectral rulesets to this template. Bound rulesets are snapshotted at application-creation time and enforced synchronously by the governance interceptor.", + "Governance.Templates.Wizard.GeneralDetails.description.helper": "Briefly describe the purpose and intended audience of this template", + "Governance.Templates.Wizard.GeneralDetails.description.label": "Description", + "Governance.Templates.Wizard.GeneralDetails.heading": "General Details", + "Governance.Templates.Wizard.GeneralDetails.isDefault.helper": "Applied as the fallback when a developer does not explicitly select a template", + "Governance.Templates.Wizard.GeneralDetails.isDefault.label": "Set as Default Template", + "Governance.Templates.Wizard.GeneralDetails.isGlobal.helper": "Visible to all organizations as a cross-tenant fallback; only manageable by Super Tenant admins", + "Governance.Templates.Wizard.GeneralDetails.isGlobal.label": "Set as Global Template", + "Governance.Templates.Wizard.GeneralDetails.name.helper": "A unique, human-readable name for this template", + "Governance.Templates.Wizard.GeneralDetails.name.label": "Template Name", + "Governance.Templates.Wizard.GeneralDetails.name.required": "Template name is required", + "Governance.Templates.Wizard.GeneralDetails.options.heading": "Template Options", + "Governance.Templates.Wizard.GeneralDetails.status.draft": "Draft", + "Governance.Templates.Wizard.GeneralDetails.status.helper": "Only PUBLISHED templates are visible to Devportal users", + "Governance.Templates.Wizard.GeneralDetails.status.label": "Status", + "Governance.Templates.Wizard.GeneralDetails.status.published": "Published", + "Governance.Templates.Wizard.GeneralDetails.subheading": "Provide basic information about this template and its lifecycle state.", + "Governance.Templates.Wizard.btn.back": "Back", + "Governance.Templates.Wizard.btn.cancel": "Cancel", + "Governance.Templates.Wizard.btn.next": "Next", + "Governance.Templates.Wizard.load.error": "Failed to load template", + "Governance.Templates.Wizard.save.error": "Failed to save template", + "Governance.Templates.Wizard.title.create": "Create Template", + "Governance.Templates.Wizard.title.edit": "Edit Template", "KeyManager.AddEdit.Invalid.Roles.Found": "Invalid Role(s) Found", "KeyManager.AddEdit.roles.help": "Enter a valid role and press `Enter`", "KeyManager.AddEditKeyManager.permissions.add.description": "Permissions for the Key Manager", diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/TemplateWizard.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/TemplateWizard.jsx index 06cde117975..dacb79567d1 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/TemplateWizard.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/TemplateWizard.jsx @@ -27,7 +27,6 @@ import { Step, StepLabel, Stepper, - Typography, } from '@mui/material'; import ContentBase from 'AppComponents/AdminPages/Addons/ContentBase'; import Alert from 'AppComponents/Shared/Alert'; diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/FormBuilderStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/FormBuilderStep.jsx index b6fc77585aa..dccf351056c 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/FormBuilderStep.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/FormBuilderStep.jsx @@ -76,8 +76,8 @@ function FieldRow({ description, hidden, onToggleHidden, - defaultInput, // JSX for the default-value control - noDefault, // true = no default editor exists (e.g. additionalProperties) + defaultInput, // JSX for the default-value control + noDefault, // true = no default editor exists (e.g. additionalProperties) }) { const intl = useIntl(); return ( @@ -136,7 +136,12 @@ function FieldRow({ defaultMessage: 'Default value is managed by the Key Manager configuration', })} > - + updateField('application', 'throttlingPolicy', 'defaultValue', e.target.value)} + onChange={(e) => updateField( + 'application', 'throttlingPolicy', 'defaultValue', e.target.value, + )} > @@ -410,7 +417,9 @@ export default function FormBuilderStep({ templateState, dispatch }) { fullWidth size='small' value={appTokenType.defaultValue} - onChange={(e) => updateField('application', 'tokenType', 'defaultValue', e.target.value)} + onChange={(e) => updateField( + 'application', 'tokenType', 'defaultValue', e.target.value, + )} > {TOKEN_TYPES.map((t) => ( {t.label} @@ -441,7 +450,9 @@ export default function FormBuilderStep({ templateState, dispatch }) { size='small' placeholder='https://example.com/callback' value={appCallbackUrl.defaultValue} - onChange={(e) => updateField('application', 'callbackUrl', 'defaultValue', e.target.value)} + onChange={(e) => updateField( + 'application', 'callbackUrl', 'defaultValue', e.target.value, + )} /> )} /> @@ -491,7 +502,9 @@ export default function FormBuilderStep({ templateState, dispatch }) { size='small' displayEmpty value={subThrottling.defaultValue} - onChange={(e) => updateField('subscription', 'throttlingPolicy', 'defaultValue', e.target.value)} + onChange={(e) => updateField( + 'subscription', 'throttlingPolicy', 'defaultValue', e.target.value, + )} > @@ -552,7 +565,9 @@ export default function FormBuilderStep({ templateState, dispatch }) { fullWidth size='small' value={keyGenKeyType.defaultValue} - onChange={(e) => updateField('keyGeneration', 'keyType', 'defaultValue', e.target.value)} + onChange={(e) => updateField( + 'keyGeneration', 'keyType', 'defaultValue', e.target.value, + )} > {KEY_TYPES.map((k) => ( {k.label} @@ -584,7 +599,9 @@ export default function FormBuilderStep({ templateState, dispatch }) { size='small' displayEmpty value={grantTypesValue} - onChange={(e) => updateField('keyGeneration', 'grantTypes', 'defaultValue', e.target.value)} + onChange={(e) => updateField( + 'keyGeneration', 'grantTypes', 'defaultValue', e.target.value, + )} input={} renderValue={(selected) => { if (selected.length === 0) { @@ -598,7 +615,12 @@ export default function FormBuilderStep({ templateState, dispatch }) { ); } return ( - + {selected.map((val) => { const gt = GRANT_TYPES.find((g) => g.value === val); return ( @@ -623,7 +645,7 @@ export default function FormBuilderStep({ templateState, dispatch }) { color='text.secondary' sx={{ ml: 1 }} > - ({gt.value}) + {`(${gt.value})`} ))} @@ -696,7 +718,7 @@ export default function FormBuilderStep({ templateState, dispatch }) { FormBuilderStep.propTypes = { templateState: PropTypes.shape({ - formConfig: PropTypes.object.isRequired, + formConfig: PropTypes.shape({}).isRequired, }).isRequired, dispatch: PropTypes.func.isRequired, }; diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/GeneralDetailsStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/GeneralDetailsStep.jsx index 1a97327286d..4bf17a5a10d 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/GeneralDetailsStep.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/GeneralDetailsStep.jsx @@ -178,7 +178,10 @@ export default function GeneralDetailsStep({ templateState, dispatch }) { @@ -209,7 +212,10 @@ export default function GeneralDetailsStep({ templateState, dispatch }) { diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx index 69f927f7f0b..7a8c4ae9760 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx @@ -302,7 +302,11 @@ export default function ReviewPublishStep({ templateState }) { defaultMessage: 'Description', })} > - + {description || intl.formatMessage({ id: 'Governance.Templates.ReviewPublish.label.noDescription', defaultMessage: 'No description provided', @@ -567,7 +571,7 @@ ReviewPublishStep.propTypes = { status: PropTypes.string.isRequired, isDefault: PropTypes.bool.isRequired, isGlobal: PropTypes.bool.isRequired, - formConfig: PropTypes.object.isRequired, - rulesetBindings: PropTypes.array.isRequired, + formConfig: PropTypes.shape({}).isRequired, + rulesetBindings: PropTypes.arrayOf(PropTypes.shape({})).isRequired, }).isRequired, }; diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx index 89a36ed8a79..7f0f7b7304b 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx @@ -88,7 +88,12 @@ function AvailableRulesetRow({ ruleset, isBound, onAdd }) { > {ruleset.name} - + updateBinding(binding.rulesetId, { bindingOrder: order })} - onScopeChange={(scopes) => updateBinding(binding.rulesetId, { keyManagerScopes: scopes })} + onOrderChange={(order) => updateBinding( + binding.rulesetId, { bindingOrder: order }, + )} + onScopeChange={(scopes) => updateBinding( + binding.rulesetId, { keyManagerScopes: scopes }, + )} onRemove={() => removeBinding(binding.rulesetId)} /> )) 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..b9b86df1567 100644 --- a/portals/devportal/src/main/webapp/site/public/locales/en.json +++ b/portals/devportal/src/main/webapp/site/public/locales/en.json @@ -532,6 +532,11 @@ "Applications.Create.ApplicationFormHandler.error.while.creating.the.application": "Error while creating the application", "Applications.Create.ApplicationFormHandler.save": "SAVE", "Applications.Create.Listing.add.new.application": "Add New Application", + "Applications.Create.TemplateSelector.btn.select": "Select", + "Applications.Create.TemplateSelector.card.noDescription": "No description provided.", + "Applications.Create.TemplateSelector.chip.global": "Global Template", + "Applications.Create.TemplateSelector.heading": "Choose a Template", + "Applications.Create.TemplateSelector.subheading": "Select a governance template to configure your application. The template defines default settings and the policies that will be enforced.", "Applications.Details.InfoBar.application.deleted.successfully": "In Application {name} deleted successfully!", "Applications.Details.InfoBar.application.deleting.error": "Error while deleting application {name}", "Applications.Details.InfoBar.business.plan": "Business Plan", diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionSection.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionSection.jsx index 6a6ace3e9ce..cddb8f9394f 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionSection.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionSection.jsx @@ -170,6 +170,7 @@ const SubscriptionSection = ({ noSubscriptionsMessage, noSubscriptionsContent, entityNameColumn, + formConfig, ...otherProps }) => { return ( @@ -261,6 +262,7 @@ const SubscriptionSection = ({ subscription={subscription} handleSubscriptionDelete={handleSubscriptionDelete} handleSubscriptionUpdate={handleSubscriptionUpdate} + formConfig={formConfig} /> ); })} @@ -288,12 +290,14 @@ SubscriptionSection.propTypes = { noSubscriptionsMessage: PropTypes.node.isRequired, noSubscriptionsContent: PropTypes.node.isRequired, entityNameColumn: PropTypes.node.isRequired, + formConfig: PropTypes.shape({}), }; SubscriptionSection.defaultProps = { subscriptions: [], subscriptionsNotFound: false, pseudoSubscriptions: false, + formConfig: null, }; export default SubscriptionSection; diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionTableData.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionTableData.jsx index 0fef993a51c..5b6025668d4 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionTableData.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionTableData.jsx @@ -102,11 +102,16 @@ class SubscriptionTableData extends React.Component { * @memberof SubscriptionTableData */ componentDidMount() { - const { subscription } = this.props; + const { subscription, formConfig } = this.props; this.checkIfWebhookAPI(); this.checkIfMonetizedAPI(subscription.apiId); this.checkIfDynamicUsagePolicy(subscription.subscriptionId); this.populateSubscriptionTiers(subscription.apiId); + // If the template hides the tier selector, pre-lock selectedTier to the forced defaultValue + const subTierCfg = formConfig?.subscription?.throttlingPolicy; + if (subTierCfg?.hidden && subTierCfg?.defaultValue) { + this.setState({ selectedTier: subTierCfg.defaultValue }); + } } /** @@ -292,6 +297,9 @@ class SubscriptionTableData extends React.Component { const { openMenu, isMonetizedAPI, isDynamicUsagePolicy, openMenuEdit, selectedTier, tiers, isWebhookAPI, callbackLinkAnchor, } = this.state; + const { formConfig } = this.props; + const subTierCfg = formConfig?.subscription?.throttlingPolicy; + const isSubTierHidden = !!subTierCfg?.hidden; const isSubValidationDisabled = tiers && tiers.length === 1 && tiers[0].value.includes(CONSTANTS.DEFAULT_SUBSCRIPTIONLESS_PLAN); const link = ( @@ -458,7 +466,21 @@ class SubscriptionTableData extends React.Component { + ' before editing the tier'} /> ) - : ( + : isSubTierHidden ? ( + + + + + + + ) : (
{ const client = new API(); const applicationId = this.props.match.params.application_uuid; - const promisedApplication = client.getApplication(applicationId); - promisedApplication + client.getApplication(applicationId) .then((response) => { - this.setState({ application: response.obj }); + const application = response.obj; + this.setState({ application }); + // If this app was created with a governance template, load its formConfig + if (application.templateId) { + client.getDevportalGovernanceTemplateById(application.templateId) + .then((templateRes) => { + this.setState({ formConfig: templateRes.body.formConfig ?? null }); + }) + .catch(() => { + // Fail open — unresolvable template leaves formConfig null + }); + } return Promise.all([response]); }) .catch((error) => { @@ -235,11 +246,13 @@ class Details extends Component { }; renderManager = (application, keyType, secScheme) => { + const { formConfig } = this.state; return ( {secScheme === 'oauth' && ( ( - + )} /> diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/KeyConfiguration.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/KeyConfiguration.jsx index c60c9d167cf..748a03f9cd9 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/KeyConfiguration.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/KeyConfiguration.jsx @@ -179,8 +179,19 @@ const KeyConfiguration = (props) => { const intl = useIntl(); const { notFound, isUserOwner, keyManagerConfig, updateKeyRequest, keyRequest, updateHasError, callbackError, mode, - selectedApp, keyValue, + selectedApp, keyValue, formConfig, } = props; + + // When the template hides grant types, lock the keyRequest to the forced defaultValue + useEffect(() => { + const kgCfg = formConfig?.keyGeneration; + if (!kgCfg?.grantTypes?.hidden) return; + const locked = Array.isArray(kgCfg.grantTypes.defaultValue) ? kgCfg.grantTypes.defaultValue : []; + if (keyRequest.selectedGrantTypes === null + || JSON.stringify(keyRequest.selectedGrantTypes) !== JSON.stringify(locked)) { + updateKeyRequest({ ...keyRequest, selectedGrantTypes: locked }); + } + }, [formConfig]); // eslint-disable-line react-hooks/exhaustive-deps const { selectedGrantTypes, callbackUrl, } = keyRequest; @@ -483,48 +494,50 @@ const KeyConfiguration = (props) => { {mode !== 'MAPPED' && (() => { const advancedConfigurations = ( <> - {/* Grant Types */} - - - - - -
- {Object.keys(grantTypeDisplayListMap).map((key) => { - const value = grantTypeDisplayListMap[key]; - return ( - handleChange('grantType', e)} - value={value} - disabled={!isOrgWideAppUpdateEnabled && !isUserOwner} - color='grey' - data-testid={key} - /> - )} - label={value} - key={key} - /> - ); - })} -
- + {/* Grant Types — hidden when template governs them */} + {!formConfig?.keyGeneration?.grantTypes?.hidden && ( + + - -
-
+ + +
+ {Object.keys(grantTypeDisplayListMap).map((key) => { + const value = grantTypeDisplayListMap[key]; + return ( + handleChange('grantType', e)} + value={value} + disabled={!isOrgWideAppUpdateEnabled && !isUserOwner} + color='grey' + data-testid={key} + /> + )} + label={value} + key={key} + /> + ); + })} +
+ + + +
+ + )} {/* Callback URL */} @@ -629,6 +642,7 @@ KeyConfiguration.defaultProps = { notFound: false, validating: false, mode: null, + formConfig: null, }; KeyConfiguration.propTypes = { classes: PropTypes.instanceOf(Object).isRequired, @@ -651,6 +665,7 @@ KeyConfiguration.propTypes = { owner: PropTypes.string, hashEnabled: PropTypes.bool, }), + formConfig: PropTypes.shape({}), }; diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/TokenManager.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/TokenManager.jsx index 2467ad039e1..cd36990e67f 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/TokenManager.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/TokenManager.jsx @@ -1074,6 +1074,7 @@ class TokenManager extends React.Component { setValidating={this.setValidating} defaultTokenEndpoint={defaultTokenEndpoint} mode={mode} + formConfig={this.props.formConfig} />
{ }, summary: false, + formConfig: null, }; TokenManager.propTypes = { classes: PropTypes.instanceOf(Object).isRequired, @@ -1497,6 +1500,7 @@ TokenManager.propTypes = { updateSubscriptionData: PropTypes.func, intl: PropTypes.shape({ formatMessage: PropTypes.func }).isRequired, summary: PropTypes.bool, + formConfig: PropTypes.shape({}), }; export default injectIntl((TokenManager)); diff --git a/portals/devportal/src/main/webapp/source/src/app/data/api.jsx b/portals/devportal/src/main/webapp/source/src/app/data/api.jsx index 509813fdd3d..09e62e268ce 100644 --- a/portals/devportal/src/main/webapp/source/src/app/data/api.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/data/api.jsx @@ -1337,4 +1337,40 @@ export default class API extends Resource { }, })); } + + /** + * Fetch a single Devportal Governance Template by ID. + * Used by the DevPortal to load the formConfig for an existing application's template. + * + * @param {string} templateId - The template UUID + * @returns {Promise<{body: Object}>} Resolves to swagger-client-shaped response with the template DTO + */ + getDevportalGovernanceTemplateById(templateId) { + const user = AuthManager.getUser(Utils.getEnvironment().label); + const token = user ? user.getPartialToken() : ''; + + const headers = { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + if (window.location) { + const search = new URLSearchParams(window.location.search); + const tenant = search.get('tenant'); + if (tenant) headers['X-WSO2-Tenant'] = tenant; + } + + return fetch(`/api/am/governance/v1/templates/${encodeURIComponent(templateId)}`, { + credentials: 'include', + headers, + }) + .then((res) => { + if (!res.ok) { + const err = new Error(res.statusText); + err.status = res.status; + throw err; + } + return res.json(); + }) + .then((json) => ({ body: json })); + } } From 5cccd12373eee256394958d2bef1999b1981d381 Mon Sep 17 00:00:00 2001 From: ashiduDissanayake Date: Mon, 11 May 2026 00:53:21 +0530 Subject: [PATCH 3/3] feat: refine governance template experience --- .../main/webapp/site/public/locales/en.json | 146 +- .../main/webapp/site/public/locales/fr.json | 186 ++ .../RulesetCatalog/AddEditRuleset.jsx | 24 +- .../Governance/Templates/ListTemplates.jsx | 138 +- .../Governance/Templates/TemplateWizard.jsx | 414 ++++- .../Templates/steps/DeveloperViewStep.jsx | 464 +++++ .../Templates/steps/FormBuilderStep.jsx | 1533 ++++++++++++----- .../Templates/steps/GeneralDetailsStep.jsx | 310 +++- .../Templates/steps/ReviewPublishStep.jsx | 993 ++++++++--- .../Templates/steps/RulesetBindingsStep.jsx | 728 +++++--- .../webapp/source/src/app/data/Constants.js | 8 + .../source/src/app/data/GovernanceAPI.js | 15 + .../webapp/services/login/login_callback.jsp | 7 + .../main/webapp/site/public/locales/en.json | 23 +- .../components/Apis/Listing/APICardView.jsx | 5 +- .../Apis/Listing/SubscriptionPolicySelect.jsx | 82 +- .../Applications/ApplicationFormHandler.jsx | 139 +- .../Create/ApplicationCreateBase.jsx | 1 + .../Create/TemplatePreviewDialog.jsx | 167 ++ .../Applications/Create/TemplateSelector.jsx | 621 ++++++- .../Create/templateDeveloperViewUtils.js | 174 ++ .../Details/SubscriptionTableData.jsx | 10 +- .../Applications/Details/Subscriptions.jsx | 6 + .../components/Applications/Details/index.jsx | 8 +- .../AppsAndKeys/ApplicationCreateForm.jsx | 69 +- .../Shared/AppsAndKeys/KeyConfiguration.jsx | 245 +-- .../Shared/AppsAndKeys/TokenManager.jsx | 118 +- .../source/src/app/data/AuthManager.jsx | 2 +- .../main/webapp/source/src/app/data/api.jsx | 7 +- 29 files changed, 5276 insertions(+), 1367 deletions(-) create mode 100644 portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/DeveloperViewStep.jsx create mode 100644 portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplatePreviewDialog.jsx create mode 100644 portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/templateDeveloperViewUtils.js 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 105acb071f6..14c9e56599a 100644 --- a/portals/admin/src/main/webapp/site/public/locales/en.json +++ b/portals/admin/src/main/webapp/site/public/locales/en.json @@ -841,48 +841,89 @@ "Governance.Templates.Delete.error": "Something went wrong while deleting the Template", "Governance.Templates.Delete.readOnly.tooltip": "Global templates can only be deleted by Super Tenant admins", "Governance.Templates.Delete.success": "Template deleted successfully", - "Governance.Templates.FormBuilder.app.callbackUrl.desc": "OAuth redirect URI; required for Authorization Code / Implicit flows", - "Governance.Templates.FormBuilder.app.callbackUrl.label": "Callback URL", + "Governance.Templates.DeveloperView.heading": "Developer View", + "Governance.Templates.DeveloperView.limitations.helper": "One item per line. Edit or add to the auto-generated limitations above.", + "Governance.Templates.DeveloperView.limitations.label": "Limitations shown to developers", + "Governance.Templates.DeveloperView.preview.empty": "Fill in the summary or limitations on the left to preview.", + "Governance.Templates.DeveloperView.preview.heading": "Developer View Preview", + "Governance.Templates.DeveloperView.preview.limitations": "Limitations", + "Governance.Templates.DeveloperView.preview.noSummary": "No summary provided.", + "Governance.Templates.DeveloperView.preview.rulesets": "Governance Rulesets", + "Governance.Templates.DeveloperView.preview.rulesets.docs": "Documentation", + "Governance.Templates.DeveloperView.subheading": "Craft the explanation developers see before selecting this template. The right panel shows exactly what they will see.", + "Governance.Templates.DeveloperView.summary.helper": "Shown on the template card and at the top of the developer view.", + "Governance.Templates.DeveloperView.summary.label": "Developer Summary", + "Governance.Templates.FormBuilder.app.description.desc": "Short description of the application purpose", + "Governance.Templates.FormBuilder.app.description.label": "Description", + "Governance.Templates.FormBuilder.app.groups.desc": "Group(s) the application belongs to for shared access", + "Governance.Templates.FormBuilder.app.groups.helper": "Comma-separated group names; developers can add more if visible", + "Governance.Templates.FormBuilder.app.groups.label": "Application Groups", "Governance.Templates.FormBuilder.app.throttlingPolicy.desc": "Rate limit tier applied to the application", "Governance.Templates.FormBuilder.app.throttlingPolicy.label": "Throttling Policy", - "Governance.Templates.FormBuilder.app.tokenType.desc": "JWT for self-contained tokens; Opaque for reference tokens", - "Governance.Templates.FormBuilder.app.tokenType.label": "Token Type", "Governance.Templates.FormBuilder.header.default": "Default Value", "Governance.Templates.FormBuilder.header.field": "Field", - "Governance.Templates.FormBuilder.header.visibility": "Visibility", - "Governance.Templates.FormBuilder.heading": "Form Builder", + "Governance.Templates.FormBuilder.header.visibility": "Field Settings", + "Governance.Templates.FormBuilder.heading": "Field Configuration", + "Governance.Templates.FormBuilder.hidden.noDefault.error": "Set a default — developers cannot see this field", "Governance.Templates.FormBuilder.hiddenAutoApply": "Applied automatically — developer will not see this field", - "Governance.Templates.FormBuilder.hide.label": "Hide from Developer", - "Governance.Templates.FormBuilder.keyGen.additionalProps.desc": "PKCE and other Key Manager-specific settings; defaults are managed by the Key Manager configuration", - "Governance.Templates.FormBuilder.keyGen.additionalProps.label": "Additional Properties (PKCE)", - "Governance.Templates.FormBuilder.keyGen.grantTypes.desc": "Allowed OAuth2 grant flows; hiding locks developers to the defaults selected here", - "Governance.Templates.FormBuilder.keyGen.grantTypes.label": "Grant Types", - "Governance.Templates.FormBuilder.keyGen.grantTypes.placeholder": "Select grant types…", - "Governance.Templates.FormBuilder.keyGen.keyType.desc": "Production keys are used for live traffic; Sandbox for testing", - "Governance.Templates.FormBuilder.keyGen.keyType.label": "Key Type", - "Governance.Templates.FormBuilder.keyGen.validity.desc": "Token expiry in seconds; enter -1 for unlimited", - "Governance.Templates.FormBuilder.keyGen.validity.label": "Token Validity Period", - "Governance.Templates.FormBuilder.keyGen.validity.unlimited": "Token will not expire", + "Governance.Templates.FormBuilder.inactiveAttributes": "Unavailable server fields are retained and will be restored if the server config adds them again.", + "Governance.Templates.FormBuilder.keyGen.expiry.unlimited": "Token will not expire", + "Governance.Templates.FormBuilder.keyManagers.empty": "No key managers are available in this environment.", + "Governance.Templates.FormBuilder.keyManagers.intro": "Toggle a key manager on to define default OAuth settings that developers will see (or have pre-filled) when generating keys using that key manager.", + "Governance.Templates.FormBuilder.km.appTokenExpiry.desc": "Expiry for client_credentials tokens; -1 for unlimited", + "Governance.Templates.FormBuilder.km.appTokenExpiry.label": "App Access Token Expiry (s)", + "Governance.Templates.FormBuilder.km.callbackUrl.desc": "OAuth redirect URI for Authorization Code / Implicit flows", + "Governance.Templates.FormBuilder.km.callbackUrl.label": "Callback URL", + "Governance.Templates.FormBuilder.km.disabled.hint": "Toggle on to govern this key manager", + "Governance.Templates.FormBuilder.km.enablePKCE.desc": "Require PKCE for authorization_code flows (recommended for public clients)", + "Governance.Templates.FormBuilder.km.enablePKCE.label": "Enable PKCE", + "Governance.Templates.FormBuilder.km.governed": "Governed", + "Governance.Templates.FormBuilder.km.grantTypes.allowedListBadge": "Allowed list", + "Governance.Templates.FormBuilder.km.grantTypes.desc": "OAuth2 grant flows developers may select for this key manager. The selected set is the constraint — developers cannot request grants outside this list. Leave empty to inherit every grant the KM advertises.", + "Governance.Templates.FormBuilder.km.grantTypes.hint": "Select at least one grant type to unlock related fields.", + "Governance.Templates.FormBuilder.km.grantTypes.label": "Allowed Grant Types", + "Governance.Templates.FormBuilder.km.grantTypes.none": "Select grant types…", + "Governance.Templates.FormBuilder.km.idTokenExpiry.desc": "Expiry for OIDC ID tokens (authorization_code only); -1 for unlimited", + "Governance.Templates.FormBuilder.km.idTokenExpiry.label": "ID Token Expiry (s)", + "Governance.Templates.FormBuilder.km.pkceSupportsPlainText.desc": "Allow unhashed code verifiers (only when PKCE is enabled)", + "Governance.Templates.FormBuilder.km.pkceSupportsPlainText.label": "Allow PKCE Plain Text", + "Governance.Templates.FormBuilder.km.publicClient.desc": "Mark as a public client (no client secret required)", + "Governance.Templates.FormBuilder.km.publicClient.label": "Public Client", + "Governance.Templates.FormBuilder.km.refreshTokenExpiry.desc": "Expiry for refresh tokens; -1 for unlimited", + "Governance.Templates.FormBuilder.km.refreshTokenExpiry.label": "Refresh Token Expiry (s)", + "Governance.Templates.FormBuilder.km.toggle.disable": "Disable governance for this key manager", + "Governance.Templates.FormBuilder.km.toggle.enable": "Enable governance for this key manager", + "Governance.Templates.FormBuilder.km.userTokenExpiry.desc": "Expiry for password / authorization_code tokens; -1 for unlimited", + "Governance.Templates.FormBuilder.km.userTokenExpiry.label": "User Access Token Expiry (s)", "Governance.Templates.FormBuilder.noDefault.text": "Managed by Key Manager", - "Governance.Templates.FormBuilder.noDefault.tooltip": "Default value is managed by the Key Manager configuration", - "Governance.Templates.FormBuilder.policiesFetch.error": "Failed to load throttling policies. Dropdowns may be empty.", + "Governance.Templates.FormBuilder.policiesFetch.error": "Failed to load policies. Some dropdowns may be empty.", + "Governance.Templates.FormBuilder.required.optional": "Optional", + "Governance.Templates.FormBuilder.required.required": "Required", "Governance.Templates.FormBuilder.section.application": "Application Metadata", "Governance.Templates.FormBuilder.section.application.desc": "Fields shown on the \"Create Application\" form", - "Governance.Templates.FormBuilder.section.keyGen": "Key Generation", - "Governance.Templates.FormBuilder.section.keyGen.desc": "Fields shown on the \"Generate Keys\" panel", - "Governance.Templates.FormBuilder.section.subscription": "Subscriptions", - "Governance.Templates.FormBuilder.section.subscription.desc": "Fields shown on the \"Subscribe to API\" dialog", + "Governance.Templates.FormBuilder.section.keyManagers": "Key Manager Governance", + "Governance.Templates.FormBuilder.section.keyManagers.desc.count": "{count} key manager(s) governed", + "Governance.Templates.FormBuilder.section.keyManagers.desc.none": "Enable one or more key managers to govern their OAuth settings", "Governance.Templates.FormBuilder.select.noDefault": "— No default —", - "Governance.Templates.FormBuilder.sub.throttlingPolicy.desc": "Rate limit tier applied per API subscription", - "Governance.Templates.FormBuilder.sub.throttlingPolicy.label": "Subscription Throttling Policy", - "Governance.Templates.FormBuilder.subheading": "Configure which fields developers see in the application creation wizard and set organization-wide defaults for hidden fields.", + "Governance.Templates.FormBuilder.subheading": "Configure which fields developers see and set organisation-wide defaults for hidden fields.", + "Governance.Templates.FormBuilder.toggle.hidden": "Hidden", + "Governance.Templates.FormBuilder.toggle.visible": "Visible", + "Governance.Templates.GeneralDetails.icon.helper": "Max 200 KB. JPEG, PNG, SVG, or WebP.", + "Governance.Templates.GeneralDetails.icon.label": "Template Icon", + "Governance.Templates.GeneralDetails.icon.removeBtn": "Remove", + "Governance.Templates.GeneralDetails.icon.sizeError": "Icon must be smaller than 200 KB (current: {size} KB).", + "Governance.Templates.GeneralDetails.icon.typeError": "Only JPEG, PNG, SVG, and WebP images are allowed.", "Governance.Templates.List.add.title": "Create Template", "Governance.Templates.List.add.triggerButtonText": "Create Template", "Governance.Templates.List.btn.create": "Create Template", "Governance.Templates.List.column.default": "Default", + "Governance.Templates.List.column.default.tooltip": "Toggle on to make this the default template shown to developers when none is pre-selected. Only one template can be the default at a time.", "Governance.Templates.List.column.scope": "Scope", "Governance.Templates.List.column.status": "Status", "Governance.Templates.List.column.template": "Template", + "Governance.Templates.List.default.set.success": "Template set as default.", + "Governance.Templates.List.default.unset.success": "Default status removed.", + "Governance.Templates.List.default.update.error": "Failed to update default status.", "Governance.Templates.List.description": "Create and manage templates that configure the Devportal application creation workflow and bind governance rulesets to enforce developer policies.", "Governance.Templates.List.edit.title": "Edit Template", "Governance.Templates.List.empty.content": "Templates configure the Devportal application wizard and enforce ruleset bindings on developers. Click Create Template to get started.", @@ -892,47 +933,59 @@ "Governance.Templates.List.title": "Devportal Governance Templates", "Governance.Templates.ReviewPublish.chip.default": "Default", "Governance.Templates.ReviewPublish.chip.global": "Global", + "Governance.Templates.ReviewPublish.developerView.limitations": "Limitations", + "Governance.Templates.ReviewPublish.developerView.noLimitations": "Generated limitations will be shown in the Devportal", + "Governance.Templates.ReviewPublish.developerView.noSummary": "No developer summary configured", + "Governance.Templates.ReviewPublish.developerView.summary": "Summary", "Governance.Templates.ReviewPublish.field.hidden": "Hidden", - "Governance.Templates.ReviewPublish.field.hiddenFromUser": "Field hidden from user", "Governance.Templates.ReviewPublish.field.managedByKM": "Managed by Key Manager", "Governance.Templates.ReviewPublish.field.noDefault": "No default", + "Governance.Templates.ReviewPublish.field.optional": "Optional", + "Governance.Templates.ReviewPublish.field.required": "Required", + "Governance.Templates.ReviewPublish.field.unavailable": "Unavailable", "Governance.Templates.ReviewPublish.field.visible": "Visible", "Governance.Templates.ReviewPublish.flags.none": "None", "Governance.Templates.ReviewPublish.heading": "Review & Publish", + "Governance.Templates.ReviewPublish.keyManagers.none": "No key manager governance configured", "Governance.Templates.ReviewPublish.label.description": "Description", "Governance.Templates.ReviewPublish.label.flags": "Flags", "Governance.Templates.ReviewPublish.label.name": "Template Name", "Governance.Templates.ReviewPublish.label.noDescription": "No description provided", - "Governance.Templates.ReviewPublish.label.status": "Status", "Governance.Templates.ReviewPublish.rulesets.col.name": "Ruleset", "Governance.Templates.ReviewPublish.rulesets.col.order": "Order", - "Governance.Templates.ReviewPublish.rulesets.col.scope": "Key Manager Scope", + "Governance.Templates.ReviewPublish.rulesets.col.scope": "Applies To", "Governance.Templates.ReviewPublish.rulesets.loading": "Resolving ruleset names…", "Governance.Templates.ReviewPublish.rulesets.none": "No rulesets bound to this template", - "Governance.Templates.ReviewPublish.rulesets.scope.all": "All Key Managers", + "Governance.Templates.ReviewPublish.rulesets.scope.all": "All Allowed Key Managers", + "Governance.Templates.ReviewPublish.rulesets.scope.applicationDetails": "Application Details", "Governance.Templates.ReviewPublish.rulesets.scope.count": "{count} Key Manager(s)", + "Governance.Templates.ReviewPublish.section.developerView": "Developer View", "Governance.Templates.ReviewPublish.section.formConfig": "Form Configuration", "Governance.Templates.ReviewPublish.section.general": "General Details", + "Governance.Templates.ReviewPublish.section.keyManagers": "Key Manager Governance", "Governance.Templates.ReviewPublish.section.payload": "Raw JSON Payload", "Governance.Templates.ReviewPublish.section.rulesets": "Ruleset Bindings", - "Governance.Templates.ReviewPublish.subheading": "Review the template configuration below. Use the Back button to make changes.", + "Governance.Templates.ReviewPublish.subheading": "Review the template configuration below, then decide whether to save as draft or publish.", + "Governance.Templates.ReviewPublish.validation.saveTime": "Hidden field defaults are validated against bound rulesets when you save. If any default value violates a rule, the save will be rejected with details.", "Governance.Templates.RulesetBindings.add.tooltip": "Add to template", "Governance.Templates.RulesetBindings.available.empty": "No rulesets found. Create rulesets in the Ruleset Catalog first.", "Governance.Templates.RulesetBindings.available.heading": "Available Rulesets", "Governance.Templates.RulesetBindings.bound.chip": "Bound", - "Governance.Templates.RulesetBindings.bound.empty.hint": "Click the + button next to a ruleset on the left to add it", + "Governance.Templates.RulesetBindings.bound.empty.hint": "Click the + icon next to a ruleset on the left to add it", "Governance.Templates.RulesetBindings.bound.empty.title": "No rulesets bound yet", "Governance.Templates.RulesetBindings.bound.heading": "Bound Rulesets", "Governance.Templates.RulesetBindings.clearAll.btn": "Clear all", - "Governance.Templates.RulesetBindings.fetch.error": "Failed to load rulesets or Key Managers", + "Governance.Templates.RulesetBindings.fetch.error": "Failed to load rulesets", "Governance.Templates.RulesetBindings.footer.summary": "{count} ruleset(s) will be snapshotted at application creation", "Governance.Templates.RulesetBindings.heading": "Ruleset Bindings", - "Governance.Templates.RulesetBindings.order.helper": "Lower = evaluated first", - "Governance.Templates.RulesetBindings.order.label": "Evaluation Order", + "Governance.Templates.RulesetBindings.preview.guards": "Guards: {target}", + "Governance.Templates.RulesetBindings.preview.loading": "Reading ruleset content…", + "Governance.Templates.RulesetBindings.preview.noDerivedRules": "Rule details could not be derived from the ruleset content.", + "Governance.Templates.RulesetBindings.preview.rules": "Rules enforced", "Governance.Templates.RulesetBindings.remove.tooltip": "Remove binding", - "Governance.Templates.RulesetBindings.scope.allKm": "All Key Managers", - "Governance.Templates.RulesetBindings.scope.allKm.desc": "Ruleset applies regardless of which Key Manager issues the token", - "Governance.Templates.RulesetBindings.scope.allKm.option": "All Key Managers (Global)", + "Governance.Templates.RulesetBindings.scope.allKm": "All Allowed Key Managers", + "Governance.Templates.RulesetBindings.scope.allKm.desc": "Applies to every Key Manager permitted by this template", + "Governance.Templates.RulesetBindings.scope.allKm.option": "All Allowed Key Managers", "Governance.Templates.RulesetBindings.scope.global.hint": "Select specific Key Managers to restrict when this ruleset is enforced", "Governance.Templates.RulesetBindings.scope.label": "Key Manager Scope", "Governance.Templates.RulesetBindings.scope.noKm": "No Key Managers configured", @@ -942,23 +995,26 @@ "Governance.Templates.Wizard.GeneralDetails.description.helper": "Briefly describe the purpose and intended audience of this template", "Governance.Templates.Wizard.GeneralDetails.description.label": "Description", "Governance.Templates.Wizard.GeneralDetails.heading": "General Details", - "Governance.Templates.Wizard.GeneralDetails.isDefault.helper": "Applied as the fallback when a developer does not explicitly select a template", - "Governance.Templates.Wizard.GeneralDetails.isDefault.label": "Set as Default Template", "Governance.Templates.Wizard.GeneralDetails.isGlobal.helper": "Visible to all organizations as a cross-tenant fallback; only manageable by Super Tenant admins", "Governance.Templates.Wizard.GeneralDetails.isGlobal.label": "Set as Global Template", "Governance.Templates.Wizard.GeneralDetails.name.helper": "A unique, human-readable name for this template", "Governance.Templates.Wizard.GeneralDetails.name.label": "Template Name", + "Governance.Templates.Wizard.GeneralDetails.name.placeholder": "My Template", "Governance.Templates.Wizard.GeneralDetails.name.required": "Template name is required", "Governance.Templates.Wizard.GeneralDetails.options.heading": "Template Options", - "Governance.Templates.Wizard.GeneralDetails.status.draft": "Draft", - "Governance.Templates.Wizard.GeneralDetails.status.helper": "Only PUBLISHED templates are visible to Devportal users", - "Governance.Templates.Wizard.GeneralDetails.status.label": "Status", - "Governance.Templates.Wizard.GeneralDetails.status.published": "Published", - "Governance.Templates.Wizard.GeneralDetails.subheading": "Provide basic information about this template and its lifecycle state.", + "Governance.Templates.Wizard.GeneralDetails.subheading": "Provide basic information about this template.", + "Governance.Templates.Wizard.GeneralDetails.tags.helper": "Press Enter to add a tag. Tags help filter templates in the gallery.", + "Governance.Templates.Wizard.GeneralDetails.tags.label": "Tags", "Governance.Templates.Wizard.btn.back": "Back", "Governance.Templates.Wizard.btn.cancel": "Cancel", "Governance.Templates.Wizard.btn.next": "Next", + "Governance.Templates.Wizard.btn.publish": "Publish", + "Governance.Templates.Wizard.btn.saveDraft": "Save as Draft", + "Governance.Templates.Wizard.btn.savePublish": "Save & Publish", + "Governance.Templates.Wizard.keyManager.required.error": "Select at least one Key Manager before publishing.", "Governance.Templates.Wizard.load.error": "Failed to load template", + "Governance.Templates.Wizard.name.required.error": "Template name is required before saving.", + "Governance.Templates.Wizard.requiredHiddenDefault.error": "{field} is required and hidden, so set a default value before continuing.", "Governance.Templates.Wizard.save.error": "Failed to save template", "Governance.Templates.Wizard.title.create": "Create Template", "Governance.Templates.Wizard.title.edit": "Edit Template", diff --git a/portals/admin/src/main/webapp/site/public/locales/fr.json b/portals/admin/src/main/webapp/site/public/locales/fr.json index ea335635123..14c9e56599a 100644 --- a/portals/admin/src/main/webapp/site/public/locales/fr.json +++ b/portals/admin/src/main/webapp/site/public/locales/fr.json @@ -442,6 +442,9 @@ "Base.RouteMenuMapping.gateways.items.Editing": "Edit Gateway Environment", "Base.RouteMenuMapping.governance": "Governance", "Base.RouteMenuMapping.governance.policies": "Policies", + "Base.RouteMenuMapping.governance.templates": "Templates", + "Base.RouteMenuMapping.governance.templates.create": "Create Template", + "Base.RouteMenuMapping.governance.templates.edit": "Edit Template", "Base.RouteMenuMapping.keymanagers": "Key Managers", "Base.RouteMenuMapping.keymanagers.items.Adding": "Add Key Manager", "Base.RouteMenuMapping.keymanagers.items.Editing": "Edit Key Manager", @@ -832,6 +835,189 @@ "Governance.Rulesets.List.help.link": "Create and Manage Rulesets", "Governance.Rulesets.List.search.placeholder": "Search rulesets by name or type", "Governance.Rulesets.List.title": "Ruleset Catalog", + "Governance.Templates.Delete.confirmation": "Are you sure you want to delete this Template? This action cannot be undone.", + "Governance.Templates.Delete.dialog.btn": "Delete", + "Governance.Templates.Delete.dialog.title": "Delete Template?", + "Governance.Templates.Delete.error": "Something went wrong while deleting the Template", + "Governance.Templates.Delete.readOnly.tooltip": "Global templates can only be deleted by Super Tenant admins", + "Governance.Templates.Delete.success": "Template deleted successfully", + "Governance.Templates.DeveloperView.heading": "Developer View", + "Governance.Templates.DeveloperView.limitations.helper": "One item per line. Edit or add to the auto-generated limitations above.", + "Governance.Templates.DeveloperView.limitations.label": "Limitations shown to developers", + "Governance.Templates.DeveloperView.preview.empty": "Fill in the summary or limitations on the left to preview.", + "Governance.Templates.DeveloperView.preview.heading": "Developer View Preview", + "Governance.Templates.DeveloperView.preview.limitations": "Limitations", + "Governance.Templates.DeveloperView.preview.noSummary": "No summary provided.", + "Governance.Templates.DeveloperView.preview.rulesets": "Governance Rulesets", + "Governance.Templates.DeveloperView.preview.rulesets.docs": "Documentation", + "Governance.Templates.DeveloperView.subheading": "Craft the explanation developers see before selecting this template. The right panel shows exactly what they will see.", + "Governance.Templates.DeveloperView.summary.helper": "Shown on the template card and at the top of the developer view.", + "Governance.Templates.DeveloperView.summary.label": "Developer Summary", + "Governance.Templates.FormBuilder.app.description.desc": "Short description of the application purpose", + "Governance.Templates.FormBuilder.app.description.label": "Description", + "Governance.Templates.FormBuilder.app.groups.desc": "Group(s) the application belongs to for shared access", + "Governance.Templates.FormBuilder.app.groups.helper": "Comma-separated group names; developers can add more if visible", + "Governance.Templates.FormBuilder.app.groups.label": "Application Groups", + "Governance.Templates.FormBuilder.app.throttlingPolicy.desc": "Rate limit tier applied to the application", + "Governance.Templates.FormBuilder.app.throttlingPolicy.label": "Throttling Policy", + "Governance.Templates.FormBuilder.header.default": "Default Value", + "Governance.Templates.FormBuilder.header.field": "Field", + "Governance.Templates.FormBuilder.header.visibility": "Field Settings", + "Governance.Templates.FormBuilder.heading": "Field Configuration", + "Governance.Templates.FormBuilder.hidden.noDefault.error": "Set a default — developers cannot see this field", + "Governance.Templates.FormBuilder.hiddenAutoApply": "Applied automatically — developer will not see this field", + "Governance.Templates.FormBuilder.inactiveAttributes": "Unavailable server fields are retained and will be restored if the server config adds them again.", + "Governance.Templates.FormBuilder.keyGen.expiry.unlimited": "Token will not expire", + "Governance.Templates.FormBuilder.keyManagers.empty": "No key managers are available in this environment.", + "Governance.Templates.FormBuilder.keyManagers.intro": "Toggle a key manager on to define default OAuth settings that developers will see (or have pre-filled) when generating keys using that key manager.", + "Governance.Templates.FormBuilder.km.appTokenExpiry.desc": "Expiry for client_credentials tokens; -1 for unlimited", + "Governance.Templates.FormBuilder.km.appTokenExpiry.label": "App Access Token Expiry (s)", + "Governance.Templates.FormBuilder.km.callbackUrl.desc": "OAuth redirect URI for Authorization Code / Implicit flows", + "Governance.Templates.FormBuilder.km.callbackUrl.label": "Callback URL", + "Governance.Templates.FormBuilder.km.disabled.hint": "Toggle on to govern this key manager", + "Governance.Templates.FormBuilder.km.enablePKCE.desc": "Require PKCE for authorization_code flows (recommended for public clients)", + "Governance.Templates.FormBuilder.km.enablePKCE.label": "Enable PKCE", + "Governance.Templates.FormBuilder.km.governed": "Governed", + "Governance.Templates.FormBuilder.km.grantTypes.allowedListBadge": "Allowed list", + "Governance.Templates.FormBuilder.km.grantTypes.desc": "OAuth2 grant flows developers may select for this key manager. The selected set is the constraint — developers cannot request grants outside this list. Leave empty to inherit every grant the KM advertises.", + "Governance.Templates.FormBuilder.km.grantTypes.hint": "Select at least one grant type to unlock related fields.", + "Governance.Templates.FormBuilder.km.grantTypes.label": "Allowed Grant Types", + "Governance.Templates.FormBuilder.km.grantTypes.none": "Select grant types…", + "Governance.Templates.FormBuilder.km.idTokenExpiry.desc": "Expiry for OIDC ID tokens (authorization_code only); -1 for unlimited", + "Governance.Templates.FormBuilder.km.idTokenExpiry.label": "ID Token Expiry (s)", + "Governance.Templates.FormBuilder.km.pkceSupportsPlainText.desc": "Allow unhashed code verifiers (only when PKCE is enabled)", + "Governance.Templates.FormBuilder.km.pkceSupportsPlainText.label": "Allow PKCE Plain Text", + "Governance.Templates.FormBuilder.km.publicClient.desc": "Mark as a public client (no client secret required)", + "Governance.Templates.FormBuilder.km.publicClient.label": "Public Client", + "Governance.Templates.FormBuilder.km.refreshTokenExpiry.desc": "Expiry for refresh tokens; -1 for unlimited", + "Governance.Templates.FormBuilder.km.refreshTokenExpiry.label": "Refresh Token Expiry (s)", + "Governance.Templates.FormBuilder.km.toggle.disable": "Disable governance for this key manager", + "Governance.Templates.FormBuilder.km.toggle.enable": "Enable governance for this key manager", + "Governance.Templates.FormBuilder.km.userTokenExpiry.desc": "Expiry for password / authorization_code tokens; -1 for unlimited", + "Governance.Templates.FormBuilder.km.userTokenExpiry.label": "User Access Token Expiry (s)", + "Governance.Templates.FormBuilder.noDefault.text": "Managed by Key Manager", + "Governance.Templates.FormBuilder.policiesFetch.error": "Failed to load policies. Some dropdowns may be empty.", + "Governance.Templates.FormBuilder.required.optional": "Optional", + "Governance.Templates.FormBuilder.required.required": "Required", + "Governance.Templates.FormBuilder.section.application": "Application Metadata", + "Governance.Templates.FormBuilder.section.application.desc": "Fields shown on the \"Create Application\" form", + "Governance.Templates.FormBuilder.section.keyManagers": "Key Manager Governance", + "Governance.Templates.FormBuilder.section.keyManagers.desc.count": "{count} key manager(s) governed", + "Governance.Templates.FormBuilder.section.keyManagers.desc.none": "Enable one or more key managers to govern their OAuth settings", + "Governance.Templates.FormBuilder.select.noDefault": "— No default —", + "Governance.Templates.FormBuilder.subheading": "Configure which fields developers see and set organisation-wide defaults for hidden fields.", + "Governance.Templates.FormBuilder.toggle.hidden": "Hidden", + "Governance.Templates.FormBuilder.toggle.visible": "Visible", + "Governance.Templates.GeneralDetails.icon.helper": "Max 200 KB. JPEG, PNG, SVG, or WebP.", + "Governance.Templates.GeneralDetails.icon.label": "Template Icon", + "Governance.Templates.GeneralDetails.icon.removeBtn": "Remove", + "Governance.Templates.GeneralDetails.icon.sizeError": "Icon must be smaller than 200 KB (current: {size} KB).", + "Governance.Templates.GeneralDetails.icon.typeError": "Only JPEG, PNG, SVG, and WebP images are allowed.", + "Governance.Templates.List.add.title": "Create Template", + "Governance.Templates.List.add.triggerButtonText": "Create Template", + "Governance.Templates.List.btn.create": "Create Template", + "Governance.Templates.List.column.default": "Default", + "Governance.Templates.List.column.default.tooltip": "Toggle on to make this the default template shown to developers when none is pre-selected. Only one template can be the default at a time.", + "Governance.Templates.List.column.scope": "Scope", + "Governance.Templates.List.column.status": "Status", + "Governance.Templates.List.column.template": "Template", + "Governance.Templates.List.default.set.success": "Template set as default.", + "Governance.Templates.List.default.unset.success": "Default status removed.", + "Governance.Templates.List.default.update.error": "Failed to update default status.", + "Governance.Templates.List.description": "Create and manage templates that configure the Devportal application creation workflow and bind governance rulesets to enforce developer policies.", + "Governance.Templates.List.edit.title": "Edit Template", + "Governance.Templates.List.empty.content": "Templates configure the Devportal application wizard and enforce ruleset bindings on developers. Click Create Template to get started.", + "Governance.Templates.List.empty.title": "Devportal Governance Templates", + "Governance.Templates.List.help.link": "Create and Manage Templates", + "Governance.Templates.List.search.placeholder": "Search templates by name", + "Governance.Templates.List.title": "Devportal Governance Templates", + "Governance.Templates.ReviewPublish.chip.default": "Default", + "Governance.Templates.ReviewPublish.chip.global": "Global", + "Governance.Templates.ReviewPublish.developerView.limitations": "Limitations", + "Governance.Templates.ReviewPublish.developerView.noLimitations": "Generated limitations will be shown in the Devportal", + "Governance.Templates.ReviewPublish.developerView.noSummary": "No developer summary configured", + "Governance.Templates.ReviewPublish.developerView.summary": "Summary", + "Governance.Templates.ReviewPublish.field.hidden": "Hidden", + "Governance.Templates.ReviewPublish.field.managedByKM": "Managed by Key Manager", + "Governance.Templates.ReviewPublish.field.noDefault": "No default", + "Governance.Templates.ReviewPublish.field.optional": "Optional", + "Governance.Templates.ReviewPublish.field.required": "Required", + "Governance.Templates.ReviewPublish.field.unavailable": "Unavailable", + "Governance.Templates.ReviewPublish.field.visible": "Visible", + "Governance.Templates.ReviewPublish.flags.none": "None", + "Governance.Templates.ReviewPublish.heading": "Review & Publish", + "Governance.Templates.ReviewPublish.keyManagers.none": "No key manager governance configured", + "Governance.Templates.ReviewPublish.label.description": "Description", + "Governance.Templates.ReviewPublish.label.flags": "Flags", + "Governance.Templates.ReviewPublish.label.name": "Template Name", + "Governance.Templates.ReviewPublish.label.noDescription": "No description provided", + "Governance.Templates.ReviewPublish.rulesets.col.name": "Ruleset", + "Governance.Templates.ReviewPublish.rulesets.col.order": "Order", + "Governance.Templates.ReviewPublish.rulesets.col.scope": "Applies To", + "Governance.Templates.ReviewPublish.rulesets.loading": "Resolving ruleset names…", + "Governance.Templates.ReviewPublish.rulesets.none": "No rulesets bound to this template", + "Governance.Templates.ReviewPublish.rulesets.scope.all": "All Allowed Key Managers", + "Governance.Templates.ReviewPublish.rulesets.scope.applicationDetails": "Application Details", + "Governance.Templates.ReviewPublish.rulesets.scope.count": "{count} Key Manager(s)", + "Governance.Templates.ReviewPublish.section.developerView": "Developer View", + "Governance.Templates.ReviewPublish.section.formConfig": "Form Configuration", + "Governance.Templates.ReviewPublish.section.general": "General Details", + "Governance.Templates.ReviewPublish.section.keyManagers": "Key Manager Governance", + "Governance.Templates.ReviewPublish.section.payload": "Raw JSON Payload", + "Governance.Templates.ReviewPublish.section.rulesets": "Ruleset Bindings", + "Governance.Templates.ReviewPublish.subheading": "Review the template configuration below, then decide whether to save as draft or publish.", + "Governance.Templates.ReviewPublish.validation.saveTime": "Hidden field defaults are validated against bound rulesets when you save. If any default value violates a rule, the save will be rejected with details.", + "Governance.Templates.RulesetBindings.add.tooltip": "Add to template", + "Governance.Templates.RulesetBindings.available.empty": "No rulesets found. Create rulesets in the Ruleset Catalog first.", + "Governance.Templates.RulesetBindings.available.heading": "Available Rulesets", + "Governance.Templates.RulesetBindings.bound.chip": "Bound", + "Governance.Templates.RulesetBindings.bound.empty.hint": "Click the + icon next to a ruleset on the left to add it", + "Governance.Templates.RulesetBindings.bound.empty.title": "No rulesets bound yet", + "Governance.Templates.RulesetBindings.bound.heading": "Bound Rulesets", + "Governance.Templates.RulesetBindings.clearAll.btn": "Clear all", + "Governance.Templates.RulesetBindings.fetch.error": "Failed to load rulesets", + "Governance.Templates.RulesetBindings.footer.summary": "{count} ruleset(s) will be snapshotted at application creation", + "Governance.Templates.RulesetBindings.heading": "Ruleset Bindings", + "Governance.Templates.RulesetBindings.preview.guards": "Guards: {target}", + "Governance.Templates.RulesetBindings.preview.loading": "Reading ruleset content…", + "Governance.Templates.RulesetBindings.preview.noDerivedRules": "Rule details could not be derived from the ruleset content.", + "Governance.Templates.RulesetBindings.preview.rules": "Rules enforced", + "Governance.Templates.RulesetBindings.remove.tooltip": "Remove binding", + "Governance.Templates.RulesetBindings.scope.allKm": "All Allowed Key Managers", + "Governance.Templates.RulesetBindings.scope.allKm.desc": "Applies to every Key Manager permitted by this template", + "Governance.Templates.RulesetBindings.scope.allKm.option": "All Allowed Key Managers", + "Governance.Templates.RulesetBindings.scope.global.hint": "Select specific Key Managers to restrict when this ruleset is enforced", + "Governance.Templates.RulesetBindings.scope.label": "Key Manager Scope", + "Governance.Templates.RulesetBindings.scope.noKm": "No Key Managers configured", + "Governance.Templates.RulesetBindings.search.empty": "No rulesets match your search", + "Governance.Templates.RulesetBindings.search.placeholder": "Search rulesets…", + "Governance.Templates.RulesetBindings.subheading": "Bind Spectral rulesets to this template. Bound rulesets are snapshotted at application-creation time and enforced synchronously by the governance interceptor.", + "Governance.Templates.Wizard.GeneralDetails.description.helper": "Briefly describe the purpose and intended audience of this template", + "Governance.Templates.Wizard.GeneralDetails.description.label": "Description", + "Governance.Templates.Wizard.GeneralDetails.heading": "General Details", + "Governance.Templates.Wizard.GeneralDetails.isGlobal.helper": "Visible to all organizations as a cross-tenant fallback; only manageable by Super Tenant admins", + "Governance.Templates.Wizard.GeneralDetails.isGlobal.label": "Set as Global Template", + "Governance.Templates.Wizard.GeneralDetails.name.helper": "A unique, human-readable name for this template", + "Governance.Templates.Wizard.GeneralDetails.name.label": "Template Name", + "Governance.Templates.Wizard.GeneralDetails.name.placeholder": "My Template", + "Governance.Templates.Wizard.GeneralDetails.name.required": "Template name is required", + "Governance.Templates.Wizard.GeneralDetails.options.heading": "Template Options", + "Governance.Templates.Wizard.GeneralDetails.subheading": "Provide basic information about this template.", + "Governance.Templates.Wizard.GeneralDetails.tags.helper": "Press Enter to add a tag. Tags help filter templates in the gallery.", + "Governance.Templates.Wizard.GeneralDetails.tags.label": "Tags", + "Governance.Templates.Wizard.btn.back": "Back", + "Governance.Templates.Wizard.btn.cancel": "Cancel", + "Governance.Templates.Wizard.btn.next": "Next", + "Governance.Templates.Wizard.btn.publish": "Publish", + "Governance.Templates.Wizard.btn.saveDraft": "Save as Draft", + "Governance.Templates.Wizard.btn.savePublish": "Save & Publish", + "Governance.Templates.Wizard.keyManager.required.error": "Select at least one Key Manager before publishing.", + "Governance.Templates.Wizard.load.error": "Failed to load template", + "Governance.Templates.Wizard.name.required.error": "Template name is required before saving.", + "Governance.Templates.Wizard.requiredHiddenDefault.error": "{field} is required and hidden, so set a default value before continuing.", + "Governance.Templates.Wizard.save.error": "Failed to save template", + "Governance.Templates.Wizard.title.create": "Create Template", + "Governance.Templates.Wizard.title.edit": "Edit Template", "KeyManager.AddEdit.Invalid.Roles.Found": "Invalid Role(s) Found", "KeyManager.AddEdit.roles.help": "Enter a valid role and press `Enter`", "KeyManager.AddEditKeyManager.permissions.add.description": "Permissions for the Key Manager", diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/RulesetCatalog/AddEditRuleset.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/RulesetCatalog/AddEditRuleset.jsx index 2ac13df74ea..af9b01a72ea 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/Governance/RulesetCatalog/AddEditRuleset.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/RulesetCatalog/AddEditRuleset.jsx @@ -130,9 +130,19 @@ function AddEditRuleset(props) { rulesetContent, documentationLink, } = state; - const rulesetTypeOptions = artifactType === 'MCP' - ? CONSTS.RULESET_TYPES.filter((option) => option.value !== 'API_DEFINITION') - : CONSTS.RULESET_TYPES; + const CONSUMER_ARTIFACT_TYPES = ['APPLICATION']; + const CONSUMER_RULE_TYPES = ['APP_INFO', 'APP_OAUTH']; + const rulesetTypeOptions = (() => { + if (CONSUMER_ARTIFACT_TYPES.includes(artifactType)) { + return CONSTS.RULESET_TYPES.filter((o) => CONSUMER_RULE_TYPES.includes(o.value)); + } + if (artifactType === 'MCP') { + return CONSTS.RULESET_TYPES.filter( + (o) => !CONSUMER_RULE_TYPES.includes(o.value) && o.value !== 'API_DEFINITION', + ); + } + return CONSTS.RULESET_TYPES.filter((o) => !CONSUMER_RULE_TYPES.includes(o.value)); + })(); useEffect(() => { const restApi = new GovernanceAPI(); @@ -168,7 +178,13 @@ function AddEditRuleset(props) { }, [id]); useEffect(() => { - if (artifactType === 'MCP' && ruleType === 'API_DEFINITION') { + const isConsumerArtifact = CONSUMER_ARTIFACT_TYPES.includes(artifactType); + const isConsumerRuleType = CONSUMER_RULE_TYPES.includes(ruleType); + if (isConsumerArtifact && !isConsumerRuleType && ruleType !== '') { + dispatch({ field: 'ruleType', value: '' }); + } else if (!isConsumerArtifact && isConsumerRuleType) { + dispatch({ field: 'ruleType', value: '' }); + } else if (artifactType === 'MCP' && ruleType === 'API_DEFINITION') { dispatch({ field: 'ruleType', value: '' }); } }, [artifactType, ruleType]); diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/ListTemplates.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/ListTemplates.jsx index abb700d6a3d..5ee77309e2c 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/ListTemplates.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/ListTemplates.jsx @@ -16,12 +16,14 @@ * under the License. */ -import React from 'react'; +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; import { useIntl, FormattedMessage } from 'react-intl'; import Typography from '@mui/material/Typography'; import { - Chip, Tooltip, Button, List, ListItemButton, ListItemIcon, Link, ListItemText, + Chip, Switch, Tooltip, Button, List, ListItemButton, ListItemIcon, Link, ListItemText, } from '@mui/material'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { Link as RouterLink } from 'react-router-dom'; import EditIcon from '@mui/icons-material/Edit'; import DescriptionIcon from '@mui/icons-material/Description'; @@ -30,8 +32,78 @@ import HelpBase from 'AppComponents/AdminPages/Addons/HelpBase'; import GovernanceAPI from 'AppData/GovernanceAPI'; import Configurations from 'Config'; import { useAppContext } from 'AppComponents/Shared/AppContext'; +import Alert from 'AppComponents/Shared/Alert'; import DeleteTemplate from './DeleteTemplate'; +/** + * Toggle switch that sets a template as the default by calling the update API. + * Only one template can be default at a time — the server enforces uniqueness. + * On success the list needs a manual refresh (navigate away and back, or press + * the table refresh button) to reflect the previous default being cleared. + */ +function DefaultToggle({ + templateId, isDefault, isReadOnly, onToggled, +}) { + const intl = useIntl(); + const [checked, setChecked] = useState(isDefault); + const [updating, setUpdating] = useState(false); + + const handleChange = (event) => { + const newValue = event.target.checked; + // Optimistic flip — give the user instant feedback. On failure we revert below. + setChecked(newValue); + setUpdating(true); + new GovernanceAPI() + .getDevportalGovernanceTemplateById(templateId) + .then((res) => { + const template = res.body; + return new GovernanceAPI().updateDevportalGovernanceTemplateById(templateId, { + ...template, + isDefault: newValue, + }); + }) + .then(() => { + Alert.success(newValue + ? intl.formatMessage({ + id: 'Governance.Templates.List.default.set.success', + defaultMessage: 'Template set as default.', + }) + : intl.formatMessage({ + id: 'Governance.Templates.List.default.unset.success', + defaultMessage: 'Default status removed.', + })); + if (onToggled) onToggled(templateId, newValue); + }) + .catch(() => { + // Revert the optimistic flip when the server call fails. + setChecked(!newValue); + Alert.error(intl.formatMessage({ + id: 'Governance.Templates.List.default.update.error', + defaultMessage: 'Failed to update default status.', + })); + }) + .finally(() => setUpdating(false)); + }; + + return ( + + ); +} + +DefaultToggle.propTypes = { + templateId: PropTypes.string.isRequired, + isDefault: PropTypes.bool.isRequired, + isReadOnly: PropTypes.bool, + onToggled: PropTypes.func, +}; +DefaultToggle.defaultProps = { isReadOnly: false, onToggled: null }; + /** * Render a list of Devportal Governance Templates. * @returns {JSX} List component @@ -39,8 +111,9 @@ import DeleteTemplate from './DeleteTemplate'; export default function ListTemplates() { const intl = useIntl(); const { isSuperTenant } = useAppContext(); + // refreshKey forces ListBase to re-mount and re-fetch after a default toggle + const [refreshKey, setRefreshKey] = useState(0); - // apiCall defined as closure to capture isSuperTenant for isReadOnly computation function apiCall() { return new GovernanceAPI() .getDevportalGovernanceTemplates({ limit: 100, offset: 0 }) @@ -53,6 +126,14 @@ export default function ListTemplates() { }); } + const handleDefaultToggled = (templateId, newValue) => { + // When setting a new default, force the list to re-fetch so the previously + // default template shows its toggle updated. + if (newValue) { + setRefreshKey((k) => k + 1); + } + }; + // IMPORTANT: id must be the LAST column — ListBase reads rowData[rowData.length - 2] for routing const columProps = [ { @@ -140,13 +221,57 @@ export default function ListTemplates() { }), options: { sort: false, - customBodyRender: (value) => ( - value ? : null + customHeadLabelRender: () => ( + + + {intl.formatMessage({ + id: 'Governance.Templates.List.column.default', + defaultMessage: 'Default', + })} + + + ), - setCellProps: () => ({ style: { width: '12%', textAlign: 'center' } }), + customBodyRender: (value, tableMeta) => { + // ListBase appends a synthesized "Actions" column to rowData (see + // ListBase.jsx — columns.push({ name: '', label: 'Actions', ... }) when + // showActionColumn is true), so the user-declared "id" column is at + // length-2 and "isReadOnly" is at length-3 inside this customBodyRender. + // Reading length-1 here returns the rendered Actions JSX which is truthy + // garbage, causing the toggle's GET to 404 silently and never persist. + // rowData layout: [name, description(hidden), status, isGlobal, isDefault, + // isReadOnly(hidden), id(hidden), ]. + const rowId = tableMeta.rowData[tableMeta.rowData.length - 2]; + const isReadOnly = tableMeta.rowData[tableMeta.rowData.length - 3]; + return ( + + ); + }, + setCellProps: () => ({ style: { width: '10%', textAlign: 'center' } }), setCellHeaderProps: () => ({ style: { textAlign: 'center' } }), }, }, + { + name: 'isReadOnly', + options: { display: false }, + }, { name: 'id', options: { display: false }, // Must remain last — used by ListBase for edit routing @@ -223,6 +348,7 @@ export default function ListTemplates() { return ( }, - subscription: { - throttlingPolicy: { hidden: false, defaultValue: '' }, - }, - keyGeneration: { - keyType: { hidden: false, defaultValue: 'PRODUCTION' }, - grantTypes: { hidden: false, defaultValue: [] }, - validityPeriod: { hidden: false, defaultValue: -1 }, - additionalProperties: { hidden: false, defaultValue: {} }, + // keyManagers is a dynamic object keyed by KM name, populated in FormBuilderStep + keyManagers: {}, + developerExperience: { + summary: '', + limitations: '', }, }; +function mergeFormConfig(formConfig = {}) { + return { + ...INITIAL_FORM_CONFIG, + ...formConfig, + application: { + ...INITIAL_FORM_CONFIG.application, + ...(formConfig.application ?? {}), + }, + // Subscription section removed from INITIAL_FORM_CONFIG; retain existing data if present + ...(formConfig.subscription ? { subscription: formConfig.subscription } : {}), + // keyManagers is dynamic — preserve as-is from stored config + keyManagers: formConfig.keyManagers ?? {}, + developerExperience: { + ...INITIAL_FORM_CONFIG.developerExperience, + ...(formConfig.developerExperience ?? {}), + }, + }; +} + const INITIAL_STATE = { name: '', description: '', + tags: [], + icon: null, status: 'DRAFT', isDefault: false, isGlobal: false, @@ -72,6 +105,56 @@ function templateReducer(state, { field, value }) { return { ...state, [field]: value }; } +const isConfigTrue = (value) => value === true || value === 'true'; +const isConfigActive = (config = {}) => config.active !== false && config.active !== 'false'; + +const getEnabledKeyManagerNames = (formConfig = {}) => Object.entries(formConfig.keyManagers ?? {}) + .filter(([, config]) => config?.enabled === true) + .map(([name]) => name); + +const isDefaultEmpty = (value) => { + if (value === null || value === undefined) return true; + if (typeof value === 'string') return value.trim().length === 0; + if (Array.isArray(value)) return value.length === 0; + if (typeof value === 'object') return Object.keys(value).length === 0; + return false; +}; + +const toFieldLabel = (path) => { + if (path === 'application.description') return 'Application description'; + if (path === 'application.groups') return 'Application groups'; + if (path.startsWith('application.attributes.')) { + return `Application attribute "${path.slice('application.attributes.'.length)}"`; + } + return path || 'Field'; +}; + +const collectRequiredHiddenDefaultFields = (config, path = '') => { + if (!config || typeof config !== 'object' || Array.isArray(config)) return []; + + // Skip per-KM config objects that have governance disabled + if (config.enabled === false) return []; + + const fields = []; + if ( + isConfigActive(config) + && isConfigTrue(config.required) + && isConfigTrue(config.hidden) + && isDefaultEmpty(config.defaultValue) + ) { + fields.push(toFieldLabel(path)); + } + + Object.entries(config).forEach(([key, value]) => { + if (key === 'defaultValue') return; + if (value && typeof value === 'object' && !Array.isArray(value)) { + const childPath = path ? `${path}.${key}` : key; + fields.push(...collectRequiredHiddenDefaultFields(value, childPath)); + } + }); + return fields; +}; + const STEPS = [ { id: 'Governance.Templates.Wizard.step.general', @@ -79,25 +162,108 @@ const STEPS = [ }, { id: 'Governance.Templates.Wizard.step.formBuilder', - defaultMessage: 'Form Builder', + defaultMessage: 'Field Configuration', }, { id: 'Governance.Templates.Wizard.step.rulesets', defaultMessage: 'Ruleset Bindings', }, + { + id: 'Governance.Templates.Wizard.step.developerView', + defaultMessage: 'Developer View', + }, { id: 'Governance.Templates.Wizard.step.review', defaultMessage: 'Review & Publish', }, ]; +/** + * Split button for the final wizard step. + * Default action = Publish; dropdown reveals Save as Draft. + */ +function PublishSplitButton({ + isEditMode, saving, disabled, onPublish, onDraft, +}) { + const intl = useIntl(); + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + + const publishLabel = isEditMode + ? intl.formatMessage({ id: 'Governance.Templates.Wizard.btn.savePublish', defaultMessage: 'Save & Publish' }) + : intl.formatMessage({ id: 'Governance.Templates.Wizard.btn.publish', defaultMessage: 'Publish' }); + + const draftLabel = intl.formatMessage({ + id: 'Governance.Templates.Wizard.btn.saveDraft', + defaultMessage: 'Save as Draft', + }); + + return ( + <> + + + + + + {({ TransitionProps, placement }) => ( + + + setOpen(false)}> + + { + setOpen(false); + onDraft(); + }} + > + {draftLabel} + + + + + + )} + + + ); +} + +PublishSplitButton.propTypes = { + isEditMode: PropTypes.bool.isRequired, + saving: PropTypes.bool.isRequired, + disabled: PropTypes.bool.isRequired, + onPublish: PropTypes.func.isRequired, + onDraft: PropTypes.func.isRequired, +}; + /** * Multi-step wizard for creating and editing Devportal Governance Templates. - * Holds all template state; child step components receive templateState + dispatch. - * @returns {JSX} TemplateWizard component */ export default function TemplateWizard() { const { id: templateId } = useParams(); + const history = useHistory(); const intl = useIntl(); const isEditMode = !!templateId; @@ -114,10 +280,12 @@ export default function TemplateWizard() { const t = res.body; dispatch({ field: 'name', value: t.name || '' }); dispatch({ field: 'description', value: t.description || '' }); + dispatch({ field: 'tags', value: Array.isArray(t.tags) ? t.tags : [] }); + dispatch({ field: 'icon', value: t.icon || null }); dispatch({ field: 'status', value: t.status || 'DRAFT' }); dispatch({ field: 'isDefault', value: !!t.isDefault }); dispatch({ field: 'isGlobal', value: !!t.isGlobal }); - dispatch({ field: 'formConfig', value: t.formConfig || INITIAL_FORM_CONFIG }); + dispatch({ field: 'formConfig', value: mergeFormConfig(t.formConfig) }); dispatch({ field: 'rulesetBindings', value: t.rulesetBindings || [] }); }) .catch((error) => { @@ -131,12 +299,48 @@ export default function TemplateWizard() { .finally(() => setLoading(false)); }, [templateId]); - const handleSave = () => { + const requiredHiddenDefaultFields = collectRequiredHiddenDefaultFields(templateState.formConfig); + + const showRequiredHiddenDefaultError = () => { + Alert.error(intl.formatMessage( + { + id: 'Governance.Templates.Wizard.requiredHiddenDefault.error', + defaultMessage: '{field} is required and hidden, so set a default value before continuing.', + }, + { field: requiredHiddenDefaultFields[0] }, + )); + }; + + const handleNext = () => setActiveStep((s) => s + 1); + + const handleSaveAs = (statusOverride) => { + if (!templateState.name.trim()) { + Alert.error(intl.formatMessage({ + id: 'Governance.Templates.Wizard.name.required.error', + defaultMessage: 'Template name is required before saving.', + })); + setActiveStep(0); + return; + } + if (statusOverride === 'PUBLISHED' && getEnabledKeyManagerNames(templateState.formConfig).length === 0) { + Alert.error(intl.formatMessage({ + id: 'Governance.Templates.Wizard.keyManager.required.error', + defaultMessage: 'Select at least one Key Manager before publishing.', + })); + setActiveStep(1); + return; + } + if (requiredHiddenDefaultFields.length > 0) { + showRequiredHiddenDefaultError(); + return; + } setSaving(true); const payload = { name: templateState.name, description: templateState.description, - status: templateState.status, + tags: templateState.tags, + icon: templateState.icon, + status: statusOverride, isDefault: templateState.isDefault, isGlobal: templateState.isGlobal, formConfig: templateState.formConfig, @@ -155,6 +359,7 @@ export default function TemplateWizard() { ? 'Template updated successfully' : 'Template created successfully', })); + history.push('/governance/templates'); }) .catch((error) => { const msg = error?.response?.body?.message @@ -187,10 +392,8 @@ export default function TemplateWizard() { ); } - const isNameValid = templateState.name.trim().length > 0; const isLastStep = activeStep === STEPS.length - 1; - // Placeholder content for steps not yet implemented const stepContent = [ , + , , ]; return ( - - {/* Stepper header */} - - - {STEPS.map((step) => ( - - + + + {/* Stepper header */} + + + {STEPS.map((step, index) => ( + setActiveStep(index)} sx={{ cursor: 'pointer' }}> + + + + + ))} + + + + {/* Active step content */} + + {stepContent[activeStep]} + + + {/* Navigation — all buttons on the left, sticky at bottom */} + + {/* Step 0: Cancel + Next. Steps 1+: Back + Next (no Cancel). */} + {activeStep === 0 ? ( + + - - {activeStep > 0 && ( + + + ) : ( )} - - + {isLastStep ? ( - + handleSaveAs('PUBLISHED')} + onDraft={() => handleSaveAs('DRAFT')} + /> ) : ( + {iconPreview && ( + + )} + + + {iconError && ( + + {iconError} + + )} + + + + + ); +} + +IconUpload.propTypes = { + iconPreview: PropTypes.string, + onIconChange: PropTypes.func.isRequired, +}; +IconUpload.defaultProps = { iconPreview: null }; + /** - * Step 1 of the TemplateWizard: name, description, status, isDefault, isGlobal (super tenant only). - * @param {Object} props - * @param {Object} props.templateState - wizard state slice for this step - * @param {Function} props.dispatch - reducer dispatch from TemplateWizard - * @returns {JSX} + * Step 1 of the TemplateWizard: name, description, tags, icon, isGlobal (super tenant only). + * isDefault is managed from the template list, not here. + * Status (Draft / Published) is chosen via the wizard navigation dropdown. */ export default function GeneralDetailsStep({ templateState, dispatch }) { const intl = useIntl(); const { isSuperTenant } = useAppContext(); const { - name, description, status, isDefault, isGlobal, + name, description, tags, icon, isGlobal, } = templateState; - // Track blur to avoid showing validation errors before the user touches the field const [nameTouched, setNameTouched] = useState(false); - - const nameError = nameTouched && !name.trim(); + const [nameChanged, setNameChanged] = useState(false); + // Only show error when the user has both changed the value AND moved focus away + const nameError = nameTouched && nameChanged && !name.trim(); return ( @@ -61,13 +201,13 @@ export default function GeneralDetailsStep({ templateState, dispatch }) { {/* Name */} - + dispatch({ field: 'name', value: e.target.value })} + onChange={(e) => { + setNameChanged(true); + dispatch({ field: 'name', value: e.target.value }); + }} onBlur={() => setNameTouched(true)} error={nameError} helperText={nameError @@ -89,42 +236,16 @@ export default function GeneralDetailsStep({ templateState, dispatch }) { defaultMessage: 'A unique, human-readable name for this template', })} inputProps={{ maxLength: 256 }} + InputLabelProps={{ + required: true, + sx: { + '& .MuiFormLabel-asterisk': { color: 'error.main' }, + }, + }} variant='outlined' /> - {/* Status */} - - dispatch({ field: 'status', value: e.target.value })} - helperText={intl.formatMessage({ - id: 'Governance.Templates.Wizard.GeneralDetails.status.helper', - defaultMessage: 'Only PUBLISHED templates are visible to Devportal users', - })} - variant='outlined' - > - - - - - - - - - {/* Description */} - {/* Toggles section */} - - - - - - - - {/* isDefault toggle */} - - dispatch({ field: 'isDefault', value: e.target.checked })} - color='primary' + {/* Tags */} + + dispatch({ field: 'tags', value: newValue })} + renderTags={(value, getTagProps) => value.map((option, index) => ( + + ))} + renderInput={(params) => ( + )} - label={( - - - - - - - - - )} - sx={{ alignItems: 'flex-start', ml: 0 }} + /> + + + {/* Icon Upload */} + + dispatch({ field: 'icon', value })} /> {/* isGlobal toggle — super tenant only */} {isSuperTenant && ( - + + + + + dispatch({ field: 'isGlobal', value: e.target.checked })} - color='secondary' /> )} label={( @@ -213,9 +339,9 @@ export default function GeneralDetailsStep({ templateState, dispatch }) { @@ -233,8 +359,8 @@ GeneralDetailsStep.propTypes = { templateState: PropTypes.shape({ name: PropTypes.string.isRequired, description: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - isDefault: PropTypes.bool.isRequired, + tags: PropTypes.arrayOf(PropTypes.string), + icon: PropTypes.string, isGlobal: PropTypes.bool.isRequired, }).isRequired, dispatch: PropTypes.func.isRequired, diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx index 7a8c4ae9760..2d24bd8db91 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx @@ -23,10 +23,11 @@ import { Accordion, AccordionDetails, AccordionSummary, + Alert, Box, Chip, CircularProgress, - Grid, + Divider, Paper, Table, TableBody, @@ -38,7 +39,7 @@ import { import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import GovernanceAPI from 'AppData/GovernanceAPI'; -// ── Static label maps (mirrors FormBuilderStep; extracted here to avoid cross-import) ── +// ── Label maps ──────────────────────────────────────────────────────────────── const GRANT_TYPE_LABELS = { authorization_code: 'Authorization Code', @@ -47,21 +48,12 @@ const GRANT_TYPE_LABELS = { client_credentials: 'Client Credentials', refresh_token: 'Refresh Token', 'urn:ietf:params:oauth:grant-type:device_code': 'Device Code', -}; - -const TOKEN_TYPE_LABELS = { - JWT: 'JWT', - OAUTH: 'OAuth (Opaque)', -}; - -const KEY_TYPE_LABELS = { - PRODUCTION: 'Production', - SANDBOX: 'Sandbox', + 'urn:ietf:params:oauth:grant-type:token-exchange': 'Token Exchange', }; /** - * Metadata describing every formConfig field for human-readable rendering. - * Shape per entry: { label, section, fieldKey, valueMap?, isArray? } + * Metadata for formConfig fields — application section only. + * Key Manager governance is rendered dynamically from formConfig.keyManagers. */ const FORM_CONFIG_META = [ { @@ -69,41 +61,68 @@ const FORM_CONFIG_META = [ sectionLabel: 'Application Details', fields: [ { fieldKey: 'throttlingPolicy', label: 'Throttling Policy' }, - { fieldKey: 'tokenType', label: 'Token Type', valueMap: TOKEN_TYPE_LABELS }, - { fieldKey: 'callbackUrl', label: 'Callback URL' }, - ], - }, - { - sectionKey: 'subscription', - sectionLabel: 'Subscription', - fields: [ - { fieldKey: 'throttlingPolicy', label: 'Throttling Policy' }, + { fieldKey: 'description', label: 'Description' }, + { fieldKey: 'groups', label: 'Application Groups' }, ], }, +]; + +const KM_FIELD_META = [ { - sectionKey: 'keyGeneration', - sectionLabel: 'Key Generation', - fields: [ - { fieldKey: 'keyType', label: 'Key Type', valueMap: KEY_TYPE_LABELS }, - { - fieldKey: 'grantTypes', label: 'Grant Types', valueMap: GRANT_TYPE_LABELS, isArray: true, - }, - { fieldKey: 'validityPeriod', label: 'Validity Period (seconds)' }, - { fieldKey: 'additionalProperties', label: 'Additional Properties', noDefault: true }, - ], + fieldKey: 'grantTypes', + label: 'Grant Types', + valueMap: GRANT_TYPE_LABELS, + isArray: true, }, + { fieldKey: 'callbackUrl', label: 'Callback URL' }, + { fieldKey: 'appAccessTokenExpiry', label: 'App Access Token Expiry (s)' }, + { fieldKey: 'userAccessTokenExpiry', label: 'User Access Token Expiry (s)' }, + { fieldKey: 'refreshTokenExpiry', label: 'Refresh Token Expiry (s)' }, + { fieldKey: 'idTokenExpiry', label: 'ID Token Expiry (s)' }, + { fieldKey: 'enablePKCE', label: 'Enable PKCE' }, + { fieldKey: 'pkceSupportsPlainText', label: 'Allow PKCE Plain Text' }, + { fieldKey: 'publicClient', label: 'Public Client' }, ]; -// ── File-scope sub-components (stable references, no remounting) ────────────────────── +function getRulesetAppliesToLabel(intl, binding, ruleset) { + const isOAuthRuleset = ruleset?.ruleType === 'APP_OAUTH'; + if (!isOAuthRuleset) { + return intl.formatMessage({ + id: 'Governance.Templates.ReviewPublish.rulesets.scope.applicationDetails', + defaultMessage: 'Application Details', + }); + } + + if (binding.keyManagerScopes.length === 0) { + return intl.formatMessage({ + id: 'Governance.Templates.ReviewPublish.rulesets.scope.all', + defaultMessage: 'All Allowed Key Managers', + }); + } + + return intl.formatMessage( + { + id: 'Governance.Templates.ReviewPublish.rulesets.scope.count', + defaultMessage: '{count} Key Manager(s)', + }, + { count: binding.keyManagerScopes.length }, + ); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── -/** - * Titled section card with a colored left-border accent. - */ function SummarySection({ title, children, accentColor = 'primary.main' }) { return ( - - {title} - + {title} - + {/* prose content (developer summary, limitations) wraps; tabular content + inside its own container can still scroll horizontally with its own sx. */} + {children} @@ -132,35 +160,323 @@ SummarySection.propTypes = { children: PropTypes.node.isRequired, accentColor: PropTypes.string, }; +SummarySection.defaultProps = { accentColor: 'primary.main' }; -SummarySection.defaultProps = { - accentColor: 'primary.main', -}; - -/** - * Two-column label / value row. - */ function SummaryRow({ label, children }) { return ( - - - - {label} - - - + + + {label} + + {children} - - + + + ); +} +SummaryRow.propTypes = { label: PropTypes.node.isRequired, children: PropTypes.node.isRequired }; + +function ConfigSummaryRow({ + label, visibilityChip, requiredChip, children, +}) { + return ( + + + {label} + + + {visibilityChip} + {requiredChip} + + {children} + + + ); } -SummaryRow.propTypes = { +ConfigSummaryRow.propTypes = { label: PropTypes.node.isRequired, + visibilityChip: PropTypes.node.isRequired, + requiredChip: PropTypes.node.isRequired, + children: PropTypes.node.isRequired, +}; + +function TextSummaryBlock({ label, children }) { + return ( + + + {label} + + + {children} + + + ); +} + +TextSummaryBlock.propTypes = { label: PropTypes.node.isRequired, children: PropTypes.node.isRequired }; + +function WrappedReviewText({ + children, color, fontStyle, preserveLines, +}) { + return ( + + {children} + + ); +} + +WrappedReviewText.propTypes = { children: PropTypes.node.isRequired, + color: PropTypes.string, + fontStyle: PropTypes.string, + preserveLines: PropTypes.bool, }; -// ── Helper to render a field's default value in a human-readable way ────────────────── +WrappedReviewText.defaultProps = { + color: 'text.primary', + fontStyle: 'normal', + preserveLines: false, +}; + +function LimitationList({ items }) { + return ( + + {items.map((item) => ( + + + + {item} + + + ))} + + ); +} + +LimitationList.propTypes = { + items: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +function DefaultValueList({ items }) { + return ( + + {items.map((item) => ( + + + + {item} + + + ))} + + ); +} + +DefaultValueList.propTypes = { + items: PropTypes.arrayOf(PropTypes.string).isRequired, +}; function renderDefaultValue(fieldMeta, fieldConfig) { const { valueMap, isArray, noDefault } = fieldMeta; @@ -176,7 +492,6 @@ function renderDefaultValue(fieldMeta, fieldConfig) { ); } - if (raw === undefined || raw === null || raw === '') { return ( @@ -187,10 +502,7 @@ function renderDefaultValue(fieldMeta, fieldConfig) { ); } - - if (raw === -1) { - return Unlimited; - } + if (raw === -1) return Unlimited; if (isArray && Array.isArray(raw)) { if (raw.length === 0) { @@ -203,42 +515,46 @@ function renderDefaultValue(fieldMeta, fieldConfig) { ); } - return ( - - {raw.map((v) => ( - - ))} - - ); + return valueMap?.[v] ?? v)} />; } + return ( + + {valueMap?.[raw] ?? String(raw)} + + ); +} - const displayValue = valueMap?.[raw] ?? String(raw); - return {displayValue}; +function normalizeLimitations(limitations) { + const raw = Array.isArray(limitations) + ? limitations + : String(limitations || '').split('\n'); + return raw.map((item) => String(item).trim()).filter(Boolean); } -// ── Main component ───────────────────────────────────────────────────────────────────── +// ── Main component ──────────────────────────────────────────────────────────── /** - * Step 4 of the TemplateWizard: read-only summary of all configured values before save. - * @param {Object} props - * @param {Object} props.templateState - full wizard state - * @returns {JSX} + * Step 4 of the TemplateWizard. + * Shows a full read-only summary plus a publish/draft toggle. + * The wizard's Save button label reacts to the status choice made here. */ -export default function ReviewPublishStep({ templateState }) { +export default function ReviewPublishStep({ + templateState, +}) { const intl = useIntl(); const { - name, description, status, isDefault, isGlobal, + name, description, isDefault, isGlobal, formConfig, rulesetBindings, } = templateState; + const developerExperience = formConfig?.developerExperience ?? {}; + const developerLimitations = normalizeLimitations(developerExperience.limitations); + const [rulesetMap, setRulesetMap] = useState({}); const [loadingRulesets, setLoadingRulesets] = useState(false); - useEffect(() => { if (rulesetBindings.length === 0) return; setLoadingRulesets(true); @@ -246,26 +562,26 @@ export default function ReviewPublishStep({ templateState }) { .getRulesets({ limit: 200, offset: 0 }) .then((res) => { const map = {}; - (res.body?.list ?? []).forEach((r) => { map[r.id] = r.name; }); + (res.body?.list ?? []).forEach((r) => { map[r.id] = r; }); setRulesetMap(map); }) .catch(() => {}) .finally(() => setLoadingRulesets(false)); - }, []); + }, [rulesetBindings.length]); - // DTO payload for the raw JSON accordion const dtoPayload = useMemo(() => ({ - name, - description, - status, - isDefault, - isGlobal, - formConfig, - rulesetBindings, - }), [name, description, status, isDefault, isGlobal, formConfig, rulesetBindings]); + name, description, isDefault, isGlobal, formConfig, rulesetBindings, + }), [name, description, isDefault, isGlobal, formConfig, rulesetBindings]); return ( - + + {/* Ruleset default validation reminder */} + {rulesetBindings.length > 0 && ( + + + + )} + + + {/* ── Section 1: General Details ── */} {name || '—'} - {description || intl.formatMessage({ id: 'Governance.Templates.ReviewPublish.label.noDescription', @@ -313,28 +647,20 @@ export default function ReviewPublishStep({ templateState }) { })} - - - - - - + {isDefault && ( - {FORM_CONFIG_META.map(({ sectionKey, sectionLabel, fields }) => ( - - - {sectionLabel} - - {fields.map((fieldMeta) => { - const fieldConfig = formConfig?.[sectionKey]?.[fieldMeta.fieldKey] - ?? { hidden: false, defaultValue: fieldMeta.isArray ? [] : '' }; - const isHidden = !!fieldConfig.hidden; + {/* Application Details */} + {FORM_CONFIG_META.map(({ sectionKey, sectionLabel, fields }) => { + const extraFields = sectionKey === 'application' + ? Object.keys(formConfig?.application?.attributes ?? {}).map((attrKey) => ({ + fieldKey: `attributes.${attrKey}`, + label: attrKey, + isCustomAttr: true, + })) + : []; + const allFields = [...fields, ...extraFields]; + + return ( + + + {sectionLabel} + + {allFields.map((fieldMeta) => { + const attrName = fieldMeta.fieldKey.split('.')[1]; + const fieldConfig = fieldMeta.isCustomAttr + ? (formConfig?.application?.attributes?.[attrName] + ?? { hidden: false, defaultValue: '' }) + : (formConfig?.[sectionKey]?.[fieldMeta.fieldKey] + ?? { hidden: false, defaultValue: fieldMeta.isArray ? [] : '' }); + const isHidden = !!fieldConfig.hidden; + const isRequired = fieldConfig.required === true || fieldConfig.required === 'true'; + const isInactive = fieldConfig.active === false || fieldConfig.active === 'false'; + const hiddenLabel = intl.formatMessage({ + id: 'Governance.Templates.ReviewPublish.field.hidden', + defaultMessage: 'Hidden', + }); + const visibleLabel = intl.formatMessage({ + id: 'Governance.Templates.ReviewPublish.field.visible', + defaultMessage: 'Visible', + }); + const requiredLabel = intl.formatMessage({ + id: 'Governance.Templates.ReviewPublish.field.required', + defaultMessage: 'Required', + }); + const optionalLabel = intl.formatMessage({ + id: 'Governance.Templates.ReviewPublish.field.optional', + defaultMessage: 'Optional', + }); + const unavailableLabel = intl.formatMessage({ + id: 'Governance.Templates.ReviewPublish.field.unavailable', + defaultMessage: 'Unavailable', + }); + let visibilityLabel = visibleLabel; + let visibilityColor = 'default'; + let visibilityVariant = 'outlined'; + if (isInactive) { + visibilityLabel = unavailableLabel; + } else if (isHidden) { + visibilityLabel = hiddenLabel; + visibilityColor = 'warning'; + visibilityVariant = 'filled'; + } + return ( + + )} + requiredChip={( + + )} + > + {renderDefaultValue(fieldMeta, fieldConfig)} + + ); + })} + + ); + })} + + {/* Key Manager Governance */} + + + + + {(() => { + const kmConfigs = formConfig?.keyManagers ?? {}; + const enabledKMs = Object.entries(kmConfigs) + .filter(([, kmc]) => kmc?.enabled === true); + + if (enabledKMs.length === 0) { return ( - + + + ); + } + + return enabledKMs.map(([kmName, kmConfig]) => ( + + - - - {fieldMeta.label} - - - - - - - {isHidden ? ( - - + {KM_FIELD_META.map((fieldMeta) => { + const fieldConfig = kmConfig[fieldMeta.fieldKey] + ?? { hidden: false, defaultValue: fieldMeta.isArray ? [] : '' }; + const isHidden = !!fieldConfig.hidden; + const isRequired = fieldConfig.required === true + || fieldConfig.required === 'true'; + return ( + - - ) : ( - renderDefaultValue(fieldMeta, fieldConfig) - )} - - - ); - })} - - ))} + )} + requiredChip={( + + )} + > + {renderDefaultValue(fieldMeta, fieldConfig)} + + ); + })} + + )); + })()} + {/* ── Section 3: Ruleset Bindings ── */} @@ -456,7 +909,14 @@ export default function ReviewPublishStep({ templateState }) { ) : ( <> {loadingRulesets && ( - + )} - - - - - - - - - - - - - - - - {[...rulesetBindings] - .sort((a, b) => a.bindingOrder - b.bindingOrder) - .map((binding) => { - const rulesetName = rulesetMap[binding.rulesetId] - ?? binding.rulesetId; - const scopeLabel = binding.keyManagerScopes.length === 0 - ? intl.formatMessage({ - id: 'Governance.Templates.ReviewPublish.rulesets.scope.all', - defaultMessage: 'All Key Managers', - }) - : intl.formatMessage( - { - id: 'Governance.Templates.ReviewPublish.rulesets.scope.count', - defaultMessage: '{count} Key Manager(s)', - }, - { count: binding.keyManagerScopes.length }, + +
+ + + + + + + + + + + + + + + {[...rulesetBindings] + .sort((a, b) => a.bindingOrder - b.bindingOrder) + .map((binding) => { + const ruleset = rulesetMap[binding.rulesetId]; + const rulesetName = ruleset?.name ?? binding.rulesetId; + const appliesToLabel = getRulesetAppliesToLabel(intl, binding, ruleset); + return ( + + {binding.bindingOrder + 1} + + + {rulesetName} + + + + + {appliesToLabel} + + + ); - return ( - - {binding.bindingOrder + 1} - - {rulesetName} - - - - {scopeLabel} - - - - ); - })} - -
+ })} + + +
)} - {/* ── Section 4: Raw Payload (collapsible) ── */} + {/* ── Section 4: Developer View ── */} + + + + {developerExperience.summary || intl.formatMessage({ + id: 'Governance.Templates.ReviewPublish.developerView.noSummary', + defaultMessage: 'No developer summary configured', + })} + + + + {developerLimitations.length === 0 ? ( + + + + ) : ( + + )} + + + + {/* ── Section 5: Raw Payload (collapsible) ── */} }> @@ -550,7 +1073,14 @@ export default function ReviewPublishStep({ templateState }) { bgcolor: 'grey.50', borderTop: 1, borderColor: 'divider', + width: '100%', + boxSizing: 'border-box', + minWidth: 0, + maxWidth: '100%', overflowX: 'auto', + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + wordBreak: 'break-all', fontSize: '0.75rem', fontFamily: 'monospace', lineHeight: 1.6, @@ -568,7 +1098,6 @@ ReviewPublishStep.propTypes = { templateState: PropTypes.shape({ name: PropTypes.string.isRequired, description: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, isDefault: PropTypes.bool.isRequired, isGlobal: PropTypes.bool.isRequired, formConfig: PropTypes.shape({}).isRequired, diff --git a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx index 7f0f7b7304b..9afd5f6041e 100644 --- a/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx @@ -24,6 +24,7 @@ import { Button, Chip, CircularProgress, + Collapse, Divider, Grid, IconButton, @@ -36,8 +37,9 @@ import { Tooltip, Typography, } from '@mui/material'; -import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import AddIcon from '@mui/icons-material/Add'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import SearchIcon from '@mui/icons-material/Search'; import PublicIcon from '@mui/icons-material/Public'; import VpnKeyIcon from '@mui/icons-material/VpnKey'; @@ -51,6 +53,108 @@ import Utils from 'AppData/Utils'; // Internally we represent that as this sentinel so MUI Select has a value to display. const ALL_KM_VALUE = '__all_key_managers__'; +function cleanYamlScalar(value = '') { + return String(value) + .replace(/\s+#.*$/, '') + .replace(/^['"]|['"]$/g, '') + .trim(); +} + +function getIndent(line) { + const match = String(line).match(/^ */); + return match ? match[0].length : 0; +} + +function normalizeGiven(value) { + if (Array.isArray(value)) return value.join(', '); + return String(value ?? '').trim(); +} + +function buildRulePreview(name, rule = {}) { + const message = rule.message || rule.description || ''; + const severity = rule.severity || ''; + const given = normalizeGiven(rule.given); + return { + name, + message: String(message), + severity: String(severity), + given, + }; +} + +function parseJsonRuleset(content) { + try { + const parsed = JSON.parse(content); + if (!parsed?.rules || typeof parsed.rules !== 'object') return []; + return Object.entries(parsed.rules).map(([name, rule]) => buildRulePreview(name, rule)); + } catch (error) { + return []; + } +} + +function parseYamlRuleset(content) { + const lines = String(content ?? '').split(/\r?\n/); + const rulesLineIndex = lines.findIndex((line) => /^\s*rules\s*:/.test(line)); + if (rulesLineIndex === -1) return []; + + const rulesIndent = getIndent(lines[rulesLineIndex]); + let ruleIndent = null; + let currentRule = null; + let insideRules = true; + const rules = []; + + lines.slice(rulesLineIndex + 1).forEach((line) => { + if (!insideRules) return; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return; + + const indent = getIndent(line); + if (indent <= rulesIndent) { + insideRules = false; + return; + } + + const ruleHeader = line.match(/^\s*(['"]?[^:'"]+['"]?)\s*:\s*(?:#.*)?$/); + if (ruleHeader && (ruleIndent === null || indent === ruleIndent)) { + ruleIndent = indent; + currentRule = { + name: cleanYamlScalar(ruleHeader[1]), + message: '', + severity: '', + given: '', + }; + rules.push(currentRule); + return; + } + + if (!currentRule || indent <= ruleIndent) return; + + const property = line.match(/^\s*(description|message|severity|given)\s*:\s*(.*)$/); + if (property) { + const [, key, value] = property; + currentRule[key] = cleanYamlScalar(value); + } + }); + + return rules.map((rule) => buildRulePreview(rule.name, rule)); +} + +function deriveRulesetRules(content) { + const jsonRules = parseJsonRuleset(content); + if (jsonRules.length > 0) return jsonRules; + return parseYamlRuleset(content); +} + +function getGuardSummary(ruleset) { + if (ruleset?.ruleType === 'APP_OAUTH') { + return 'Key Manager and OAuth application settings'; + } + if (ruleset?.ruleType === 'APP_INFO') { + return 'Application details'; + } + return Utils.mapRuleTypeToLabel(ruleset?.ruleType); +} + // ─── Available-ruleset row (left pane) ──────────────────────────────────────── /** @@ -58,14 +162,13 @@ const ALL_KM_VALUE = '__all_key_managers__'; * Shows name, ruleType chip, artifactType chip, and an Add button. * The Add button is replaced with a "Bound" chip when already added. */ -function AvailableRulesetRow({ ruleset, isBound, onAdd }) { +function AvailableRulesetRow({ + ruleset, isBound, onAdd, expanded, onToggle, preview, loadingPreview, +}) { const intl = useIntl(); return ( - {/* Ruleset info */} - - - {ruleset.name} - - { + if (event.key === 'Enter' || event.key === ' ') onToggle(); + }} + sx={{ display: 'flex', - gap: 0.5, - mt: 0.5, - flexWrap: 'wrap', - }}> - - + {/* Ruleset info */} + + + {ruleset.name} + + + + + + + + + {/* Action */} + {isBound ? ( + + ) : ( + + { + event.stopPropagation(); + onAdd(); + }} + sx={{ '& svg': { fontSize: '1.5rem' } }} + > + + + + )} + - - {/* Action */} - {isBound ? ( - - ) : ( - + - - - - - )} + + + + {ruleset.description && ( + + {ruleset.description} + + )} + {loadingPreview ? ( + + + + + + + ) : ( + + + + + {(preview?.rules ?? []).some((rule) => rule.message) ? ( + + {preview.rules.filter((rule) => rule.message).map((rule) => ( + + {rule.message} + + ))} + + ) : ( + + + + )} + {preview?.content && preview.rules.length === 0 && ( + + {preview.content.slice(0, 1500)} + + )} + + )} + + ); } @@ -139,11 +379,28 @@ AvailableRulesetRow.propTypes = { ruleset: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, + description: PropTypes.string, ruleType: PropTypes.string.isRequired, artifactType: PropTypes.string.isRequired, }).isRequired, isBound: PropTypes.bool.isRequired, onAdd: PropTypes.func.isRequired, + expanded: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, + loadingPreview: PropTypes.bool.isRequired, + preview: PropTypes.shape({ + content: PropTypes.string, + rules: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string.isRequired, + message: PropTypes.string, + severity: PropTypes.string, + given: PropTypes.string, + })), + }), +}; + +AvailableRulesetRow.defaultProps = { + preview: null, }; // ─── Bound-ruleset card (right pane) ───────────────────────────────────────── @@ -154,10 +411,11 @@ AvailableRulesetRow.propTypes = { * Controls: * - Binding order: integer TextField * - Key Manager scope: multi-Select with sentinel ALL_KM_VALUE for global scope + * (only shown when ruleType === 'APP_OAUTH') * - Remove button * * KM scope behaviour: - * - Value `[ALL_KM_VALUE]` → payload `keyManagerScopes: []` (all key managers) + * - Value `[ALL_KM_VALUE]` → payload `keyManagerScopes: []` (all allowed KMs) * - Value `['km-uuid-1', ...]` → payload `keyManagerScopes: [{ keyManagerUuid: ... }]` * * The sentinel is mapped in/out only at this component boundary; the DTO never @@ -167,7 +425,7 @@ function BoundRulesetCard({ binding, ruleset, keyManagers, - onOrderChange, + allowedKeyManagerNames, onScopeChange, onRemove, }) { @@ -178,6 +436,12 @@ function BoundRulesetCard({ ? [ALL_KM_VALUE] : binding.keyManagerScopes.map((s) => s.keyManagerUuid); + // Filter KM list to those allowed by the template's formConfig. + // If allowedKeyManagerNames is empty the template permits all KMs. + const eligibleKeyManagers = allowedKeyManagerNames.length === 0 + ? keyManagers + : keyManagers.filter((km) => allowedKeyManagerNames.includes(km.name)); + const handleKmChange = (event) => { const raw = event.target.value; // string[] from MUI multi-select const lastPicked = raw[raw.length - 1]; @@ -201,6 +465,7 @@ function BoundRulesetCard({ }; const rulesetName = ruleset?.name || binding.rulesetId; + const isOAuth = ruleset?.ruleType === 'APP_OAUTH'; const isGlobalScope = binding.keyManagerScopes.length === 0; return ( @@ -222,7 +487,14 @@ function BoundRulesetCard({ mb: 1.5, }} > - + - {/* Binding order */} - - - - - onOrderChange(Math.max(0, Number(e.target.value)))} - inputProps={{ min: 0, step: 1 }} - helperText={intl.formatMessage({ - id: 'Governance.Templates.RulesetBindings.order.helper', - defaultMessage: 'Lower = evaluated first', - })} - /> - - - {/* Key Manager scope */} - - - - - } + renderValue={(selected) => { + if (selected.includes(ALL_KM_VALUE) || selected.length === 0) { + return ( + + + + + + + ); + } return ( - - - + + {selected.map((kmId) => { + const km = keyManagers.find((k) => k.id === kmId); + return ( + } + /> + ); + })} + + ); + }} + MenuProps={{ PaperProps: { style: { maxHeight: 260 } } }} + > + {/* "All Allowed Key Managers" sentinel option */} + + + + + - - ); - } - return ( - - {selected.map((kmId) => { - const km = keyManagers.find((k) => k.id === kmId); - return ( - } + + - ); - })} + + - ); - }} - MenuProps={{ PaperProps: { style: { maxHeight: 260 } } }} - > - {/* "All Key Managers" sentinel option */} - - - - - - - - + + + {/* Divider before specific KMs */} + {eligibleKeyManagers.length > 0 && } + + {/* Individual Key Manager options */} + {eligibleKeyManagers.map((km) => ( + + + + + {km.name} + {km.description && ( + + {km.description} + + )} + + {km.isGlobal && ( + + )} + + + ))} + + {eligibleKeyManagers.length === 0 && ( + + - - - - - {/* Divider before specific KMs */} - {keyManagers.length > 0 && } - - {/* Individual Key Manager options */} - {keyManagers.map((km) => ( - - - - - {km.name} - {km.description && ( - - {km.description} - - )} - - {km.isGlobal && ( - - )} - - - ))} + + )} + - {keyManagers.length === 0 && ( - - - - - + {isGlobalScope && ( + + + )} - - - {isGlobalScope && ( - - - - )} - + + )}
); @@ -420,7 +682,7 @@ BoundRulesetCard.propTypes = { name: PropTypes.string.isRequired, isGlobal: PropTypes.bool, })).isRequired, - onOrderChange: PropTypes.func.isRequired, + allowedKeyManagerNames: PropTypes.arrayOf(PropTypes.string).isRequired, onScopeChange: PropTypes.func.isRequired, onRemove: PropTypes.func.isRequired, }; @@ -453,17 +715,29 @@ export default function RulesetBindingsStep({ templateState, dispatch }) { const [keyManagers, setKeyManagers] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); + const [expandedRulesetId, setExpandedRulesetId] = useState(null); + const [rulesetPreviews, setRulesetPreviews] = useState({}); + const [loadingPreviewIds, setLoadingPreviewIds] = useState({}); + + // Derive the allowed KM name list from enabled KM governance entries. + // Empty array means "no restriction" (all KMs are eligible). + const allowedKeyManagerNames = useMemo(() => { + const kms = templateState.formConfig?.keyManagers ?? {}; + return Object.entries(kms) + .filter(([, kmc]) => kmc?.enabled === true) + .map(([name]) => name); + }, [templateState.formConfig]); // ── Data fetch ──────────────────────────────────────────────────────────── useEffect(() => { const govApi = new GovernanceAPI(); const adminApi = new API(); + const emptyKmRes = { body: { list: [] } }; Promise.all([ govApi.getRulesets({ limit: 200, offset: 0 }), - adminApi.getKeyManagersList(), - // Global KMs are visible to all tenant admins; gracefully ignore 403s - adminApi.getGlobalKeyManagersList().catch(() => ({ body: { list: [] } })), + adminApi.getKeyManagersList().catch(() => emptyKmRes), + adminApi.getGlobalKeyManagersList().catch(() => emptyKmRes), ]) .then(([rulesetRes, localKmRes, globalKmRes]) => { setAllRulesets(rulesetRes.body.list || []); @@ -481,7 +755,7 @@ export default function RulesetBindingsStep({ templateState, dispatch }) { .catch(() => { Alert.error(intl.formatMessage({ id: 'Governance.Templates.RulesetBindings.fetch.error', - defaultMessage: 'Failed to load rulesets or Key Managers', + defaultMessage: 'Failed to load rulesets', })); }) .finally(() => setLoading(false)); @@ -502,7 +776,10 @@ export default function RulesetBindingsStep({ templateState, dispatch }) { ); const filteredRulesets = useMemo( - () => allRulesets.filter((r) => r.name.toLowerCase().includes(search.toLowerCase())), + () => allRulesets.filter( + (r) => r.artifactType === 'APPLICATION' + && r.name.toLowerCase().includes(search.toLowerCase()), + ), [allRulesets, search], ); @@ -540,6 +817,41 @@ export default function RulesetBindingsStep({ templateState, dispatch }) { }); }; + const toggleRulesetPreview = (ruleset) => { + if (expandedRulesetId === ruleset.id) { + setExpandedRulesetId(null); + return; + } + setExpandedRulesetId(ruleset.id); + if (rulesetPreviews[ruleset.id] || loadingPreviewIds[ruleset.id]) return; + + setLoadingPreviewIds((prev) => ({ ...prev, [ruleset.id]: true })); + new GovernanceAPI() + .getRulesetContent(ruleset.id) + .then((contentResult) => { + const content = contentResult.text ?? ''; + setRulesetPreviews((prev) => ({ + ...prev, + [ruleset.id]: { + content, + rules: deriveRulesetRules(content), + }, + })); + }) + .catch(() => { + setRulesetPreviews((prev) => ({ + ...prev, + [ruleset.id]: { + content: '', + rules: [], + }, + })); + }) + .finally(() => { + setLoadingPreviewIds((prev) => ({ ...prev, [ruleset.id]: false })); + }); + }; + // ── Render ──────────────────────────────────────────────────────────────── if (loading) { @@ -625,7 +937,10 @@ export default function RulesetBindingsStep({ templateState, dispatch }) { ) : ( )} @@ -637,6 +952,10 @@ export default function RulesetBindingsStep({ templateState, dispatch }) { ruleset={ruleset} isBound={boundIds.has(ruleset.id)} onAdd={() => addBinding(ruleset)} + expanded={expandedRulesetId === ruleset.id} + onToggle={() => toggleRulesetPreview(ruleset)} + preview={rulesetPreviews[ruleset.id]} + loadingPreview={!!loadingPreviewIds[ruleset.id]} /> )) )} @@ -692,7 +1011,7 @@ export default function RulesetBindingsStep({ templateState, dispatch }) { color: 'text.secondary', }} > - + @@ -713,9 +1032,7 @@ export default function RulesetBindingsStep({ templateState, dispatch }) { binding={binding} ruleset={rulesetMap[binding.rulesetId]} keyManagers={keyManagers} - onOrderChange={(order) => updateBinding( - binding.rulesetId, { bindingOrder: order }, - )} + allowedKeyManagerNames={allowedKeyManagerNames} onScopeChange={(scopes) => updateBinding( binding.rulesetId, { keyManagerScopes: scopes }, )} @@ -768,6 +1085,11 @@ export default function RulesetBindingsStep({ templateState, dispatch }) { RulesetBindingsStep.propTypes = { templateState: PropTypes.shape({ + formConfig: PropTypes.shape({ + keyManagers: PropTypes.objectOf(PropTypes.shape({ + enabled: PropTypes.bool, + })), + }), rulesetBindings: PropTypes.arrayOf(PropTypes.shape({ rulesetId: PropTypes.string.isRequired, bindingOrder: PropTypes.number.isRequired, diff --git a/portals/admin/src/main/webapp/source/src/app/data/Constants.js b/portals/admin/src/main/webapp/source/src/app/data/Constants.js index 8e9c0c20cbc..f23f653e48e 100644 --- a/portals/admin/src/main/webapp/source/src/app/data/Constants.js +++ b/portals/admin/src/main/webapp/source/src/app/data/Constants.js @@ -64,6 +64,8 @@ const CONSTS = { 'apim:gov_result_read', 'apim:gov_rule_read', 'apim:gov_rule_manage', + 'apim:gov_template_read', + 'apim:gov_template_manage', ], SETTINGS_MANAGER: [ 'apim:app_owner_change', @@ -90,15 +92,21 @@ const CONSTS = { { value: 'API_DEPLOY', label: 'Deploy' }, { value: 'API_PUBLISH', label: 'Publish' }, ], + // APP_SUBSCRIPTION intentionally absent — subscription governance is out of runtime + // scope. The backend enum still has the value for snapshot back-compat, but admins + // should not be able to author new APP_SUBSCRIPTION rulesets via the UI. RULESET_TYPES: [ { value: 'API_DEFINITION', label: 'Definition' }, { value: 'API_METADATA', label: 'Metadata' }, { value: 'API_DOCUMENTATION', label: 'Documentation' }, + { value: 'APP_INFO', label: 'App Info' }, + { value: 'APP_OAUTH', label: 'App OAuth' }, ], ARTIFACT_TYPES: [ { value: 'REST_API', label: 'REST API' }, { value: 'ASYNC_API', label: 'Async API' }, { value: 'MCP', label: 'MCP' }, + { value: 'APPLICATION', label: 'Application' }, ], SEVERITY_LEVELS: [ { value: 'ERROR', label: 'Error' }, diff --git a/portals/admin/src/main/webapp/source/src/app/data/GovernanceAPI.js b/portals/admin/src/main/webapp/source/src/app/data/GovernanceAPI.js index 3514398dbd4..8c0d7c2b146 100644 --- a/portals/admin/src/main/webapp/source/src/app/data/GovernanceAPI.js +++ b/portals/admin/src/main/webapp/source/src/app/data/GovernanceAPI.js @@ -407,6 +407,21 @@ class GovernanceAPI extends Resource { }); } + /** + * Dry-run validation of a template's hidden field defaults against its bound rulesets. + * Returns violations that would block publishing without modifying the template. + * @param {string} templateId Template id + * @returns {Promise} Promised { hasViolations, violations[] } response + */ + validateTemplateDefaults(templateId) { + return this.client.then((client) => { + return client.apis['Devportal Governance Templates'].validateTemplateDefaults( + { templateId }, + this._requestMetaData(), + ); + }); + } + /** * Delete a Devportal Governance template by id * @param {string} templateId Template id diff --git a/portals/devportal/src/main/webapp/services/login/login_callback.jsp b/portals/devportal/src/main/webapp/services/login/login_callback.jsp index 281bfe3116c..2b642a14ff4 100644 --- a/portals/devportal/src/main/webapp/services/login/login_callback.jsp +++ b/portals/devportal/src/main/webapp/services/login/login_callback.jsp @@ -239,6 +239,13 @@ cookie.setMaxAge((int) expiresIn); response.addCookie(cookie); + cookie = new Cookie("AM_ACC_TOKEN_DEFAULT_P2", accessTokenPart2); + cookie.setPath(proxyContext != null ? proxyContext + "/api/am/governance/" : "/api/am/governance/"); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setMaxAge((int) expiresIn); + response.addCookie(cookie); + cookie = new Cookie("AM_REF_TOKEN_DEFAULT_P2", refreshTokenPart2); cookie.setPath(context + "/"); cookie.setHttpOnly(true); 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 b9b86df1567..48760a979c2 100644 --- a/portals/devportal/src/main/webapp/site/public/locales/en.json +++ b/portals/devportal/src/main/webapp/site/public/locales/en.json @@ -511,6 +511,7 @@ "Apis.Listing.StarRatingBar.user": "user", "Apis.Listing.StarRatingBar.users": "users", "Apis.Listing.StarRatingBar.you": "You", + "Apis.Listing.SubscriptionPolicySelect.governed.policy": "Business Plan: {policy}", "Apis.Listing.SubscriptionPolicySelect.subscribe": "Subscribe", "Apis.Listing.TableView.TableView.def.flag": "[Def]", "Apis.Listing.TableView.TableView.doc.flag": "[Doc]", @@ -524,6 +525,7 @@ "Applications.Create.ApplicationFormHandler.app.desc.long": "Exceeds maximum length limit of 512 characters", "Applications.Create.ApplicationFormHandler.app.name.required": "Application name is required", "Applications.Create.ApplicationFormHandler.cancel": "CANCEL", + "Applications.Create.ApplicationFormHandler.choose.template.heading": "Choose a Template", "Applications.Create.ApplicationFormHandler.create.application.heading": "Create an application", "Applications.Create.ApplicationFormHandler.create.application.sub.heading": "Create an application providing name and quota parameters. Description is optional.", "Applications.Create.ApplicationFormHandler.create.application.sub.heading.required": "Required fields are marked with an asterisk ( * )", @@ -531,12 +533,21 @@ "Applications.Create.ApplicationFormHandler.edit.application.sub.heading": "Edit this application. Name and quota are mandatory parameters and description is optional.", "Applications.Create.ApplicationFormHandler.error.while.creating.the.application": "Error while creating the application", "Applications.Create.ApplicationFormHandler.save": "SAVE", + "Applications.Create.ApplicationFormHandler.template.required.error": "Please fill all required template fields", "Applications.Create.Listing.add.new.application": "Add New Application", - "Applications.Create.TemplateSelector.btn.select": "Select", + "Applications.Create.TemplatePreview.noLimitations": "No developer limitations are documented for this template.", + "Applications.Create.TemplatePreview.noSummary": "No developer summary is documented for this template.", + "Applications.Create.TemplatePreview.rulesets.documentation": "Documentation", + "Applications.Create.TemplatePreview.rulesets.heading": "Governance rulesets", "Applications.Create.TemplateSelector.card.noDescription": "No description provided.", - "Applications.Create.TemplateSelector.chip.global": "Global Template", - "Applications.Create.TemplateSelector.heading": "Choose a Template", - "Applications.Create.TemplateSelector.subheading": "Select a governance template to configure your application. The template defines default settings and the policies that will be enforced.", + "Applications.Create.TemplateSelector.chip.default": "Default", + "Applications.Create.TemplateSelector.chip.global": "Global", + "Applications.Create.TemplateSelector.empty.withDefault": "No templates match your filters. Clear the filters to see the available governance templates.", + "Applications.Create.TemplateSelector.filter.label": "Filter by tag", + "Applications.Create.TemplateSelector.info.tooltip": "View governance details", + "Applications.Create.TemplateSelector.subheading": "Select a governance template to configure your application. The template defines default settings and the policies that will be enforced. Click a card to continue.", + "Applications.Create.TemplateSelector.unrestricted.desc": "Create an application without any governance template. All fields are open and no rulesets are enforced.", + "Applications.Create.TemplateSelector.unrestricted.name": "No Restrictions", "Applications.Details.InfoBar.application.deleted.successfully": "In Application {name} deleted successfully!", "Applications.Details.InfoBar.application.deleting.error": "Error while deleting application {name}", "Applications.Details.InfoBar.business.plan": "Business Plan", @@ -565,6 +576,8 @@ "Applications.Details.SubscriptionTableData.delete.text": "Delete", "Applications.Details.SubscriptionTableData.edit.text": "Edit", "Applications.Details.SubscriptionTableData.policy.default.tooltip": "This is the default subscription policy used when subscription validation was disabled.", + "Applications.Details.SubscriptionTableData.tier.governed": "The throttling policy for this subscription is managed by your organization template and cannot be changed.", + "Applications.Details.SubscriptionTableData.tier.governed.value": "Applied tier: {tier}", "Applications.Details.SubscriptionTableData.update": "Update", "Applications.Details.SubscriptionTableData.update.business.plan": "Current Business Plan :", "Applications.Details.SubscriptionTableData.update.business.plan.name": "Business Plan", @@ -997,6 +1010,8 @@ "Shared.AppsAndKeys.TokenManager.key.provide.error": "Error occurred when providing application keys", "Shared.AppsAndKeys.TokenManager.key.provide.success": "Application keys provided successfully", "Shared.AppsAndKeys.TokenManager.key.update.success": "Application keys updated successfully", + "Shared.AppsAndKeys.TokenManager.no.allowed.km": "No Allowed Key Managers", + "Shared.AppsAndKeys.TokenManager.no.allowed.km.content": "This application template does not allow any currently enabled Key Manager.", "Shared.AppsAndKeys.TokenManager.no.km": "No Key Managers", "Shared.AppsAndKeys.TokenManager.no.km.content": "No Key Managers active to generate keys.", "Shared.AppsAndKeys.TokenManager.oauth2.keys.main.title": "OAuth2 Keys", diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Listing/APICardView.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Listing/APICardView.jsx index 50f64f1305e..56ea3f093e4 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Listing/APICardView.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Listing/APICardView.jsx @@ -221,7 +221,7 @@ class APICardView extends React.Component { } const { - handleSubscribe, applicationId, intl, entityType, + handleSubscribe, applicationId, intl, entityType, formConfig, } = this.props; const isMCPServersRoute = entityType === 'MCP'; const columns = [ @@ -312,6 +312,7 @@ class APICardView extends React.Component { apiId={apiId} handleSubscribe={(app, api, policy) => handleSubscribe(app, api, policy)} applicationId={applicationId} + formConfig={formConfig} /> ); } @@ -392,6 +393,7 @@ APICardView.propTypes = { apisNotFound: PropTypes.bool, setTenantDomain: PropTypes.func, entityType: PropTypes.oneOf(['API', 'MCP']), + formConfig: PropTypes.shape({}), }; APICardView.defaultProps = { @@ -400,5 +402,6 @@ APICardView.defaultProps = { apisNotFound: false, setTenantDomain: () => {}, entityType: 'API', + formConfig: null, }; export default injectIntl((APICardView)); diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Listing/SubscriptionPolicySelect.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Listing/SubscriptionPolicySelect.jsx index ec634491364..3f95cafd5e5 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Listing/SubscriptionPolicySelect.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Listing/SubscriptionPolicySelect.jsx @@ -23,7 +23,7 @@ import Button from '@mui/material/Button'; import Autocomplete from '@mui/material/Autocomplete'; import TextField from '@mui/material/TextField'; import { FormattedMessage } from 'react-intl'; -import { useTheme } from '@mui/material'; +import { Typography, useTheme } from '@mui/material'; import { ScopeValidation, resourceMethods, resourcePaths } from '../../Shared/ScopeValidation'; const PREFIX = 'SubscriptionPolicySelectLegacy'; @@ -80,7 +80,21 @@ class SubscriptionPolicySelectLegacy extends React.Component { componentDidMount() { const { policies } = this.props; - this.setState({ selectedPolicy: policies[0] }); + this.setState({ selectedPolicy: this.getGovernedPolicy() || policies[0] }); + } + + componentDidUpdate(prevProps) { + const governedPolicy = this.getGovernedPolicy(); + const previousGovernedPolicy = this.getGovernedPolicy(prevProps); + if (governedPolicy !== previousGovernedPolicy || this.props.policies !== prevProps.policies) { + this.setState({ selectedPolicy: governedPolicy || this.props.policies[0] }); + } + } + + getGovernedPolicy(props = this.props) { + const tierConfig = props.formConfig?.subscription?.throttlingPolicy; + const hidden = tierConfig?.hidden === true || tierConfig?.hidden === 'true'; + return hidden ? tierConfig?.defaultValue : null; } /** @@ -92,32 +106,44 @@ class SubscriptionPolicySelectLegacy extends React.Component { policies, apiId, handleSubscribe, applicationId, } = this.props; const { selectedPolicy } = this.state; + const governedPolicy = this.getGovernedPolicy(); + const effectivePolicy = governedPolicy || selectedPolicy; return ( policies && ( - { - this.setState({ selectedPolicy: value }); - }} - style={{ width: 150 }} - renderInput={(params) => ()} - renderOption={(props, policy) => ( - - {policy} - - )} - /> + {governedPolicy ? ( + + + + ) : ( + { + this.setState({ selectedPolicy: value }); + }} + style={{ width: 150 }} + renderInput={(params) => ()} + renderOption={(props, policy) => ( + + {policy} + + )} + /> + )} { - handleSubscribe(applicationId, apiId, selectedPolicy); + handleSubscribe(applicationId, apiId, effectivePolicy); }} id={'policy-subscribe-btn-' + apiId} > @@ -150,11 +176,16 @@ SubscriptionPolicySelectLegacy.propTypes = { apiId: PropTypes.string.isRequired, handleSubscribe: PropTypes.func.isRequired, applicationId: PropTypes.string.isRequired, + formConfig: PropTypes.shape({}), +}; + +SubscriptionPolicySelectLegacy.defaultProps = { + formConfig: null, }; function SubscriptionPolicySelect(props) { const { - key, policies, apiId, handleSubscribe, applicationId, + key, policies, apiId, handleSubscribe, applicationId, formConfig, } = props; const theme = useTheme(); return ( @@ -164,6 +195,7 @@ function SubscriptionPolicySelect(props) { apiId={apiId} handleSubscribe={handleSubscribe} applicationId={applicationId} + formConfig={formConfig} theme={theme} /> ); diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/ApplicationFormHandler.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/ApplicationFormHandler.jsx index 5cbd89302af..82506b7c371 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Applications/ApplicationFormHandler.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/ApplicationFormHandler.jsx @@ -95,6 +95,12 @@ class ApplicationFormHandler extends React.Component { this.backLink = props.location.pathname.indexOf('/fromView') === -1 ? '/applications/' : `/applications/${params.application_id}/`; } + isTemplateTruthy = (value) => value === true || value === 'true'; + + isTemplateHidden = (fieldConfig) => this.isTemplateTruthy(fieldConfig?.hidden); + + isTemplateRequired = (fieldConfig) => this.isTemplateTruthy(fieldConfig?.required); + /** * Get all the throttling Policies from backend and * update the state @@ -268,16 +274,17 @@ class ApplicationFormHandler extends React.Component { validateAttributes = (attributes) => { const { intl } = this.props; const { allAppAttributes } = this.state; + const configuredAttributes = allAppAttributes ?? []; let isValidAttribute = true; const attributeNameList = Object.keys(attributes); - if (allAppAttributes.length > 0) { - for (let i = 0; i < allAppAttributes.length; i++) { - if (allAppAttributes[i].required === 'true' && allAppAttributes[i].hidden !== 'true') { - if (attributeNameList.indexOf(allAppAttributes[i].attribute) === -1) { + if (configuredAttributes.length > 0) { + for (let i = 0; i < configuredAttributes.length; i++) { + if (configuredAttributes[i].required === 'true' && configuredAttributes[i].hidden !== 'true') { + if (attributeNameList.indexOf(configuredAttributes[i].attribute) === -1) { isValidAttribute = false; - } else if (attributeNameList.indexOf(allAppAttributes[i].attribute) > -1 - && (!attributes[allAppAttributes[i].attribute] - || attributes[allAppAttributes[i].attribute].trim() === '')) { + } else if (attributeNameList.indexOf(configuredAttributes[i].attribute) > -1 + && (!attributes[configuredAttributes[i].attribute] + || attributes[configuredAttributes[i].attribute].trim() === '')) { isValidAttribute = false; } } @@ -293,6 +300,51 @@ class ApplicationFormHandler extends React.Component { } }; + validateTemplateRequiredFields = () => { + const { intl } = this.props; + const { + selectedTemplate, applicationRequest, isApplicationSharingEnabled, allAppAttributes, + } = this.state; + if (!selectedTemplate?.formConfig?.application) { + return Promise.resolve(true); + } + const appConfig = selectedTemplate.formConfig.application; + const isBlank = (value) => value === null || value === undefined || String(value).trim() === ''; + const missingFields = []; + if (this.isTemplateRequired(appConfig.description) + && !this.isTemplateHidden(appConfig.description) + && isBlank(applicationRequest.description)) { + missingFields.push('description'); + } + if (isApplicationSharingEnabled + && this.isTemplateRequired(appConfig.groups) + && !this.isTemplateHidden(appConfig.groups) + && (!applicationRequest.groups || applicationRequest.groups.length === 0)) { + missingFields.push('groups'); + } + const configuredAttributes = new Map( + (allAppAttributes ?? []).map((attr) => [attr.attribute, attr]), + ); + Object.entries(appConfig.attributes ?? {}).forEach(([attributeName, attributeConfig]) => { + const serverAttribute = configuredAttributes.get(attributeName); + if (!serverAttribute || serverAttribute.hidden === 'true') { + return; + } + if (this.isTemplateRequired(attributeConfig) + && !this.isTemplateHidden(attributeConfig) + && isBlank(applicationRequest.attributes?.[attributeName])) { + missingFields.push(attributeName); + } + }); + if (missingFields.length > 0) { + return Promise.reject(new Error(intl.formatMessage({ + id: 'Applications.Create.ApplicationFormHandler.template.required.error', + defaultMessage: 'Please fill all required template fields', + }))); + } + return Promise.resolve(true); + }; + /** * Validate and send the application create * request to the backend @@ -305,6 +357,7 @@ class ApplicationFormHandler extends React.Component { this.validateName(applicationRequest.name) .then(() => this.validateDescription(applicationRequest.description)) .then(() => this.validateAttributes(applicationRequest.attributes)) + .then(() => this.validateTemplateRequiredFields()) .then(() => api.createApplication(applicationRequest)) .then((response) => { if (response.body.status === 'CREATED') { @@ -470,14 +523,52 @@ class ApplicationFormHandler extends React.Component { */ handleTemplateSelect = (template) => { const appConfig = template?.formConfig?.application ?? {}; + const hasDefault = (fieldConfig) => fieldConfig + && fieldConfig.defaultValue !== undefined + && fieldConfig.defaultValue !== null + && !(Array.isArray(fieldConfig.defaultValue) && fieldConfig.defaultValue.length === 0) + && fieldConfig.defaultValue !== ''; + const toGroups = (value) => { + if (Array.isArray(value)) { + return value.map((item) => String(item).trim()).filter(Boolean); + } + return String(value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + }; this.setState((prevState) => { const newRequest = { ...prevState.applicationRequest }; - if (appConfig.throttlingPolicy?.defaultValue) { + const configuredAttributes = prevState.allAppAttributes + ? new Map(prevState.allAppAttributes.map((attr) => [attr.attribute, attr])) + : null; + if (template?.id) { + newRequest.templateId = template.id; + } + if (hasDefault(appConfig.throttlingPolicy)) { newRequest.throttlingPolicy = appConfig.throttlingPolicy.defaultValue; } - if (appConfig.tokenType?.defaultValue) { + if (hasDefault(appConfig.description)) { + newRequest.description = appConfig.description.defaultValue; + } + if (hasDefault(appConfig.tokenType)) { newRequest.tokenType = appConfig.tokenType.defaultValue; } + if (prevState.isApplicationSharingEnabled && hasDefault(appConfig.groups)) { + newRequest.groups = toGroups(appConfig.groups.defaultValue); + } + if (appConfig.attributes) { + newRequest.attributes = { ...(newRequest.attributes ?? {}) }; + Object.entries(appConfig.attributes).forEach(([attributeName, attributeConfig]) => { + const serverAttribute = configuredAttributes?.get(attributeName); + if (configuredAttributes && (!serverAttribute || serverAttribute.hidden === 'true')) { + return; + } + if (hasDefault(attributeConfig)) { + newRequest.attributes[attributeName] = attributeConfig.defaultValue; + } + }); + } return { selectedTemplate: template, applicationRequest: newRequest }; }); } @@ -504,26 +595,26 @@ class ApplicationFormHandler extends React.Component { // Template selection gate: only for new applications, not edits. // selectedTemplate===null means not yet decided; false means skipped; object means chosen. + // The gallery is rendered OUTSIDE ApplicationCreateBase's md={6} wrapper because the + // form-style ~50%-width container made every card stack into a single column even + // though there was room for 3+. The actual create form (below) still uses the narrow + // wrapper; only the selection step gets the full width. if (!isEdit && selectedTemplate === null) { return ( - + + - )} - > - - - this.setState({ selectedTemplate: false })} - /> - - - + this.setState({ selectedTemplate: false })} + allAppAttributes={allAppAttributes} + isApplicationSharingEnabled={isApplicationSharingEnabled} + /> + ); } diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/ApplicationCreateBase.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/ApplicationCreateBase.jsx index 33afc393f64..9afd7b35630 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/ApplicationCreateBase.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/ApplicationCreateBase.jsx @@ -72,6 +72,7 @@ function ApplicationCreateBase(props) { + ); diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplatePreviewDialog.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplatePreviewDialog.jsx new file mode 100644 index 00000000000..b833c5fdea4 --- /dev/null +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplatePreviewDialog.jsx @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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 PropTypes from 'prop-types'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { + Box, + Divider, + Dialog, + DialogContent, + DialogTitle, + IconButton, + Link, + Typography, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { + getDeveloperExperience, + getDeveloperLimitations, +} from './templateDeveloperViewUtils'; + +/** + * Shows only the developer-facing explanation of a governance template before selection. + */ +export default function TemplatePreviewDialog({ template, onClose }) { + if (!template) return null; + + const developerExperience = getDeveloperExperience(template); + const developerLimitations = getDeveloperLimitations(template); + const summary = developerExperience.summary || template.description; + const rulesetBindings = template.rulesetBindings ?? []; + + return ( + + + + {template.name} + + + + + + + + {summary ? ( + + {summary} + + ) : ( + + + + )} + {developerLimitations.length > 0 ? ( + 0 ? 3 : 0 }}> + {developerLimitations.map((item) => ( + + {item} + + ))} + + ) : ( + 0 ? 3 : 0 }} + > + + + )} + {rulesetBindings.length > 0 && ( + <> + + + + + + {rulesetBindings.map((binding, index) => { + const rulesetDescription = binding.rulesetDescription || binding.description; + const documentationLink = binding.documentationLink || binding.rulesetDocumentationLink; + return ( + + + {binding.rulesetName || binding.rulesetId} + + {rulesetDescription && ( + + {rulesetDescription} + + )} + {documentationLink && ( + + + + )} + + ); + })} + + + )} + + + ); +} + +TemplatePreviewDialog.propTypes = { + template: PropTypes.shape({ + name: PropTypes.string, + description: PropTypes.string, + formConfig: PropTypes.shape({}), + rulesetBindings: PropTypes.arrayOf(PropTypes.shape({ + bindingId: PropTypes.string, + rulesetId: PropTypes.string, + rulesetName: PropTypes.string, + description: PropTypes.string, + rulesetDescription: PropTypes.string, + documentationLink: PropTypes.string, + rulesetDocumentationLink: PropTypes.string, + })), + }), + onClose: PropTypes.func.isRequired, +}; + +TemplatePreviewDialog.defaultProps = { template: null }; diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplateSelector.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplateSelector.jsx index 2ad4ba928d9..bb1993d52bc 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplateSelector.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplateSelector.jsx @@ -16,42 +16,362 @@ * under the License. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; import { Box, - Button, - Card, - CardActions, - CardContent, - CardHeader, + Checkbox, Chip, CircularProgress, - Grid, + FormControlLabel, + FormGroup, + IconButton, + InputAdornment, + Pagination, + Paper, + TextField, + Tooltip, Typography, } from '@mui/material'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import SearchIcon from '@mui/icons-material/Search'; import API from 'AppData/api'; +import TemplatePreviewDialog from './TemplatePreviewDialog'; +import { + getDeveloperExperience, +} from './templateDeveloperViewUtils'; + +const TEMPLATE_FETCH_TIMEOUT_MS = 8000; + +// ─── UnrestrictedCard ───────────────────────────────────────────────────────── + +function UnrestrictedCard({ onSkip }) { + return ( + + + + — + + + + + + + + + + ); +} + +UnrestrictedCard.propTypes = { onSkip: PropTypes.func.isRequired }; + +// ─── TemplateCard ───────────────────────────────────────────────────────────── + +function TemplateCard({ template, onSelect, onPreview }) { + const developerExperience = getDeveloperExperience(template); + const summary = developerExperience.summary || template.description; + const tags = Array.isArray(template.tags) ? template.tags : []; + + return ( + onSelect(template)} + sx={{ + display: 'flex', + flexDirection: 'column', + p: 2.5, + borderRadius: 2, + cursor: 'pointer', + transition: 'box-shadow 0.18s, border-color 0.18s', + '&:hover': { + boxShadow: 4, + borderColor: 'primary.main', + }, + position: 'relative', + minHeight: 200, + }} + > + {/* Icon */} + {template.icon ? ( + + ) : ( + + + {(template.name || '?').charAt(0).toUpperCase()} + + + )} + + {/* Name */} + + {template.name} + {template.isDefault && ( + } + size='small' + color='primary' + variant='outlined' + sx={{ + ml: 1, height: 18, fontSize: '0.65rem', verticalAlign: 'middle', + }} + /> + )} + {template.isGlobal && ( + } + size='small' + color='secondary' + variant='outlined' + sx={{ + ml: 0.5, height: 18, fontSize: '0.65rem', verticalAlign: 'middle', + }} + /> + )} + + + {/* Tags */} + {tags.length > 0 && ( + + {tags.map((tag) => ( + e.stopPropagation()} + /> + ))} + + )} + + {/* Description */} + + {summary || ( + + )} + + + {/* Info icon at bottom-right — click to open developer view dialog */} + + + )} + arrow + > + { + e.stopPropagation(); + onPreview(template); + }} + sx={{ color: 'text.secondary', '&:hover': { color: 'primary.main' } }} + > + + + + + + ); +} + +TemplateCard.propTypes = { + template: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + description: PropTypes.string, + icon: PropTypes.string, + tags: PropTypes.arrayOf(PropTypes.string), + isDefault: PropTypes.bool, + isGlobal: PropTypes.bool, + formConfig: PropTypes.shape({}), + rulesetBindings: PropTypes.arrayOf(PropTypes.shape({})), + }).isRequired, + onSelect: PropTypes.func.isRequired, + onPreview: PropTypes.func.isRequired, +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function isTrue(value) { + return value === true || value === 'true'; +} + +function getEffectiveTemplate(template, allAppAttributes, isApplicationSharingEnabled) { + if (!template?.formConfig) { + return template; + } + const currentAttributes = allAppAttributes + ? new Map( + allAppAttributes + .filter((attr) => attr.hidden !== 'true' && attr.attribute) + .map((attr) => [attr.attribute, attr]), + ) + : null; + const existingAttributes = template.formConfig?.application?.attributes ?? {}; + const effectiveAttributes = {}; + Object.entries(existingAttributes).forEach(([attrName, attrConfig]) => { + const serverAttribute = currentAttributes?.get(attrName); + effectiveAttributes[attrName] = { + ...attrConfig, + required: isTrue(attrConfig?.required) || serverAttribute?.required === 'true', + active: currentAttributes ? !!serverAttribute : attrConfig?.active, + }; + }); + if (currentAttributes) { + currentAttributes.forEach((serverAttribute, attrName) => { + effectiveAttributes[attrName] = { + ...(effectiveAttributes[attrName] ?? { hidden: false, required: false, defaultValue: '' }), + required: isTrue(effectiveAttributes[attrName]?.required) || serverAttribute.required === 'true', + active: true, + }; + }); + } + return { + ...template, + formConfig: { + ...template.formConfig, + application: { + ...(template.formConfig.application ?? {}), + groups: { + ...((template.formConfig.application ?? {}).groups ?? {}), + active: isApplicationSharingEnabled, + }, + attributes: effectiveAttributes, + }, + }, + }; +} + +// ─── Main component ─────────────────────────────────────────────────────────── /** - * Intercept the "Add Application" flow and let the developer choose a Governance Template - * before the create form is shown. + * Intercept the application creation flow and let the developer choose a Governance Template. * - * Fail-open contract: if no published templates are available (empty list or fetch error), - * onSkip() is called automatically so the standard un-governed form is presented. + * Features: + * - Search bar to filter by name or description + * - Tag filter (radio buttons: All + each unique tag) + * - Modern card grid — click a card to select, info icon opens developer view + * - Full-width layout (no maxWidth constraint) * - * @param {Object} props - * @param {Function} props.onSelect - Called with the full template object when the user picks one - * @param {Function} props.onSkip - Called when no templates exist or the fetch fails + * Fail-open contract: if no published templates exist or the fetch fails, + * onSkip() is called automatically. */ -export default function TemplateSelector({ onSelect, onSkip }) { +export default function TemplateSelector({ + onSelect, + onSkip, + allAppAttributes, + isApplicationSharingEnabled, +}) { const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); + const [previewTemplate, setPreviewTemplate] = useState(null); + const [search, setSearch] = useState(''); + const [selectedTags, setSelectedTags] = useState(new Set()); + const [page, setPage] = useState(1); + const PAGE_SIZE = 12; useEffect(() => { + let active = true; + const failOpenTimer = setTimeout(() => { + if (active) { + setLoading(false); + onSkip(); + } + }, TEMPLATE_FETCH_TIMEOUT_MS); + new API() .getDevportalGovernanceTemplates({ limit: 100, offset: 0 }) .then((res) => { + if (!active) { + return; + } + clearTimeout(failOpenTimer); const list = res.body?.list ?? []; if (list.length === 0) { onSkip(); @@ -60,12 +380,75 @@ export default function TemplateSelector({ onSelect, onSkip }) { } }) .catch(() => { - // Fail open — governance unavailable should never block app creation - onSkip(); + if (active) { + clearTimeout(failOpenTimer); + onSkip(); + } }) - .finally(() => setLoading(false)); + .finally(() => { + if (active) { + setLoading(false); + } + }); + return () => { + active = false; + clearTimeout(failOpenTimer); + }; }, []); + // Collect all unique tags across all templates + const allTags = useMemo(() => { + const tagSet = new Set(); + templates.forEach((t) => { + (Array.isArray(t.tags) ? t.tags : []).forEach((tag) => tagSet.add(tag)); + }); + return Array.from(tagSet).sort(); + }, [templates]); + + const toggleTag = (tag) => { + setSelectedTags((prev) => { + const next = new Set(prev); + if (next.has(tag)) { + next.delete(tag); + } else { + next.add(tag); + } + return next; + }); + }; + + // Filtered template list based on search text and active tag filter + const filteredTemplates = useMemo(() => { + const q = search.toLowerCase(); + return templates.filter((t) => { + const developerExperience = getDeveloperExperience(t); + const summary = developerExperience.summary || t.description || ''; + const matchesSearch = !q + || t.name.toLowerCase().includes(q) + || summary.toLowerCase().includes(q); + const matchesTag = selectedTags.size === 0 + || (Array.isArray(t.tags) && t.tags.some((tag) => selectedTags.has(tag))); + return matchesSearch && matchesTag; + }); + }, [templates, search, selectedTags]); + + // Whether any of the loaded templates is marked default. The "No Restrictions" + // fallback card is only shown when there is NO default — otherwise the admin's + // default IS the fallback, and an extra "no rules" card would just confuse devs. + const hasDefaultTemplate = useMemo( + () => templates.some((t) => !!t.isDefault), + [templates], + ); + + // Pagination derived from filteredTemplates. Reset to page 1 whenever the filter set + // changes so the user doesn't end up on a now-empty page. + const pageCount = Math.max(1, Math.ceil(filteredTemplates.length / PAGE_SIZE)); + useEffect(() => { setPage(1); }, [search, selectedTags]); + const paginatedTemplates = useMemo(() => { + const start = (page - 1) * PAGE_SIZE; + return filteredTemplates.slice(start, start + PAGE_SIZE); + }, [filteredTemplates, page]); + if (loading) { return ( @@ -74,89 +457,146 @@ export default function TemplateSelector({ onSelect, onSkip }) { ); } - // If templates is empty after load, onSkip was already called — render nothing if (templates.length === 0) { return null; } return ( - - - - - + + {/* Subheading */} + - - {templates.map((template) => ( - - setSearch(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ mb: 3, maxWidth: 480 }} + /> + + + {/* Tag filter sidebar — only shown when there are tags */} + {allTags.length > 0 && ( + + - - {template.name} - - )} - action={template.isGlobal ? ( - - )} - size='small' - color='secondary' - variant='outlined' - sx={{ mt: 1, mr: 1 }} - /> - ) : null} - sx={{ pb: 0 }} + - - - {template.description || ( - + + {allTags.map((tag) => ( + toggleTag(tag)} /> )} - - - - - - - - ))} - + ); + })} + {!hasDefaultTemplate && } + + )} + {pageCount > 1 && ( + + setPage(p)} + size='small' + color='primary' + /> + + )} + + + + {previewTemplate && ( + setPreviewTemplate(null)} + /> + )} ); } @@ -164,4 +604,13 @@ export default function TemplateSelector({ onSelect, onSkip }) { TemplateSelector.propTypes = { onSelect: PropTypes.func.isRequired, onSkip: PropTypes.func.isRequired, + allAppAttributes: PropTypes.arrayOf(PropTypes.shape({ + attribute: PropTypes.string, + })), + isApplicationSharingEnabled: PropTypes.bool, +}; + +TemplateSelector.defaultProps = { + allAppAttributes: null, + isApplicationSharingEnabled: true, }; diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/templateDeveloperViewUtils.js b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/templateDeveloperViewUtils.js new file mode 100644 index 00000000000..72d01206597 --- /dev/null +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/templateDeveloperViewUtils.js @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * 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. + */ + +const GRANT_TYPE_LABELS = { + authorization_code: 'Authorization Code', + implicit: 'Implicit', + password: 'Password', + client_credentials: 'Client Credentials', + refresh_token: 'Refresh Token', + 'urn:ietf:params:oauth:grant-type:device_code': 'Device Code', + 'urn:ietf:params:oauth:grant-type:token-exchange': 'Token Exchange', +}; +const RESIDENT_KEY_MANAGER_NAME = 'Resident Key Manager'; + +function isHidden(fieldConfig) { + return fieldConfig?.hidden === true || fieldConfig?.hidden === 'true'; +} + +function isInactive(fieldConfig) { + return fieldConfig?.active === false || fieldConfig?.active === 'false'; +} + +function isRequired(fieldConfig) { + return fieldConfig?.required === true || fieldConfig?.required === 'true'; +} + +function hasValue(value) { + if (Array.isArray(value)) return value.length > 0; + return value !== undefined && value !== null && value !== ''; +} + +function formatValue(value, valueMap = {}) { + if (Array.isArray(value)) { + return value.map((item) => valueMap[item] ?? item).join(', '); + } + if (value === -1) return 'Unlimited'; + if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled'; + return valueMap[value] ?? String(value); +} + +function addFieldLimit(items, label, fieldConfig, options = {}) { + const { + valueMap, + hiddenOnly = false, + includeVisibleDefault = true, + } = options; + if (isInactive(fieldConfig)) { + return; + } + const hidden = isHidden(fieldConfig); + const value = fieldConfig?.defaultValue; + + if (hidden && hasValue(value)) { + items.push(`${label} is fixed to ${formatValue(value, valueMap)}.`); + } else if (hidden) { + items.push(`${label} is hidden from developers.`); + } else if (!hiddenOnly && includeVisibleDefault && hasValue(value)) { + items.push(`${label} defaults to ${formatValue(value, valueMap)}.`); + } +} + +function addRequiredLimit(items, label, fieldConfig) { + if (!isInactive(fieldConfig) && !isHidden(fieldConfig) && isRequired(fieldConfig)) { + items.push(`${label} is required.`); + } +} + +export function normalizeLimitations(limitations) { + const raw = Array.isArray(limitations) + ? limitations + : String(limitations || '').split('\n'); + return raw.map((item) => String(item).trim()).filter(Boolean); +} + +export function getDeveloperExperience(template) { + return template?.formConfig?.developerExperience ?? {}; +} + +export function buildDeveloperLimitations(template) { + const formConfig = template?.formConfig ?? {}; + const application = formConfig.application ?? {}; + const subscription = formConfig.subscription ?? {}; + const keyGeneration = formConfig.keyGeneration ?? {}; + const items = []; + + addFieldLimit(items, 'Application throttling policy', application.throttlingPolicy); + addFieldLimit(items, 'Application description', application.description); + addFieldLimit(items, 'Application groups', application.groups); + addRequiredLimit(items, 'Application description', application.description); + addRequiredLimit(items, 'Application groups', application.groups); + + Object.entries(application.attributes ?? {}).forEach(([attrName, attrConfig]) => { + addFieldLimit(items, `Application attribute "${attrName}"`, attrConfig); + if (!isInactive(attrConfig) && !isHidden(attrConfig) && isRequired(attrConfig)) { + items.push(`Application attribute "${attrName}" is required.`); + } + }); + + addFieldLimit(items, 'Subscription throttling policy', subscription.throttlingPolicy); + + const allowedKeyManagers = Array.isArray(keyGeneration.allowedKeyManagers?.defaultValue) + ? keyGeneration.allowedKeyManagers.defaultValue + : []; + if (allowedKeyManagers.length > 0) { + items.push(`Key generation is limited to ${allowedKeyManagers.join(', ')}.`); + } + + const residentKeyManagerAllowed = allowedKeyManagers.length === 0 + || allowedKeyManagers.includes(RESIDENT_KEY_MANAGER_NAME); + if (residentKeyManagerAllowed) { + const selectedGrantTypes = Array.isArray(keyGeneration.grantTypes?.defaultValue) + ? keyGeneration.grantTypes.defaultValue + : []; + const hasGrantType = (...grantTypes) => grantTypes.some((grantType) => selectedGrantTypes.includes(grantType)); + + addFieldLimit(items, 'OAuth grant types', keyGeneration.grantTypes, { valueMap: GRANT_TYPE_LABELS }); + if (hasGrantType('authorization_code', 'implicit')) { + addFieldLimit(items, 'Callback URL', keyGeneration.callbackUrl); + } + if (hasGrantType('client_credentials')) { + addFieldLimit(items, 'Application access token expiry', keyGeneration.appAccessTokenExpiry, { + hiddenOnly: true, + }); + } + if (hasGrantType('password', 'authorization_code', 'implicit')) { + addFieldLimit(items, 'User access token expiry', keyGeneration.userAccessTokenExpiry, { + hiddenOnly: true, + }); + } + if (hasGrantType('refresh_token')) { + addFieldLimit(items, 'Refresh token expiry', keyGeneration.refreshTokenExpiry, { + hiddenOnly: true, + }); + } + if (hasGrantType('authorization_code')) { + addFieldLimit(items, 'ID token expiry', keyGeneration.idTokenExpiry, { + hiddenOnly: true, + }); + addFieldLimit(items, 'PKCE', keyGeneration.enablePKCE, { hiddenOnly: true }); + addFieldLimit(items, 'PKCE plain text support', keyGeneration.pkceSupportsPlainText, { + hiddenOnly: true, + }); + addFieldLimit(items, 'Public client mode', keyGeneration.publicClient, { hiddenOnly: true }); + } + } + + const rulesetCount = template?.rulesetBindings?.length ?? 0; + if (rulesetCount > 0) { + items.push(`${rulesetCount} governance ruleset${rulesetCount === 1 ? '' : 's'} will validate application changes.`); + } + + return items; +} + +export function getDeveloperLimitations(template) { + const developerExperience = getDeveloperExperience(template); + const documentedLimitations = normalizeLimitations(developerExperience.limitations); + return documentedLimitations.length > 0 ? documentedLimitations : buildDeveloperLimitations(template); +} diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionTableData.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionTableData.jsx index 5b6025668d4..b9752e83dbb 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionTableData.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/SubscriptionTableData.jsx @@ -407,7 +407,7 @@ class SubscriptionTableData extends React.Component { onClick={this.handleRequestOpenEditMenu} startIcon={edit} disabled={tiers.length === 0 || status === SUBSCRIPTION_STATUS.BLOCKED - || status === SUBSCRIPTION_STATUS.PROD_ONLY_BLOCKED} + || status === SUBSCRIPTION_STATUS.PROD_ONLY_BLOCKED || isSubTierHidden} > this.handleSubscriptionTierUpdate(apiId, diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/Subscriptions.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/Subscriptions.jsx index 6a8b880d778..9c99f585f27 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/Subscriptions.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/Subscriptions.jsx @@ -774,6 +774,7 @@ class SubscriptionsBase extends React.Component { handleSubscribe={(appInner, api, policy) => this.handleSubscribe(appInner, api, policy)} searchText={searchText} entityType='API' + formConfig={this.props.formConfig} /> @@ -870,6 +871,7 @@ class SubscriptionsBase extends React.Component { handleSubscribe={(appInner, api, policy) => this.handleSubscribe(appInner, api, policy)} searchText={searchText} entityType='MCP' + formConfig={this.props.formConfig} /> @@ -926,4 +928,8 @@ Subscriptions.propTypes = { formConfig: PropTypes.shape({}), }; +Subscriptions.defaultProps = { + formConfig: null, +}; + export default injectIntl(Subscriptions); diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/index.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/index.jsx index 7cd3baf4777..eed5d050f66 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/index.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Details/index.jsx @@ -202,9 +202,11 @@ class Details extends Component { client.getApplication(applicationId) .then((response) => { const application = response.obj; - this.setState({ application }); - // If this app was created with a governance template, load its formConfig - if (application.templateId) { + const governanceFormConfig = application.governanceFormConfig ?? null; + this.setState({ application, formConfig: governanceFormConfig }); + // Older backends only expose the template id; prefer the captured + // snapshot formConfig when it is present on the application. + if (!governanceFormConfig && application.templateId) { client.getDevportalGovernanceTemplateById(application.templateId) .then((templateRes) => { this.setState({ formConfig: templateRes.body.formConfig ?? null }); diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/ApplicationCreateForm.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/ApplicationCreateForm.jsx index 4596412381b..34873bdd1d5 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/ApplicationCreateForm.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/ApplicationCreateForm.jsx @@ -140,7 +140,16 @@ const ApplicationCreate = (props) => { formConfig, } = props; - const isHidden = (fieldKey) => formConfig?.application?.[fieldKey]?.hidden === true; + const isHiddenValue = (value) => value === true || value === 'true'; + const isRequiredValue = (value) => value === true || value === 'true'; + const isHidden = (fieldKey) => isHiddenValue(formConfig?.application?.[fieldKey]?.hidden); + const isTemplateRequired = (fieldKey) => isRequiredValue(formConfig?.application?.[fieldKey]?.required); + const isAttributeHidden = (attributeName) => { + return isHiddenValue(formConfig?.application?.attributes?.[attributeName]?.hidden); + }; + const isTemplateAttributeRequired = (attributeName) => { + return isRequiredValue(formConfig?.application?.attributes?.[attributeName]?.required); + }; const description = applicationRequest.description || ''; const showDescError = () => { const descLength = description.length; @@ -231,29 +240,35 @@ const ApplicationCreate = (props) => { ))} )} - 512} - onBlur={(e) => validateDescription(e.target.value)} + {!isHidden('description') && ( + 512} + onBlur={(e) => validateDescription(e.target.value)} - /> + /> + )} { isOrgAccessControlEnabled && sessionStorage.getItem('userOrganization') && ( @@ -284,14 +299,15 @@ const ApplicationCreate = (props) => { {allAppAttributes && ( Object.entries(allAppAttributes).map((item) => ( - item[1].hidden !== 'true' ? ( + item[1].hidden !== 'true' && !isAttributeHidden(item[1].attribute) ? ( { /> ) : (null))) )} - {isApplicationSharingEnabled && ( + {isApplicationSharingEnabled && !isHidden('groups') && ( { margin='normal' variant='outlined' fullWidth + required={isTemplateRequired('groups')} {...applicationRequest} value={applicationRequest.groups || []} onAdd={(chip) => handleAddChip(chip, applicationRequest.groups)} diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/KeyConfiguration.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/KeyConfiguration.jsx index 748a03f9cd9..daf93691d47 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/KeyConfiguration.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/KeyConfiguration.jsx @@ -23,6 +23,7 @@ import TextField from '@mui/material/TextField'; import FormHelperText from '@mui/material/FormHelperText'; import Checkbox from '@mui/material/Checkbox'; import FormControlLabel from '@mui/material/FormControlLabel'; +import Chip from '@mui/material/Chip'; import Tooltip from '@mui/material/Tooltip'; import IconButton from '@mui/material/IconButton'; import Icon from '@mui/material/Icon'; @@ -182,16 +183,56 @@ const KeyConfiguration = (props) => { selectedApp, keyValue, formConfig, } = props; - // When the template hides grant types, lock the keyRequest to the forced defaultValue - useEffect(() => { - const kgCfg = formConfig?.keyGeneration; - if (!kgCfg?.grantTypes?.hidden) return; - const locked = Array.isArray(kgCfg.grantTypes.defaultValue) ? kgCfg.grantTypes.defaultValue : []; - if (keyRequest.selectedGrantTypes === null - || JSON.stringify(keyRequest.selectedGrantTypes) !== JSON.stringify(locked)) { - updateKeyRequest({ ...keyRequest, selectedGrantTypes: locked }); + // Grant types the template allows the developer to choose from (null = no restriction, show all) + const templateAllowedGrantTypes = (() => { + const gt = formConfig?.keyGeneration?.grantTypes?.defaultValue; + return Array.isArray(gt) && gt.length > 0 ? gt : null; + })(); + + // Grant types currently selected by the developer — drives conditional field visibility + const activeGrantTypes = keyRequest.selectedGrantTypes ?? []; + + // Map from WSO2 internal additionalProperties key → formConfig field key. + // Used to check per-field explicit hide overrides from the template. + const CONFIG_NAME_TO_FORM_FIELD = { + application_access_token_expiry_time: 'appAccessTokenExpiry', + user_access_token_expiry_time: 'userAccessTokenExpiry', + refresh_token_expiry_time: 'refreshTokenExpiry', + id_token_expiry_time: 'idTokenExpiry', + pkceMandatory: 'enablePKCE', + pkceSupportPlain: 'pkceSupportsPlainText', + bypassClientCredentials: 'publicClient', + }; + + // Returns false when a config entry should be hidden from the developer. + const isAppConfigVisible = (config) => { + const formFieldKey = CONFIG_NAME_TO_FORM_FIELD[config.name]; + + // Explicit hide from template overrides everything + if (formFieldKey && formConfig?.keyGeneration?.[formFieldKey]?.hidden === true) return false; + + switch (config.name) { + case 'application_access_token_expiry_time': + return activeGrantTypes.includes('client_credentials'); + case 'user_access_token_expiry_time': + return activeGrantTypes.includes('password') + || activeGrantTypes.includes('authorization_code') + || activeGrantTypes.includes('implicit'); + case 'refresh_token_expiry_time': + return activeGrantTypes.includes('refresh_token'); + case 'id_token_expiry_time': + return activeGrantTypes.includes('authorization_code'); + case 'pkceMandatory': + case 'pkceSupportPlain': + return activeGrantTypes.includes('authorization_code'); + default: + return true; } - }, [formConfig]); // eslint-disable-line react-hooks/exhaustive-deps + }; + + // Callback URL is only relevant for flows that use a redirect URI + const showCallbackUrl = activeGrantTypes.includes('authorization_code') + || activeGrantTypes.includes('implicit'); const { selectedGrantTypes, callbackUrl, } = keyRequest; @@ -373,6 +414,12 @@ const KeyConfiguration = (props) => { availableGrantTypes, Settings.grantTypes, ); + // Filter to only template-allowed grant types when a governance template is applied + const visibleGrantTypeDisplayListMap = templateAllowedGrantTypes + ? Object.fromEntries( + Object.entries(grantTypeDisplayListMap).filter(([key]) => templateAllowedGrantTypes.includes(key)), + ) + : grantTypeDisplayListMap; // Check for additional properties for token endpoint and revoke endpoints. return ( @@ -494,109 +541,109 @@ const KeyConfiguration = (props) => { {mode !== 'MAPPED' && (() => { const advancedConfigurations = ( <> - {/* Grant Types — hidden when template governs them */} - {!formConfig?.keyGeneration?.grantTypes?.hidden && ( + + + + + +
+ {Object.keys(visibleGrantTypeDisplayListMap).map((key) => { + const value = visibleGrantTypeDisplayListMap[key]; + return ( + handleChange('grantType', e)} + value={value} + disabled={!isOrgWideAppUpdateEnabled && !isUserOwner} + color='grey' + data-testid={key} + /> + )} + label={value} + key={key} + /> + ); + })} +
+ + + +
+
+ + {/* Callback URL — hidden when template locks grant types to non-redirect flows */} + {showCallbackUrl && ( -
- {Object.keys(grantTypeDisplayListMap).map((key) => { - const value = grantTypeDisplayListMap[key]; - return ( - handleChange('grantType', e)} - value={value} - disabled={!isOrgWideAppUpdateEnabled && !isUserOwner} - color='grey' - data-testid={key} - /> - )} - label={value} - key={key} + + - ); - })} -
- - handleChange('callbackUrl', e)} + helperText={callbackHelper || ( + + )} + variant='outlined' + disabled={(!isOrgWideAppUpdateEnabled && !isUserOwner) || + (selectedGrantTypes && + !selectedGrantTypes.includes('authorization_code') && + !selectedGrantTypes.includes('implicit'))} + error={hasCallbackError} + placeholder={intl.formatMessage({ + defaultMessage: 'http://url-to-webapp', + id: 'Shared.AppsAndKeys.KeyConfiguration.url.to.webapp', + })} + fullWidth /> - +
)} - {/* Callback URL */} - - - 0 + && applicationConfiguration.filter(isAppConfigVisible).map((config) => ( + - - - - - )} - value={callbackUrl} - name='callbackURL' - onChange={(e) => handleChange('callbackUrl', e)} - helperText={callbackHelper || ( - - )} - variant='outlined' - disabled={(!isOrgWideAppUpdateEnabled && !isUserOwner) || - (selectedGrantTypes && - !selectedGrantTypes.includes('authorization_code') && - !selectedGrantTypes.includes('implicit'))} - error={hasCallbackError} - placeholder={intl.formatMessage({ - defaultMessage: 'http://url-to-webapp', - id: 'Shared.AppsAndKeys.KeyConfiguration.url.to.webapp', - })} - fullWidth - /> - - - - - {/* App Configurations */} - {applicationConfiguration.length > 0 && applicationConfiguration.map((config) => ( - - ))} + ))} ); diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/TokenManager.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/TokenManager.jsx index cd36990e67f..3b11fffc5c1 100755 --- a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/TokenManager.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/TokenManager.jsx @@ -279,12 +279,14 @@ class TokenManager extends React.Component { this.isOrgWideAppUpdateEnabled(); } - componentDidUpdate(nextProps) { - const { keyType: nextKeyType } = nextProps; - const { keyType: prevKeyType } = this.props; - if (nextKeyType !== prevKeyType) { + componentDidUpdate(prevProps) { + const { keyType } = this.props; + if (prevProps.keyType !== keyType) { this.loadApplication(); } + if (prevProps.formConfig !== this.props.formConfig) { + this.syncSelectedTabWithGovernance(); + } } /** @@ -352,6 +354,26 @@ class TokenManager extends React.Component { return isEnabled; } + getVisibleKeyManagers = (keyManagers = []) => { + const kmConfig = this.props.formConfig?.keyManagers; + if (!kmConfig) return keyManagers; + return keyManagers.filter((km) => { + const entry = kmConfig[km.name]; + return entry && entry.enabled !== false; + }); + }; + + syncSelectedTabWithGovernance = () => { + const { keyManagers, selectedTab } = this.state; + if (!keyManagers) { + return; + } + const visibleKeyManagers = this.getVisibleKeyManagers(keyManagers); + if (visibleKeyManagers.length > 0 && !visibleKeyManagers.find((km) => km.name === selectedTab)) { + this.handleTabChange(null, visibleKeyManagers[0].name); + } + }; + getMultipleSecretsAllowed = (keyManager) => { return isMultipleClientSecretsEnabled(keyManager?.additionalProperties); }; @@ -426,11 +448,23 @@ class TokenManager extends React.Component { this.setState({ keyManagers: [] }); return; } + const visibleKeyManagerList = this.getVisibleKeyManagers(responseKeyManagerList); + if (visibleKeyManagerList.length === 0) { + this.setState({ + keys: response[1], + keyManagers: responseKeyManagerList, + selectedTab: null, + importDisabled: false, + mode: null, + }); + return; + } // Selecting a key manager from the list of key managers. let { selectedTab } = this.state; - if (!selectedTab && responseKeyManagerList.length > 0) { - selectedTab = responseKeyManagerList.find((x) => x.name === 'Resident Key Manager') ? 'Resident Key Manager' - : responseKeyManagerList[0].name; + if (!selectedTab || !visibleKeyManagerList.find((x) => x.name === selectedTab)) { + selectedTab = visibleKeyManagerList.find((x) => x.name === 'Resident Key Manager') + ? 'Resident Key Manager' + : visibleKeyManagerList[0].name; } const selectdKM = responseKeyManagerList.find((x) => x.name === selectedTab); const isMultipleSecretsAllowed = this.getMultipleSecretsAllowed(selectdKM); @@ -784,12 +818,7 @@ class TokenManager extends React.Component { } getKeyManagerIdentifier() { - const { keyManagers, selectedTab } = this.state; - const selectedKMObject = keyManagers.filter((item) => item.name === selectedTab); - if (selectedKMObject && selectedKMObject.length === 1) { - return selectedKMObject[0].id; - } - return selectedTab; + return this.state.selectedTab; } setValidating = (validatingState) => { @@ -831,6 +860,15 @@ class TokenManager extends React.Component { initialValidityTime, initialScopes, importDisabled, mode, tokenType, isOrgWideAppUpdateEnabled, isAccordionExpanded, } = this.state; + // Filter KM list to only those permitted by the governance template. + // Empty/absent allowedKeyManagers means no restriction — show all. + const visibleKeyManagers = keyManagers ? this.getVisibleKeyManagers(keyManagers) : keyManagers; + + // If the currently selected tab was filtered out, fall back to the first visible KM. + const effectiveTab = visibleKeyManagers && visibleKeyManagers.find((km) => km.name === selectedTab) + ? selectedTab + : (visibleKeyManagers && visibleKeyManagers[0]?.name); + if (keyManagers && keyManagers.length === 0) { return ( @@ -862,6 +900,28 @@ class TokenManager extends React.Component { ); } + if (keyManagers && visibleKeyManagers && visibleKeyManagers.length === 0) { + return ( + +
+ + + + + + + + +
+
+ ); + } if (!keys || !selectedTab || !keyRequest.selectedGrantTypes) { return ; } @@ -942,12 +1002,13 @@ class TokenManager extends React.Component { if (key && (key.keyState === this.keyStates.CREATED || key.keyState === this.keyStates.REJECTED)) { return ; } + const hasSelectedGrantTypes = keyRequest.selectedGrantTypes.length > 0; return ( - {(keyManagers && keyManagers.length > 1) && ( + {(visibleKeyManagers && visibleKeyManagers.length > 1) && ( - {keyManagers.map((keymanager) => ( + {visibleKeyManagers.map((keymanager) => ( - {(keyManagers && keyManagers.length > 0) && keyManagers.map((keymanager) => ( + {(visibleKeyManagers && visibleKeyManagers.length > 0) && visibleKeyManagers.map((keymanager) => (
{keymanager.tokenType === 'DIRECT' && ( - +
)} {keymanager.tokenType === 'EXCHANGED' && ( - + )} {keymanager.tokenType === 'BOTH' && ( - + @@ -1319,7 +1383,9 @@ class TokenManager extends React.Component { callbackError={hasError} setValidating={this.setValidating} defaultTokenEndpoint={defaultTokenEndpoint} - formConfig={this.props.formConfig} + formConfig={this.props.formConfig?.keyManagers?.[keymanager.name] + ? { keyGeneration: this.props.formConfig.keyManagers[keymanager.name] } + : null} />
{key ? 'Update keys' : 'Generate Keys'} @@ -1356,6 +1423,9 @@ class TokenManager extends React.Component { color='primary' className={classes.button} onClick={key ? this.updateKeys : this.handleGenerateKeysClick} + disabled={!hasSelectedGrantTypes || hasError + || (isLoading || !keymanager.enableOAuthAppCreation) + || (mode && mode === 'MAPPED')} > {key ? 'Update' : 'Generate Keys'} @@ -1385,7 +1455,7 @@ class TokenManager extends React.Component { )} {(tokenType === 'EXCHANGED' && isResidentKeyManagerTokensAvailable) && ( - + } Resolves to swagger-client-shaped response */ getDevportalGovernanceTemplates(params = {}) { - const user = AuthManager.getUser(Utils.getEnvironment().label); - const token = user ? user.getPartialToken() : ''; + const token = Utils.getCookie('WSO2_AM_TOKEN_1', Utils.getEnvironment().label) || ''; const queryParams = new URLSearchParams({ limit: params.limit ?? 25, @@ -1346,8 +1344,7 @@ export default class API extends Resource { * @returns {Promise<{body: Object}>} Resolves to swagger-client-shaped response with the template DTO */ getDevportalGovernanceTemplateById(templateId) { - const user = AuthManager.getUser(Utils.getEnvironment().label); - const token = user ? user.getPartialToken() : ''; + const token = Utils.getCookie('WSO2_AM_TOKEN_1', Utils.getEnvironment().label) || ''; const headers = { Accept: 'application/json',