From 7e0c1db3865aedc2cd6c96e7f3d9a42427c08d4c Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Thu, 16 Jul 2026 23:40:59 +0530 Subject: [PATCH 1/3] Implement "all api keys" page --- docs/rest-apis/devportal/README.md | 1 + docs/rest-apis/devportal/api-keys.md | 137 +++++++- docs/rest-apis/devportal/mcp-server-keys.md | 5 - docs/rest-apis/devportal/schemas.md | 8 +- .../docs/devportal-openapi-spec-v0.9.yaml | 40 ++- .../it/rest-api/api-keys/api-keys.spec.js | 28 ++ portals/developer-portal/src/app.js | 2 + .../src/controllers/apiContentController.js | 65 +++- .../src/controllers/apiKeyController.js | 57 +++- .../controllers/apiKeysOverviewController.js | 101 ++++++ .../src/defaultContent/partials/sidebar.hbs | 6 +- .../src/pages/api-keys-overview/page.hbs | 208 ++++++++++++ .../src/routes/api/handlers/apiKeysHandler.js | 1 + .../src/routes/pages/apiKeysOverviewRoute.js | 31 ++ .../src/scripts/api-keys-overview.js | 313 ++++++++++++++++++ .../developer-portal/src/scripts/common.js | 66 ++-- 16 files changed, 1002 insertions(+), 67 deletions(-) create mode 100644 portals/developer-portal/src/controllers/apiKeysOverviewController.js create mode 100644 portals/developer-portal/src/pages/api-keys-overview/page.hbs create mode 100644 portals/developer-portal/src/routes/pages/apiKeysOverviewRoute.js create mode 100644 portals/developer-portal/src/scripts/api-keys-overview.js diff --git a/docs/rest-apis/devportal/README.md b/docs/rest-apis/devportal/README.md index 2b716329a0..ffd71c347a 100644 --- a/docs/rest-apis/devportal/README.md +++ b/docs/rest-apis/devportal/README.md @@ -106,6 +106,7 @@ Base URLs: ### [API Keys](api-keys.md) +- [List all API keys for the current user](api-keys.md#list-all-api-keys-for-the-current-user) - [Generate an API key](api-keys.md#generate-an-api-key) - [List API keys](api-keys.md#list-api-keys) - [Regenerate an API key](api-keys.md#regenerate-an-api-key) diff --git a/docs/rest-apis/devportal/api-keys.md b/docs/rest-apis/devportal/api-keys.md index 98a3d90a79..ae19b81a96 100644 --- a/docs/rest-apis/devportal/api-keys.md +++ b/docs/rest-apis/devportal/api-keys.md @@ -1,5 +1,135 @@

API Keys

+## List all API keys for the current user + + + +`GET /api-keys` + +> Code samples + +```shell + +curl -X GET https://localhost:3000/api/v0.9/api-keys \ + -u {username}:{password} \ + -H 'Accept: application/json' \ + -H 'Authorization: Bearer {access-token}' + +``` + +Lists every API key created by the authenticated user across all APIs in the organization. Powers the Developer Portal's global "API Keys" page. Each item additionally carries the owning API's name, version, and type. Secret material is never returned. + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|limit|query|integer|false|Maximum number of records to return.| +|offset|query|integer|false|Number of records to skip before returning results.| + +> Example responses + +> 200 Response + +```json +{ + "list": [ + { + "id": "weather_prod_key", + "displayName": "Weather Prod Key", + "apiId": "weather-api-v1", + "appId": "my-weather-app", + "appDisplayName": "My Mobile App", + "status": "ACTIVE", + "expiresAt": "2026-12-31T23:59:59Z", + "createdAt": "2019-08-24T14:15:22Z", + "revokedAt": "2019-08-24T14:15:22Z" + } + ], + "count": 1, + "pagination": { + "total": 42, + "limit": 20, + "offset": 0 + } +} +``` + +> Bad request. Validation and other bad-request errors are returned as a standard error object (field-level details, when present, are carried in its `errors` array); some legacy handlers return a message-only object. + +```json +{ + "status": "error", + "code": "MISSING_REQUIRED_PARAMETER", + "message": "Missing required parameter." +} +``` + +```json +{ + "message": "Missing or invalid fields in the request payload" +} +``` + +> 500 Response + +```json +{ + "status": "error", + "code": "INTERNAL_SERVER_ERROR", + "message": "An unexpected error occurred." +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|List of API key metadata records.|Inline| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Bad request. Validation and other bad-request errors are returned as a standard error object (field-level details, when present, are carried in its `errors` array); some legacy handlers return a message-only object.|Inline| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error.|[ErrorResponse](schemas.md#schemaerrorresponse)| + +

Response Schema

