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..14c9e56599a 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,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/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/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/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/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..5ee77309e2c --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/ListTemplates.jsx @@ -0,0 +1,385 @@ +/* + * 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 { useIntl, FormattedMessage } from 'react-intl'; +import Typography from '@mui/material/Typography'; +import { + 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'; +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 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 + */ +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); + + 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; + }); + } + + 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 = [ + { + 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, + customHeadLabelRender: () => ( + + + {intl.formatMessage({ + id: 'Governance.Templates.List.column.default', + defaultMessage: 'Default', + })} + + + + ), + 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 + }, + ]; + + 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..a5e4a71ab06 --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/TemplateWizard.jsx @@ -0,0 +1,565 @@ +/* + * 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, useRef, +} from 'react'; +import PropTypes from 'prop-types'; +import { useParams, useHistory, Link as RouterLink } from 'react-router-dom'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + Box, + Button, + ButtonGroup, + CircularProgress, + ClickAwayListener, + Grow, + MenuItem, + MenuList, + Paper, + Popper, + Step, + StepLabel, + Stepper, +} from '@mui/material'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +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 DeveloperViewStep from './steps/DeveloperViewStep'; +import ReviewPublishStep from './steps/ReviewPublishStep'; + +/** + * Initial form config. + * Sections: application (metadata), keyManagers (per-KM governance). + * Subscription throttling policy removed — cannot meaningfully pre-set a + * per-API policy from a template. + */ +const INITIAL_FORM_CONFIG = { + application: { + throttlingPolicy: { hidden: false, defaultValue: '' }, + description: { hidden: false, required: false, defaultValue: '' }, + groups: { + hidden: false, required: false, active: true, defaultValue: '', + }, + // Custom attributes are stored under application.attributes. + }, + // 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, + formConfig: INITIAL_FORM_CONFIG, + rulesetBindings: [], +}; + +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', + defaultMessage: 'General Details', + }, + { + id: 'Governance.Templates.Wizard.step.formBuilder', + 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. + */ +export default function TemplateWizard() { + const { id: templateId } = useParams(); + const history = useHistory(); + 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: '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: mergeFormConfig(t.formConfig) }); + 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 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, + tags: templateState.tags, + icon: templateState.icon, + status: statusOverride, + 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', + })); + history.push('/governance/templates'); + }) + .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 isLastStep = activeStep === STEPS.length - 1; + + const stepContent = [ + , + , + , + , + , + ]; + + return ( + + + {/* 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 ? ( + + + + ) : ( + + )} + + {isLastStep ? ( + handleSaveAs('PUBLISHED')} + onDraft={() => handleSaveAs('DRAFT')} + /> + ) : ( + + )} + + + + ); +} 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/DeveloperViewStep.jsx b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/DeveloperViewStep.jsx new file mode 100644 index 00000000000..58c5e112d1a --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/DeveloperViewStep.jsx @@ -0,0 +1,464 @@ +/* + * 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 React, { useMemo, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { + Box, + Divider, + Grid, + Link, + Paper, + TextField, + Typography, +} from '@mui/material'; +import GovernanceAPI from 'AppData/GovernanceAPI'; + +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', +}; +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 normalizeLimitations(limitations) { + const raw = Array.isArray(limitations) + ? limitations + : String(limitations || '').split('\n'); + return raw.map((item) => String(item).trim()).filter(Boolean); +} + +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.`); + } +} + +function buildDeveloperLimitations(templateState) { + const formConfig = templateState.formConfig ?? {}; + const application = formConfig.application ?? {}; + const keyManagers = formConfig.keyManagers ?? {}; + 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.`); + } + }); + + const enabledKMs = Object.entries(keyManagers).filter(([, kmc]) => kmc?.enabled === true); + if (enabledKMs.length > 0) { + items.push(`Key generation is limited to: ${enabledKMs.map(([name]) => name).join(', ')}.`); + + enabledKMs.forEach(([kmName, kmConfig]) => { + const selectedGrantTypes = Array.isArray(kmConfig.grantTypes?.defaultValue) + ? kmConfig.grantTypes.defaultValue + : []; + const hasGrantType = (...gts) => gts.some((gt) => selectedGrantTypes.includes(gt)); + const prefix = enabledKMs.length > 1 ? `[${kmName}] ` : ''; + + addFieldLimit(items, `${prefix}OAuth grant types`, kmConfig.grantTypes, { valueMap: GRANT_TYPE_LABELS }); + if (hasGrantType('authorization_code', 'implicit')) { + addFieldLimit(items, `${prefix}Callback URL`, kmConfig.callbackUrl); + } + if (hasGrantType('client_credentials')) { + addFieldLimit(items, `${prefix}Application access token expiry`, kmConfig.appAccessTokenExpiry, { + hiddenOnly: true, + }); + } + if (hasGrantType('password', 'authorization_code', 'implicit')) { + addFieldLimit(items, `${prefix}User access token expiry`, kmConfig.userAccessTokenExpiry, { + hiddenOnly: true, + }); + } + if (hasGrantType('refresh_token')) { + addFieldLimit(items, `${prefix}Refresh token expiry`, kmConfig.refreshTokenExpiry, { + hiddenOnly: true, + }); + } + if (hasGrantType('authorization_code')) { + addFieldLimit(items, `${prefix}ID token expiry`, kmConfig.idTokenExpiry, { hiddenOnly: true }); + addFieldLimit(items, `${prefix}PKCE`, kmConfig.enablePKCE, { hiddenOnly: true }); + addFieldLimit(items, `${prefix}PKCE plain text support`, kmConfig.pkceSupportsPlainText, { + hiddenOnly: true, + }); + addFieldLimit(items, `${prefix}Public client mode`, kmConfig.publicClient, { hiddenOnly: true }); + } + }); + } + + const rulesetCount = templateState.rulesetBindings?.length ?? 0; + if (rulesetCount > 0) { + const rulesetLabel = rulesetCount === 1 ? 'ruleset' : 'rulesets'; + items.push(`${rulesetCount} governance ${rulesetLabel} will validate application changes.`); + } + + return items; +} + +/** + * Step 4 of the TemplateWizard — Developer View. + * + * Two-column layout: + * Left — Editable fields: summary text, limitations text area. + * Limitations are always pre-populated with generated limitations so + * the admin sees exactly what developers will see and can edit inline. + * Right — Read-only final developer view: summary, limitations, and descriptions + * of all bound rulesets (fetched from the API). + */ +export default function DeveloperViewStep({ templateState, dispatch }) { + const developerExperience = templateState.formConfig?.developerExperience ?? {}; + const [rulesets, setRulesets] = useState([]); + + const generatedLimitations = useMemo( + () => buildDeveloperLimitations(templateState), + [templateState.formConfig, templateState.rulesetBindings], + ); + + // Pre-populate limitations with generated text on first render when they are empty + useEffect(() => { + const currentLimitations = developerExperience.limitations; + const isEmpty = !currentLimitations + || (typeof currentLimitations === 'string' && currentLimitations.trim() === '') + || (Array.isArray(currentLimitations) && currentLimitations.length === 0); + + if (isEmpty && generatedLimitations.length > 0) { + dispatch({ + field: 'formConfig', + value: { + ...templateState.formConfig, + developerExperience: { + ...developerExperience, + limitations: generatedLimitations.join('\n'), + }, + }, + }); + } + }, []); // Only on mount + + // Fetch ruleset details for descriptions in the developer view panel + useEffect(() => { + const boundIds = (templateState.rulesetBindings ?? []).map((b) => b.rulesetId); + if (boundIds.length === 0) { + setRulesets([]); + return; + } + new GovernanceAPI() + .getRulesets({ limit: 200, offset: 0 }) + .then((res) => { + const all = res.body?.list ?? []; + setRulesets(all.filter((r) => boundIds.includes(r.id))); + }) + .catch(() => {}); // Non-critical — descriptions are optional + }, [templateState.rulesetBindings]); + + const updateDeveloperExperience = (patch) => { + dispatch({ + field: 'formConfig', + value: { + ...templateState.formConfig, + developerExperience: { + ...developerExperience, + ...patch, + }, + }, + }); + }; + + const limitationsText = Array.isArray(developerExperience.limitations) + ? normalizeLimitations(developerExperience.limitations).join('\n') + : (developerExperience.limitations ?? ''); + + const previewLimitations = normalizeLimitations(developerExperience.limitations); + + const hasSummary = !!(developerExperience.summary ?? '').trim(); + const hasLimitations = previewLimitations.length > 0; + + return ( + + + + + + + + + + {/* ── LEFT: Editable fields ── */} + + {/* Summary */} + + )} + value={developerExperience.summary ?? ''} + onChange={(e) => updateDeveloperExperience({ summary: e.target.value })} + helperText={( + + )} + variant='outlined' + sx={{ mb: 3 }} + /> + + {/* Limitations — always pre-populated with generated, fully editable */} + + )} + value={limitationsText} + onChange={(e) => updateDeveloperExperience({ limitations: e.target.value })} + helperText={( + + )} + variant='outlined' + /> + + + {/* ── RIGHT: Final developer view ── */} + + + + + + + + {/* Summary section */} + {hasSummary ? ( + + {developerExperience.summary} + + ) : ( + + + + )} + + {/* Limitations section */} + {hasLimitations && ( + <> + + + + + + + {previewLimitations.map((item) => ( + + {item} + + ))} + + + + )} + + {/* Ruleset descriptions */} + {rulesets.length > 0 && ( + <> + + + + + + + {rulesets.map((rs) => ( + + + {rs.name} + + {rs.description && ( + + {rs.description} + + )} + {rs.documentationLink && ( + + + + )} + + ))} + + + + )} + + {!hasSummary && !hasLimitations && rulesets.length === 0 && ( + + + + )} + + + + + ); +} + +DeveloperViewStep.propTypes = { + templateState: PropTypes.shape({ + formConfig: PropTypes.shape({ + developerExperience: PropTypes.shape({ + summary: PropTypes.string, + limitations: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ]), + }), + }).isRequired, + rulesetBindings: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + }).isRequired, + dispatch: PropTypes.func.isRequired, +}; 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..20fd6cc793e --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/FormBuilderStep.jsx @@ -0,0 +1,1431 @@ +/* + * 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, + Grid, + MenuItem, + OutlinedInput, + Select, + Switch, + TextField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; +import Alert from 'AppComponents/Shared/Alert'; +import API from 'AppData/api'; + +// ─── Grant type metadata ────────────────────────────────────────────────────── + +const GRANT_TYPE_META = { + authorization_code: { + label: 'Authorization Code', needsCallback: true, needsUserToken: true, needsIdToken: true, needsPKCE: true, + }, + client_credentials: { label: 'Client Credentials', needsCallback: false, needsAppToken: true }, + password: { + label: 'Resource Owner Password', needsCallback: false, needsUserToken: true, needsRefreshToken: true, + }, + refresh_token: { label: 'Refresh Token', needsRefreshToken: true }, + implicit: { label: 'Implicit', needsCallback: true, needsUserToken: true }, + 'urn:ietf:params:oauth:grant-type:jwt-bearer': { label: 'JWT Bearer' }, + 'urn:ietf:params:oauth:grant-type:saml2-bearer': { label: 'SAML Extension' }, + kerberos: { label: 'Kerberos' }, + 'iwa:ntlm': { label: 'NTLM' }, + 'urn:ietf:params:oauth:grant-type:device_code': { label: 'Device Code' }, + 'urn:ietf:params:oauth:grant-type:token-exchange': { label: 'Token Exchange' }, +}; + +const RESIDENT_KM_TYPE = 'DEFAULT'; + +const RESIDENT_FALLBACK_GRANT_TYPES = [ + 'authorization_code', + 'client_credentials', + 'password', + 'refresh_token', + 'implicit', + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'urn:ietf:params:oauth:grant-type:saml2-bearer', + 'kerberos', + 'iwa:ntlm', +]; + +const EXTERNAL_FALLBACK_GRANT_TYPES = [ + 'authorization_code', + 'client_credentials', + 'password', + 'refresh_token', + 'implicit', + 'urn:ietf:params:oauth:grant-type:jwt-bearer', +]; + +// ─── Default per-KM governance config ──────────────────────────────────────── + +function defaultKMConfig() { + return { + enabled: false, + // grantTypes is an ALLOWED LIST, not a hideable scalar — no `hidden` flag. + grantTypes: { defaultValue: [] }, + callbackUrl: { hidden: false, defaultValue: '' }, + appAccessTokenExpiry: { hidden: false, defaultValue: 3600 }, + userAccessTokenExpiry: { hidden: false, defaultValue: 3600 }, + refreshTokenExpiry: { hidden: false, defaultValue: -1 }, + idTokenExpiry: { hidden: false, defaultValue: 3600 }, + enablePKCE: { hidden: false, defaultValue: false }, + pkceSupportsPlainText: { hidden: false, defaultValue: false }, + publicClient: { hidden: false, defaultValue: false }, + }; +} + +// ─── FieldRow ───────────────────────────────────────────────────────────────── + +function FieldRow({ + label, + description, + hidden, + onToggleHidden, + defaultInput, + defaultEmpty, + noDefault, + isRequired, + required, + requiredLocked, + onToggleRequired, + omitHiddenToggle, + statusBadge, +}) { + const effectiveRequired = isRequired || required; + const showError = !omitHiddenToggle && hidden && !noDefault && defaultEmpty && effectiveRequired; + const showRequiredControl = !omitHiddenToggle && (requiredLocked || onToggleRequired); + + return ( + + + {label} + {description && ( + + {description} + + )} + + + + {/* For most fields, admin chooses Visible vs Hidden. For "list constraint" + fields like grant types, there is no Visible/Hidden — the admin's selection + IS the allowed set. omitHiddenToggle suppresses the toggle and shows + the supplied statusBadge instead (e.g. "Allowed list"). */} + {omitHiddenToggle ? ( + statusBadge ?? null + ) : ( + + { + if (newValue !== null) onToggleHidden(newValue === 'hidden'); + }} + > + + + + + + + + + )} + + {showRequiredControl && ( + { + if (newValue !== null && onToggleRequired && !requiredLocked) { + onToggleRequired(newValue === 'required'); + } + }} + disabled={requiredLocked} + > + + + + + + + + )} + + + + {noDefault ? ( + + + + ) : ( + + {React.cloneElement(defaultInput, showError ? { error: true } : {})} + {showError && ( + + + + )} + {hidden && !showError && ( + + + + )} + + )} + + + ); +} + +FieldRow.propTypes = { + label: PropTypes.node.isRequired, + description: PropTypes.node, + hidden: PropTypes.bool, + onToggleHidden: PropTypes.func, + defaultInput: PropTypes.element, + defaultEmpty: PropTypes.bool, + noDefault: PropTypes.bool, + isRequired: PropTypes.bool, + required: PropTypes.bool, + requiredLocked: PropTypes.bool, + onToggleRequired: PropTypes.func, + omitHiddenToggle: PropTypes.bool, + statusBadge: PropTypes.node, +}; +FieldRow.defaultProps = { + description: null, + hidden: false, + onToggleHidden: null, + defaultInput: null, + defaultEmpty: false, + noDefault: false, + isRequired: false, + required: false, + requiredLocked: false, + onToggleRequired: null, + omitHiddenToggle: false, + statusBadge: null, +}; + +// ─── SectionHeader ──────────────────────────────────────────────────────────── + +function SectionHeader() { + return ( + + + + + + + + + + + + + + + + + + ); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function isEmpty(value) { + if (value === null || value === undefined || value === '') return true; + if (Array.isArray(value) && value.length === 0) return true; + return false; +} + +function isTrue(value) { + return value === true || value === 'true'; +} + +function normalizeStringList(value) { + if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean); + return String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean); +} + +function dedupeStringList(values) { + return [...new Set(normalizeStringList(values))]; +} + +function toCommaSeparated(value) { + return normalizeStringList(value).join(', '); +} + +function getTokenExpiryHelperText(fieldConfig, intl) { + if (fieldConfig.defaultValue !== -1) return ''; + return intl.formatMessage({ + id: 'Governance.Templates.FormBuilder.keyGen.expiry.unlimited', + defaultMessage: 'Token will not expire', + }); +} + +function reconcileServerBackedFields(formConfig, applicationSharingEnabled, applicationAttributes) { + const currentAttributeNames = new Set( + (applicationAttributes ?? []).map((attr) => attr.attribute).filter(Boolean), + ); + const existingAttributes = formConfig?.application?.attributes ?? {}; + const reconciledAttributes = {}; + Object.entries(existingAttributes).forEach(([attrName, attrConfig]) => { + reconciledAttributes[attrName] = { ...attrConfig, active: currentAttributeNames.has(attrName) }; + }); + currentAttributeNames.forEach((attrName) => { + reconciledAttributes[attrName] = { + ...(reconciledAttributes[attrName] ?? { hidden: false, required: false, defaultValue: '' }), + active: true, + }; + }); + return { + ...formConfig, + application: { + ...(formConfig?.application ?? {}), + groups: { + ...((formConfig?.application ?? {}).groups ?? { hidden: false, required: false, defaultValue: '' }), + active: applicationSharingEnabled, + }, + attributes: reconciledAttributes, + }, + }; +} + +// ─── KMGovernancePanel — governance fields for one key manager ──────────────── + +function KMGovernancePanel({ + kmConfig, availableGrantTypes, isResidentKM, onUpdate, +}) { + const intl = useIntl(); + + const getField = (fieldKey, emptyDefault = '') => ( + kmConfig?.[fieldKey] ?? { hidden: false, defaultValue: emptyDefault } + ); + + const updateField = (fieldKey, configKey, value) => { + onUpdate(fieldKey, configKey, value); + }; + + const grantTypesField = getField('grantTypes', []); + const grantTypesValue = Array.isArray(grantTypesField.defaultValue) ? grantTypesField.defaultValue : []; + + const hasGrant = (...grants) => grants.some((g) => grantTypesValue.includes(g)); + const needsCallback = hasGrant('authorization_code', 'implicit'); + const needsAppToken = hasGrant('client_credentials'); + const needsUserToken = hasGrant('password', 'authorization_code', 'implicit'); + const needsRefreshToken = hasGrant('refresh_token'); + const needsIdToken = hasGrant('authorization_code'); + const needsPKCE = isResidentKM && hasGrant('authorization_code'); + + const callbackUrl = getField('callbackUrl'); + const appTokenExpiry = getField('appAccessTokenExpiry', 3600); + const userTokenExpiry = getField('userAccessTokenExpiry', 3600); + const refreshTokenExpiry = getField('refreshTokenExpiry', -1); + const idTokenExpiry = getField('idTokenExpiry', 3600); + const enablePKCE = getField('enablePKCE', false); + const pkceSupportsPlainText = getField('pkceSupportsPlainText', false); + const publicClient = getField('publicClient', false); + + const grantTypeOptions = availableGrantTypes.map((gt) => ({ + value: gt, + label: GRANT_TYPE_META[gt]?.label ?? gt, + })); + + return ( + + + + {/* Grant Types — admin chooses the ALLOWED SET. Unlike single-value fields, + grant types do not have a Visible/Hidden toggle: the admin's selection IS + the constraint, and developers can pick any subset of these at runtime. + Per-grant configs (callbackUrl, expiry, PKCE) below DO have hidden/visible. */} + + )} + description={( + + )} + omitHiddenToggle + statusBadge={( + + )} + sx={{ height: 22, fontSize: '0.7rem' }} + /> + )} + defaultEmpty={isEmpty(grantTypesValue)} + isRequired + defaultInput={( + + )} + /> + + {grantTypesValue.length === 0 && ( + + + + + + )} + + {/* Callback URL */} + {needsCallback && ( + + )} + description={( + + )} + hidden={callbackUrl.hidden} + onToggleHidden={(v) => updateField('callbackUrl', 'hidden', v)} + defaultEmpty={isEmpty(callbackUrl.defaultValue)} + defaultInput={( + updateField('callbackUrl', 'defaultValue', e.target.value)} + /> + )} + /> + )} + + {/* App Access Token Expiry */} + {needsAppToken && ( + + )} + description={( + + )} + hidden={appTokenExpiry.hidden} + onToggleHidden={(v) => updateField('appAccessTokenExpiry', 'hidden', v)} + defaultEmpty={isEmpty(appTokenExpiry.defaultValue)} + defaultInput={( + updateField( + 'appAccessTokenExpiry', 'defaultValue', Number(e.target.value), + )} + inputProps={{ min: -1 }} + helperText={getTokenExpiryHelperText(appTokenExpiry, intl)} + /> + )} + /> + )} + + {/* User Access Token Expiry */} + {needsUserToken && ( + + )} + description={( + + )} + hidden={userTokenExpiry.hidden} + onToggleHidden={(v) => updateField('userAccessTokenExpiry', 'hidden', v)} + defaultEmpty={isEmpty(userTokenExpiry.defaultValue)} + defaultInput={( + updateField( + 'userAccessTokenExpiry', 'defaultValue', Number(e.target.value), + )} + inputProps={{ min: -1 }} + helperText={getTokenExpiryHelperText(userTokenExpiry, intl)} + /> + )} + /> + )} + + {/* Refresh Token Expiry */} + {needsRefreshToken && ( + + )} + description={( + + )} + hidden={refreshTokenExpiry.hidden} + onToggleHidden={(v) => updateField('refreshTokenExpiry', 'hidden', v)} + defaultEmpty={isEmpty(refreshTokenExpiry.defaultValue)} + defaultInput={( + updateField('refreshTokenExpiry', 'defaultValue', Number(e.target.value))} + inputProps={{ min: -1 }} + helperText={getTokenExpiryHelperText(refreshTokenExpiry, intl)} + /> + )} + /> + )} + + {/* ID Token Expiry */} + {needsIdToken && ( + + )} + description={( + + )} + hidden={idTokenExpiry.hidden} + onToggleHidden={(v) => updateField('idTokenExpiry', 'hidden', v)} + defaultEmpty={isEmpty(idTokenExpiry.defaultValue)} + defaultInput={( + updateField('idTokenExpiry', 'defaultValue', Number(e.target.value))} + inputProps={{ min: -1 }} + helperText={getTokenExpiryHelperText(idTokenExpiry, intl)} + /> + )} + /> + )} + + {/* PKCE — Resident KM only, shown when auth_code grant selected */} + {needsPKCE && ( + <> + + )} + description={( + + )} + hidden={enablePKCE.hidden} + onToggleHidden={(v) => updateField('enablePKCE', 'hidden', v)} + defaultEmpty={false} + defaultInput={( + updateField('enablePKCE', 'defaultValue', e.target.checked)} + color='primary' + /> + )} + /> + + )} + description={( + + )} + hidden={pkceSupportsPlainText.hidden} + onToggleHidden={(v) => updateField('pkceSupportsPlainText', 'hidden', v)} + defaultEmpty={false} + defaultInput={( + updateField('pkceSupportsPlainText', 'defaultValue', e.target.checked)} + color='primary' + /> + )} + /> + + )} + description={( + + )} + hidden={publicClient.hidden} + onToggleHidden={(v) => updateField('publicClient', 'hidden', v)} + defaultEmpty={false} + defaultInput={( + updateField('publicClient', 'defaultValue', e.target.checked)} + color='primary' + /> + )} + /> + + )} + + ); +} + +KMGovernancePanel.propTypes = { + kmConfig: PropTypes.shape({}).isRequired, + availableGrantTypes: PropTypes.arrayOf(PropTypes.string).isRequired, + isResidentKM: PropTypes.bool.isRequired, + onUpdate: PropTypes.func.isRequired, +}; + +// ─── KMAccordion — one accordion per key manager ────────────────────────────── + +function KMAccordion({ + km, kmConfig, onToggleEnabled, onUpdateField, +}) { + const intl = useIntl(); + const [fullDetails, setFullDetails] = useState(null); + const [loadingDetails, setLoadingDetails] = useState(false); + const [grantTypesInitialized, setGrantTypesInitialized] = useState(false); + + const isEnabled = !!kmConfig?.enabled; + const isResidentKM = km.type === RESIDENT_KM_TYPE; + const [expanded, setExpanded] = useState(false); + + // Open the details as soon as governance is enabled so admins can configure + // the KM immediately; keep manual collapse/expand working afterwards. + useEffect(() => { + setExpanded(isEnabled); + if (!isEnabled) { + setGrantTypesInitialized(false); + } + }, [isEnabled]); + + // Fetch full KM details (including availableGrantTypes) when first enabled + useEffect(() => { + if (!isEnabled || fullDetails !== null || loadingDetails) return; + setLoadingDetails(true); + new API() + .keyManagerGet(km.id) + .then((res) => { + setFullDetails(res.body); + }) + .catch(() => { + // Fallback to a sensible default grant type list based on KM type + setFullDetails({ + availableGrantTypes: isResidentKM + ? RESIDENT_FALLBACK_GRANT_TYPES + : EXTERNAL_FALLBACK_GRANT_TYPES, + }); + }) + .finally(() => setLoadingDetails(false)); + }, [isEnabled]); + + const grantTypesFromList = Array.isArray(km.availableGrantTypes) && km.availableGrantTypes.length > 0 + ? km.availableGrantTypes + : null; + const hasResolvedGrantTypes = fullDetails !== null || grantTypesFromList !== null; + const availableGrantTypes = dedupeStringList( + fullDetails?.availableGrantTypes + ?? grantTypesFromList + ?? (isResidentKM ? RESIDENT_FALLBACK_GRANT_TYPES : EXTERNAL_FALLBACK_GRANT_TYPES), + ); + + // A governed KM with an empty grant list is hard to reason about: the + // developer UI treats it as unconstrained, while the admin cannot see any + // grant-specific settings. Seed the list from the KM once, then let admins + // remove grants intentionally. + useEffect(() => { + if (!isEnabled || loadingDetails || !hasResolvedGrantTypes + || grantTypesInitialized || availableGrantTypes.length === 0) return; + const selectedGrantTypes = Array.isArray(kmConfig?.grantTypes?.defaultValue) + ? kmConfig.grantTypes.defaultValue + : []; + if (selectedGrantTypes.length === 0) { + onUpdateField(km.name, 'grantTypes', 'defaultValue', availableGrantTypes); + } + setGrantTypesInitialized(true); + }, [ + isEnabled, + loadingDetails, + hasResolvedGrantTypes, + grantTypesInitialized, + fullDetails, + availableGrantTypes.join('|'), + ]); + + const handleToggle = (e) => { + e.stopPropagation(); + onToggleEnabled(km.name, !isEnabled); + if (!isEnabled) { + setExpanded(true); + } + }; + + return ( + isEnabled && setExpanded(newExpanded)} + sx={{ + mb: 0.5, + opacity: isEnabled ? 1 : 0.7, + }} + > + + : } + sx={{ cursor: isEnabled ? 'pointer' : 'default !important' }} + > + + {/* Enable/disable toggle — stopPropagation so it doesn't expand/collapse */} + + e.stopPropagation()} + color='primary' + size='small' + /> + + + + + {km.displayName || km.name} + + + + {isResidentKM && ( + + )} + {isEnabled && ( + + )} + + + + {!isEnabled && ( + + + + )} + + + + {isEnabled && ( + + {loadingDetails ? ( + + + + ) : ( + ( + onUpdateField(km.name, fieldKey, configKey, value) + )} + /> + )} + + )} + + ); +} + +KMAccordion.propTypes = { + km: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + displayName: PropTypes.string, + type: PropTypes.string, + }).isRequired, + kmConfig: PropTypes.shape({}), + onToggleEnabled: PropTypes.func.isRequired, + onUpdateField: PropTypes.func.isRequired, +}; +KMAccordion.defaultProps = { kmConfig: null }; + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function FormBuilderStep({ templateState, dispatch }) { + const intl = useIntl(); + + const [appPolicies, setAppPolicies] = useState([]); + const [loadingPolicies, setLoadingPolicies] = useState(true); + const [customAttributes, setCustomAttributes] = useState([]); + const [loadingAttributes, setLoadingAttributes] = useState(true); + const [groupsEnabled, setGroupsEnabled] = useState(false); + const [availableKMs, setAvailableKMs] = useState([]); + + useEffect(() => { + const api = new API(); + Promise.all([ + api.applicationThrottlingPoliciesGet(), + api.getSettings(), + api.getKeyManagersList().catch(() => ({ body: { list: [] } })), + ]) + .then(([appRes, settingsRes, kmsRes]) => { + const applicationSharingEnabled = !!settingsRes.applicationSharingEnabled; + const applicationAttributes = settingsRes.applicationAttributes ?? []; + setAppPolicies(appRes.body.list.map((p) => p.policyName)); + setGroupsEnabled(applicationSharingEnabled); + setCustomAttributes(applicationAttributes); + dispatch({ + field: 'formConfig', + value: reconcileServerBackedFields( + templateState.formConfig, + applicationSharingEnabled, + applicationAttributes, + ), + }); + setAvailableKMs((kmsRes.body?.list ?? []).filter((km) => km.enabled)); + setLoadingAttributes(false); + }) + .catch(() => { + Alert.error(intl.formatMessage({ + id: 'Governance.Templates.FormBuilder.policiesFetch.error', + defaultMessage: 'Failed to load policies. Some dropdowns may be empty.', + })); + setLoadingAttributes(false); + }) + .finally(() => setLoadingPolicies(false)); + }, []); + + // ── Application section helpers ────────────────────────────────────────── + + const getField = (section, fieldKey, emptyDefault = '') => { + return templateState.formConfig?.[section]?.[fieldKey] ?? { hidden: false, defaultValue: emptyDefault }; + }; + + const updateField = (section, fieldKey, configKey, value) => { + dispatch({ + field: 'formConfig', + value: { + ...templateState.formConfig, + [section]: { + ...templateState.formConfig[section], + [fieldKey]: { + ...(templateState.formConfig[section]?.[fieldKey] ?? {}), + [configKey]: value, + }, + }, + }, + }); + }; + + const getAttrField = (attrName) => { + return templateState.formConfig?.application?.attributes?.[attrName] + ?? { hidden: false, defaultValue: '' }; + }; + + const updateAttrField = (attrName, configKey, value) => { + const currentAttributes = templateState.formConfig?.application?.attributes ?? {}; + dispatch({ + field: 'formConfig', + value: { + ...templateState.formConfig, + application: { + ...templateState.formConfig.application, + attributes: { + ...currentAttributes, + [attrName]: { ...(currentAttributes[attrName] ?? {}), [configKey]: value }, + }, + }, + }, + }); + }; + + // ── Key Manager section helpers ────────────────────────────────────────── + + const getKMConfig = (kmName) => { + return templateState.formConfig?.keyManagers?.[kmName] ?? defaultKMConfig(); + }; + + const handleKMToggleEnabled = (kmName, enabled) => { + const currentKMs = templateState.formConfig?.keyManagers ?? {}; + const existing = currentKMs[kmName] ?? defaultKMConfig(); + dispatch({ + field: 'formConfig', + value: { + ...templateState.formConfig, + keyManagers: { + ...currentKMs, + [kmName]: { ...existing, enabled }, + }, + }, + }); + }; + + const handleKMUpdateField = (kmName, fieldKey, configKey, value) => { + const currentKMs = templateState.formConfig?.keyManagers ?? {}; + const existing = currentKMs[kmName] ?? defaultKMConfig(); + dispatch({ + field: 'formConfig', + value: { + ...templateState.formConfig, + keyManagers: { + ...currentKMs, + [kmName]: { + ...existing, + [fieldKey]: { + ...(existing[fieldKey] ?? {}), + [configKey]: value, + }, + }, + }, + }, + }); + }; + + // ── Derived values ─────────────────────────────────────────────────────── + + const appThrottling = getField('application', 'throttlingPolicy'); + const appDescription = getField('application', 'description'); + const appGroups = getField('application', 'groups'); + + const inactiveAttributes = Object.entries(templateState.formConfig?.application?.attributes ?? {}) + .filter(([, attrConfig]) => attrConfig?.active === false) + .map(([attrName]) => attrName); + + const governedKMCount = availableKMs.filter( + (km) => !!templateState.formConfig?.keyManagers?.[km.name]?.enabled, + ).length; + + if (loadingPolicies) { + return ( + + + + ); + } + + return ( + + + + + + + + {/* ── Section 1: Application Metadata (expanded by default) ── */} + + }> + + + + + + + + + + + + + + )} + description={( + + )} + hidden={appThrottling.hidden} + onToggleHidden={(v) => updateField('application', 'throttlingPolicy', 'hidden', v)} + defaultEmpty={isEmpty(appThrottling.defaultValue)} + isRequired + defaultInput={( + + )} + /> + + + )} + description={( + + )} + hidden={appDescription.hidden} + onToggleHidden={(v) => updateField('application', 'description', 'hidden', v)} + defaultEmpty={isEmpty(appDescription.defaultValue)} + required={isTrue(appDescription.required)} + onToggleRequired={(v) => updateField('application', 'description', 'required', v)} + defaultInput={( + updateField( + 'application', 'description', 'defaultValue', e.target.value, + )} + /> + )} + /> + + {groupsEnabled && ( + + )} + description={( + + )} + hidden={appGroups.hidden} + onToggleHidden={(v) => updateField('application', 'groups', 'hidden', v)} + defaultEmpty={isEmpty(appGroups.defaultValue)} + required={isTrue(appGroups.required)} + onToggleRequired={(v) => updateField('application', 'groups', 'required', v)} + defaultInput={( + updateField( + 'application', 'groups', 'defaultValue', e.target.value, + )} + helperText={intl.formatMessage({ + id: 'Governance.Templates.FormBuilder.app.groups.helper', + defaultMessage: 'Comma-separated group names; ' + + 'developers can add more if visible', + })} + /> + )} + /> + )} + + {!loadingAttributes && customAttributes.map((attr) => { + const attrField = getAttrField(attr.attribute); + const attrRequired = isTrue(attr.required); + const templateRequired = isTrue(attrField.required); + return ( + + + + {/* ── Section 2: Key Manager Governance ── */} + + }> + + + + + + {governedKMCount === 0 ? ( + + ) : ( + + )} + + + + + {availableKMs.length === 0 ? ( + + + + ) : ( + <> + + + + {availableKMs.map((km) => ( + + ))} + + )} + + + + ); +} + +FormBuilderStep.propTypes = { + templateState: PropTypes.shape({ + 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 new file mode 100644 index 00000000000..2a233e7838b --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/GeneralDetailsStep.jsx @@ -0,0 +1,367 @@ +/* + * 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, { useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + Autocomplete, + Box, + Button, + Chip, + FormControlLabel, + Grid, + Switch, + TextField, + Typography, + Divider, +} from '@mui/material'; +import ImageIcon from '@mui/icons-material/Image'; +import { useAppContext } from 'AppComponents/Shared/AppContext'; + +const MAX_ICON_SIZE_BYTES = 200 * 1024; // 200 KB +const ALLOWED_ICON_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml', 'image/webp']; + +function IconUpload({ iconPreview, onIconChange }) { + const intl = useIntl(); + const fileInputRef = useRef(null); + const [iconError, setIconError] = useState(''); + + const handleFileChange = (event) => { + const file = event.target.files?.[0]; + // Reset input so the same file can be re-selected after removal + // eslint-disable-next-line no-param-reassign + event.target.value = ''; + if (!file) return; + + if (!ALLOWED_ICON_TYPES.includes(file.type)) { + setIconError(intl.formatMessage({ + id: 'Governance.Templates.GeneralDetails.icon.typeError', + defaultMessage: 'Only JPEG, PNG, SVG, and WebP images are allowed.', + })); + return; + } + + if (file.size > MAX_ICON_SIZE_BYTES) { + setIconError(intl.formatMessage( + { + id: 'Governance.Templates.GeneralDetails.icon.sizeError', + defaultMessage: 'Icon must be smaller than 200 KB (current: {size} KB).', + }, + { size: Math.ceil(file.size / 1024) }, + )); + return; + } + + setIconError(''); + const reader = new FileReader(); + reader.onload = (e) => onIconChange(e.target.result); + reader.readAsDataURL(file); + }; + + return ( + + + + + + {iconPreview ? ( + + ) : ( + + + + )} + + + + {iconPreview && ( + + )} + + + {iconError && ( + + {iconError} + + )} + + + + + ); +} + +IconUpload.propTypes = { + iconPreview: PropTypes.string, + onIconChange: PropTypes.func.isRequired, +}; +IconUpload.defaultProps = { iconPreview: null }; + +/** + * 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, tags, icon, isGlobal, + } = templateState; + + const [nameTouched, setNameTouched] = useState(false); + 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 ( + + + + + + + + + + {/* Name */} + + { + setNameChanged(true); + 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 }} + InputLabelProps={{ + required: true, + sx: { + '& .MuiFormLabel-asterisk': { color: 'error.main' }, + }, + }} + 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' + /> + + + {/* Tags */} + + dispatch({ field: 'tags', value: newValue })} + renderTags={(value, getTagProps) => value.map((option, index) => ( + + ))} + renderInput={(params) => ( + + )} + /> + + + {/* Icon Upload */} + + dispatch({ field: 'icon', value })} + /> + + + {/* isGlobal toggle — super tenant only */} + {isSuperTenant && ( + + + + + + dispatch({ field: 'isGlobal', value: e.target.checked })} + /> + )} + label={( + + + + + + + + + )} + sx={{ alignItems: 'flex-start', ml: 0 }} + /> + + )} + + + ); +} + +GeneralDetailsStep.propTypes = { + templateState: PropTypes.shape({ + name: PropTypes.string.isRequired, + description: PropTypes.string.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 new file mode 100644 index 00000000000..2d24bd8db91 --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/ReviewPublishStep.jsx @@ -0,0 +1,1106 @@ +/* + * 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, + Alert, + Box, + Chip, + CircularProgress, + Divider, + Paper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import GovernanceAPI from 'AppData/GovernanceAPI'; + +// ── Label maps ──────────────────────────────────────────────────────────────── + +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', +}; + +/** + * Metadata for formConfig fields — application section only. + * Key Manager governance is rendered dynamically from formConfig.keyManagers. + */ +const FORM_CONFIG_META = [ + { + sectionKey: 'application', + sectionLabel: 'Application Details', + fields: [ + { fieldKey: 'throttlingPolicy', label: 'Throttling Policy' }, + { fieldKey: 'description', label: 'Description' }, + { fieldKey: 'groups', label: 'Application Groups' }, + ], + }, +]; + +const KM_FIELD_META = [ + { + 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' }, +]; + +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 ──────────────────────────────────────────────────────────── + +function SummarySection({ title, children, accentColor = 'primary.main' }) { + return ( + + + {title} + + {/* prose content (developer summary, limitations) wraps; tabular content + inside its own container can still scroll horizontally with its own sx. */} + + {children} + + + ); +} + +SummarySection.propTypes = { + title: PropTypes.node.isRequired, + children: PropTypes.node.isRequired, + accentColor: PropTypes.string, +}; +SummarySection.defaultProps = { accentColor: 'primary.main' }; + +function SummaryRow({ label, children }) { + return ( + + + {label} + + + {children} + + + ); +} +SummaryRow.propTypes = { label: PropTypes.node.isRequired, children: PropTypes.node.isRequired }; + +function ConfigSummaryRow({ + label, visibilityChip, requiredChip, children, +}) { + return ( + + + {label} + + + {visibilityChip} + {requiredChip} + + {children} + + + + ); +} + +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, +}; + +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; + 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 valueMap?.[v] ?? v)} />; + } + return ( + + {valueMap?.[raw] ?? String(raw)} + + ); +} + +function normalizeLimitations(limitations) { + const raw = Array.isArray(limitations) + ? limitations + : String(limitations || '').split('\n'); + return raw.map((item) => String(item).trim()).filter(Boolean); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +/** + * 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, +}) { + const intl = useIntl(); + const { + 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); + new GovernanceAPI() + .getRulesets({ limit: 200, offset: 0 }) + .then((res) => { + const map = {}; + (res.body?.list ?? []).forEach((r) => { map[r.id] = r; }); + setRulesetMap(map); + }) + .catch(() => {}) + .finally(() => setLoadingRulesets(false)); + }, [rulesetBindings.length]); + + const dtoPayload = useMemo(() => ({ + 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', + defaultMessage: 'No description provided', + })} + + + + + {isDefault && ( + + )} + {isGlobal && ( + + )} + {!isDefault && !isGlobal && ( + + + + )} + + + + + {/* ── Section 2: Form Configuration ── */} + + {/* 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]) => ( + + + {kmName} + + {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 ( + + )} + requiredChip={( + + )} + > + {renderDefaultValue(fieldMeta, fieldConfig)} + + ); + })} + + )); + })()} + + + + {/* ── Section 3: Ruleset Bindings ── */} + + {rulesetBindings.length === 0 ? ( + + + + ) : ( + <> + {loadingRulesets && ( + + + + + + + )} + + + + + + + + + + + + + + + + + {[...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} + + + + ); + })} + +
+
+ + )} +
+ + {/* ── 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) ── */} + + }> + + + + + + + {JSON.stringify(dtoPayload, null, 2)} + + + +
+ ); +} + +ReviewPublishStep.propTypes = { + templateState: PropTypes.shape({ + name: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + isDefault: PropTypes.bool.isRequired, + isGlobal: PropTypes.bool.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 new file mode 100644 index 00000000000..9afd5f6041e --- /dev/null +++ b/portals/admin/src/main/webapp/source/src/app/components/Governance/Templates/steps/RulesetBindingsStep.jsx @@ -0,0 +1,1102 @@ +/* + * 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, + Collapse, + Divider, + Grid, + IconButton, + InputAdornment, + MenuItem, + OutlinedInput, + Paper, + Select, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +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'; +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__'; + +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) ──────────────────────────────────────── + +/** + * 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, expanded, onToggle, preview, loadingPreview, +}) { + const intl = useIntl(); + return ( + + { + if (event.key === 'Enter' || event.key === ' ') onToggle(); + }} + sx={{ + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + cursor: 'pointer', + outline: 'none', + }} + > + {/* Ruleset info */} + + + {ruleset.name} + + + + + + + + + {/* Action */} + {isBound ? ( + + ) : ( + + { + event.stopPropagation(); + onAdd(); + }} + sx={{ '& svg': { fontSize: '1.5rem' } }} + > + + + + )} + + + + + + + + + {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)} + + )} + + )} + + + + ); +} + +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) ───────────────────────────────────────── + +/** + * 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 + * (only shown when ruleType === 'APP_OAUTH') + * - Remove button + * + * KM scope behaviour: + * - 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 + * sees it. + */ +function BoundRulesetCard({ + binding, + ruleset, + keyManagers, + allowedKeyManagerNames, + 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); + + // 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]; + + 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 isOAuth = ruleset?.ruleType === 'APP_OAUTH'; + const isGlobalScope = binding.keyManagerScopes.length === 0; + + return ( + + {/* Header row: name + remove */} + + + + {rulesetName} + + {ruleset && ( + + )} + + + + + + + + + + + {/* Controls */} + + {/* Key Manager scope — only relevant for APP_OAUTH rulesets */} + {isOAuth && ( + + + + + + + {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, + allowedKeyManagerNames: PropTypes.arrayOf(PropTypes.string).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(''); + 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().catch(() => emptyKmRes), + adminApi.getGlobalKeyManagersList().catch(() => emptyKmRes), + ]) + .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', + })); + }) + .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.artifactType === 'APPLICATION' + && 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 + )), + }); + }; + + 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) { + 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)} + expanded={expandedRulesetId === ruleset.id} + onToggle={() => toggleRulesetPreview(ruleset)} + preview={rulesetPreviews[ruleset.id]} + loadingPreview={!!loadingPreviewIds[ruleset.id]} + /> + )) + )} + + + + + {/* ── Right pane: Bound Rulesets ── */} + + + {/* Panel header */} + + + + + 0 ? 'primary' : 'default'} + variant='outlined' + /> + + + {/* Scrollable bound list */} + + {rulesetBindings.length === 0 ? ( + + + + + + + + + + ) : ( + sortedBindings.map((binding) => ( + updateBinding( + binding.rulesetId, { keyManagerScopes: scopes }, + )} + onRemove={() => removeBinding(binding.rulesetId)} + /> + )) + )} + + + {/* Bound count summary footer */} + {rulesetBindings.length > 0 && ( + + + + + + + )} + + + + + ); +} + +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, + 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/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 b36e9ca8649..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 @@ -334,6 +334,107 @@ 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(), + ); + }); + } + + /** + * 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 + * @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/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 c09b8e24a33..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,7 +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.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.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", @@ -560,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", @@ -992,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 5aa396fac58..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 @@ -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); @@ -92,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 @@ -265,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; } } @@ -290,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 @@ -302,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') { @@ -323,11 +379,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 +515,64 @@ 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 ?? {}; + 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 }; + 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 (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 }; + }); + } + /** * Check whether there are any changes in the form */ @@ -469,10 +589,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. + // 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 })} + allAppAttributes={allAppAttributes} + isApplicationSharingEnabled={isApplicationSharingEnabled} + /> + + ); + } + const CreatePageTitle = ( <> @@ -545,6 +690,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/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 new file mode 100644 index 00000000000..bb1993d52bc --- /dev/null +++ b/portals/devportal/src/main/webapp/source/src/app/components/Applications/Create/TemplateSelector.jsx @@ -0,0 +1,616 @@ +/* + * 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, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { + Box, + Checkbox, + Chip, + CircularProgress, + 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 application creation flow and let the developer choose a Governance Template. + * + * 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) + * + * Fail-open contract: if no published templates exist or the fetch fails, + * onSkip() is called automatically. + */ +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(); + } else { + setTemplates(list); + } + }) + .catch(() => { + if (active) { + clearTimeout(failOpenTimer); + onSkip(); + } + }) + .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 ( + + + + ); + } + + if (templates.length === 0) { + return null; + } + + return ( + + {/* Subheading */} + + + + + {/* Search bar */} + setSearch(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ mb: 3, maxWidth: 480 }} + /> + + + {/* Tag filter sidebar — only shown when there are tags */} + {allTags.length > 0 && ( + + + + + + {allTags.map((tag) => ( + toggleTag(tag)} + /> + )} + label={{tag}} + /> + ))} + + + )} + + {/* Template grid. + The "No Restrictions" fallback card only appears when there is NO default + template configured. If an admin has marked a template as default, that + one is the implicit fallback — showing both would compete for the + "do-nothing path" slot and confuse developers. */} + + {filteredTemplates.length === 0 ? ( + + {!hasDefaultTemplate && } + {hasDefaultTemplate && ( + + + + + + )} + + ) : ( + + {paginatedTemplates.map((template) => { + const effectiveTemplate = getEffectiveTemplate( + template, + allAppAttributes, + isApplicationSharingEnabled, + ); + return ( + onSelect(t)} + onPreview={setPreviewTemplate} + /> + ); + })} + {!hasDefaultTemplate && } + + )} + {pageCount > 1 && ( + + setPage(p)} + size='small' + color='primary' + /> + + )} + + + + {previewTemplate && ( + setPreviewTemplate(null)} + /> + )} + + ); +} + +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/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..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 @@ -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 = ( @@ -399,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} > ) - : ( + : isSubTierHidden ? ( + + + + + + + ) : (
this.handleSubscriptionTierUpdate(apiId, @@ -619,5 +645,9 @@ SubscriptionTableData.propTypes = { }).isRequired, handleSubscriptionDelete: PropTypes.func.isRequired, handleSubscriptionUpdate: PropTypes.func.isRequired, + formConfig: PropTypes.shape({}), +}; +SubscriptionTableData.defaultProps = { + formConfig: null, }; export default SubscriptionTableData; 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 218f943d7ec..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 @@ -617,6 +617,7 @@ class SubscriptionsBase extends React.Component { onAddClick={this.handleOpenDialog} handleSubscriptionDelete={this.handleSubscriptionDelete} handleSubscriptionUpdate={this.handleSubscriptionUpdate} + formConfig={this.props.formConfig} noSubscriptionsMessage={( this.handleSubscribe(appInner, api, policy)} searchText={searchText} entityType='API' + formConfig={this.props.formConfig} /> @@ -868,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} /> @@ -891,6 +895,11 @@ SubscriptionsBase.propTypes = { }).isRequired, apisAccessible: PropTypes.bool.isRequired, mcpServersAccessible: PropTypes.bool.isRequired, + formConfig: PropTypes.shape({}), +}; + +SubscriptionsBase.defaultProps = { + formConfig: null, }; /** @@ -916,6 +925,11 @@ Subscriptions.propTypes = { applicationId: PropTypes.string.isRequired, }).isRequired, getApplication: PropTypes.func.isRequired, + 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 d2de4679e93..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 @@ -182,6 +182,7 @@ class Details extends Component { super(props); this.state = { application: null, + formConfig: null, }; this.getApplication = this.getApplication.bind(this); } @@ -198,10 +199,22 @@ class Details extends Component { getApplication = () => { 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; + 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 }); + }) + .catch(() => { + // Fail open — unresolvable template leaves formConfig null + }); + } return Promise.all([response]); }) .catch((error) => { @@ -235,11 +248,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/ApplicationCreateForm.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/ApplicationCreateForm.jsx index 87a4d20fc1d..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 @@ -137,7 +137,19 @@ const ApplicationCreate = (props) => { isOrgAccessControlEnabled, handleAddChip, handleDeleteChip, + formConfig, } = props; + + 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; @@ -186,69 +198,77 @@ 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} - - ))} - - 512} - onBlur={(e) => validateDescription(e.target.value)} + 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} + + ))} + + )} + {!isHidden('description') && ( + 512} + onBlur={(e) => validateDescription(e.target.value)} - /> + /> + )} { isOrgAccessControlEnabled && sessionStorage.getItem('userOrganization') && ( @@ -279,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)} @@ -336,9 +358,11 @@ const ApplicationCreate = (props) => { }; 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/components/Shared/AppsAndKeys/KeyConfiguration.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/KeyConfiguration.jsx index c60c9d167cf..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'; @@ -179,8 +180,59 @@ const KeyConfiguration = (props) => { const intl = useIntl(); const { notFound, isUserOwner, keyManagerConfig, updateKeyRequest, keyRequest, updateHasError, callbackError, mode, - selectedApp, keyValue, + selectedApp, keyValue, formConfig, } = props; + + // 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; + } + }; + + // 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; @@ -362,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 ( @@ -483,7 +541,6 @@ const KeyConfiguration = (props) => { {mode !== 'MAPPED' && (() => { const advancedConfigurations = ( <> - {/* Grant Types */} {
- {Object.keys(grantTypeDisplayListMap).map((key) => { - const value = grantTypeDisplayListMap[key]; + {Object.keys(visibleGrantTypeDisplayListMap).map((key) => { + const value = visibleGrantTypeDisplayListMap[key]; return ( {
@@ -526,64 +583,67 @@ const KeyConfiguration = (props) => {
- {/* Callback URL */} - - - - - - - - )} - 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 + {/* Callback URL — hidden when template locks grant types to non-redirect flows */} + {showCallbackUrl && ( + + + - - - - - {/* App Configurations */} - {applicationConfiguration.length > 0 && applicationConfiguration.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 — filtered by grant-type relevance and template hide flags */} + {applicationConfiguration.length > 0 + && applicationConfiguration.filter(isAppConfigVisible).map((config) => ( + + ))} ); @@ -629,6 +689,7 @@ KeyConfiguration.defaultProps = { notFound: false, validating: false, mode: null, + formConfig: null, }; KeyConfiguration.propTypes = { classes: PropTypes.instanceOf(Object).isRequired, @@ -651,6 +712,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..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' && ( - + @@ -1318,6 +1383,9 @@ class TokenManager extends React.Component { callbackError={hasError} setValidating={this.setValidating} defaultTokenEndpoint={defaultTokenEndpoint} + formConfig={this.props.formConfig?.keyManagers?.[keymanager.name] + ? { keyGeneration: this.props.formConfig.keyManagers[keymanager.name] } + : null} />
{key ? 'Update keys' : 'Generate Keys'} @@ -1354,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'} @@ -1383,7 +1455,7 @@ class TokenManager extends React.Component { )} {(tokenType === 'EXCHANGED' && isResidentKeyManagerTokensAvailable) && ( - + { }, summary: false, + formConfig: null, }; TokenManager.propTypes = { classes: PropTypes.instanceOf(Object).isRequired, @@ -1497,6 +1570,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/AuthManager.jsx b/portals/devportal/src/main/webapp/source/src/app/data/AuthManager.jsx index b52b3dba61c..63be2e060f6 100644 --- a/portals/devportal/src/main/webapp/source/src/app/data/AuthManager.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/data/AuthManager.jsx @@ -318,7 +318,7 @@ class AuthManager { AuthManager.CONST = { USER_SCOPES: 'apim:api_key apim:app_manage apim:app_update apim:dedicated_gateway apim:self-signup ' - + 'apim:store_settings apim:sub_alert_manage apim:sub_manage apim:subscribe openid', + + 'apim:gov_template_read apim:store_settings apim:sub_alert_manage apim:sub_manage apim:subscribe openid', }; export default AuthManager; 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..bc87c780641 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 @@ -1286,4 +1286,88 @@ 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 token = Utils.getCookie('WSO2_AM_TOKEN_1', Utils.getEnvironment().label) || ''; + + 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, + }, + })); + } + + /** + * 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 token = Utils.getCookie('WSO2_AM_TOKEN_1', Utils.getEnvironment().label) || ''; + + 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 })); + } }