Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions portals/developer-portal/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Developer Portal — Claude Code Guide

## Project overview

Express.js + Handlebars (`.hbs`) server-rendered developer portal for WSO2 API Manager.
Two distinct UI surfaces share the same server:

| Surface | Templates | CSS |
|---|---|---|
| **Developer portal** (public-facing) | `src/defaultContent/pages/` | `src/defaultContent/styles/` |
| **Portal management / admin** | `src/pages/` | `src/styles/` |

These are separate concerns — do not mix their CSS files.

## CSS architecture

### Developer portal (`src/defaultContent/styles/`)

```
main.css
├── @import components.css ← canonical shared component library
├── @import home.css
├── @import footer.css
├── @import header.css
├── @import api-listing.css
├── @import api-content.css
├── @import api-landing.css
├── @import doc.css
├── @import side-bar.css
└── @import default-api.css
```

**Standalone page CSS files** (loaded on their own route, not via `main.css`) must start with:
```css
@import "/styles/components.css";
```
Affected files: `subscriptions.css`, `login.css`, `os.css`.
This means `components.css` classes (`.page-header`, `.dp-btn`, `.dp-breadcrumb`, `.dp-empty`, etc.) are always available everywhere.

### Never touch
`src/defaultContent/styles/async-tryout.css` and `src/styles/async-tryout.css` — third-party bundles.

## Design tokens (CSS custom properties)

Defined in `main.css` `:root`:

```css
--wso2-gradient: linear-gradient(135deg, #ef4223 0%, #ff8636 100%);
--primary-gradient: linear-gradient(135deg, #3d6b8a, #1c3b52);
--primary-main-color: #1A4C6D;
--border-radius: 0.5rem;
--font-family-sans: "Montserrat", "Quicksand", "Noto Sans", "Poppins", sans-serif;
```

## CSS conventions

- **Units**: `rem` for all sizes (divide px by 16). Exception: `border-width` and `box-shadow` stay in `px`.
- **Component prefix**: shared components use `dp-*` (e.g. `dp-btn`, `dp-breadcrumb`, `dp-empty`).
- **No debug borders**: never commit `border: 1px solid red`.

## Canonical shared components (`components.css`)

### Page header
```css
.page-header /* flex row, space-between, gap: 16px, margin-bottom: 26px */
.page-title /* 1.5rem, font-weight: 700, color: #1a2433 */
.page-desc /* 0.875rem, line-height: 1.5, color: #637282 */
```
Use these global classes everywhere. Do not invent page-specific header classes (e.g. `sub-page-header`, `apps-page-header` are legacy — migrate away when touching those pages).

### Breadcrumb
```hbs
<nav class="dp-breadcrumb">
<a class="dp-breadcrumb-item" href="...">Parent</a>
<i class="bi bi-chevron-right dp-breadcrumb-sep"></i>
<span class="dp-breadcrumb-current">Current Page</span>
</nav>
```
Top-level pages (APIs, MCP Servers, Subscriptions, API Workflows) have no parent — omit breadcrumb there.

### Buttons
```css
.dp-btn /* base */
.dp-btn--primary /* wso2-gradient fill */
.dp-btn--secondary /* outlined */
.dp-btn--icon /* square icon-only */
```

### Empty state
```css
.dp-empty /* flex column, centered, padding: 3.75rem 0 5rem, NO border */
.dp-empty-icon
.dp-empty-title
.dp-empty-desc
```

## Hard rules

1. **Unimplemented action buttons** must always have:
```html
disabled title="Backend not wired yet — UI preview only"
```

2. **Do not change any file without asking first** unless it's the file directly under discussion.

3. **Breadcrumb on sub-pages**: all API/MCP detail sub-pages (subscriptions, keys, docs, flows detail) must have a breadcrumb.

## Handlebars template patterns

