diff --git a/portals/developer-portal/CLAUDE.md b/portals/developer-portal/CLAUDE.md new file mode 100644 index 0000000000..d26552f54c --- /dev/null +++ b/portals/developer-portal/CLAUDE.md @@ -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 + +``` +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` | diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml new file mode 100644 index 0000000000..09682d73ab --- /dev/null +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -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: diff --git a/portals/developer-portal/nodemon.json b/portals/developer-portal/nodemon.json new file mode 100644 index 0000000000..823816e88a --- /dev/null +++ b/portals/developer-portal/nodemon.json @@ -0,0 +1,8 @@ +{ + "ext": "js,hbs,yaml,json,css", + "ignore": [ + "devportal.db", + "node_modules/", + "*.test.js" + ] +} diff --git a/portals/developer-portal/package.json b/portals/developer-portal/package.json index 93a73eb7d4..09f8df1a71 100644 --- a/portals/developer-portal/package.json +++ b/portals/developer-portal/package.json @@ -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", diff --git a/portals/developer-portal/src/app.js b/portals/developer-portal/src/app.js index d8af8995ba..520e4fbf44 100644 --- a/portals/developer-portal/src/app.js +++ b/portals/developer-portal/src/app.js @@ -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) => { diff --git a/portals/developer-portal/src/controllers/apiContentController.js b/portals/developer-portal/src/controllers/apiContentController.js index 199547c1e1..3157ad55ed 100644 --- a/portals/developer-portal/src/controllers/apiContentController.js +++ b/portals/developer-portal/src/controllers/apiContentController.js @@ -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'); @@ -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); @@ -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), @@ -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); @@ -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 = { @@ -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, @@ -1505,4 +1535,5 @@ module.exports = { loadAPIContentMd, loadDocumentMd, loadSpecificationRaw: loadAPIDefinitionRaw, + seedSamples, }; diff --git a/portals/developer-portal/src/controllers/orgContentController.js b/portals/developer-portal/src/controllers/orgContentController.js index ef8a2f183e..3ee35c1f6a 100644 --- a/portals/developer-portal/src/controllers/orgContentController.js +++ b/portals/developer-portal/src/controllers/orgContentController.js @@ -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 diff --git a/portals/developer-portal/src/controllers/viewConfigureController.js b/portals/developer-portal/src/controllers/viewConfigureController.js index c795175efe..ad7d5cc628 100644 --- a/portals/developer-portal/src/controllers/viewConfigureController.js +++ b/portals/developer-portal/src/controllers/viewConfigureController.js @@ -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'); @@ -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 }); diff --git a/portals/developer-portal/src/defaultContent/images/devportal-logo.png b/portals/developer-portal/src/defaultContent/images/devportal-logo.png index ae6ac6b1c2..6b7bda2f92 100644 Binary files a/portals/developer-portal/src/defaultContent/images/devportal-logo.png and b/portals/developer-portal/src/defaultContent/images/devportal-logo.png differ diff --git a/portals/developer-portal/src/defaultContent/images/devportal-logo.svg b/portals/developer-portal/src/defaultContent/images/devportal-logo.svg new file mode 100644 index 0000000000..e972ab512b --- /dev/null +++ b/portals/developer-portal/src/defaultContent/images/devportal-logo.svg @@ -0,0 +1,5 @@ + diff --git a/portals/developer-portal/src/defaultContent/images/favicon.ico b/portals/developer-portal/src/defaultContent/images/favicon.ico new file mode 100644 index 0000000000..fafb3e61cc Binary files /dev/null and b/portals/developer-portal/src/defaultContent/images/favicon.ico differ diff --git a/portals/developer-portal/src/defaultContent/images/mcp-icon.svg b/portals/developer-portal/src/defaultContent/images/mcp-icon.svg new file mode 100644 index 0000000000..3be9fb4bab --- /dev/null +++ b/portals/developer-portal/src/defaultContent/images/mcp-icon.svg @@ -0,0 +1,4 @@ + diff --git a/portals/developer-portal/src/defaultContent/layout/main.hbs b/portals/developer-portal/src/defaultContent/layout/main.hbs index ab8770ef12..890f29eef0 100644 --- a/portals/developer-portal/src/defaultContent/layout/main.hbs +++ b/portals/developer-portal/src/defaultContent/layout/main.hbs @@ -5,7 +5,7 @@