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 && ( + + )} + + + + + + + + + + + + + {portal.updatedAt && } + + + )} + + + {/* Edit portal */} + (submitting ? undefined : setEditOpen(false))} + fullWidth + maxWidth="sm" + > + Edit Portal + + + + Handle + + + + Name + setName(event.target.value)} + disabled={submitting} + /> + + + Description + setDescription(event.target.value)} + disabled={submitting} + /> + + + Login environment + {/* Current env is added as a synthetic option if missing from the list, so an out-of-band deletion shows as a mismatch rather than a silent swap. */} + + {envsError && ( + + Failed to load environments: {envsError.message} + + )} + + + + + + + + + + {/* Delete portal */} + setDeleteOpen(false)}> + Delete Portal + + + Are you sure you want to delete {portal?.name}? This action cannot be undone. + + + + + + + + + ); +} 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. + + + + + + + + {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 */} + { + if (submitting) return; + resetCreateForm(); + setCreateOpen(false); + }} + fullWidth + maxWidth="sm" + > + Create Portal + + + + Handle + setHandle(event.target.value)} + disabled={submitting} + /> + + + Name + setName(event.target.value)} + disabled={submitting} + /> + + + Description + setDescription(event.target.value)} + disabled={submitting} + /> + + {/* No Login-environment field on Create; the server picks the org's preferred env, and Edit exposes the picker later. */} + + + + + + + + + {/* Delete portal */} + setDeleteTarget(null)}> + Delete Portal + + + Are you sure you want to delete {deleteTarget?.name}? This action cannot be undone. + + + + + + + +
+ ); +} 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"],