- `{{baseUrl}}` — portal base URL available in all templates
- `{{apiMetadata.apiHandle}}` — API/MCP handle (slug) for URL construction
- `{{or apiMetadata.apiInfo.apiTitle apiMetadata.apiInfo.apiName}}` — display name pattern (title with name fallback)
- `{{apiName}}` — passed explicitly to docs pages from `apiContentController.js`
- `{{baseDocUrl}}` — API/MCP detail page URL, passed explicitly to docs pages

## Controller locations

| Controller | File |
|---|---|
| API/MCP docs pages | `src/controllers/apiContentController.js` |
| Application management views | `src/controllers/viewConfigureController.js` |

`loadDocsPage` and `loadDocument` in `apiContentController.js` each have a design-mode and non-design-mode path — template context variables must be added in all four locations.

## Key page files

| Page | Template | CSS |
|---|---|---|
| API listing | `src/defaultContent/pages/apis/page.hbs` | `api-listing.css` |
| API detail | `src/defaultContent/pages/api-landing/page.hbs` | `api-landing.css` |
| API subscriptions | `src/defaultContent/pages/api-subscriptions/page.hbs` | `api-content.css` |
| API keys | `src/defaultContent/pages/api-keys/page.hbs` | `api-content.css` |
| MCP landing | `src/defaultContent/pages/mcp-landing/page.hbs` | `api-landing.css` |
| Docs | `src/defaultContent/pages/docs/page.hbs` | `doc.css` |
| Subscriptions | `src/defaultContent/pages/subscriptions/page.hbs` | `subscriptions.css` |
| API Flows | `src/defaultContent/pages/api-flows/page.hbs` | (via main.css) |
| Applications | `src/pages/applications/partials/applications-listing.hbs` | `src/styles/applications.css` |
51 changes: 51 additions & 0 deletions portals/developer-portal/docker-compose.platform-api.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# --------------------------------------------------------------------
# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
#
# WSO2 LLC. licenses this file to you under the Apache License,
# Version 2.0 (the "License"); you may not use this file except
# in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# --------------------------------------------------------------------

# Platform API only — for local development with npm start
#
# Quick start:
# 1. docker compose -f docker-compose.platform-api.yaml up -d
# 2. npm start
# 3. Open http://localhost:3000
# Login: admin / admin
#
# Set AUTH_JWT_SECRET_KEY in your shell or a .env file to keep sessions alive
# across Platform API restarts. When unset a random key is generated at startup.

services:
platform-api:
image: ghcr.io/wso2/api-platform/platform-api:0.10.0
container_name: platform-api
restart: unless-stopped
command: ["-config", "/etc/platform-api/config-platform-api.toml"]
environment:
- AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-}
volumes:
- ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro
- platform-api-data:/app/data
ports:
- "9243:9243"
healthcheck:
test: ["CMD", "curl", "-fk", "https://localhost:9243/health"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3

volumes:
platform-api-data:
8 changes: 8 additions & 0 deletions portals/developer-portal/nodemon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"ext": "js,hbs,yaml,json,css",
"ignore": [
"devportal.db",
"node_modules/",
"*.test.js"
]
}
2 changes: 1 addition & 1 deletion portals/developer-portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"design-mode": "npm run build-css --watch & nodemon src/dev-server.js",
"start": "npm rebuild better-sqlite3 --ignore-scripts=false && nodemon src/server.js",
"start": "npm rebuild better-sqlite3 --ignore-scripts=false && NODE_ENV=development nodemon src/server.js",
"debug": "nodemon --inspect src/server.js",
"multi-tenant": "npm run build-css --watch & nodemon src/multi-tenant.js",
"build-css": "node watcher.js",
Expand Down
5 changes: 5 additions & 0 deletions portals/developer-portal/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ configurePassport(SERVER_ID);
app.use(constants.ROUTE.TECHNICAL_STYLES, express.static(path.join(require.main.filename, '../styles')));
app.use(constants.ROUTE.TECHNICAL_SCRIPTS, express.static(path.join(require.main.filename, '../scripts')));

// Dev live-reload SSE endpoint — must be registered before org-resolution routes
if (process.env.NODE_ENV === 'development') {
require('./liveReload').setup(app);
}