+ +Status Code **200** + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|» list|[[ApiKeyMetadataResponse](schemas.md#schemaapikeymetadataresponse)]|false|none|[API key metadata returned by list operations. Secret material is omitted.]| +|»» id|string|false|none|none| +|»» displayName|string|false|none|none| +|»» apiId|string|false|none|Developer Portal API ID the key belongs to.| +|»» appId|string¦null|false|none|ID of the application this key is associated with, if any. Analytics attribution only.| +|»» appDisplayName|string¦null|false|none|Display name of the associated application, if any.| +|»» status|string|false|none|none| +|»» expiresAt|string(date-time)¦null|false|none|none| +|»» createdAt|string(date-time)|false|none|none| +|»» revokedAt|string(date-time)¦null|false|none|none| +|» count|integer|false|none|Number of items returned in this page.| +|» pagination|[Pagination](schemas.md#schemapagination)|false|none|Standard pagination metadata returned with collection responses.| +|»» total|integer|true|none|Total number of records matching the query.| +|»» limit|integer|true|none|Maximum number of records returned in this response.| +|»» offset|integer|true|none|Number of records skipped before this page.| + +#### Enumerated Values + +|Property|Value| +|---|---| +|status|ACTIVE| +|status|REVOKED| + +#### Enumerated Values + +|Property|Value| +|---|---| +|status|error| + ## Generate an API key @@ -50,7 +180,6 @@ This operation requires Basic Auth authentication. ```json { - "keyId": "key-12345", "id": "weather_prod_key", "displayName": "Weather Prod Key", "key": "ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ", @@ -172,7 +301,6 @@ This operation requires Basic Auth authentication. { "list": [ { - "keyId": "key-12345", "id": "weather_prod_key", "displayName": "Weather Prod Key", "apiId": "weather-api-v1", @@ -234,7 +362,6 @@ Status Code **200** |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| |» list|[[ApiKeyMetadataResponse](schemas.md#schemaapikeymetadataresponse)]|false|none|[API key metadata returned by list operations. Secret material is omitted.]| -|»» keyId|string|false|none|Developer Portal key identifier.| |»» id|string|false|none|none| |»» displayName|string|false|none|none| |»» apiId|string|false|none|Developer Portal API ID the key belongs to.| @@ -317,7 +444,6 @@ This operation requires Basic Auth authentication. ```json { - "keyId": "key-12345", "id": "weather_prod_key", "displayName": "Weather Prod Key", "key": "ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ", @@ -524,7 +650,6 @@ This operation requires Basic Auth authentication. ```json { - "keyId": "key-12345", "application": { "id": "my-weather-app", "displayName": "My Mobile App" @@ -734,7 +859,6 @@ This operation requires Basic Auth authentication. { "list": [ { - "keyId": "key-12345", "id": "weather_prod_key", "displayName": "Weather Prod Key", "apiId": "weather-api-v1", @@ -790,7 +914,6 @@ Status Code **200** |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| |» list|[[ApiKeyMetadataResponse](schemas.md#schemaapikeymetadataresponse)]|false|none|[API key metadata returned by list operations. Secret material is omitted.]| -|»» keyId|string|false|none|Developer Portal key identifier.| |»» id|string|false|none|none| |»» displayName|string|false|none|none| |»» apiId|string|false|none|Developer Portal API ID the key belongs to.| diff --git a/docs/rest-apis/devportal/mcp-server-keys.md b/docs/rest-apis/devportal/mcp-server-keys.md index c9da636293..f0e278399a 100644 --- a/docs/rest-apis/devportal/mcp-server-keys.md +++ b/docs/rest-apis/devportal/mcp-server-keys.md @@ -50,7 +50,6 @@ This operation requires Basic Auth authentication. ```json { - "keyId": "key-12345", "id": "weather_prod_key", "displayName": "Weather Prod Key", "key": "ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ", @@ -172,7 +171,6 @@ This operation requires Basic Auth authentication. { "list": [ { - "keyId": "key-12345", "id": "weather_prod_key", "displayName": "Weather Prod Key", "apiId": "weather-api-v1", @@ -234,7 +232,6 @@ Status Code **200** |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| |» list|[[ApiKeyMetadataResponse](schemas.md#schemaapikeymetadataresponse)]|false|none|[API key metadata returned by list operations. Secret material is omitted.]| -|»» keyId|string|false|none|Developer Portal key identifier.| |»» id|string|false|none|none| |»» displayName|string|false|none|none| |»» apiId|string|false|none|Developer Portal API ID the key belongs to.| @@ -317,7 +314,6 @@ This operation requires Basic Auth authentication. ```json { - "keyId": "key-12345", "id": "weather_prod_key", "displayName": "Weather Prod Key", "key": "ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ", @@ -524,7 +520,6 @@ This operation requires Basic Auth authentication. ```json { - "keyId": "key-12345", "application": { "id": "my-weather-app", "displayName": "My Mobile App" diff --git a/docs/rest-apis/devportal/schemas.md b/docs/rest-apis/devportal/schemas.md index 078088c501..70984a58c7 100644 --- a/docs/rest-apis/devportal/schemas.md +++ b/docs/rest-apis/devportal/schemas.md @@ -1184,7 +1184,6 @@ xor ```json { - "keyId": "key-12345", "id": "weather_prod_key", "displayName": "Weather Prod Key", "apiId": "weather-api-v1", @@ -1204,7 +1203,6 @@ API key metadata returned by list operations. Secret material is omitted. |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|keyId|string|false|none|Developer Portal key identifier.| |id|string|false|none|none| |displayName|string|false|none|none| |apiId|string|false|none|Developer Portal API ID the key belongs to.| @@ -1231,7 +1229,6 @@ API key metadata returned by list operations. Secret material is omitted. ```json { - "keyId": "key-12345", "id": "weather_prod_key", "displayName": "Weather Prod Key", "key": "ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ", @@ -1241,13 +1238,12 @@ API key metadata returned by list operations. Secret material is omitted. ``` -API key response returned by generate/regenerate only. Unlike ApiKeyMetadataResponse, this does not include apiId, appId, appDisplayName, createdAt, or revokedAt — generate/regenerate return only these six fields. +API key response returned by generate/regenerate only. Unlike ApiKeyMetadataResponse, this does not include apiId, appId, appDisplayName, createdAt, or revokedAt — generate/regenerate return only these five fields. ### Properties |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|keyId|string|false|none|Developer Portal key identifier.| |id|string|false|none|none| |displayName|string|false|none|none| |key|string|false|none|One-time plaintext API key secret.| @@ -1270,7 +1266,6 @@ API key response returned by generate/regenerate only. Unlike ApiKeyMetadataResp ```json { - "keyId": "key-12345", "application": { "id": "my-weather-app", "displayName": "My Mobile App" @@ -1283,7 +1278,6 @@ API key response returned by generate/regenerate only. Unlike ApiKeyMetadataResp |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|keyId|string|false|none|none| |application|object|false|none|none| |» id|string|false|none|none| |» displayName|string|false|none|none| diff --git a/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml b/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml index 9f451a32e6..64cb8596a5 100644 --- a/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml +++ b/portals/developer-portal/docs/devportal-openapi-spec-v0.9.yaml @@ -1079,6 +1079,31 @@ paths: security: - OAuth2Security: - dp:subscription_manage + /api-keys: + get: + tags: + - API Keys + operationId: listAllApiKeys + summary: List all API keys for the current user + description: >- + Lists every API key created by the authenticated user across all APIs in the + organization. Powers the Developer Portal's global "API Keys" page. Each item + additionally carries the owning API's name, version, and type. Secret material + is never returned. + parameters: + - $ref: "#/components/parameters/limit" + - $ref: "#/components/parameters/offset" + responses: + "200": + $ref: "#/components/responses/ApiKeyListResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalServerError" + security: + - OAuth2Security: + - dp:api_key_read + - dp:api_key_manage /apis/{apiId}/api-keys/generate: parameters: - $ref: "#/components/parameters/apiId" @@ -1109,7 +1134,6 @@ paths: schema: $ref: "#/components/schemas/ApiKeyResponse" example: - keyId: key-12345 id: weather_prod_key displayName: Weather Prod Key key: ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ @@ -3894,7 +3918,6 @@ components: schema: $ref: "#/components/schemas/ApiKeyResponse" example: - keyId: key-12345 id: weather_prod_key displayName: Weather Prod Key key: ak_dGhpcyBpcyBub3QgYSByZWFsIGtleQ @@ -4970,10 +4993,6 @@ components: type: object description: API key metadata returned by list operations. Secret material is omitted. properties: - keyId: - type: string - description: Developer Portal key identifier. - example: key-12345 id: type: string example: weather_prod_key @@ -5017,12 +5036,8 @@ components: type: object description: >- API key response returned by generate/regenerate only. Unlike ApiKeyMetadataResponse, this does not include - apiId, appId, appDisplayName, createdAt, or revokedAt — generate/regenerate return only these six fields. + apiId, appId, appDisplayName, createdAt, or revokedAt — generate/regenerate return only these five fields. properties: - keyId: - type: string - description: Developer Portal key identifier. - example: key-12345 id: type: string example: weather_prod_key @@ -5048,9 +5063,6 @@ components: ApiKeyApplicationResponse: type: object properties: - keyId: - type: string - example: key-12345 application: type: object properties: diff --git a/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js b/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js index 2d2cf157dc..4bc48f2a24 100644 --- a/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js +++ b/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js @@ -107,6 +107,7 @@ describe('API keys', () => { const res = await client.as('developer').post(`/apis/${api.id}/api-keys/associate`, { keyId, appId }); expect(res.status).toBe(200); + expect(res.body.application.id).toBe(appId); }); it('dissociates an API key from an application', async () => { @@ -125,4 +126,31 @@ describe('API keys', () => { expect(res.status).toBe(400); }); + // GET /api-keys — lists every key created by the authenticated caller across all + // APIs in the org (powers the developer portal's global "API Keys" page). Each item + // carries the owning API's name/version/type so the page renders without a second call. + describe('GET /api-keys (all keys for the caller)', () => { + it('lists the caller\'s keys across APIs with owning-API metadata', async () => { + const id = uniqueHandle('key').toLowerCase(); + await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id }); + + const res = await client.as('publisher').get('/api-keys'); + expect(res.status).toBe(200); + + const key = res.body.list.find((k) => k.id === id); + expect(key).toBeDefined(); + expect(key.apiName).toBe(api.name); + expect(key.apiVersion).toBe(api.version); + expect(key.apiType).toBe(api.type); + // The internal uuid is never exposed, and secret material is never listed. + expect(key.keyId).toBeUndefined(); + expect(key.key).toBeUndefined(); + }); + + it('rejects an invalid status filter', async () => { + const res = await client.as('publisher').get('/api-keys?status=BOGUS'); + expect(res.status).toBe(400); + }); + }); + }); diff --git a/portals/developer-portal/src/app.js b/portals/developer-portal/src/app.js index 6e2e40b552..c9fd468b6f 100644 --- a/portals/developer-portal/src/app.js +++ b/portals/developer-portal/src/app.js @@ -29,6 +29,7 @@ const apiContent = require('./routes/pages/apiContentRoute'); const applicationContent = require('./routes/pages/applicationsContentRoute'); const customContent = require('./routes/pages/customPageRoute'); const subscriptionsContent = require('./routes/pages/subscriptionsContentRoute'); +const apiKeysOverviewContent = require('./routes/pages/apiKeysOverviewRoute'); const mcpRegistryRoute = require('./routes/pages/mcpRegistryRoute'); const { config } = require('./config/configLoader'); const Handlebars = require('handlebars'); @@ -177,6 +178,7 @@ if (config.designMode?.enabled) { app.use(constants.ROUTE.DEFAULT, settingsRoute); app.use(constants.ROUTE.DEFAULT, apiWorkflowsRoute); app.use(constants.ROUTE.DEFAULT, subscriptionsContent); + app.use(constants.ROUTE.DEFAULT, apiKeysOverviewContent); app.use(constants.ROUTE.DEFAULT, customContent); } diff --git a/portals/developer-portal/src/controllers/apiContentController.js b/portals/developer-portal/src/controllers/apiContentController.js index 60cf116f9d..5d9d10d372 100644 --- a/portals/developer-portal/src/controllers/apiContentController.js +++ b/portals/developer-portal/src/controllers/apiContentController.js @@ -206,6 +206,7 @@ const loadAPIContent = async (req, res, next) => { } } + const definitionResponse = await getAPIDefinition(orgName, viewName, apiHandle); const templateContent = { devMode: true, apiContent: '', @@ -216,7 +217,7 @@ const loadAPIContent = async (req, res, next) => { subscriptionPlans: metaData.subscriptionPlans, baseUrl: config.server.baseUrl + constants.ROUTE.VIEWS_PATH + viewName, schemaUrl: `/mock/${apiHandle}/definition.yml`, - showApiKeysNav: apiUsesApiKeySecurity(metaData), + showApiKeysNav: await resolveShowApiKeysNav(null, null, apiType, metaData, definitionResponse.swagger ?? null), showSubscriptionsNav: (metaData.subscriptionPlans || []).length > 0, } const landingPage = isMCP ? 'pages/mcp-landing' : 'pages/api-landing'; @@ -424,7 +425,7 @@ const loadAPIContent = async (req, res, next) => { profile: req.isAuthenticated() ? profile : null, isReadOnlyMode: config.server.readOnlyMode, }; - templateContent.showApiKeysNav = apiUsesApiKeySecurity(metaData, apiDefinitionForNav); + templateContent.showApiKeysNav = await resolveShowApiKeysNav(orgId, apiId, metaData.type, metaData, apiDefinitionForNav); templateContent.showSubscriptionsNav = (metaData?.subscriptionPlans || []).length > 0; templateContent.hasSubscriptionToken = !!findSubscriptionTokenHeader(apiDefinitionForNav); if (metaData.type == constants.API_TYPE.MCP) { @@ -514,6 +515,10 @@ const loadDocsPage = async (req, res, next) => { const metaForNav = { refId: apiMetadata.refId, }; + // Load the definition so the API Keys nav is computed the same way as every + // other API-scoped page — without it the check always returns false and the + // API Keys item is hidden on the documentation page. + const definitionResponse = await getAPIDefinition(orgName, viewName, apiHandle); const templateContent = { apiMD: '', baseUrl: config.server.baseUrl + constants.ROUTE.VIEWS_PATH + viewName + '/api/' + apiHandle, @@ -521,7 +526,7 @@ const loadDocsPage = async (req, res, next) => { docTypes: docNames, apiType: apiMetadata.type, apiName: apiMetadata.name || '', - showApiKeysNav: apiUsesApiKeySecurity(metaForNav), + showApiKeysNav: await resolveShowApiKeysNav(null, null, apiMetadata.type, metaForNav, definitionResponse.swagger ?? null), } html = renderTemplate(layoutPath + 'pages/docs/page.hbs', layoutPath + 'layout/main.hbs', templateContent, false); } else { @@ -572,7 +577,7 @@ const loadDocsPage = async (req, res, next) => { apiName: apiMetadata[0].dataValues.name || '', profile: req.isAuthenticated() ? profile : null, devportalMode: devportalMode, - showApiKeysNav: apiUsesApiKeySecurity(metaForNav, apiDefinitionForNav), + showApiKeysNav: await resolveShowApiKeysNav(orgId, apiId, apiType, metaForNav, apiDefinitionForNav), }; html = await renderTemplateFromAPI(templateContent, orgId, orgName, "pages/docs", viewName); } catch (error) { @@ -636,7 +641,10 @@ const loadDocument = async (req, res, next) => { templateContent.currentDocType = docType || null; templateContent.apiName = metaData.name || ''; const metaForNav = { refId: metaData.refId }; - templateContent.showApiKeysNav = apiUsesApiKeySecurity(metaForNav); + // Pass the definition so the API Keys nav is computed the same way as every + // other API-scoped page — without it the check always returns false and the + // API Keys item is hidden on the documentation page. + templateContent.showApiKeysNav = await resolveShowApiKeysNav(null, null, definitionResponse.apiType, metaForNav, definitionResponse.swagger ?? null); const html = renderTemplate(layoutPath + 'pages/docs/page.hbs', layoutPath + 'layout/main.hbs', templateContent, false); res.send(html); return; @@ -782,7 +790,8 @@ const loadDocument = async (req, res, next) => { const metaForNav = { refId: row.ref_id, }; - templateContent.showApiKeysNav = apiUsesApiKeySecurity(metaForNav); + // Compute the API Keys nav the same way as every other API-scoped page. + templateContent.showApiKeysNav = await resolveShowApiKeysNav(orgId, apiId, apiType, metaForNav, definitionResponse.swagger ?? null); html = await renderTemplateFromAPI(templateContent, orgId, orgName, "pages/docs", viewName); } catch (error) { logger.error('Failed to load api content', { @@ -881,6 +890,49 @@ async function getApiDefinitionFileContent(orgId, apiId) { throw new Error('API definition file not found'); } +/** + * Single source of truth for the "API Keys" sidebar item visibility on any + * API-scoped page (landing, documentation, api-keys). It loads the stored API + * definition and inspects its security schemes so every page derives the flag + * the same way — previously each page computed it separately and some (e.g. the + * documentation page) omitted the definition, hiding the nav item inconsistently. + * + * GraphQL/MCP/SOAP definitions are not OpenAPI security-scheme documents we can + * inspect for apiKey security, so they resolve to false without a definition load. + * + * @param {string} orgId + * @param {string} apiId + * @param {string} apiType - constants.API_TYPE.* + * @param {object} metaData - any truthy metadata object for the API + * @param {string|object} [preloadedDefinition] - the API definition if the caller + * already loaded it (pass to avoid a second read); omit to load from storage. + * @returns {Promise} + */ +async function resolveShowApiKeysNav(orgId, apiId, apiType, metaData, preloadedDefinition) { + if (!metaData) { + return false; + } + if (apiType === constants.API_TYPE.GRAPHQL + || apiType === constants.API_TYPE.MCP + || apiType === constants.API_TYPE.SOAP) { + return false; + } + let apiDefinition = preloadedDefinition; + if (apiDefinition === undefined) { + apiDefinition = null; + try { + apiDefinition = await getApiDefinitionFileContent(orgId, apiId); + } catch (definitionErr) { + logger.debug('Could not load API definition for API keys nav check', { + orgId, + apiId, + error: definitionErr.message + }); + } + } + return apiUsesApiKeySecurity(metaData, apiDefinition); +} + function parseApiDefinitionContent(apiDefinitionContent) { if (!apiDefinitionContent || typeof apiDefinitionContent !== 'string') { throw new Error('Invalid API definition content'); @@ -1511,4 +1563,5 @@ module.exports = { loadAPIContentMd, loadDocumentMd, loadSpecificationRaw: loadAPIDefinitionRaw, + resolveShowApiKeysNav, }; diff --git a/portals/developer-portal/src/controllers/apiKeyController.js b/portals/developer-portal/src/controllers/apiKeyController.js index 69dd891212..916e38b9f8 100644 --- a/portals/developer-portal/src/controllers/apiKeyController.js +++ b/portals/developer-portal/src/controllers/apiKeyController.js @@ -85,7 +85,6 @@ async function resolveKeyIdOrRespond(orgId, apiId, keyHandle, res) { function mapKey(k, audit) { const app = k.dp_api_key_app_mapping?.dp_application; return { - keyId: k.uuid, id: k.handle, displayName: k.display_name, status: k.status, @@ -130,7 +129,8 @@ async function generateApiKey(req, res) { actor: util.resolveActor(req), userToken: req.user?.accessToken, }); logUserAction('API_KEY_GENERATED', req, { orgId, apiId: apiHandle, keyId: result.keyId, resourceUuid: result.keyId, resourceType: 'api_key' }); - return res.status(201).json(result); + const { keyId: _uuid, ...body } = result; + return res.status(201).json(body); } catch (err) { logger.error('Failed to generate API key', { error: err.message, orgId, apiId: apiHandle }); return util.sendError(res, errorStatus(err), 'Failed to generate API key'); @@ -193,6 +193,51 @@ async function listApiKeys(req, res) { } } +/** + * GET /api/v0.9/api-keys + * Lists every API key created by the authenticated user across all APIs in the org. + * Powers the developer portal's global "API Keys" page (client-rendered). Each item + * carries the owning API's name/version/type in addition to the standard metadata so + * the page can render and link without a second call. Secret material is never returned. + */ +async function listAllApiKeys(req, res) { + const orgId = req.orgId; + const { status } = req.query; + + if (status && !Object.values(constants.API_KEY_STATUS).includes(status)) { + return res.status(400).json({ + status: 'error', code: 'COMMON_VALIDATION_ERROR', message: 'Bad Request', + errors: [{ field: 'status', message: `status must be one of: ${Object.values(constants.API_KEY_STATUS).join(', ')}` }], + }); + } + + try { + const keys = await apiKeyService.list(orgId, { + status: status || undefined, + createdBy: util.resolveActor(req), + }); + const auditList = await userIdpReferenceDao.buildListAuditFields(keys); + const mapped = keys.map((k, i) => { + const m = mapKey(k, auditList[i]); + return { + ...m, + apiName: k.dp_api_metadata?.name || '', + apiVersion: k.dp_api_metadata?.version || '', + apiType: k.dp_api_metadata?.type || '', + }; + }); + return res.status(200).json(util.toPaginatedList(mapped, req)); + } catch (err) { + logger.error('Failed to list all API keys', { error: err.message, orgId }); + return res.status(errorStatus(err)).json({ + status: 'error', + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to list API keys', + errors: [], + }); + } +} + /** * POST /api/v0.9/apis/:apiId/api-keys/regenerate * Body: { keyId, expiresAt? } — keyId is the key's handle (the `id` returned by generate/list). @@ -215,7 +260,8 @@ async function regenerateApiKey(req, res) { orgId, apiId, keyId, expiresAt, actor: util.resolveActor(req), userToken: req.user?.accessToken, }); logUserAction('API_KEY_REGENERATED', req, { orgId, apiId: apiHandle, keyId, resourceUuid: keyId, resourceType: 'api_key' }); - return res.status(200).json(result); + const { keyId: _uuid, ...body } = result; + return res.status(200).json(body); } catch (err) { logger.error('Failed to regenerate API key', { error: err.message, orgId, apiId: apiHandle, keyHandle }); return util.sendError(res, errorStatus(err), 'Failed to regenerate API key'); @@ -278,7 +324,8 @@ async function associateApiKeyApplication(req, res) { orgId, apiId, keyId, appId, actor: util.resolveActor(req), }); logUserAction('API_KEY_APP_ASSOCIATED', req, { orgId, apiId: apiHandle, keyId, appId: appHandle, resourceUuid: keyId, resourceType: 'api_key' }); - return res.status(200).json(result); + const { keyId: _uuid, ...body } = result; + return res.status(200).json(body); } catch (err) { logger.error('Failed to associate application with API key', { error: err.message, orgId, apiId: apiHandle, keyHandle }); return util.sendError(res, errorStatus(err), 'Failed to associate application with API key'); @@ -340,6 +387,6 @@ async function listApplicationApiKeys(req, res) { } module.exports = { - generateApiKey, listApiKeys, regenerateApiKey, revokeApiKey, + generateApiKey, listApiKeys, listAllApiKeys, regenerateApiKey, revokeApiKey, associateApiKeyApplication, removeApiKeyApplication, listApplicationApiKeys }; diff --git a/portals/developer-portal/src/controllers/apiKeysOverviewController.js b/portals/developer-portal/src/controllers/apiKeysOverviewController.js new file mode 100644 index 0000000000..e83604309e --- /dev/null +++ b/portals/developer-portal/src/controllers/apiKeysOverviewController.js @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) 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. + */ +/* eslint-disable no-undef */ +const { renderTemplateWithView, resolveActor } = require('../utils/util'); +const { config } = require('../config/configLoader'); +const logger = require('../config/logger'); +const constants = require('../utils/constants'); +const orgDao = require('../dao/organizationDao'); +const apiKeyService = require('../services/apiKeyService'); + +/** + * Renders the org-wide "API Keys" page — every API key the signed-in user has + * created, across all APIs and MCP servers. The list is loaded server-side (like the + * global Subscriptions page) so all keys render without client-side pagination. Per-key + * management (regenerate / revoke / associate) is handled inline by + * /technical-scripts/api-keys-overview.js, which calls the existing per-API/-MCP REST + * endpoints and reloads on success. + */ +const loadApiKeysOverview = async (req, res, next) => { + let html; + const { orgName, viewName } = req.params; + + try { + const orgDetails = await orgDao.get(orgName); + const orgId = orgDetails.uuid; + + if (!req.user) { + return res.redirect(`/${orgName}${constants.ROUTE.VIEWS_PATH}${viewName}/login`); + } + const devportalMode = orgDetails.configuration?.devportalMode || constants.DEVPORTAL_MODE.DEFAULT; + + let apiKeys = []; + let apiKeysLoadError = false; + try { + const keys = await apiKeyService.list(orgId, { createdBy: resolveActor(req) }); + apiKeys = (keys || []).map((k) => ({ + id: k.handle, + displayName: k.display_name, + status: String(k.status || 'ACTIVE'), + expiresAt: k.expires_at, + apiName: k.dp_api_metadata?.name || '', + apiVersion: k.dp_api_metadata?.version || '', + // apiHandle is the path segment the per-API / per-MCP endpoints expect. + apiHandle: k.dp_api_metadata?.handle || k.api_uuid, + apiType: k.dp_api_metadata?.type || '', + appId: k.dp_api_key_app_mapping?.dp_application?.handle || '', + appDisplayName: k.dp_api_key_app_mapping?.dp_application?.display_name || '', + })); + } catch (dbError) { + apiKeysLoadError = true; + logger.warn('Failed to load API keys overview', { error: dbError.message, orgId }); + } + + const profile = { + firstName: req.user.firstName, + lastName: req.user.lastName, + email: req.user.email, + imageURL: req.user.picture || req.user.imageURL || '/images/default-profile.png', + isAdmin: req.user.isAdmin, + }; + + const templateContent = { + baseUrl: '/' + orgName + constants.ROUTE.VIEWS_PATH + viewName, + profile: profile, + devportalMode: devportalMode, + orgId: orgId, + apiKeys: apiKeys, + apiKeysCount: apiKeys.length, + apiKeysLoadError, + isReadOnlyMode: config.server.readOnlyMode, + }; + + html = await renderTemplateWithView('../pages/api-keys-overview/page.hbs', './src/defaultContent/layout/main.hbs', templateContent, true, orgId, viewName); + res.send(html); + } catch (error) { + logger.error('Error loading API keys overview page', { + error: error.message, + stack: error.stack, + orgName, + }); + error.status = 500; + return next(error); + } +}; + +module.exports = { loadApiKeysOverview }; diff --git a/portals/developer-portal/src/defaultContent/partials/sidebar.hbs b/portals/developer-portal/src/defaultContent/partials/sidebar.hbs index bea8c18f86..99539dea91 100644 --- a/portals/developer-portal/src/defaultContent/partials/sidebar.hbs +++ b/portals/developer-portal/src/defaultContent/partials/sidebar.hbs @@ -44,12 +44,10 @@ {{/in}} - {{#if showApiWorkflowsNav}} API Workflows - {{/if}} Applications @@ -58,6 +56,10 @@ Subscriptions + + + API Keys + {{#if profile.isAdmin}} diff --git a/portals/developer-portal/src/pages/api-keys-overview/page.hbs b/portals/developer-portal/src/pages/api-keys-overview/page.hbs new file mode 100644 index 0000000000..8fab3defc6 --- /dev/null +++ b/portals/developer-portal/src/pages/api-keys-overview/page.hbs @@ -0,0 +1,208 @@ + + +{{#pageHead}} + +{{/pageHead}} +{{#pageScripts}} + +{{/pageScripts}} + +
+ + + {{!-- The list is rendered server-side (all keys, no client pagination). Per-key + management happens inline through the modals below (mirrors the Subscriptions + page); api-keys-overview.js reads each row's data-* attributes and calls the + per-API / per-MCP REST endpoints. --}} +
+ + {{#if apiKeysLoadError}} +
+ Unable to load API keys. Refresh the page or try again later. +
+ {{else if apiKeys.length}} +
+ + + + + + + + {{#each apiKeys}} + {{!-- The key handle is the external identifier; the mutation endpoints expect it + as `keyId`. The uuid is internal and never rendered. --}} + + + + + + + + + {{/each}} + +
APINameStatusExpiresApplicationActions
+ {{#if this.apiName}}{{this.apiName}}{{else}}{{this.apiHandle}}{{/if}} + {{#if this.apiVersion}}{{this.apiVersion}}{{/if}} + {{this.displayName}} + + {{lowercase this.status}} + + {{#if this.expiresAt}}{{formatExpiresAt this.expiresAt}}{{else}}Never{{/if}}{{#if this.appDisplayName}}{{this.appDisplayName}}{{else}}-{{/if}} + {{#if @root.isReadOnlyMode}} + Read only + {{else}} +
+ + + +
+ {{/if}} +
+
+ {{else}} +
+ +

No API keys yet

+

Open an API that supports API-key access and generate a key from its API Keys tab. Your keys will show up here.

+
+ {{/if}} +
+ + {{> alert }} + + {{#unless isReadOnlyMode}} + +
+
+
+

Regenerate API key

+ +
+
+

The previous key will stop working immediately after regeneration.

+ +
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+

API key generated

+

Key was generated successfully.

+
+
+
Secret key
+
+ + +
+
+ + Copy this key now. For your security, the full value is shown only once and can't be retrieved after closing. +
+
+ +
+
+ + +
+
+
+ +

Revoke API key

+
+

Are you sure you want to revoke ? Requests using this key will immediately stop working. This action can't be undone.

+ +
+
+ + +
+
+
+

Associate with an app

+ +
+
+

Optional — used for usage analytics only. It has no effect on this key's validity.

+ +
+ + +
+
+ +
+
+ {{/unless}} +
diff --git a/portals/developer-portal/src/routes/api/handlers/apiKeysHandler.js b/portals/developer-portal/src/routes/api/handlers/apiKeysHandler.js index 3631628fb3..73026e9d53 100644 --- a/portals/developer-portal/src/routes/api/handlers/apiKeysHandler.js +++ b/portals/developer-portal/src/routes/api/handlers/apiKeysHandler.js @@ -29,6 +29,7 @@ const { compose } = require('./compose'); module.exports = { generateApiKey: compose(requireCsrfForMutatingApi, apiKeyController.generateApiKey), listApiKeys: apiKeyController.listApiKeys, + listAllApiKeys: apiKeyController.listAllApiKeys, regenerateApiKey: compose(requireCsrfForMutatingApi, apiKeyController.regenerateApiKey), revokeApiKey: compose(requireCsrfForMutatingApi, apiKeyController.revokeApiKey), associateApiKeyApplication: compose(requireCsrfForMutatingApi, apiKeyController.associateApiKeyApplication), diff --git a/portals/developer-portal/src/routes/pages/apiKeysOverviewRoute.js b/portals/developer-portal/src/routes/pages/apiKeysOverviewRoute.js new file mode 100644 index 0000000000..d278be472e --- /dev/null +++ b/portals/developer-portal/src/routes/pages/apiKeysOverviewRoute.js @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) 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 express = require('express'); +const router = express.Router(); +const apiKeysOverviewController = require('../../controllers/apiKeysOverviewController'); +const registerPartials = require('../../middlewares/registerPartials'); +const { ensureAuthenticated } = require('../../middlewares/ensureAuthenticated'); + +router.get('/:orgName/views/:viewName/api-keys', (req, res, next) => { + if (req.params.orgName === 'favicon.ico') { + return res.status(404).send('Not Found'); + } + next(); +}, registerPartials, ensureAuthenticated, apiKeysOverviewController.loadApiKeysOverview); + +module.exports = router; diff --git a/portals/developer-portal/src/scripts/api-keys-overview.js b/portals/developer-portal/src/scripts/api-keys-overview.js new file mode 100644 index 0000000000..f637fb6a92 --- /dev/null +++ b/portals/developer-portal/src/scripts/api-keys-overview.js @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) 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. + */ +/* eslint-disable no-undef */ + +// Global "API Keys" page. The list is rendered server-side; this script handles inline +// per-key management using the SAME modals as the per-API API Keys page +// (regenerateApiKeyModal, showApiKeySecretModal, ak-revoke-modal, ak-app-modal). It reads +// each row's data-* attributes and calls the per-API or per-MCP-server key endpoints, +// then reloads on success. MCP-server keys are routed to /mcp-servers/{handle}/api-keys/... +// — the /apis/{apiId} path only resolves non-MCP APIs. +(function () { + var root = document.getElementById('api-keys-overview-root'); + if (!root) return; + var readOnly = root.dataset.readOnly === 'true'; + + var _manageKey = null; // key whose row action is currently in progress + var _apps = null; // cached applications list (fetched lazily on first associate) + var _copyTimer = null; + var _actionInFlight = false; + + /* ── helpers ──────────────────────────────────────────────── */ + + function mutationHeaders() { + return { 'Content-Type': 'application/json', 'X-CSRF-Token': window.devportalApi.csrfToken() }; + } + function show(id) { var el = document.getElementById(id); if (el) el.style.display = 'flex'; } + function hide(id) { var el = document.getElementById(id); if (el) el.style.display = 'none'; } + async function alertMsg(message, type) { + if (typeof showAlert === 'function') { try { await showAlert(message, type); } catch (e) { /* noop */ } } + } + function isMcp(key) { + return String((key && key.apiType) || '').toUpperCase() === 'MCP'; + } + function expiresToIso(val) { + if (!val || !String(val).trim()) return null; + var d = new Date(val); + if (isNaN(d.getTime())) return null; + return d.toISOString(); + } + + // Resolve the acting key from the clicked button's row. Keys are identified by their + // handle (the external id); the uuid is never rendered or used here. + function setActingKey(btn) { + var tr = btn.closest ? btn.closest('tr') : null; + var d = tr && tr.dataset; + if (!d || !d.keyHandle) return false; + _manageKey = { + handle: d.keyHandle, // what the mutation endpoints expect as `keyId` + apiId: d.apiId, + apiType: d.apiType, + displayName: d.keyName || '', + status: d.status || '', + appId: d.appId || '', + appDisplayName: d.appDisplayName || '', + }; + return true; + } + + // MCP servers expose an identical but separately-rooted key API + // (/mcp-servers/{handle}/api-keys/...). Pick the base from the key's apiType. + function keyEndpoint(suffix) { + var base = isMcp(_manageKey) ? '/mcp-servers/' : '/apis/'; + return devportalApi.root(base + encodeURIComponent(_manageKey.apiId) + '/api-keys/' + suffix); + } + + /* ── regenerate modal ─────────────────────────────────────── */ + + function openRegenerate() { + document.getElementById('regenerate-key-id').value = _manageKey.handle; + document.getElementById('regenerate-api-key-name').value = _manageKey.displayName || ''; + var exp = document.getElementById('regenerate-api-key-expires'); + if (exp) exp.value = ''; + show('regenerateApiKeyModal'); + } + + async function submitRegenerate() { + if (!_manageKey || _actionInFlight) return; + var key = _manageKey; + var expInput = document.getElementById('regenerate-api-key-expires'); + var body = { keyId: key.handle }; + var iso = expInput ? expiresToIso(expInput.value) : null; + if (iso) body.expiresAt = iso; + _actionInFlight = true; + var btn = document.getElementById('btn-submit-regenerate-api-key'); + btn.disabled = true; + try { + var resp = await fetch(keyEndpoint('regenerate'), { + method: 'POST', credentials: 'same-origin', + headers: mutationHeaders(), body: JSON.stringify(body), + }); + var data = await resp.json().catch(function () { return {}; }); + if (!resp.ok) throw new Error(data.description || data.message || 'Failed to regenerate API key'); + hide('regenerateApiKeyModal'); + showSecret(data.key || '', key.displayName || ''); + } catch (e) { + await alertMsg(e.message || 'Failed to regenerate API key', 'error'); + } finally { + _actionInFlight = false; + btn.disabled = false; + } + } + + /* ── revoke modal ─────────────────────────────────────────── */ + + function openRevoke() { + document.getElementById('ak-revoke-name').textContent = _manageKey.displayName || ''; + var confirmBtn = document.getElementById('ak-revoke-confirm'); + if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = 'Revoke key'; } + show('ak-revoke-modal'); + } + + async function submitRevoke() { + if (!_manageKey || _actionInFlight) return; + var key = _manageKey; + _actionInFlight = true; + var btn = document.getElementById('ak-revoke-confirm'); + btn.disabled = true; btn.textContent = 'Revoking…'; + try { + var resp = await fetch(keyEndpoint('revoke'), { + method: 'POST', credentials: 'same-origin', + headers: mutationHeaders(), body: JSON.stringify({ keyId: key.handle }), + }); + if (!resp.ok) { + var data = await resp.json().catch(function () { return {}; }); + throw new Error(data.description || data.message || 'Failed to revoke API key'); + } + hide('ak-revoke-modal'); + await alertMsg('API key revoked.', 'success'); + window.location.reload(); + } catch (e) { + await alertMsg(e.message || 'Failed to revoke API key', 'error'); + _actionInFlight = false; + btn.disabled = false; btn.textContent = 'Revoke key'; + } + } + + /* ── associate app modal ──────────────────────────────────── */ + + async function loadApps() { + if (_apps) return _apps; + try { + var resp = await fetch(devportalApi.root('/applications'), { headers: mutationHeaders() }); + if (!resp.ok) { _apps = []; return _apps; } + var data = await resp.json(); + // The associate endpoint expects the app handle, returned as `id` here. + _apps = ((data && data.list) || []).map(function (a) { return { appId: a.id, displayName: a.displayName }; }); + } catch (e) { + _apps = []; + } + return _apps; + } + + async function openAppModal() { + if (!_manageKey) return; + var keyIdInput = document.getElementById('ak-app-key-id'); + if (keyIdInput) keyIdInput.value = _manageKey.handle; + var apps = await loadApps(); + var select = document.getElementById('ak-app-select'); + select.innerHTML = ''; + apps.forEach(function (app) { + var opt = document.createElement('option'); + opt.value = app.appId; + opt.textContent = app.displayName; + if (app.appId === _manageKey.appId) opt.selected = true; + select.appendChild(opt); + }); + show('ak-app-modal'); + } + + async function saveAppAssociation() { + if (!_manageKey || _actionInFlight) return; + var key = _manageKey; + var select = document.getElementById('ak-app-select'); + var appId = select ? select.value : ''; + _actionInFlight = true; + var btn = document.getElementById('btn-submit-app-association'); + btn.disabled = true; + try { + var resp = await fetch(keyEndpoint(appId ? 'associate' : 'dissociate'), { + method: 'POST', credentials: 'same-origin', + headers: mutationHeaders(), + body: JSON.stringify(appId ? { keyId: key.handle, appId: appId } : { keyId: key.handle }), + }); + if (!resp.ok) { + var data = await resp.json().catch(function () { return {}; }); + throw new Error(data.description || data.message || 'Failed to update app association'); + } + hide('ak-app-modal'); + window.location.reload(); + } catch (e) { + await alertMsg(e.message || 'Failed to update app association', 'error'); + _actionInFlight = false; + btn.disabled = false; + } + } + + /* ── secret modal ─────────────────────────────────────────── */ + + function showSecret(value, keyName) { + document.getElementById('api-key-secret-value').textContent = value || ''; + document.getElementById('ak-secret-key-name').textContent = keyName || ''; + var copyBtn = document.getElementById('btn-copy-api-key-secret'); + if (copyBtn) copyBtn.classList.remove('copy-btn--copied'); + show('showApiKeySecretModal'); + } + + function closeSecret() { + hide('showApiKeySecretModal'); + window.location.reload(); // refresh the list after regeneration + } + + function copySecret() { + var codeEl = document.getElementById('api-key-secret-value'); + var text = codeEl ? codeEl.textContent : ''; + if (!text) return; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch(function () {}); + } else { + var ta = document.createElement('textarea'); + ta.value = text; ta.style.cssText = 'position:fixed;opacity:0'; + document.body.appendChild(ta); ta.select(); document.execCommand('copy'); + document.body.removeChild(ta); + } + } catch (e) { /* noop */ } + var btn = document.getElementById('btn-copy-api-key-secret'); + if (btn) { + btn.classList.add('copy-btn--copied'); + if (_copyTimer) clearTimeout(_copyTimer); + _copyTimer = setTimeout(function () { btn.classList.remove('copy-btn--copied'); }, 1600); + } + } + + /* ── wiring ───────────────────────────────────────────────── */ + + function backdrop(id) { + var el = document.getElementById(id); + if (el) el.addEventListener('click', function (e) { if (e.target === el) hide(id); }); + } + + function wire() { + if (readOnly) return; + + // Row action buttons (server-rendered). + document.querySelectorAll('.ak-row-app').forEach(function (btn) { + btn.addEventListener('click', function () { if (btn.disabled || !setActingKey(btn)) return; openAppModal(); }); + }); + document.querySelectorAll('.ak-row-regen').forEach(function (btn) { + btn.addEventListener('click', function () { if (btn.disabled || !setActingKey(btn)) return; openRegenerate(); }); + }); + document.querySelectorAll('.ak-row-revoke').forEach(function (btn) { + btn.addEventListener('click', function () { if (btn.disabled || !setActingKey(btn)) return; openRevoke(); }); + }); + + // Regenerate modal + var regClose = document.getElementById('ak-regen-close'); + if (regClose) regClose.addEventListener('click', function () { hide('regenerateApiKeyModal'); }); + var regCancel = document.getElementById('ak-regen-cancel'); + if (regCancel) regCancel.addEventListener('click', function () { hide('regenerateApiKeyModal'); }); + var regSubmit = document.getElementById('btn-submit-regenerate-api-key'); + if (regSubmit) regSubmit.addEventListener('click', submitRegenerate); + backdrop('regenerateApiKeyModal'); + + // Revoke modal + var revCancel = document.getElementById('ak-revoke-cancel'); + if (revCancel) revCancel.addEventListener('click', function () { hide('ak-revoke-modal'); }); + var revConfirm = document.getElementById('ak-revoke-confirm'); + if (revConfirm) revConfirm.addEventListener('click', submitRevoke); + backdrop('ak-revoke-modal'); + + // Associate app modal + var appClose = document.getElementById('ak-app-close'); + if (appClose) appClose.addEventListener('click', function () { hide('ak-app-modal'); }); + var appCancel = document.getElementById('ak-app-cancel'); + if (appCancel) appCancel.addEventListener('click', function () { hide('ak-app-modal'); }); + var appSubmit = document.getElementById('btn-submit-app-association'); + if (appSubmit) appSubmit.addEventListener('click', saveAppAssociation); + backdrop('ak-app-modal'); + + // Secret modal + var secDone = document.getElementById('ak-secret-done'); + if (secDone) secDone.addEventListener('click', closeSecret); + var secClose = document.getElementById('ak-secret-close'); + if (secClose) secClose.addEventListener('click', closeSecret); + var secCopy = document.getElementById('btn-copy-api-key-secret'); + if (secCopy) secCopy.addEventListener('click', copySecret); + var secretModal = document.getElementById('showApiKeySecretModal'); + if (secretModal) secretModal.addEventListener('click', function (e) { if (e.target === secretModal) closeSecret(); }); + } + + function init() { wire(); } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +}()); diff --git a/portals/developer-portal/src/scripts/common.js b/portals/developer-portal/src/scripts/common.js index ecba0f384a..522d05554a 100644 --- a/portals/developer-portal/src/scripts/common.js +++ b/portals/developer-portal/src/scripts/common.js @@ -93,20 +93,34 @@ document.addEventListener("DOMContentLoaded", function () { const basePath = extractBasePath(); + // Resolve the path segment that follows the view-scoped base path so nav + // matching is exact. e.g. "/org/views/default/api-keys" -> firstSegment "api-keys". + // Settings is org-scoped (/:org/settings) and does not sit under basePath, so it + // is handled via a suffix check below. + let rest = currentPath; + if (basePath && currentPath.indexOf(basePath) === 0) { + rest = currentPath.slice(basePath.length); + } + const firstSegment = rest.replace(/^\/+/, '').split('/')[0]; + // Remove active class from all links navLinks.forEach(link => link.classList.remove('active')); - // Set the active class based on path - if (currentPath.endsWith('/') || currentPath === '') { + // Match on the first path segment. Order the singular API/MCP detail routes + // (submenu-bearing) before the plural listing routes, and guard optional + // submenu elements — they are absent in single-mode portals + // (APIS_ONLY / MCP_SERVERS_ONLY). + if (firstSegment === '') { document.getElementById('home')?.classList.add('active'); - apiSubmenu.classList.remove('show'); + apiSubmenu?.classList.remove('show'); apisLink?.classList.remove('has-active-submenu'); - } else if (currentPath.includes('/apis')) { - apisLink?.classList.add('active'); - apiSubmenu.classList.remove('show'); - apisLink?.classList.remove('has-active-submenu'); - } else if (currentPath.includes('/api/')) { - apiSubmenu.classList.add('show'); + } else if (firstSegment === 'api-workflows') { + document.getElementById('api-workflows')?.classList.add('active'); + } else if (firstSegment === 'api-keys') { + // Global API Keys page (distinct from the per-API /api/:id/api-keys submenu item) + document.getElementById('api-keys')?.classList.add('active'); + } else if (firstSegment === 'api') { + apiSubmenu?.classList.add('show'); apisLink?.classList.add('active'); apisLink?.classList.add('has-active-submenu'); @@ -116,8 +130,10 @@ document.addEventListener("DOMContentLoaded", function () { const apiId = apiIdMatch[1]; // Update the submenu links with the correct API ID and base path - document.getElementById('api-overview').href = `${basePath}/api/${apiId}`; - document.getElementById('api-docs').href = `${basePath}/api/${apiId}/docs/specification`; + const overviewLink = document.getElementById('api-overview'); + if (overviewLink) overviewLink.href = `${basePath}/api/${apiId}`; + const docsLink = document.getElementById('api-docs'); + if (docsLink) docsLink.href = `${basePath}/api/${apiId}/docs/specification`; const apiKeysLink = document.getElementById('api-keys-nav'); if (apiKeysLink) { apiKeysLink.href = `${basePath}/api/${apiId}/api-keys`; @@ -132,14 +148,14 @@ document.addEventListener("DOMContentLoaded", function () { document.getElementById('api-overview')?.classList.add('active'); } } - } else if (currentPath.includes('/applications/') || currentPath.includes('/applications')) { + } else if (firstSegment === 'apis') { + apisLink?.classList.add('active'); + apiSubmenu?.classList.remove('show'); + apisLink?.classList.remove('has-active-submenu'); + } else if (firstSegment === 'applications') { applicationsLink?.classList.add('active'); - } else if (currentPath.includes('/mcps')) { - document.getElementById('mcps')?.classList.add('active'); - mcpSubmenu.classList.remove('show'); - mcpLink?.classList.remove('has-active-submenu'); - } else if (currentPath.includes('/mcp/')) { - mcpSubmenu.classList.add('show'); + } else if (firstSegment === 'mcp') { + mcpSubmenu?.classList.add('show'); mcpLink?.classList.add('active'); mcpLink?.classList.add('has-active-submenu'); @@ -149,8 +165,10 @@ document.addEventListener("DOMContentLoaded", function () { const apiId = apiIdMatch[1]; // Update the submenu links with the correct API ID and base path - document.getElementById('mcp-overview').href = `${basePath}/mcp/${apiId}`; - document.getElementById('mcp-docs').href = `${basePath}/mcp/${apiId}/docs/specification`; + const mcpOverviewLink = document.getElementById('mcp-overview'); + if (mcpOverviewLink) mcpOverviewLink.href = `${basePath}/mcp/${apiId}`; + const mcpDocsLink = document.getElementById('mcp-docs'); + if (mcpDocsLink) mcpDocsLink.href = `${basePath}/mcp/${apiId}/docs/specification`; // Set active submenu item if (currentPath.includes('/docs')) { @@ -159,8 +177,14 @@ document.addEventListener("DOMContentLoaded", function () { document.getElementById('mcp-overview')?.classList.add('active'); } } - } else if (currentPath.includes('/subscriptions')) { + } else if (firstSegment === 'mcps') { + document.getElementById('mcps')?.classList.add('active'); + mcpSubmenu?.classList.remove('show'); + mcpLink?.classList.remove('has-active-submenu'); + } else if (firstSegment === 'subscriptions') { document.getElementById('subscriptions')?.classList.add('active'); + } else if (firstSegment === 'settings' || currentPath.includes('/settings')) { + document.getElementById('admin-settings')?.classList.add('active'); } }; From dab74103059479abcaf523d8e75a589d2c063fa3 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Fri, 17 Jul 2026 09:57:28 +0530 Subject: [PATCH 2/3] Fix failing tests --- .../it/rest-api/api-keys/api-keys.spec.js | 7 ++++++- .../it/rest-api/api-keys/webhook-events.spec.js | 16 ++++++++++------ .../it/rest-api/mcp-servers/mcp-servers.spec.js | 1 + .../src/controllers/apiContentController.js | 17 +++-------------- .../src/controllers/apiKeyController.js | 7 +------ .../src/pages/api-keys-overview/page.hbs | 2 +- 6 files changed, 22 insertions(+), 28 deletions(-) diff --git a/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js b/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js index 4bc48f2a24..6dd3be3526 100644 --- a/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js +++ b/portals/developer-portal/it/rest-api/api-keys/api-keys.spec.js @@ -44,6 +44,7 @@ describe('API keys', () => { expect(res.body.id).toBe(id); expect(res.body.key).toBeDefined(); expect(res.body.status).toBe('ACTIVE'); + expect(res.body.keyId).toBeUndefined(); }); it('lists API keys for an API', async () => { @@ -52,7 +53,9 @@ describe('API keys', () => { const res = await client.as('publisher').get(`/apis/${api.id}/api-keys`); expect(res.status).toBe(200); - expect(res.body.list.some((k) => k.id === id)).toBe(true); + const key = res.body.list.find((k) => k.id === id); + expect(key).toBeDefined(); + expect(key.keyId).toBeUndefined(); }); it('regenerates an API key', async () => { @@ -63,6 +66,7 @@ describe('API keys', () => { expect(res.status).toBe(200); expect(res.body.key).toBeDefined(); expect(res.body.key).not.toBe(create.body.key); + expect(res.body.keyId).toBeUndefined(); }); it('revokes an API key', async () => { @@ -108,6 +112,7 @@ describe('API keys', () => { const res = await client.as('developer').post(`/apis/${api.id}/api-keys/associate`, { keyId, appId }); expect(res.status).toBe(200); expect(res.body.application.id).toBe(appId); + expect(res.body.keyId).toBeUndefined(); }); it('dissociates an API key from an application', async () => { diff --git a/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js b/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js index bd1b6d3206..0948384304 100644 --- a/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js +++ b/portals/developer-portal/it/rest-api/api-keys/webhook-events.spec.js @@ -79,7 +79,9 @@ describe('api-keys webhook events', () => { // fields rather than the plaintext ever appearing in `data`. expect(received.body.encrypted_fields).toEqual([]); expect(received.body.data).toEqual({ - key_id: res.body.keyId, + // key_id is the key's internal uuid — not exposed in the REST response, so pin it + // against the persisted event's aggregate_uuid (published with aggregateId: keyId). + key_id: event.aggregate_uuid, handle: keyId, display_name: keyId, expires_at: null, @@ -90,7 +92,7 @@ describe('api-keys webhook events', () => { it('publishes and delivers apikey.regenerated', async () => { const keyId = uniqueHandle('key').toLowerCase(); - const generated = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); const since = new Date(); const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/regenerate`, { keyId }); @@ -106,7 +108,7 @@ describe('api-keys webhook events', () => { expect(received).toBeDefined(); expect(received.body.encrypted_fields).toEqual([]); expect(received.body.data).toEqual({ - key_id: generated.body.keyId, + key_id: event.aggregate_uuid, // key's internal uuid; not exposed in the REST response handle: keyId, display_name: keyId, expires_at: null, @@ -117,7 +119,7 @@ describe('api-keys webhook events', () => { it('publishes and delivers apikey.revoked', async () => { const keyId = uniqueHandle('key').toLowerCase(); - const generated = await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); + await client.as('publisher').post(`/apis/${api.id}/api-keys/generate`, { id: keyId }); const since = new Date(); const res = await client.as('publisher').post(`/apis/${api.id}/api-keys/revoke`, { keyId }); @@ -132,7 +134,7 @@ describe('api-keys webhook events', () => { const received = sink.findDeliveryFor('apikey.revoked'); expect(received).toBeDefined(); expect(received.body.data).toEqual({ - key_id: generated.body.keyId, + key_id: event.aggregate_uuid, // key's internal uuid; not exposed in the REST response handle: keyId, display_name: keyId, api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type }, @@ -163,10 +165,12 @@ describe('api-keys webhook events', () => { // `application.id` here is the app's internal uuid (apiKeyService.resolveApp), // never exposed over REST — only handle/display_name are checkable directly. expect(received.body.data).toEqual({ - key_id: expect.any(String), + key_id: event.aggregate_uuid, // key's internal uuid; not exposed in the REST response handle: keyId, display_name: keyId, api: { name: api.name, version: api.version, ref_id: api.refId || '', type: api.type }, + // application.id is the app's internal uuid (never exposed over REST); its handle + // is the checkable external identifier, so pin id only by shape. application: { id: expect.any(String), display_name: 'Assoc App', handle: appId }, }); }); diff --git a/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js b/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js index 7c39d520ed..f12f72ad2c 100644 --- a/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js +++ b/portals/developer-portal/it/rest-api/mcp-servers/mcp-servers.spec.js @@ -197,6 +197,7 @@ describe('MCP servers', () => { expect(res.status).toBe(201); expect(res.body.id).toBe(keyId); expect(res.body.key).toBeDefined(); + expect(res.body.keyId).toBeUndefined(); // internal uuid must not leak }); // An MCP server's contract is its tools schema, supplied via the single `definition` diff --git a/portals/developer-portal/src/controllers/apiContentController.js b/portals/developer-portal/src/controllers/apiContentController.js index 5d9d10d372..045d52c7aa 100644 --- a/portals/developer-portal/src/controllers/apiContentController.js +++ b/portals/developer-portal/src/controllers/apiContentController.js @@ -556,19 +556,6 @@ const loadDocsPage = async (req, res, next) => { refId: apiMetadata[0].dataValues.ref_id, }; - let apiDefinitionForNav = null; - if (apiType !== constants.API_TYPE.GRAPHQL && apiType !== constants.API_TYPE.MCP) { - try { - apiDefinitionForNav = await getApiDefinitionFileContent(orgId, apiId); - } catch (definitionErr) { - logger.debug('Could not load API definition for API keys nav check', { - orgId, - apiId, - error: definitionErr.message - }); - } - } - const templateContent = { baseUrl: '/' + orgName + '/views/' + viewName + "/api/" + apiHandle, baseDocUrl: '/' + orgName + '/views/' + viewName + "/api/" + apiHandle, @@ -577,7 +564,9 @@ const loadDocsPage = async (req, res, next) => { apiName: apiMetadata[0].dataValues.name || '', profile: req.isAuthenticated() ? profile : null, devportalMode: devportalMode, - showApiKeysNav: await resolveShowApiKeysNav(orgId, apiId, apiType, metaForNav, apiDefinitionForNav), + // resolveShowApiKeysNav returns false early for GraphQL/MCP/SOAP and lazily + // fetches the definition itself for the remaining types, so no preload here. + showApiKeysNav: await resolveShowApiKeysNav(orgId, apiId, apiType, metaForNav), }; html = await renderTemplateFromAPI(templateContent, orgId, orgName, "pages/docs", viewName); } catch (error) { diff --git a/portals/developer-portal/src/controllers/apiKeyController.js b/portals/developer-portal/src/controllers/apiKeyController.js index 916e38b9f8..9d76ab922b 100644 --- a/portals/developer-portal/src/controllers/apiKeyController.js +++ b/portals/developer-portal/src/controllers/apiKeyController.js @@ -229,12 +229,7 @@ async function listAllApiKeys(req, res) { return res.status(200).json(util.toPaginatedList(mapped, req)); } catch (err) { logger.error('Failed to list all API keys', { error: err.message, orgId }); - return res.status(errorStatus(err)).json({ - status: 'error', - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to list API keys', - errors: [], - }); + return util.sendError(res, errorStatus(err), 'Failed to list API keys'); } } diff --git a/portals/developer-portal/src/pages/api-keys-overview/page.hbs b/portals/developer-portal/src/pages/api-keys-overview/page.hbs index 8fab3defc6..30c2669c59 100644 --- a/portals/developer-portal/src/pages/api-keys-overview/page.hbs +++ b/portals/developer-portal/src/pages/api-keys-overview/page.hbs @@ -63,7 +63,7 @@ data-app-id="{{this.appId}}" data-app-display-name="{{this.appDisplayName}}"> - {{#if this.apiName}}{{this.apiName}}{{else}}{{this.apiHandle}}{{/if}} + {{#if this.apiName}}{{this.apiName}}{{else}}{{this.apiHandle}}{{/if}} {{#if this.apiVersion}}{{this.apiVersion}}{{/if}} {{this.displayName}} From ae09162eab09782fcaa7c147b09392dcaca822cb Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Fri, 17 Jul 2026 12:26:23 +0530 Subject: [PATCH 3/3] Fix minor bug --- portals/developer-portal/src/scripts/api-keys-overview.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/portals/developer-portal/src/scripts/api-keys-overview.js b/portals/developer-portal/src/scripts/api-keys-overview.js index f637fb6a92..b9f52452c7 100644 --- a/portals/developer-portal/src/scripts/api-keys-overview.js +++ b/portals/developer-portal/src/scripts/api-keys-overview.js @@ -155,12 +155,12 @@ if (_apps) return _apps; try { var resp = await fetch(devportalApi.root('/applications'), { headers: mutationHeaders() }); - if (!resp.ok) { _apps = []; return _apps; } + if (!resp.ok) return []; var data = await resp.json(); // The associate endpoint expects the app handle, returned as `id` here. _apps = ((data && data.list) || []).map(function (a) { return { appId: a.id, displayName: a.displayName }; }); } catch (e) { - _apps = []; + return []; } return _apps; }