diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/package.json b/portals/cloud-plugins/apip-cloud-ui-managed-portals/package.json
new file mode 100644
index 0000000000..7b8d66061f
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@wso2-enterprise/apip-cloud-ui-managed-portals",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "tsc --noEmit",
+ "_phase:build": "tsc --noEmit",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@wso2/oxygen-ui": "0.5.0",
+ "@wso2/oxygen-ui-icons-react": "0.5.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.3"
+ },
+ "devDependencies": {
+ "@types/react": "19.2.17",
+ "react": "19.2.3",
+ "typescript": "5.9.3"
+ }
+}
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/ManagedPortalDetail.tsx b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/ManagedPortalDetail.tsx
new file mode 100644
index 0000000000..112ce48d2e
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/ManagedPortalDetail.tsx
@@ -0,0 +1,293 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved.
+ *
+ * This software is the property of WSO2 LLC and its suppliers, if any.
+ * Dissemination of any information or reproduction of any material contained
+ * herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+ * You may not alter or remove any copyright or other notice from copies of this content.
+ */
+
+import { useEffect, useState } from 'react';
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ FormControl,
+ FormLabel,
+ IconButton,
+ MenuItem,
+ PageContent,
+ Select,
+ Stack,
+ TextField,
+ Typography,
+} from '@wso2/oxygen-ui';
+import { ArrowLeft, ArrowUpRight, Pencil, Trash2 } from '@wso2/oxygen-ui-icons-react';
+
+import { useManagedPortal, useOrgEnvironments } from './hooks';
+
+export type ManagedPortalDetailProps = {
+ id: string;
+ /** Invoked on back button or after a successful delete; the page shell owns list/detail switching. */
+ onBack: () => void;
+};
+
+/** Labelled read-only field for the detail summary. */
+function Field({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value || '—'}
+
+
+ );
+}
+
+export default function ManagedPortalDetail({ id, onBack }: ManagedPortalDetailProps) {
+ const { portal, isLoading, error, update, remove } = useManagedPortal(id);
+ // Populates the login-environment picker with the org's real envs so the operator can't type an env that fails at provision-time.
+ const { environments, isLoading: envsLoading, error: envsError } = useOrgEnvironments();
+
+ const [editOpen, setEditOpen] = useState(false);
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [loginEnvironment, setLoginEnvironment] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+
+ // Reseed on each open so Cancel-then-reopen reflects the latest server-side values.
+ useEffect(() => {
+ if (editOpen && portal) {
+ setName(portal.name);
+ setDescription(portal.description ?? '');
+ setLoginEnvironment(portal.loginEnvironment ?? '');
+ }
+ }, [editOpen, portal]);
+
+ const handleSave = async () => {
+ if (!portal) return;
+ setSubmitting(true);
+ try {
+ // Send only changed fields so a no-op save doesn't restamp values and untouched fields aren't cleared.
+ const patch = {
+ ...(name.trim() !== portal.name ? { name: name.trim() } : {}),
+ ...(description !== (portal.description ?? '') ? { description: description.trim() } : {}),
+ ...(loginEnvironment !== (portal.loginEnvironment ?? '')
+ ? { loginEnvironment: loginEnvironment.trim() }
+ : {}),
+ };
+ if (Object.keys(patch).length === 0) {
+ setEditOpen(false);
+ return;
+ }
+ await update(patch);
+ setEditOpen(false);
+ } catch {
+ // Hook already notified; leave the dialog open with user input for retry.
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDeleteConfirm = async () => {
+ try {
+ await remove();
+ setDeleteOpen(false);
+ onBack();
+ } catch {
+ setDeleteOpen(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ Managed API Portals
+
+
+
+ {isLoading ? (
+
+ Loading portal…
+
+ ) : error ? (
+
+ {error.message}
+
+ ) : !portal ? (
+
+ Portal not found.
+
+ ) : (
+ <>
+
+
+ {portal.name}
+
+ {portal.handle}
+
+
+
+ {portal.url && (
+ }
+ // New tab preserves the console session; noreferrer for external navigation.
+ href={portal.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Visit Portal
+
+ )}
+ }
+ onClick={() => setEditOpen(true)}
+ >
+ Edit
+
+ }
+ onClick={() => setDeleteOpen(true)}
+ >
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+ {portal.updatedAt && }
+
+ >
+ )}
+
+
+ {/* Edit portal */}
+
+
+ {/* Delete portal */}
+
+
+ );
+}
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/ManagedPortalsList.tsx b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/ManagedPortalsList.tsx
new file mode 100644
index 0000000000..bce0176e6b
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/ManagedPortalsList.tsx
@@ -0,0 +1,282 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved.
+ *
+ * This software is the property of WSO2 LLC and its suppliers, if any.
+ * Dissemination of any information or reproduction of any material contained
+ * herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+ * You may not alter or remove any copyright or other notice from copies of this content.
+ */
+
+import { useState } from 'react';
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ FormControl,
+ FormLabel,
+ IconButton,
+ PageContent,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Typography,
+} from '@wso2/oxygen-ui';
+import { Plus, Trash2 } from '@wso2/oxygen-ui-icons-react';
+
+import { useManagedPortalList } from './hooks';
+import type { ManagedPortal } from './types';
+
+export type ManagedPortalsListProps = {
+ /** Invoked when a row's non-action area is clicked; the delete icon stops propagation to avoid double-firing. */
+ onSelect: (id: string) => void;
+};
+
+export default function ManagedPortalsList({ onSelect }: ManagedPortalsListProps) {
+ const { portals, isLoading, error, create, remove } = useManagedPortalList();
+
+ // No loginEnvironment on create: server is authoritative on org bootstrap; Edit exposes the switch later.
+ const [createOpen, setCreateOpen] = useState(false);
+ const [handle, setHandle] = useState('');
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const resetCreateForm = () => {
+ setHandle('');
+ setName('');
+ setDescription('');
+ };
+
+ const handleCreate = async () => {
+ setSubmitting(true);
+ try {
+ await create({
+ handle: handle.trim(),
+ name: name.trim(),
+ description: description.trim() || undefined,
+ // loginEnvironment omitted; server picks the org's preferred env.
+ });
+ resetCreateForm();
+ setCreateOpen(false);
+ } catch {
+ // Hook already notified; leave the dialog open with user input for retry.
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!deleteTarget) return;
+ try {
+ await remove(deleteTarget.id);
+ } catch {
+ // Hook already notified.
+ } finally {
+ setDeleteTarget(null);
+ }
+ };
+
+ return (
+
+
+
+
+ Managed API Portals
+
+ WSO2-managed developer portals for your organization.
+
+
+ }
+ onClick={() => setCreateOpen(true)}
+ sx={{ flexShrink: 0 }}
+ >
+ Create Portal
+
+
+
+
+
+ {isLoading ? (
+
+ Loading portals…
+
+ ) : error ? (
+
+ {error.message}
+
+ ) : portals.length === 0 ? (
+
+ No managed portals yet.
+
+ ) : (
+
+
+
+
+ Portal
+ URL
+ Login environment
+ Actions
+
+
+
+ {portals.map((portal) => (
+ onSelect(portal.id)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onSelect(portal.id);
+ }
+ }}
+ sx={{ cursor: 'pointer' }}
+ >
+
+
+ {portal.name}
+
+
+ {portal.handle}
+
+
+
+
+ {portal.url ?? '—'}
+
+
+
+
+ {portal.loginEnvironment ?? '—'}
+
+
+
+ {
+ // Stop the row's onSelect from firing on delete.
+ event.stopPropagation();
+ setDeleteTarget(portal);
+ }}
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Create portal */}
+
+
+ {/* Delete portal */}
+
+
+ );
+}
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/ManagedPortalsPage.tsx b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/ManagedPortalsPage.tsx
new file mode 100644
index 0000000000..d7b01b76f4
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/ManagedPortalsPage.tsx
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved.
+ *
+ * This software is the property of WSO2 LLC and its suppliers, if any.
+ * Dissemination of any information or reproduction of any material contained
+ * herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+ * You may not alter or remove any copyright or other notice from copies of this content.
+ */
+
+import { useMemo, useState } from 'react';
+import { PageContent, Typography } from '@wso2/oxygen-ui';
+
+import type { CloudHostPort } from './hostPort';
+import ManagedPortalDetail from './ManagedPortalDetail';
+import ManagedPortalsList from './ManagedPortalsList';
+import { PortalFeatureProvider } from './portContext';
+import { createRealPortalPort, resolveApiBase } from './realPort';
+
+export type ManagedPortalsPageProps = {
+ /** Host capabilities supplied by the mounting console; kept as a prop so the feature stays host-agnostic. */
+ port: CloudHostPort;
+};
+
+export function ManagedPortalsPage({ port }: ManagedPortalsPageProps) {
+ // Fail closed when the platform-api base is missing; tests / storybook build the mock port directly.
+ const portalPort = useMemo(() => {
+ const base = resolveApiBase();
+ return base ? createRealPortalPort(base, port.orgHandle) : null;
+ }, [port.orgHandle]);
+
+ // Local state (no URL param) keeps react-router out of this feature package; refresh loses the selection.
+ const [selectedId, setSelectedId] = useState(null);
+
+ if (!portalPort) {
+ return (
+
+ Managed API Portals
+
+ Managed API Portals is not available: platform-api base URL is not
+ configured. Set window.__RUNTIME_CONFIG__.platformApiBaseUrl (or
+ window.config.platformApiBaseUrl) on the host to enable this feature.
+
+
+ );
+ }
+
+ return (
+
+ {selectedId ? (
+ setSelectedId(null)} />
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/hooks.ts b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/hooks.ts
new file mode 100644
index 0000000000..c5a8e021fc
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/hooks.ts
@@ -0,0 +1,176 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved.
+ *
+ * This software is the property of WSO2 LLC and its suppliers, if any.
+ * Dissemination of any information or reproduction of any material contained
+ * herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+ * You may not alter or remove any copyright or other notice from copies of this content.
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+
+import { usePortalFeature } from './portContext';
+import type {
+ CreateManagedPortalInput,
+ ManagedPortal,
+ OrgEnvironment,
+ UpdateManagedPortalInput,
+} from './types';
+
+function errorMessage(err: unknown, fallback: string): string {
+ return err instanceof Error && err.message ? err.message : fallback;
+}
+
+/** Loads one portal by id (GET returns metadata the list projection strips) and exposes update/delete. */
+export function useManagedPortal(id: string) {
+ const { port, host } = usePortalFeature();
+
+ const [portal, setPortal] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refetch = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ setPortal(await port.get(id));
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error('Failed to load portal'));
+ } finally {
+ setIsLoading(false);
+ }
+ }, [port, id]);
+
+ useEffect(() => {
+ void refetch();
+ }, [refetch]);
+
+ const update = useCallback(
+ async (input: UpdateManagedPortalInput) => {
+ try {
+ const updated = await port.update(id, input);
+ host.notify(`Portal "${updated.name}" updated`, 'success');
+ setPortal(updated);
+ return updated;
+ } catch (err) {
+ host.notify(errorMessage(err, 'Failed to update portal'), 'error');
+ throw err;
+ }
+ },
+ [port, id, host]
+ );
+
+ const remove = useCallback(async () => {
+ try {
+ await port.remove(id);
+ host.notify('Portal deleted', 'success');
+ } catch (err) {
+ host.notify(errorMessage(err, 'Failed to delete portal'), 'error');
+ throw err;
+ }
+ }, [port, id, host]);
+
+ return { portal, isLoading, error, refetch, update, remove };
+}
+
+/** List + create + update + delete managed portals via the feature's PortalPort. */
+export function useManagedPortalList() {
+ const { port, host } = usePortalFeature();
+
+ const [portals, setPortals] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refetch = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ setPortals(await port.list());
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error('Failed to load portals'));
+ } finally {
+ setIsLoading(false);
+ }
+ }, [port]);
+
+ useEffect(() => {
+ void refetch();
+ }, [refetch]);
+
+ const create = useCallback(
+ async (input: CreateManagedPortalInput) => {
+ try {
+ const portal = await port.create(input);
+ host.notify(`Portal "${portal.name}" created`, 'success');
+ await refetch();
+ return portal;
+ } catch (err) {
+ host.notify(errorMessage(err, 'Failed to create portal'), 'error');
+ throw err;
+ }
+ },
+ [port, refetch, host]
+ );
+
+ const update = useCallback(
+ async (id: string, input: UpdateManagedPortalInput) => {
+ try {
+ const portal = await port.update(id, input);
+ host.notify(`Portal "${portal.name}" updated`, 'success');
+ await refetch();
+ return portal;
+ } catch (err) {
+ host.notify(errorMessage(err, 'Failed to update portal'), 'error');
+ throw err;
+ }
+ },
+ [port, refetch, host]
+ );
+
+ const remove = useCallback(
+ async (id: string) => {
+ try {
+ await port.remove(id);
+ host.notify('Portal deleted', 'success');
+ await refetch();
+ } catch (err) {
+ host.notify(errorMessage(err, 'Failed to delete portal'), 'error');
+ throw err;
+ }
+ },
+ [port, refetch, host]
+ );
+
+ return { portals, isLoading, error, refetch, create, update, remove };
+}
+
+/** Loads the org's data-plane environments once on mount; used to populate env selectors. */
+export function useOrgEnvironments() {
+ const { port } = usePortalFeature();
+
+ const [environments, setEnvironments] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setIsLoading(true);
+ setError(null);
+ port
+ .listEnvironments()
+ .then((envs) => {
+ if (!cancelled) setEnvironments(envs);
+ })
+ .catch((err) => {
+ if (!cancelled) setError(err instanceof Error ? err : new Error('Failed to load environments'));
+ })
+ .finally(() => {
+ if (!cancelled) setIsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [port]);
+
+ return { environments, isLoading, error };
+}
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/hostPort.ts b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/hostPort.ts
new file mode 100644
index 0000000000..785dea6089
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/hostPort.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved.
+ *
+ * This software is the property of WSO2 LLC and its suppliers, if any.
+ * Dissemination of any information or reproduction of any material contained
+ * herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+ * You may not alter or remove any copyright or other notice from copies of this content.
+ */
+
+// Duplicated (not imported) from api-platform's api-control-plane/hostPort so the two repos stay decoupled; keep in sync by hand.
+
+export type NotifySeverity = 'success' | 'info' | 'warning' | 'error';
+
+export type CloudHostPort = {
+ orgHandle: string;
+ projectHandle?: string;
+ navigate: (path: string) => void;
+ notify: (message: string, severity?: NotifySeverity) => void;
+};
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/index.ts b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/index.ts
new file mode 100644
index 0000000000..6a222532e8
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved.
+ *
+ * This software is the property of WSO2 LLC and its suppliers, if any.
+ * Dissemination of any information or reproduction of any material contained
+ * herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+ * You may not alter or remove any copyright or other notice from copies of this content.
+ */
+
+export { ManagedPortalsPage, type ManagedPortalsPageProps } from './ManagedPortalsPage';
+export type { CloudHostPort, NotifySeverity } from './hostPort';
+export { createMockPortalPort } from './mockPort';
+export { createRealPortalPort, resolveApiBase } from './realPort';
+export { useManagedPortalList } from './hooks';
+export type {
+ CreateManagedPortalInput,
+ ManagedPortal,
+ PortalPort,
+ UpdateManagedPortalInput,
+} from './types';
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/mockPort.ts b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/mockPort.ts
new file mode 100644
index 0000000000..f08f904cb3
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/mockPort.ts
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved.
+ *
+ * This software is the property of WSO2 LLC and its suppliers, if any.
+ * Dissemination of any information or reproduction of any material contained
+ * herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+ * You may not alter or remove any copyright or other notice from copies of this content.
+ */
+
+// In-memory PortalPort fallback for tests / storybook when no platform-api base is configured.
+
+import type {
+ CreateManagedPortalInput,
+ ManagedPortal,
+ OrgEnvironment,
+ PortalPort,
+ UpdateManagedPortalInput,
+} from './types';
+
+const delay = (value: T, ms = 300): Promise =>
+ new Promise((resolve) => setTimeout(() => resolve(value), ms));
+
+const clone = (value: T): T => JSON.parse(JSON.stringify(value));
+
+// Real port relies on the server to reject bad handles; the mock has to check
+// itself so a value like "team/portal" doesn't produce an invalid hostname.
+const HANDLE_PATTERN = /^[a-z0-9-]+$/;
+
+/** Builds an in-memory PortalPort, optionally seeded. Seed is copied so callers can reuse it across instances. */
+export function createMockPortalPort(seed?: ManagedPortal[]): PortalPort {
+ const portals: ManagedPortal[] = seed ? clone(seed) : [];
+
+ return {
+ async list() {
+ return delay(clone(portals));
+ },
+ async get(id: string) {
+ const found = portals.find((p) => p.id === id);
+ if (!found) throw new Error('Portal not found');
+ return delay(clone(found));
+ },
+ async create(input: CreateManagedPortalInput) {
+ const handle = input.handle.trim();
+ const name = input.name.trim();
+ if (!handle) throw new Error('A portal handle is required');
+ if (!HANDLE_PATTERN.test(handle)) {
+ throw new Error('Portal handle must contain only lowercase letters, digits, and hyphens');
+ }
+ if (!name) throw new Error('A portal name is required');
+ if (portals.some((p) => p.handle === handle)) {
+ throw new Error(`A portal "${handle}" already exists`);
+ }
+ const portal: ManagedPortal = {
+ id: handle,
+ handle,
+ name,
+ description: input.description?.trim() || undefined,
+ loginEnvironment: input.loginEnvironment?.trim() || 'production',
+ url: `https://pending-${handle}.portals.invalid`,
+ updatedAt: new Date().toISOString(),
+ };
+ portals.push(portal);
+ return delay(clone(portal));
+ },
+ async update(id: string, input: UpdateManagedPortalInput) {
+ const portal = portals.find((p) => p.id === id);
+ if (!portal) throw new Error('Portal not found');
+ if (input.name !== undefined) {
+ const trimmed = input.name.trim();
+ if (!trimmed) throw new Error('Name cannot be empty');
+ portal.name = trimmed;
+ }
+ if (input.description !== undefined) {
+ portal.description = input.description.trim() || undefined;
+ }
+ if (input.loginEnvironment !== undefined) {
+ const trimmed = input.loginEnvironment.trim();
+ if (!trimmed) throw new Error('loginEnvironment cannot be empty');
+ portal.loginEnvironment = trimmed;
+ }
+ portal.updatedAt = new Date().toISOString();
+ return delay(clone(portal));
+ },
+ async remove(id: string) {
+ const idx = portals.findIndex((p) => p.id === id);
+ if (idx === -1) throw new Error('Portal not found');
+ portals.splice(idx, 1);
+ await delay(undefined);
+ },
+ async listEnvironments(): Promise {
+ // Fixed non-empty list so form interactions render sensibly offline.
+ return delay([
+ { name: 'development', displayName: 'Development' },
+ { name: 'staging', displayName: 'Staging' },
+ { name: 'production', displayName: 'Production' },
+ ]);
+ },
+ };
+}
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/portContext.tsx b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/portContext.tsx
new file mode 100644
index 0000000000..7f27c41f6a
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/portContext.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved.
+ *
+ * This software is the property of WSO2 LLC and its suppliers, if any.
+ * Dissemination of any information or reproduction of any material contained
+ * herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+ * You may not alter or remove any copyright or other notice from copies of this content.
+ */
+
+// Package-local context so hooks aren't forced to prop-drill port/notify through every dialog.
+import { createContext, useContext, type ReactNode } from 'react';
+
+import type { CloudHostPort } from './hostPort';
+import type { PortalPort } from './types';
+
+type PortalFeatureContextValue = {
+ port: PortalPort;
+ host: CloudHostPort;
+};
+
+const PortalFeatureContext = createContext(null);
+
+export function PortalFeatureProvider({
+ value,
+ children,
+}: {
+ value: PortalFeatureContextValue;
+ children: ReactNode;
+}) {
+ return (
+ {children}
+ );
+}
+
+export function usePortalFeature(): PortalFeatureContextValue {
+ const ctx = useContext(PortalFeatureContext);
+ if (!ctx) {
+ throw new Error(
+ 'usePortalFeature must be used within a PortalFeatureProvider (rendered by ManagedPortalsPage)'
+ );
+ }
+ return ctx;
+}
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/realPort.ts b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/realPort.ts
new file mode 100644
index 0000000000..6a936f6c99
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/realPort.ts
@@ -0,0 +1,177 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved.
+ *
+ * This software is the property of WSO2 LLC and its suppliers, if any.
+ * Dissemination of any information or reproduction of any material contained
+ * herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+ * You may not alter or remove any copyright or other notice from copies of this content.
+ */
+
+// PortalPort backed by apip-platform-api via the console BFF's same-origin proxy.
+// LIST strips metadata by design so loginEnvironment is empty on list rows and only populated by GET.
+
+import type {
+ CreateManagedPortalInput,
+ ManagedPortal,
+ OrgEnvironment,
+ PortalPort,
+ UpdateManagedPortalInput,
+} from './types';
+
+const CSRF_HEADER = 'X-Requested-By';
+const CSRF_HEADER_VALUE = 'api-control-plane';
+const ORG_HEADER = 'X-Org-Id';
+
+// Bounded so a stalled BFF request can't leave UI mutations pending indefinitely.
+const REQUEST_TIMEOUT_MS = 30_000;
+
+type WindowRuntimeConfig = Partial<{
+ platformApiBaseUrl: string;
+ PLATFORM_API_BASE_URL: string;
+ platformApiVersion: string;
+ PLATFORM_API_VERSION: string;
+}>;
+
+function windowConfig(): WindowRuntimeConfig {
+ if (typeof window === 'undefined') return {};
+ const w = window as unknown as {
+ __RUNTIME_CONFIG__?: WindowRuntimeConfig;
+ config?: WindowRuntimeConfig;
+ };
+ return { ...(w.__RUNTIME_CONFIG__ ?? {}), ...(w.config ?? {}) };
+}
+
+/** Same-origin request base for platform-api calls, or null when unconfigured (tests fall back to mock). */
+export function resolveApiBase(): string | null {
+ const cfg = windowConfig();
+ const base = cfg.platformApiBaseUrl || cfg.PLATFORM_API_BASE_URL || '';
+ if (!base) return null;
+ const version = cfg.platformApiVersion || cfg.PLATFORM_API_VERSION || 'v0.9';
+ return `${base}/api/${version}`;
+}
+
+async function request(
+ base: string,
+ orgRef: string,
+ method: string,
+ path: string,
+ body?: unknown
+): Promise {
+ const mutating = method !== 'GET';
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+ let response: Response;
+ try {
+ response = await fetch(`${base}${path}`, {
+ method,
+ credentials: 'same-origin',
+ signal: controller.signal,
+ headers: {
+ Accept: 'application/json',
+ [ORG_HEADER]: orgRef,
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
+ ...(mutating ? { [CSRF_HEADER]: CSRF_HEADER_VALUE } : {}),
+ },
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
+ });
+ } catch (err) {
+ if ((err as { name?: string }).name === 'AbortError') {
+ throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
+ }
+ throw err;
+ } finally {
+ clearTimeout(timeout);
+ }
+
+ if (!response.ok) {
+ let message = `Request failed (${response.status})`;
+ try {
+ const errBody = (await response.json()) as {
+ message?: string;
+ description?: string;
+ error?: string;
+ };
+ message = errBody.description || errBody.message || errBody.error || message;
+ } catch {
+ // Non-JSON body; keep the status-based message.
+ }
+ throw new Error(message);
+ }
+
+ if (response.status === 204) return undefined as T;
+ return (await response.json()) as T;
+}
+
+// Wire shapes match the plugin's OAS.
+type WirePortal = {
+ id?: string;
+ handle?: string;
+ name: string;
+ description?: string | null;
+ url?: string;
+ loginEnvironment?: string;
+ updatedAt?: string;
+};
+type WirePortalList = { count?: number; list?: WirePortal[] };
+
+// `name` is the value loginEnvironment expects; other response fields are ignored.
+type WireEnvironment = { name?: string; displayName?: string };
+type WireEnvironmentList = { count?: number; list?: WireEnvironment[] };
+
+function fromWire(w: WirePortal): ManagedPortal {
+ const identifier = w.id ?? w.handle;
+ if (!identifier) {
+ throw new Error('Portal response is missing both id and handle');
+ }
+ return {
+ id: identifier,
+ handle: w.handle ?? identifier,
+ name: w.name,
+ description: w.description ?? undefined,
+ url: w.url,
+ loginEnvironment: w.loginEnvironment,
+ updatedAt: w.updatedAt,
+ };
+}
+
+/** PortalPort backed by the BFF proxy at `base`, scoped to `orgHandle` via the X-Org-Id header. */
+export function createRealPortalPort(base: string, orgHandle: string): PortalPort {
+ const url = (id: string) => `/managed-api-portals/${encodeURIComponent(id)}`;
+ return {
+ async list() {
+ const body = await request(base, orgHandle, 'GET', '/managed-api-portals');
+ return (body.list ?? []).map(fromWire);
+ },
+ async get(id: string) {
+ const body = await request(base, orgHandle, 'GET', url(id));
+ return fromWire(body);
+ },
+ async create(input: CreateManagedPortalInput) {
+ const body = await request(base, orgHandle, 'POST', '/managed-api-portals', {
+ handle: input.handle,
+ name: input.name,
+ description: input.description,
+ loginEnvironment: input.loginEnvironment,
+ });
+ return fromWire(body);
+ },
+ async update(id: string, input: UpdateManagedPortalInput) {
+ const body = await request(base, orgHandle, 'PUT', url(id), {
+ name: input.name,
+ description: input.description,
+ loginEnvironment: input.loginEnvironment,
+ });
+ return fromWire(body);
+ },
+ async remove(id: string) {
+ await request(base, orgHandle, 'DELETE', url(id));
+ },
+ async listEnvironments(): Promise {
+ // Sibling endpoint reached via the same BFF proxy and org-id header as the portal calls.
+ const body = await request(base, orgHandle, 'GET', '/environments');
+ return (body.list ?? [])
+ .filter((e): e is WireEnvironment & { name: string } => typeof e.name === 'string' && e.name.length > 0)
+ .map((e) => ({ name: e.name, displayName: e.displayName }));
+ },
+ };
+}
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/types.ts b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/types.ts
new file mode 100644
index 0000000000..4c9655749b
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/src/types.ts
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2026, WSO2 LLC (http://www.wso2.com). All Rights Reserved.
+ *
+ * This software is the property of WSO2 LLC and its suppliers, if any.
+ * Dissemination of any information or reproduction of any material contained
+ * herein in any form is strictly forbidden, unless permitted by WSO2 expressly.
+ * You may not alter or remove any copyright or other notice from copies of this content.
+ */
+
+// Feature-owned domain types; data reaches this feature only through PortalPort.
+
+export interface ManagedPortal {
+ /** Portal id; equal to `handle` in the current backend. */
+ id: string;
+ /** Immutable URL-friendly slug supplied on create. */
+ handle: string;
+ /** Display name. */
+ name: string;
+ /** Optional caller-supplied description. */
+ description?: string;
+ /** Public portal URL allocated by the cloud plugin; undefined until the runtime is provisioned. */
+ url?: string;
+ /** Data-plane env whose auth server backs portal-user login. Empty on list responses; populated by GET. */
+ loginEnvironment?: string;
+ /** ISO timestamp of the last row change. */
+ updatedAt?: string;
+}
+
+export interface CreateManagedPortalInput {
+ handle: string;
+ name: string;
+ description?: string;
+ /** When omitted, the server picks the org's preferred env; pass only to override that choice. */
+ loginEnvironment?: string;
+}
+
+export interface UpdateManagedPortalInput {
+ name?: string;
+ description?: string;
+ loginEnvironment?: string;
+}
+
+/** One data-plane environment; only the fields the UI's env selector needs. */
+export interface OrgEnvironment {
+ /** Canonical env name; what the loginEnvironment field expects. */
+ name: string;
+ /** Optional label; the UI falls back to `name` when empty. */
+ displayName?: string;
+}
+
+/** Data seam this feature depends on; satisfied by real (BFF) or mock (tests) implementations. */
+export interface PortalPort {
+ list(): Promise;
+ get(id: string): Promise;
+ create(input: CreateManagedPortalInput): Promise;
+ update(id: string, input: UpdateManagedPortalInput): Promise;
+ remove(id: string): Promise;
+ /** Empty list means the org has no environments yet; callers should surface that rather than defaulting. */
+ listEnvironments(): Promise;
+}
diff --git a/portals/cloud-plugins/apip-cloud-ui-managed-portals/tsconfig.json b/portals/cloud-plugins/apip-cloud-ui-managed-portals/tsconfig.json
new file mode 100644
index 0000000000..a9928c2918
--- /dev/null
+++ b/portals/cloud-plugins/apip-cloud-ui-managed-portals/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "baseUrl": ".",
+ "paths": {
+ "@wso2/oxygen-ui": ["../../ai-workspace/node_modules/@wso2/oxygen-ui"],
+ "@wso2/oxygen-ui-icons-react": ["../../ai-workspace/node_modules/@wso2/oxygen-ui-icons-react"],
+ "react": ["../../ai-workspace/node_modules/@types/react/index.d.ts"],
+ "react/jsx-runtime": ["../../ai-workspace/node_modules/@types/react/jsx-runtime.d.ts"]
+ },
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src"]
+}
diff --git a/portals/cloud-plugins/apip-cloud-ui/package.json b/portals/cloud-plugins/apip-cloud-ui/package.json
index 0c47347927..422e0d24f4 100644
--- a/portals/cloud-plugins/apip-cloud-ui/package.json
+++ b/portals/cloud-plugins/apip-cloud-ui/package.json
@@ -14,6 +14,7 @@
"@wso2-enterprise/apip-cloud-ui-deploy": "file:../apip-cloud-ui-deploy",
"@wso2-enterprise/apip-cloud-ui-environments-new": "file:../apip-cloud-ui-environments-new",
"@wso2-enterprise/apip-cloud-ui-gateways": "file:../apip-cloud-ui-gateways",
+ "@wso2-enterprise/apip-cloud-ui-managed-portals": "file:../apip-cloud-ui-managed-portals",
"@wso2-enterprise/apip-cloud-ui-pipelines": "file:../apip-cloud-ui-pipelines",
"@wso2/oxygen-ui": "0.5.0",
"@wso2/oxygen-ui-icons-react": "0.5.0",
diff --git a/portals/cloud-plugins/apip-cloud-ui/src/hosts/api-control-plane.tsx b/portals/cloud-plugins/apip-cloud-ui/src/hosts/api-control-plane.tsx
index 02cad50760..1e84194f70 100644
--- a/portals/cloud-plugins/apip-cloud-ui/src/hosts/api-control-plane.tsx
+++ b/portals/cloud-plugins/apip-cloud-ui/src/hosts/api-control-plane.tsx
@@ -7,11 +7,12 @@
* You may not alter or remove any copyright or other notice from copies of this content.
*/
-import { Layers, Workflow } from '@wso2/oxygen-ui-icons-react';
+import { Globe, Layers, Workflow } from '@wso2/oxygen-ui-icons-react';
import { DeployFeature } from '@wso2-enterprise/apip-cloud-ui-deploy';
import { EnvironmentsFeature } from '@wso2-enterprise/apip-cloud-ui-environments-new';
import { GatewaysFeature } from '@wso2-enterprise/apip-cloud-ui-gateways';
+import { ManagedPortalsPage } from '@wso2-enterprise/apip-cloud-ui-managed-portals';
import {
PipelinesFeature,
ProjectPipelinesFeature,
@@ -57,10 +58,15 @@ import { defineCloudPlugin, getCloudExtensions, type CloudPluginFeature } from '
* what renders there changes. It is the one API-scoped feature here, so it needs
* the API in scope, which the Port carries as `apiHandle`. Because the override
* replaces the whole page, it also replaces the `ScopeGate` the built-in page
- * wraps itself in — so it is re-applied here. Without it, reaching Deploy from an
+ * wraps itself in - so it is re-applied here. Without it, reaching Deploy from an
* organization- or project-level page (which the sidebar allows, and is a normal
* thing to do) left a dead end instead of the project/API picker that navigates
* to the scoped URL.
+ *
+ * `managed-api-portals` is an organization-level sidebar item, one WSO2-managed
+ * developer portal per entry in the org. Talks to apip-platform-api's cloud-only
+ * `/managed-api-portals` resource via the host port; SaaS-only, distinct from
+ * the OSS `/api-portals` registry (SaaS lifecycle vs plain registry).
*/
export const cloudPluginFeatures: CloudPluginFeature[] = [
defineCloudPlugin({
@@ -164,6 +170,23 @@ export const cloudPluginFeatures: CloudPluginFeature[]
},
],
}),
+ defineCloudPlugin({
+ id: 'managed-api-portals',
+ version: '0.1.0',
+ extensions: [
+ {
+ id: 'managed-api-portals',
+ slot: 'sidebar.organization',
+ // Placed after Pipelines (50); no built-in item competes for 60.
+ order: 60,
+ routePath: 'managed-api-portals',
+ render: (port) => ,
+ label: 'Managed API Portals',
+ icon: ,
+ level: 'organization',
+ },
+ ],
+ }),
];
export const cloudExtensions = getCloudExtensions(cloudPluginFeatures);
diff --git a/portals/cloud-plugins/apip-cloud-ui/tsconfig.json b/portals/cloud-plugins/apip-cloud-ui/tsconfig.json
index 75ced84105..f0c9ad248c 100644
--- a/portals/cloud-plugins/apip-cloud-ui/tsconfig.json
+++ b/portals/cloud-plugins/apip-cloud-ui/tsconfig.json
@@ -7,6 +7,7 @@
"@wso2-enterprise/apip-cloud-ui-deploy": ["../apip-cloud-ui-deploy/src/index.ts"],
"@wso2-enterprise/apip-cloud-ui-environments-new": ["../apip-cloud-ui-environments-new/src/index.ts"],
"@wso2-enterprise/apip-cloud-ui-gateways": ["../apip-cloud-ui-gateways/src/index.ts"],
+ "@wso2-enterprise/apip-cloud-ui-managed-portals": ["../apip-cloud-ui-managed-portals/src/index.ts"],
"@wso2-enterprise/apip-cloud-ui-pipelines": ["../apip-cloud-ui-pipelines/src/index.ts"],
"@wso2/oxygen-ui": ["../../ai-workspace/node_modules/@wso2/oxygen-ui"],
"@wso2/oxygen-ui-icons-react": ["../../ai-workspace/node_modules/@wso2/oxygen-ui-icons-react"],