// Redirect unrecognised root-level paths (e.g. /robots.txt, /sitemap.xml) before
// the /:orgName route can treat them as org IDs.
app.use((req, res, next) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const apiMetadataService = require('../services/apiMetadataService');
const { apiUsesApiKeySecurity, findSubscriptionTokenHeader } = require('../utils/apiDefinitionUtil');
const sampleApiLoader = require('../utils/sampleApiLoader');
const adminService = require('../services/adminService');
const { seedSampleAPIs } = require('../services/sampleSeederService');
const apiFlowService = require('../services/apiFlowService');
const { buildSchema, getIntrospectionQuery, graphql: executeGraphQL } = require('graphql');
const yaml = require('js-yaml');
Expand Down Expand Up @@ -505,8 +506,10 @@ const loadDocsPage = async (req, res) => {
const templateContent = {
apiMD: '',
baseUrl: config.baseUrl + constants.ROUTE.VIEWS_PATH + viewName + '/api/' + apiHandle,
baseDocUrl: config.baseUrl + constants.ROUTE.VIEWS_PATH + viewName + '/api/' + apiHandle,
docTypes: docNames,
apiType: apiMetadata.apiInfo?.apiType,
apiName: apiMetadata.apiInfo?.apiName || '',
showApiKeysNav: apiUsesApiKeySecurity(metaForNav),
}
html = renderTemplate(layoutPath + 'pages/docs/page.hbs', layoutPath + 'layout/main.hbs', templateContent, false);
Expand Down Expand Up @@ -554,8 +557,10 @@ const loadDocsPage = async (req, res) => {

const templateContent = {
baseUrl: '/' + orgName + '/views/' + viewName + "/api/" + apiHandle,
baseDocUrl: '/' + orgName + '/views/' + viewName + "/api/" + apiHandle,
docTypes: docNames,
apiType: apiType,
apiName: apiMetadata[0].dataValues.API_NAME || '',
profile: req.isAuthenticated() ? profile : null,
devportalMode: devportalMode,
showApiKeysNav: apiUsesApiKeySecurity(metaForNav, apiDefinitionForNav),
Expand Down Expand Up @@ -623,6 +628,9 @@ const loadDocument = async (req, res) => {
templateContent.baseUrl = config.baseUrl + constants.ROUTE.VIEWS_PATH + viewName;
templateContent.baseDocUrl = config.baseUrl + constants.ROUTE.VIEWS_PATH + viewName + '/api/' + apiHandle;
templateContent.docTypes = metaData.docTypes;
templateContent.currentDocName = docName || null;
templateContent.currentDocType = docType || null;
templateContent.apiName = metaData.apiInfo?.apiName || '';
const metaForNav = { apiInfo: { gatewayType: metaData.apiInfo?.gatewayType }, apiReferenceID: metaData.apiReferenceID };
templateContent.showApiKeysNav = apiUsesApiKeySecurity(metaForNav);
const html = renderTemplate(layoutPath + 'pages/docs/page.hbs', layoutPath + 'layout/main.hbs', templateContent, false);
Expand Down Expand Up @@ -751,6 +759,9 @@ const loadDocument = async (req, res) => {
templateContent.baseUrl = '/' + orgName + constants.ROUTE.VIEWS_PATH + viewName;
templateContent.baseDocUrl = baseDocUrl;
templateContent.docTypes = docNames;
templateContent.currentDocName = docName || null;
templateContent.currentDocType = docType || null;
templateContent.apiName = apiMetadata[0].dataValues.API_NAME || '';
let profile = null;
if (req.user) {
profile = {
Expand Down Expand Up @@ -1493,6 +1504,25 @@ const loadDocumentMd = async (req, res) => {
}
};

const seedSamples = async (req, res) => {
const { orgName } = req.params;
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Access denied' });
}
try {
const orgDetails = await orgDao.get(orgName);
const results = await seedSampleAPIs(orgDetails.ORG_ID);
const deployed = results.filter(r => r.status === 'ok').length;
const skipped = results.filter(r => r.status === 'exists').length;
const failed = results.filter(r => r.status === 'failed').length;
logger.info('Sample seed complete', { orgName, deployed, skipped, failed });
res.json({ results, deployed, skipped, failed });
} catch (err) {
logger.error('Sample seed error', { orgName, error: err.message });
res.status(500).json({ error: err.message });
}
};

module.exports = {
loadAPIs,
loadAPIContent,
Expand All @@ -1505,4 +1535,5 @@ module.exports = {
loadAPIContentMd,
loadDocumentMd,
loadSpecificationRaw: loadAPIDefinitionRaw,
seedSamples,
};
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ const loadOrgContentFromAPI = async (req, res) => {
templateContent = {
devportalMode: devportalMode,
baseUrl: '/' + orgName + constants.ROUTE.VIEWS_PATH + req.params.viewName,
profile: req.isAuthenticated() ? profile : null
profile: req.isAuthenticated() ? profile : null,
showOnboarding: !!(profile?.isAdmin),
};
html = await renderTemplateFromAPI(templateContent, orgId, orgName, 'pages/home', req.params.viewName);
// Track home page visit telemetry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const logger = require('../config/logger');
const orgDao = require('../dao/organizationDao');
const apiDao = require('../dao/apiDao');
const viewDao = require('../dao/viewDao');
const labelDao = require('../dao/labelDao');
const subscriptionPolicyDao = require('../dao/subscriptionPolicyDao');
const apiFlowService = require('../services/apiFlowService');
const { renderGivenTemplate, loadLayoutFromAPI } = require('../utils/util');
const { getSessionCsrfToken } = require('../middlewares/csrfProtection');
Expand Down Expand Up @@ -54,17 +56,49 @@ const loadViewSettingsPage = async (req, res) => {
const apiFlows = await apiFlowService.getAllAPIFlowsFromDB(orgID, viewId);
templateContent.apiFlows = apiFlows;

const allAPIs = await apiDao.getByCondition({ ORG_ID: orgID, STATUS: constants.API_STATUS.PUBLISHED });
const allAPIs = await apiDao.getByCondition({ ORG_ID: orgID });
templateContent.orgAPIs = allAPIs.map(api => ({
apiId: api.API_ID,
apiName: api.API_NAME,
apiHandle: api.API_HANDLE,
apiDescription: api.API_DESCRIPTION,
apiType: api.API_TYPE,
apiVersion: api.API_VERSION,
apiStatus: api.STATUS,
productionUrl: api.PRODUCTION_URL,
agentVisibility: api.AGENT_VISIBILITY
sandboxUrl: api.SANDBOX_URL,
provider: api.PROVIDER,
gatewayType: api.GATEWAY_TYPE,
tags: api.TAGS || '',
agentVisibility: api.AGENT_VISIBILITY,
subscriptionPolicies: (api.SubscriptionPolicies || []).map(p => p.POLICY_NAME),
}));

let orgLabels = [];
try {
const labelsRaw = await labelDao.list(orgID);
orgLabels = labelsRaw.map(l => ({ labelId: l.LABEL_ID, name: l.NAME, displayName: l.DISPLAY_NAME }));
} catch (err) {
logger.warn('Failed to load labels for settings page', { error: err.message });
}
templateContent.orgLabels = orgLabels;

let orgPolicies = [];
try {
const policiesRaw = await subscriptionPolicyDao.list(orgID);
orgPolicies = policiesRaw.map(p => ({
policyId: p.POLICY_ID,
policyName: p.POLICY_NAME,
displayName: p.DISPLAY_NAME,
description: p.DESCRIPTION || '',
requestCount: p.REQUEST_COUNT,
refId: p.REF_ID || '',
}));
} catch (err) {
logger.warn('Failed to load subscription policies for settings page', { error: err.message });
}
templateContent.orgPolicies = orgPolicies;

const configAsset = await orgDao.getContent({
orgId: orgID, fileType: constants.FILE_TYPE.LLMS_CONFIG, viewName, fileName: constants.FILE_NAME.LLMS_CONFIG
});
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading