From eef5b28bb657f8bce34097bd11a657616fa2cd33 Mon Sep 17 00:00:00 2001 From: pranavansu Date: Mon, 10 Aug 2026 11:29:44 +0530 Subject: [PATCH 01/33] Add Dev Portal feature with routing and UI components --- .../src/features/devportal/DevPortalPage.tsx | 85 +++++++++++++++++++ .../src/navigation/navigationRegistry.tsx | 11 +++ .../src/routes/AppRoutes.tsx | 6 ++ portals/api-control-plane/src/routes/paths.ts | 2 + 4 files changed, 104 insertions(+) create mode 100644 portals/api-control-plane/src/features/devportal/DevPortalPage.tsx diff --git a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx new file mode 100644 index 0000000000..ff3d2eeff2 --- /dev/null +++ b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx @@ -0,0 +1,85 @@ +/* + * 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. + */ + +import { + Box, + Button, + InputAdornment, + PageContent, + PageTitle, + TextField, +} from '@wso2/oxygen-ui'; +import { Plus, Search } from '@wso2/oxygen-ui-icons-react'; +import { useState } from 'react'; + +import { EmptyState } from '../../components/StateViews'; + +export function DevPortalPage() { + const [search, setSearch] = useState(''); + + const provision = () => { + // Devportal provisioning is not implemented yet. + }; + + return ( + + + Dev Portal + + Provision and manage the developer portal for your organization. + + + + + + + + setSearch(event.target.value)} + placeholder="Search Dev Portal" + size="small" + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ maxWidth: 420, minWidth: 240, width: '100%' }} + value={search} + /> + + + + + ); +} diff --git a/portals/api-control-plane/src/navigation/navigationRegistry.tsx b/portals/api-control-plane/src/navigation/navigationRegistry.tsx index 7b728cddd6..9e1b9a165b 100644 --- a/portals/api-control-plane/src/navigation/navigationRegistry.tsx +++ b/portals/api-control-plane/src/navigation/navigationRegistry.tsx @@ -19,6 +19,7 @@ import { Boxes, ClipboardList, + Globe, Home, Network, Rocket, @@ -60,6 +61,16 @@ export const navigationRegistry: NavigationDefinition[] = [ params.orgHandle ? routes.gateways(params.orgHandle) : undefined, match: (pathname) => /\/organizations\/[^/]+\/gateways(\/[^/]+)?$/.test(pathname), }, + { + id: 'devportal', + label: 'Dev Portal', + level: 'organization', + order: 35, + icon: , + to: ({ params }) => + params.orgHandle ? routes.devportal(params.orgHandle) : undefined, + match: (pathname) => /\/organizations\/[^/]+\/devportal$/.test(pathname), + }, { id: 'project-home', label: 'Project Home', diff --git a/portals/api-control-plane/src/routes/AppRoutes.tsx b/portals/api-control-plane/src/routes/AppRoutes.tsx index d2bef3afb5..a98b2d70c8 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.tsx @@ -60,6 +60,11 @@ const GatewayDetailPage = lazy(() => default: m.GatewayDetailPage, })) ); +const DevPortalPage = lazy(() => + import('../features/devportal/DevPortalPage').then((m) => ({ + default: m.DevPortalPage, + })) +); const ProjectHomePage = lazy(() => import('../features/projects/ProjectHomePage').then((m) => ({ default: m.ProjectHomePage, @@ -124,6 +129,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/portals/api-control-plane/src/routes/paths.ts b/portals/api-control-plane/src/routes/paths.ts index 313aad8df6..e1770ac7f3 100644 --- a/portals/api-control-plane/src/routes/paths.ts +++ b/portals/api-control-plane/src/routes/paths.ts @@ -34,6 +34,8 @@ export const routes = { `/organizations/${orgHandle}/gateways/new`, gateway: (orgHandle = ':orgHandle', gatewayId = ':gatewayId') => `/organizations/${orgHandle}/gateways/${gatewayId}`, + devportal: (orgHandle = ':orgHandle') => + `/organizations/${orgHandle}/devportal`, projectHome: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => `/organizations/${orgHandle}/projects/${projectHandler}/home`, apis: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => From 909f97d4f019aaaa313d9ed4a970edd8fe52eab6 Mon Sep 17 00:00:00 2001 From: pranavansu Date: Mon, 10 Aug 2026 14:02:15 +0530 Subject: [PATCH 02/33] Add Dev Portal management features including creation and listing --- .../src/api/ApiClientProvider.tsx | 5 + portals/api-control-plane/src/api/adapters.ts | 45 +++ .../src/api/devportal/devPortalClient.ts | 45 +++ .../src/api/hooks/useMvpQueries.ts | 31 ++ .../api-control-plane/src/api/mocks/data.ts | 3 + portals/api-control-plane/src/api/mvpApi.ts | 1 + .../devportal/DevPortalCreatePage.tsx | 196 +++++++++ .../src/features/devportal/DevPortalPage.tsx | 375 ++++++++++++++++-- .../src/navigation/navigationRegistry.tsx | 3 +- .../src/routes/AppRoutes.tsx | 9 + portals/api-control-plane/src/routes/paths.ts | 2 + portals/api-control-plane/src/types/domain.ts | 26 ++ 12 files changed, 710 insertions(+), 31 deletions(-) create mode 100644 portals/api-control-plane/src/api/devportal/devPortalClient.ts create mode 100644 portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx diff --git a/portals/api-control-plane/src/api/ApiClientProvider.tsx b/portals/api-control-plane/src/api/ApiClientProvider.tsx index 23aabbdcb3..80f91e3270 100644 --- a/portals/api-control-plane/src/api/ApiClientProvider.tsx +++ b/portals/api-control-plane/src/api/ApiClientProvider.tsx @@ -21,6 +21,7 @@ import { createContext, type ReactNode, useContext } from 'react'; import { createApi, createApiKey, + createDevPortal, createGateway, createGatewayToken, createProject, @@ -37,6 +38,7 @@ import { listApiKeys, listApis, listDeployments, + listDevPortals, listEnvironments, listGatewayDeployments, listGateways, @@ -79,6 +81,9 @@ export const realApiClient = { getGateway, createGateway, createGatewayToken, + // dev portals + listDevPortals, + createDevPortal, // gateway deployments (the deploy path) listGatewayDeployments, deployApi, diff --git a/portals/api-control-plane/src/api/adapters.ts b/portals/api-control-plane/src/api/adapters.ts index 8126ce6d98..d01ed14d58 100644 --- a/portals/api-control-plane/src/api/adapters.ts +++ b/portals/api-control-plane/src/api/adapters.ts @@ -22,6 +22,9 @@ import type { ApiKind, ApiStatus, Deployment, + DevPortal, + DevPortalAuthType, + DevPortalWorkflowStatus, Environment, Gateway, GatewayDeployment, @@ -298,6 +301,48 @@ export const toGateway = (value: unknown): Gateway => { }; }; +const DEV_PORTAL_AUTH_TYPES: DevPortalAuthType[] = [ + 'local', + 'idp_client_credentials', +]; + +const asDevPortalAuthType = (value: unknown): DevPortalAuthType => { + const normalized = asString(value).toLowerCase(); + return DEV_PORTAL_AUTH_TYPES.includes(normalized as DevPortalAuthType) + ? (normalized as DevPortalAuthType) + : DEV_PORTAL_AUTH_TYPES[0]; +}; + +const DEV_PORTAL_WORKFLOW_STATUSES: DevPortalWorkflowStatus[] = [ + 'pending', + 'active', + 'failed', +]; + +const asDevPortalWorkflowStatus = (value: unknown): DevPortalWorkflowStatus => { + const normalized = asString(value).toLowerCase(); + return DEV_PORTAL_WORKFLOW_STATUSES.includes( + normalized as DevPortalWorkflowStatus + ) + ? (normalized as DevPortalWorkflowStatus) + : DEV_PORTAL_WORKFLOW_STATUSES[0]; +}; + +export const toDevPortal = (value: unknown): DevPortal => { + const source = asRecord(value); + const name = asString(source.name, 'unknown-devportal'); + return { + id: asString(source.id, name), + name, + handle: asString(source.handle, name), + description: asOptionalString(source.description), + url: asOptionalString(source.url), + workflowStatus: asDevPortalWorkflowStatus(source.workflowStatus), + authType: asDevPortalAuthType(source.authType), + createdAt: asOptionalString(source.createdAt), + }; +}; + const GATEWAY_DEPLOYMENT_STATUSES: GatewayDeploymentStatus[] = [ 'DEPLOYED', 'UNDEPLOYED', diff --git a/portals/api-control-plane/src/api/devportal/devPortalClient.ts b/portals/api-control-plane/src/api/devportal/devPortalClient.ts new file mode 100644 index 0000000000..b1ac07a73c --- /dev/null +++ b/portals/api-control-plane/src/api/devportal/devPortalClient.ts @@ -0,0 +1,45 @@ +/* + * 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. + */ + +import type { CreateDevPortalInput, DevPortal } from '../../types/domain'; +import { toDevPortal } from '../adapters'; +import { devPortals } from '../mocks/data'; + +/** + * Devportal management has no platform-api backend yet (console-only feature + * for now), so this always operates on the in-memory mock store — unlike + * gatewayClient, there is no real REST endpoint to call. Swap this for a real + * client (platformGet/platformPost against DevPortalResponse) once + * platform-api adds one. + */ +export async function listDevPortals(): Promise { + return devPortals.map(toDevPortal); +} + +export async function createDevPortal( + input: CreateDevPortalInput +): Promise { + const devPortal: DevPortal = { + ...input, + id: input.handle, + workflowStatus: 'pending', + createdAt: new Date().toISOString(), + }; + devPortals.push(devPortal); + return toDevPortal(devPortal); +} diff --git a/portals/api-control-plane/src/api/hooks/useMvpQueries.ts b/portals/api-control-plane/src/api/hooks/useMvpQueries.ts index 9295e34a5e..0fa9d50404 100644 --- a/portals/api-control-plane/src/api/hooks/useMvpQueries.ts +++ b/portals/api-control-plane/src/api/hooks/useMvpQueries.ts @@ -25,6 +25,7 @@ import type { ApiDetail, CreateApiInput, CreateApiKeyInput, + CreateDevPortalInput, CreateGatewayInput, CreateProjectInput, DeployApiInput, @@ -58,6 +59,7 @@ export const queryKeys = { gateways: (orgHandle: string) => ['gateways', orgHandle] as const, gateway: (orgHandle: string, gatewayId: string) => ['gateway', orgHandle, gatewayId] as const, + devPortals: (orgHandle: string) => ['devPortals', orgHandle] as const, }; /** @@ -532,6 +534,35 @@ export const useCreateGatewayToken = ( }); }; +export const useDevPortals = (orgHandleArg?: string) => { + const client = useApiClient(); + const { orgHandle } = useScopeArgs(orgHandleArg); + return useQuery({ + queryKey: queryKeys.devPortals(orgHandle || ''), + queryFn: () => { + if (!orgHandle) { + throw new Error('orgHandle is required to list dev portals'); + } + return client.listDevPortals(); + }, + enabled: !!orgHandle, + }); +}; + +export const useCreateDevPortal = (orgHandleArg?: string) => { + const client = useApiClient(); + const queryClient = useQueryClient(); + const { orgHandle = '' } = useScopeArgs(orgHandleArg); + return useMutation({ + mutationFn: (input: CreateDevPortalInput) => client.createDevPortal(input), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.devPortals(orgHandle), + }); + }, + }); +}; + export const useCreateApi = ( orgHandleArg?: string, projectHandlerArg?: string diff --git a/portals/api-control-plane/src/api/mocks/data.ts b/portals/api-control-plane/src/api/mocks/data.ts index 8e681eff29..8eb57ae8e3 100644 --- a/portals/api-control-plane/src/api/mocks/data.ts +++ b/portals/api-control-plane/src/api/mocks/data.ts @@ -20,6 +20,7 @@ import type { ApiProxy, Api, Deployment, + DevPortal, Environment, Gateway, Organization, @@ -125,6 +126,8 @@ export const gateways: Gateway[] = [ }, ]; +export const devPortals: DevPortal[] = []; + export const apiProxies: ApiProxy[] = [ { id: 'api-proxy-1', diff --git a/portals/api-control-plane/src/api/mvpApi.ts b/portals/api-control-plane/src/api/mvpApi.ts index 945503668e..af7eca377f 100644 --- a/portals/api-control-plane/src/api/mvpApi.ts +++ b/portals/api-control-plane/src/api/mvpApi.ts @@ -44,6 +44,7 @@ export { getGateway, listGateways, } from './gateways/gatewayClient'; +export { createDevPortal, listDevPortals } from './devportal/devPortalClient'; export { listEnvironments } from './environments/environmentClient'; export { getOrganization, diff --git a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx new file mode 100644 index 0000000000..286061974c --- /dev/null +++ b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx @@ -0,0 +1,196 @@ +/* + * 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. + */ + +import { useState } from 'react'; +import { + Box, + Button, + FormControl, + FormLabel, + MenuItem, + PageContent, + PageTitle, + Select, + Stack, + TextField, +} from '@wso2/oxygen-ui'; +import { Link, useNavigate, useParams } from 'react-router-dom'; + +import { useCreateDevPortal } from '../../api/hooks/useMvpQueries'; +import { useNotifications } from '../../components/Notifications'; +import { routes } from '../../routes/paths'; +import type { DevPortalAuthType } from '../../types/domain'; + +const HANDLE_PATTERN = /^[a-z0-9-]{3,64}$/; + +const slugify = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + +const AUTH_TYPES: { value: DevPortalAuthType; label: string }[] = [ + { value: 'local', label: 'Local' }, + { value: 'idp_client_credentials', label: 'IdP Client Credentials' }, +]; + +export function DevPortalCreatePage() { + const { orgHandle = '' } = useParams(); + const navigate = useNavigate(); + const { notify } = useNotifications(); + const createDevPortal = useCreateDevPortal(); + + const [displayName, setDisplayName] = useState(''); + const [handle, setHandle] = useState(''); + const [handleEdited, setHandleEdited] = useState(false); + const [description, setDescription] = useState(''); + const [authType, setAuthType] = useState( + AUTH_TYPES[0].value + ); + const [url, setUrl] = useState(''); + + const onDisplayNameChange = (value: string) => { + setDisplayName(value); + if (!handleEdited) setHandle(slugify(value)); + }; + + const handleValid = HANDLE_PATTERN.test(handle); + const canSubmit = + displayName.trim() !== '' && + handleValid && + url.trim() !== '' && + !createDevPortal.isPending; + + const submit = () => { + createDevPortal.mutate( + { + name: displayName, + handle, + url: url.trim(), + authType, + description: description || undefined, + }, + { + onSuccess: (devPortal) => { + notify(`Devportal "${devPortal.name}" provisioned.`, 'success'); + navigate(routes.devportal(orgHandle)); + }, + onError: (error) => + notify( + error instanceof Error + ? error.message + : 'Failed to provision devportal', + 'error' + ), + } + ); + }; + + return ( + + + + Back to Dev Portal + + Provision a devportal + + Register a developer portal, then connect it to the platform. + + + + + + + Display name + onDisplayNameChange(event.target.value)} + placeholder="Production Devportal" + value={displayName} + /> + + + + Name + { + setHandleEdited(true); + setHandle(event.target.value); + }} + placeholder="prod-devportal" + value={handle} + /> + + + + Description (optional) + setDescription(event.target.value)} + value={description} + /> + + + + Authentication + + + + + URL + setUrl(event.target.value)} + placeholder="https://devportal.example.com" + value={url} + /> + + + + + + + + + + ); +} diff --git a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx index ff3d2eeff2..ed6a281257 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx @@ -16,25 +16,294 @@ * under the License. */ +import { useMemo, useState } from 'react'; import { + alpha, Box, Button, + IconButton, InputAdornment, PageContent, PageTitle, + Stack, TextField, + Tooltip, + Typography, } from '@wso2/oxygen-ui'; -import { Plus, Search } from '@wso2/oxygen-ui-icons-react'; -import { useState } from 'react'; +import { + Check, + Clock, + Copy, + Globe, + Plus, + Search, + ShieldCheck, +} from '@wso2/oxygen-ui-icons-react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { useDevPortals } from '../../api/hooks/useMvpQueries'; +import { + EmptyState, + ErrorState, + LoadingState, +} from '../../components/StateViews'; +import { routes } from '../../routes/paths'; +import { relativeTime } from '../../utils/relativeTime'; +import type { + DevPortal, + DevPortalAuthType, + DevPortalWorkflowStatus, +} from '../../types/domain'; + +const AUTH_LABEL: Record = { + local: 'Local', + idp_client_credentials: 'IdP Client Credentials', +}; + +const STATUS_LABEL: Record = { + pending: 'Pending', + active: 'Active', + failed: 'Failed', +}; + +const STATUS_COLOR: Record = { + pending: 'warning.main', + active: 'success.main', + failed: 'error.main', +}; + +/** A small KPI summary tile. */ +function StatCard({ + label, + value, + dotColor, +}: { + label: string; + value: number; + dotColor: string; +}) { + return ( + + + + + {label} + + + + {value} + + + ); +} + +function DevPortalCard({ devPortal }: { devPortal: DevPortal }) { + const [copied, setCopied] = useState(false); + const statusColor = STATUS_COLOR[devPortal.workflowStatus]; + + const copyUrl = (event: React.MouseEvent) => { + event.stopPropagation(); + if (!devPortal.url) return; + navigator.clipboard?.writeText(devPortal.url).catch(() => undefined); + setCopied(true); + setTimeout(() => setCopied(false), 1400); + }; -import { EmptyState } from '../../components/StateViews'; + const chipSx = { + alignItems: 'center', + bgcolor: 'action.hover', + border: '1px solid', + borderColor: 'divider', + borderRadius: 1, + color: 'text.secondary', + display: 'inline-flex', + fontSize: 12, + fontWeight: 500, + gap: 0.75, + px: 1.25, + py: 0.5, + }; + + return ( + + + + + + + + {devPortal.name} + + {devPortal.url && ( + + + {devPortal.url} + + + + {copied ? : } + + + + )} + + + + {devPortal.description && ( + + {devPortal.description} + + )} + + + alpha(t.palette.primary.main, 0.14), + borderColor: (t) => alpha(t.palette.primary.main, 0.3), + color: 'primary.main', + fontWeight: 600, + }} + > + + {AUTH_LABEL[devPortal.authType]} + + + + + + + + {STATUS_LABEL[devPortal.workflowStatus]} + + + {devPortal.createdAt && ( + + + + {relativeTime(devPortal.createdAt)} + + + )} + + + ); +} export function DevPortalPage() { + const { orgHandle = '' } = useParams(); + const navigate = useNavigate(); + const devPortalsQuery = useDevPortals(); const [search, setSearch] = useState(''); - const provision = () => { - // Devportal provisioning is not implemented yet. - }; + const provision = () => navigate(routes.newDevportal(orgHandle)); + + const devPortals = useMemo( + () => devPortalsQuery.data || [], + [devPortalsQuery.data] + ); + + const filtered = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) return devPortals; + return devPortals.filter((devPortal) => + [devPortal.name, devPortal.handle, devPortal.url] + .filter(Boolean) + .some((field) => field!.toLowerCase().includes(term)) + ); + }, [devPortals, search]); + + const activeCount = devPortals.filter( + (devPortal) => devPortal.workflowStatus === 'active' + ).length; return ( @@ -55,31 +324,77 @@ export function DevPortalPage() { - - setSearch(event.target.value)} - placeholder="Search Dev Portal" - size="small" - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - sx={{ maxWidth: 420, minWidth: 240, width: '100%' }} - value={search} + {devPortalsQuery.isLoading ? ( + + ) : devPortalsQuery.error ? ( + + ) : devPortals.length === 0 ? ( + - - - + ) : ( + + {/* KPI summary */} + + + + + + {/* Toolbar */} + setSearch(event.target.value)} + placeholder="Search Dev Portal" + size="small" + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ maxWidth: 420, minWidth: 240, width: '100%' }} + value={search} + /> + + {filtered.length === 0 ? ( + + ) : ( + + {filtered.map((devPortal) => ( + + ))} + + )} + + )} ); } diff --git a/portals/api-control-plane/src/navigation/navigationRegistry.tsx b/portals/api-control-plane/src/navigation/navigationRegistry.tsx index 9e1b9a165b..883d176672 100644 --- a/portals/api-control-plane/src/navigation/navigationRegistry.tsx +++ b/portals/api-control-plane/src/navigation/navigationRegistry.tsx @@ -69,7 +69,8 @@ export const navigationRegistry: NavigationDefinition[] = [ icon: , to: ({ params }) => params.orgHandle ? routes.devportal(params.orgHandle) : undefined, - match: (pathname) => /\/organizations\/[^/]+\/devportal$/.test(pathname), + match: (pathname) => + /\/organizations\/[^/]+\/devportal(\/[^/]+)?$/.test(pathname), }, { id: 'project-home', diff --git a/portals/api-control-plane/src/routes/AppRoutes.tsx b/portals/api-control-plane/src/routes/AppRoutes.tsx index a98b2d70c8..410e5f81ca 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.tsx @@ -65,6 +65,11 @@ const DevPortalPage = lazy(() => default: m.DevPortalPage, })) ); +const DevPortalCreatePage = lazy(() => + import('../features/devportal/DevPortalCreatePage').then((m) => ({ + default: m.DevPortalCreatePage, + })) +); const ProjectHomePage = lazy(() => import('../features/projects/ProjectHomePage').then((m) => ({ default: m.ProjectHomePage, @@ -130,6 +135,10 @@ export function AppRoutes() { } /> } /> } /> + } + /> } /> } /> } /> diff --git a/portals/api-control-plane/src/routes/paths.ts b/portals/api-control-plane/src/routes/paths.ts index e1770ac7f3..27806fd336 100644 --- a/portals/api-control-plane/src/routes/paths.ts +++ b/portals/api-control-plane/src/routes/paths.ts @@ -36,6 +36,8 @@ export const routes = { `/organizations/${orgHandle}/gateways/${gatewayId}`, devportal: (orgHandle = ':orgHandle') => `/organizations/${orgHandle}/devportal`, + newDevportal: (orgHandle = ':orgHandle') => + `/organizations/${orgHandle}/devportal/new`, projectHome: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => `/organizations/${orgHandle}/projects/${projectHandler}/home`, apis: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => diff --git a/portals/api-control-plane/src/types/domain.ts b/portals/api-control-plane/src/types/domain.ts index 6d3d31bdf3..4dffadb5c7 100644 --- a/portals/api-control-plane/src/types/domain.ts +++ b/portals/api-control-plane/src/types/domain.ts @@ -246,6 +246,32 @@ export type GatewayToken = { message?: string; }; +/** How the platform authenticates to a Developer Portal instance. */ +export type DevPortalAuthType = 'local' | 'idp_client_credentials'; + +/** Provisioning state of a devportal (platform-api DevPortalResponse.workflowStatus). */ +export type DevPortalWorkflowStatus = 'pending' | 'active' | 'failed'; + +/** Maps 1:1 to platform-api's DevPortalResponse schema. */ +export type DevPortal = { + id: string; + name: string; + handle: string; + description?: string; + url?: string; + workflowStatus: DevPortalWorkflowStatus; + authType: DevPortalAuthType; + createdAt?: string; +}; + +export type CreateDevPortalInput = { + name: string; + handle: string; + url: string; + authType: DevPortalAuthType; + description?: string; +}; + /** * How the API definition is sourced on create. `scratch` builds an empty proxy; * the import variants create from an OpenAPI definition (URL or uploaded file). From 89196fe7dd6fd05d382c49b06fa7a3222398a33e Mon Sep 17 00:00:00 2001 From: pranavansu Date: Mon, 10 Aug 2026 15:12:24 +0530 Subject: [PATCH 03/33] Add delete functionality for Dev Portals with UI integration --- .../src/api/ApiClientProvider.tsx | 2 + .../src/api/devportal/devPortalClient.ts | 9 ++ .../src/api/hooks/useMvpQueries.ts | 15 +++ portals/api-control-plane/src/api/mvpApi.ts | 6 +- .../src/features/devportal/DevPortalPage.tsx | 102 +++++++++++++++++- 5 files changed, 129 insertions(+), 5 deletions(-) diff --git a/portals/api-control-plane/src/api/ApiClientProvider.tsx b/portals/api-control-plane/src/api/ApiClientProvider.tsx index 80f91e3270..2c6d0b743d 100644 --- a/portals/api-control-plane/src/api/ApiClientProvider.tsx +++ b/portals/api-control-plane/src/api/ApiClientProvider.tsx @@ -26,6 +26,7 @@ import { createGatewayToken, createProject, deleteApi, + deleteDevPortal, deleteGatewayDeployment, deleteProject, deployApi, @@ -84,6 +85,7 @@ export const realApiClient = { // dev portals listDevPortals, createDevPortal, + deleteDevPortal, // gateway deployments (the deploy path) listGatewayDeployments, deployApi, diff --git a/portals/api-control-plane/src/api/devportal/devPortalClient.ts b/portals/api-control-plane/src/api/devportal/devPortalClient.ts index b1ac07a73c..8a4f6a3d13 100644 --- a/portals/api-control-plane/src/api/devportal/devPortalClient.ts +++ b/portals/api-control-plane/src/api/devportal/devPortalClient.ts @@ -19,6 +19,7 @@ import type { CreateDevPortalInput, DevPortal } from '../../types/domain'; import { toDevPortal } from '../adapters'; import { devPortals } from '../mocks/data'; +import { ApiError } from '../types/errors'; /** * Devportal management has no platform-api backend yet (console-only feature @@ -43,3 +44,11 @@ export async function createDevPortal( devPortals.push(devPortal); return toDevPortal(devPortal); } + +export async function deleteDevPortal(id: string): Promise { + const index = devPortals.findIndex((item) => item.id === id); + if (index < 0) { + throw new ApiError('Devportal not found', 'NOT_FOUND', 404); + } + devPortals.splice(index, 1); +} diff --git a/portals/api-control-plane/src/api/hooks/useMvpQueries.ts b/portals/api-control-plane/src/api/hooks/useMvpQueries.ts index 0fa9d50404..754f408825 100644 --- a/portals/api-control-plane/src/api/hooks/useMvpQueries.ts +++ b/portals/api-control-plane/src/api/hooks/useMvpQueries.ts @@ -29,6 +29,7 @@ import type { CreateGatewayInput, CreateProjectInput, DeployApiInput, + DevPortal, GatewayDeployment, Project, } from '../../types/domain'; @@ -563,6 +564,20 @@ export const useCreateDevPortal = (orgHandleArg?: string) => { }); }; +export const useDeleteDevPortal = (orgHandleArg?: string) => { + const client = useApiClient(); + const queryClient = useQueryClient(); + const { orgHandle = '' } = useScopeArgs(orgHandleArg); + return useMutation({ + mutationFn: (devPortal: DevPortal) => client.deleteDevPortal(devPortal.id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.devPortals(orgHandle), + }); + }, + }); +}; + export const useCreateApi = ( orgHandleArg?: string, projectHandlerArg?: string diff --git a/portals/api-control-plane/src/api/mvpApi.ts b/portals/api-control-plane/src/api/mvpApi.ts index af7eca377f..43ffeb511a 100644 --- a/portals/api-control-plane/src/api/mvpApi.ts +++ b/portals/api-control-plane/src/api/mvpApi.ts @@ -44,7 +44,11 @@ export { getGateway, listGateways, } from './gateways/gatewayClient'; -export { createDevPortal, listDevPortals } from './devportal/devPortalClient'; +export { + createDevPortal, + deleteDevPortal, + listDevPortals, +} from './devportal/devPortalClient'; export { listEnvironments } from './environments/environmentClient'; export { getOrganization, diff --git a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx index ed6a281257..62a1d48f49 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx @@ -23,6 +23,10 @@ import { Button, IconButton, InputAdornment, + ListItemIcon, + ListItemText, + Menu, + MenuItem, PageContent, PageTitle, Stack, @@ -35,13 +39,17 @@ import { Clock, Copy, Globe, + MoreVertical, Plus, Search, ShieldCheck, + Trash2, } from '@wso2/oxygen-ui-icons-react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useDevPortals } from '../../api/hooks/useMvpQueries'; +import { useDeleteDevPortal, useDevPortals } from '../../api/hooks/useMvpQueries'; +import { ConfirmDialog } from '../../components/ConfirmDialog'; +import { useNotifications } from '../../components/Notifications'; import { EmptyState, ErrorState, @@ -115,8 +123,15 @@ function StatCard({ ); } -function DevPortalCard({ devPortal }: { devPortal: DevPortal }) { +function DevPortalCard({ + devPortal, + onDelete, +}: { + devPortal: DevPortal; + onDelete?: (devPortal: DevPortal) => void; +}) { const [copied, setCopied] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(null); const statusColor = STATUS_COLOR[devPortal.workflowStatus]; const copyUrl = (event: React.MouseEvent) => { @@ -127,6 +142,11 @@ function DevPortalCard({ devPortal }: { devPortal: DevPortal }) { setTimeout(() => setCopied(false), 1400); }; + const closeMenu = (event?: React.MouseEvent) => { + event?.stopPropagation(); + setMenuAnchor(null); + }; + const chipSx = { alignItems: 'center', bgcolor: 'action.hover', @@ -170,7 +190,7 @@ function DevPortalCard({ devPortal }: { devPortal: DevPortal }) { > - + {devPortal.name} @@ -203,6 +223,41 @@ function DevPortalCard({ devPortal }: { devPortal: DevPortal }) { )} + {onDelete && ( + <> + + { + event.stopPropagation(); + setMenuAnchor(event.currentTarget); + }} + size="small" + sx={{ alignSelf: 'flex-start', flex: 'none' }} + > + + + + closeMenu()} + open={Boolean(menuAnchor)} + > + { + closeMenu(event); + onDelete(devPortal); + }} + sx={{ color: 'error.main' }} + > + + + + Delete + + + + )} {devPortal.description && ( @@ -281,11 +336,29 @@ function DevPortalCard({ devPortal }: { devPortal: DevPortal }) { export function DevPortalPage() { const { orgHandle = '' } = useParams(); const navigate = useNavigate(); + const { notify } = useNotifications(); const devPortalsQuery = useDevPortals(); + const deleteDevPortalMutation = useDeleteDevPortal(); const [search, setSearch] = useState(''); + const [toDelete, setToDelete] = useState(null); const provision = () => navigate(routes.newDevportal(orgHandle)); + const confirmDelete = () => { + if (!toDelete) return; + deleteDevPortalMutation.mutate(toDelete, { + onSuccess: () => { + notify(`Deleted "${toDelete.name}".`, 'success'); + setToDelete(null); + }, + onError: (error) => + notify( + error instanceof Error ? error.message : 'Delete failed', + 'error' + ), + }); + }; + const devPortals = useMemo( () => devPortalsQuery.data || [], [devPortalsQuery.data] @@ -389,12 +462,33 @@ export function DevPortalPage() { }} > {filtered.map((devPortal) => ( - + ))} )} )} + + setToDelete(null)} + onConfirm={confirmDelete} + open={toDelete !== null} + title="Delete devportal" + /> ); } From 1aea97d2d7bfad235923e5a721e454da6ee78a7e Mon Sep 17 00:00:00 2001 From: pranavansu Date: Mon, 10 Aug 2026 16:35:22 +0530 Subject: [PATCH 04/33] Enhance Dev Portal creation with IDP client credentials support --- .../src/api/devportal/devPortalClient.ts | 9 +- .../devportal/DevPortalCreatePage.tsx | 96 +++++++++++++++++++ portals/api-control-plane/src/types/domain.ts | 4 + 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/portals/api-control-plane/src/api/devportal/devPortalClient.ts b/portals/api-control-plane/src/api/devportal/devPortalClient.ts index 8a4f6a3d13..8f0bd24ca8 100644 --- a/portals/api-control-plane/src/api/devportal/devPortalClient.ts +++ b/portals/api-control-plane/src/api/devportal/devPortalClient.ts @@ -35,9 +35,16 @@ export async function listDevPortals(): Promise { export async function createDevPortal( input: CreateDevPortalInput ): Promise { + // Picked explicitly (not `...input`) so idp_client_credentials secrets + // (stsTokenUrl/clientId/clientSecret) never end up on the stored/returned + // record — those are write-only, forwarded to the real backend once it exists. const devPortal: DevPortal = { - ...input, id: input.handle, + name: input.name, + handle: input.handle, + description: input.description, + url: input.url, + authType: input.authType, workflowStatus: 'pending', createdAt: new Date().toISOString(), }; diff --git a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx index 286061974c..0ea163f771 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx @@ -22,13 +22,17 @@ import { Button, FormControl, FormLabel, + IconButton, + InputAdornment, MenuItem, PageContent, PageTitle, Select, Stack, TextField, + Typography, } from '@wso2/oxygen-ui'; +import { Eye, EyeOff } from '@wso2/oxygen-ui-icons-react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { useCreateDevPortal } from '../../api/hooks/useMvpQueries'; @@ -64,6 +68,10 @@ export function DevPortalCreatePage() { AUTH_TYPES[0].value ); const [url, setUrl] = useState(''); + const [stsTokenUrl, setStsTokenUrl] = useState(''); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [secretVisible, setSecretVisible] = useState(false); const onDisplayNameChange = (value: string) => { setDisplayName(value); @@ -71,10 +79,17 @@ export function DevPortalCreatePage() { }; const handleValid = HANDLE_PATTERN.test(handle); + const isIdpAuth = authType === 'idp_client_credentials'; + const idpFieldsValid = + !isIdpAuth || + (stsTokenUrl.trim() !== '' && + clientId.trim() !== '' && + clientSecret.trim() !== ''); const canSubmit = displayName.trim() !== '' && handleValid && url.trim() !== '' && + idpFieldsValid && !createDevPortal.isPending; const submit = () => { @@ -85,6 +100,13 @@ export function DevPortalCreatePage() { url: url.trim(), authType, description: description || undefined, + ...(isIdpAuth + ? { + stsTokenUrl: stsTokenUrl.trim(), + clientId: clientId.trim(), + clientSecret, + } + : {}), }, { onSuccess: (devPortal) => { @@ -166,6 +188,80 @@ export function DevPortalCreatePage() { + {isIdpAuth && ( + + + IDP CLIENT CREDENTIALS + + + + STS token URL + setStsTokenUrl(event.target.value)} + placeholder="https://idp.example.com/oauth2/token" + value={stsTokenUrl} + /> + + + + Client ID + setClientId(event.target.value)} + value={clientId} + /> + + + + Client secret + setClientSecret(event.target.value)} + slotProps={{ + input: { + endAdornment: ( + + setSecretVisible((v) => !v)} + size="small" + > + {secretVisible ? ( + + ) : ( + + )} + + + ), + }, + }} + type={secretVisible ? 'text' : 'password'} + value={clientSecret} + /> + + + + )} + URL Date: Mon, 10 Aug 2026 17:19:33 +0530 Subject: [PATCH 05/33] Add Dev Portal detail and update features with IDP client credentials support --- .../src/api/ApiClientProvider.tsx | 4 + .../src/api/devportal/devPortalClient.ts | 32 +- .../src/api/hooks/useMvpQueries.ts | 39 +++ portals/api-control-plane/src/api/mvpApi.ts | 2 + .../devportal/DevPortalCreatePage.tsx | 95 +----- .../devportal/DevPortalDetailPage.tsx | 307 ++++++++++++++++++ .../src/features/devportal/DevPortalPage.tsx | 37 +-- .../devportal/IdpCredentialsFields.tsx | 125 +++++++ .../features/devportal/devPortalDisplay.ts | 51 +++ .../src/routes/AppRoutes.tsx | 9 + portals/api-control-plane/src/routes/paths.ts | 4 + portals/api-control-plane/src/types/domain.ts | 12 + 12 files changed, 611 insertions(+), 106 deletions(-) create mode 100644 portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx create mode 100644 portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx create mode 100644 portals/api-control-plane/src/features/devportal/devPortalDisplay.ts diff --git a/portals/api-control-plane/src/api/ApiClientProvider.tsx b/portals/api-control-plane/src/api/ApiClientProvider.tsx index 2c6d0b743d..7f4f171479 100644 --- a/portals/api-control-plane/src/api/ApiClientProvider.tsx +++ b/portals/api-control-plane/src/api/ApiClientProvider.tsx @@ -33,6 +33,7 @@ import { getApi, getApiDetail, getApiProxy, + getDevPortal, getGateway, getOrganization, getProject, @@ -49,6 +50,7 @@ import { revokeApiKey, undeployGatewayDeployment, updateApi, + updateDevPortal, } from './mvpApi'; import { getPolicyDefinition, @@ -84,7 +86,9 @@ export const realApiClient = { createGatewayToken, // dev portals listDevPortals, + getDevPortal, createDevPortal, + updateDevPortal, deleteDevPortal, // gateway deployments (the deploy path) listGatewayDeployments, diff --git a/portals/api-control-plane/src/api/devportal/devPortalClient.ts b/portals/api-control-plane/src/api/devportal/devPortalClient.ts index 8f0bd24ca8..3c3b60aa30 100644 --- a/portals/api-control-plane/src/api/devportal/devPortalClient.ts +++ b/portals/api-control-plane/src/api/devportal/devPortalClient.ts @@ -16,7 +16,11 @@ * under the License. */ -import type { CreateDevPortalInput, DevPortal } from '../../types/domain'; +import type { + CreateDevPortalInput, + DevPortal, + UpdateDevPortalInput, +} from '../../types/domain'; import { toDevPortal } from '../adapters'; import { devPortals } from '../mocks/data'; import { ApiError } from '../types/errors'; @@ -32,6 +36,11 @@ export async function listDevPortals(): Promise { return devPortals.map(toDevPortal); } +export async function getDevPortal(id: string): Promise { + const found = devPortals.find((item) => item.id === id); + return found ? toDevPortal(found) : undefined; +} + export async function createDevPortal( input: CreateDevPortalInput ): Promise { @@ -52,6 +61,27 @@ export async function createDevPortal( return toDevPortal(devPortal); } +export async function updateDevPortal( + id: string, + input: UpdateDevPortalInput +): Promise { + const index = devPortals.findIndex((item) => item.id === id); + if (index < 0) { + throw new ApiError('Devportal not found', 'NOT_FOUND', 404); + } + // Picked explicitly, same reasoning as createDevPortal: idp_client_credentials + // secrets are write-only and never end up on the stored/returned record. + const updated: DevPortal = { + ...devPortals[index], + name: input.name, + description: input.description, + url: input.url, + authType: input.authType, + }; + devPortals[index] = updated; + return toDevPortal(updated); +} + export async function deleteDevPortal(id: string): Promise { const index = devPortals.findIndex((item) => item.id === id); if (index < 0) { diff --git a/portals/api-control-plane/src/api/hooks/useMvpQueries.ts b/portals/api-control-plane/src/api/hooks/useMvpQueries.ts index 754f408825..9d96f98a4b 100644 --- a/portals/api-control-plane/src/api/hooks/useMvpQueries.ts +++ b/portals/api-control-plane/src/api/hooks/useMvpQueries.ts @@ -32,6 +32,7 @@ import type { DevPortal, GatewayDeployment, Project, + UpdateDevPortalInput, } from '../../types/domain'; import { useApiClient } from '../ApiClientProvider'; @@ -61,6 +62,8 @@ export const queryKeys = { gateway: (orgHandle: string, gatewayId: string) => ['gateway', orgHandle, gatewayId] as const, devPortals: (orgHandle: string) => ['devPortals', orgHandle] as const, + devPortal: (orgHandle: string, devPortalId: string) => + ['devPortal', orgHandle, devPortalId] as const, }; /** @@ -550,6 +553,21 @@ export const useDevPortals = (orgHandleArg?: string) => { }); }; +export const useDevPortal = (orgHandleArg?: string, devPortalId?: string) => { + const client = useApiClient(); + const { orgHandle } = useScopeArgs(orgHandleArg); + return useQuery({ + queryKey: queryKeys.devPortal(orgHandle || '', devPortalId || ''), + queryFn: () => { + if (!devPortalId) { + throw new Error('devPortalId is required to fetch a dev portal'); + } + return client.getDevPortal(devPortalId); + }, + enabled: !!orgHandle && !!devPortalId, + }); +}; + export const useCreateDevPortal = (orgHandleArg?: string) => { const client = useApiClient(); const queryClient = useQueryClient(); @@ -564,6 +582,27 @@ export const useCreateDevPortal = (orgHandleArg?: string) => { }); }; +export const useUpdateDevPortal = ( + orgHandleArg?: string, + devPortalId = '' +) => { + const client = useApiClient(); + const queryClient = useQueryClient(); + const { orgHandle = '' } = useScopeArgs(orgHandleArg); + return useMutation({ + mutationFn: (input: UpdateDevPortalInput) => + client.updateDevPortal(devPortalId, input), + onSuccess: (updated) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.devPortal(orgHandle, updated.id), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.devPortals(orgHandle), + }); + }, + }); +}; + export const useDeleteDevPortal = (orgHandleArg?: string) => { const client = useApiClient(); const queryClient = useQueryClient(); diff --git a/portals/api-control-plane/src/api/mvpApi.ts b/portals/api-control-plane/src/api/mvpApi.ts index 43ffeb511a..eb0211646d 100644 --- a/portals/api-control-plane/src/api/mvpApi.ts +++ b/portals/api-control-plane/src/api/mvpApi.ts @@ -47,7 +47,9 @@ export { export { createDevPortal, deleteDevPortal, + getDevPortal, listDevPortals, + updateDevPortal, } from './devportal/devPortalClient'; export { listEnvironments } from './environments/environmentClient'; export { diff --git a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx index 0ea163f771..938446435d 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx @@ -22,23 +22,21 @@ import { Button, FormControl, FormLabel, - IconButton, - InputAdornment, MenuItem, PageContent, PageTitle, Select, Stack, TextField, - Typography, } from '@wso2/oxygen-ui'; -import { Eye, EyeOff } from '@wso2/oxygen-ui-icons-react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { useCreateDevPortal } from '../../api/hooks/useMvpQueries'; import { useNotifications } from '../../components/Notifications'; import { routes } from '../../routes/paths'; import type { DevPortalAuthType } from '../../types/domain'; +import { AUTH_TYPE_OPTIONS } from './devPortalDisplay'; +import { IdpCredentialsFields } from './IdpCredentialsFields'; const HANDLE_PATTERN = /^[a-z0-9-]{3,64}$/; @@ -49,11 +47,6 @@ const slugify = (value: string) => .replace(/^-+|-+$/g, '') .slice(0, 64); -const AUTH_TYPES: { value: DevPortalAuthType; label: string }[] = [ - { value: 'local', label: 'Local' }, - { value: 'idp_client_credentials', label: 'IdP Client Credentials' }, -]; - export function DevPortalCreatePage() { const { orgHandle = '' } = useParams(); const navigate = useNavigate(); @@ -65,13 +58,12 @@ export function DevPortalCreatePage() { const [handleEdited, setHandleEdited] = useState(false); const [description, setDescription] = useState(''); const [authType, setAuthType] = useState( - AUTH_TYPES[0].value + AUTH_TYPE_OPTIONS[0].value ); const [url, setUrl] = useState(''); const [stsTokenUrl, setStsTokenUrl] = useState(''); const [clientId, setClientId] = useState(''); const [clientSecret, setClientSecret] = useState(''); - const [secretVisible, setSecretVisible] = useState(false); const onDisplayNameChange = (value: string) => { setDisplayName(value); @@ -180,7 +172,7 @@ export function DevPortalCreatePage() { size="small" value={authType} > - {AUTH_TYPES.map((option) => ( + {AUTH_TYPE_OPTIONS.map((option) => ( {option.label} @@ -189,77 +181,14 @@ export function DevPortalCreatePage() { {isIdpAuth && ( - - - IDP CLIENT CREDENTIALS - - - - STS token URL - setStsTokenUrl(event.target.value)} - placeholder="https://idp.example.com/oauth2/token" - value={stsTokenUrl} - /> - - - - Client ID - setClientId(event.target.value)} - value={clientId} - /> - - - - Client secret - setClientSecret(event.target.value)} - slotProps={{ - input: { - endAdornment: ( - - setSecretVisible((v) => !v)} - size="small" - > - {secretVisible ? ( - - ) : ( - - )} - - - ), - }, - }} - type={secretVisible ? 'text' : 'password'} - value={clientSecret} - /> - - - + )} diff --git a/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx new file mode 100644 index 0000000000..53e7c98a7a --- /dev/null +++ b/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx @@ -0,0 +1,307 @@ +/* + * 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. + */ + +import { useEffect, useState } from 'react'; +import { + Button, + Card, + CardContent, + Chip, + FormControl, + FormLabel, + Grid, + MenuItem, + PageContent, + PageTitle, + Select, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { Link, useParams } from 'react-router-dom'; + +import { useDevPortal, useUpdateDevPortal } from '../../api/hooks/useMvpQueries'; +import { useNotifications } from '../../components/Notifications'; +import { ErrorState, LoadingState } from '../../components/StateViews'; +import { routes } from '../../routes/paths'; +import type { DevPortal, DevPortalAuthType } from '../../types/domain'; +import { relativeTime } from '../../utils/relativeTime'; +import { + AUTH_TYPE_OPTIONS, + STATUS_CHIP_COLOR, + STATUS_LABEL, +} from './devPortalDisplay'; +import { IdpCredentialsFields } from './IdpCredentialsFields'; + +export function DevPortalDetailPage() { + const { orgHandle = '', devPortalId = '' } = useParams(); + const { notify } = useNotifications(); + const devPortalQuery = useDevPortal(orgHandle, devPortalId); + const updateDevPortal = useUpdateDevPortal(orgHandle, devPortalId); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [url, setUrl] = useState(''); + const [authType, setAuthType] = useState('local'); + const [stsTokenUrl, setStsTokenUrl] = useState(''); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [seededId, setSeededId] = useState(); + + // Seed the editable fields once per loaded record — a poll/refetch after + // save must not clobber in-progress edits, so this only re-seeds when the + // devportal id itself changes (i.e. navigating to a different one). + useEffect(() => { + if (!devPortalQuery.data || devPortalQuery.data.id === seededId) return; + const devPortal = devPortalQuery.data; + setName(devPortal.name); + setDescription(devPortal.description || ''); + setUrl(devPortal.url || ''); + setAuthType(devPortal.authType); + setStsTokenUrl(''); + setClientId(''); + setClientSecret(''); + setSeededId(devPortal.id); + }, [devPortalQuery.data, seededId]); + + if (devPortalQuery.isLoading) { + return ; + } + if (devPortalQuery.error) { + return ; + } + if (!devPortalQuery.data) { + return ; + } + + const devPortal: DevPortal = devPortalQuery.data; + const isIdpAuth = authType === 'idp_client_credentials'; + // Credentials are never returned by the backend (write-only), so an + // already-idp devportal always reloads with these three fields blank. + // Once idp is already the active auth type, each field updates + // independently — leaving one blank keeps its existing value, so changing + // just the secret (say) doesn't require re-entering the other two. Only + // when switching into idp from a different auth type is there no existing + // credential to fall back to, so all three are required in that case. + const switchingToIdp = isIdpAuth && devPortal.authType !== 'idp_client_credentials'; + const stsTokenUrlEntered = stsTokenUrl.trim() !== ''; + const clientIdEntered = clientId.trim() !== ''; + const clientSecretEntered = clientSecret.trim() !== ''; + const anyCredentialEntered = + stsTokenUrlEntered || clientIdEntered || clientSecretEntered; + const idpFieldsValid = + !isIdpAuth || + !switchingToIdp || + (stsTokenUrlEntered && clientIdEntered && clientSecretEntered); + // Gate on the fields actually having changed from the loaded record — not + // just "is currently valid" — otherwise Save starts enabled on page load + // with zero edits. Only compare once this record's fields have been seeded + // (seededId === devPortal.id), so the one render before the seeding effect + // runs can't momentarily read as dirty. + const isDirty = + seededId === devPortal.id && + (name !== devPortal.name || + description !== (devPortal.description || '') || + url !== (devPortal.url || '') || + authType !== devPortal.authType || + anyCredentialEntered); + const canSave = + isDirty && + name.trim() !== '' && + url.trim() !== '' && + idpFieldsValid && + !updateDevPortal.isPending; + + const save = () => { + updateDevPortal.mutate( + { + name, + url: url.trim(), + authType, + description: description || undefined, + // Only the fields the user actually typed into are sent — an + // untouched field must never overwrite the existing value with ''. + ...(isIdpAuth && stsTokenUrlEntered + ? { stsTokenUrl: stsTokenUrl.trim() } + : {}), + ...(isIdpAuth && clientIdEntered ? { clientId: clientId.trim() } : {}), + ...(isIdpAuth && clientSecretEntered ? { clientSecret } : {}), + }, + { + onSuccess: (updated) => { + notify(`Devportal "${updated.name}" updated.`, 'success'); + setStsTokenUrl(''); + setClientId(''); + setClientSecret(''); + }, + onError: (error) => + notify( + error instanceof Error ? error.message : 'Failed to update devportal', + 'error' + ), + } + ); + }; + + const cancel = () => { + setName(devPortal.name); + setDescription(devPortal.description || ''); + setUrl(devPortal.url || ''); + setAuthType(devPortal.authType); + setStsTokenUrl(''); + setClientId(''); + setClientSecret(''); + }; + + return ( + + + + Back to Dev Portal + + {devPortal.name} + {devPortal.url || devPortal.handle} + + + + {/* Editable form */} + + + + + Devportal settings + + + Update the devportal's details and authentication. + + + + + Name + setName(event.target.value)} + value={name} + /> + + + + Description (optional) + setDescription(event.target.value)} + value={description} + /> + + + + Authentication + + + + {isIdpAuth && ( + + {!switchingToIdp && ( + + Credentials are never displayed after saving. Leave a + field blank to keep its existing value — fill in only + the ones you want to change. + + )} + + + )} + + + URL + setUrl(event.target.value)} + placeholder="https://devportal.example.com" + value={url} + /> + + + + + + + + + + + + {/* Details */} + + + + + Details + + + + + + Handle: {devPortal.handle} + + {devPortal.createdAt && ( + + Created {relativeTime(devPortal.createdAt)} + + )} + + + + + + ); +} diff --git a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx index 62a1d48f49..3aa8f5abf4 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx @@ -57,28 +57,8 @@ import { } from '../../components/StateViews'; import { routes } from '../../routes/paths'; import { relativeTime } from '../../utils/relativeTime'; -import type { - DevPortal, - DevPortalAuthType, - DevPortalWorkflowStatus, -} from '../../types/domain'; - -const AUTH_LABEL: Record = { - local: 'Local', - idp_client_credentials: 'IdP Client Credentials', -}; - -const STATUS_LABEL: Record = { - pending: 'Pending', - active: 'Active', - failed: 'Failed', -}; - -const STATUS_COLOR: Record = { - pending: 'warning.main', - active: 'success.main', - failed: 'error.main', -}; +import type { DevPortal } from '../../types/domain'; +import { AUTH_LABEL, STATUS_COLOR, STATUS_LABEL } from './devPortalDisplay'; /** A small KPI summary tile. */ function StatCard({ @@ -125,9 +105,11 @@ function StatCard({ function DevPortalCard({ devPortal, + onOpen, onDelete, }: { devPortal: DevPortal; + onOpen: (devPortal: DevPortal) => void; onDelete?: (devPortal: DevPortal) => void; }) { const [copied, setCopied] = useState(false); @@ -164,12 +146,20 @@ function DevPortalCard({ return ( onOpen(devPortal)} sx={{ bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', borderRadius: 2, + cursor: 'pointer', p: 2.5, + transition: 'border-color .2s, box-shadow .2s, transform .2s', + '&:hover': { + borderColor: 'primary.main', + boxShadow: 3, + transform: 'translateY(-2px)', + }, }} > @@ -343,6 +333,8 @@ export function DevPortalPage() { const [toDelete, setToDelete] = useState(null); const provision = () => navigate(routes.newDevportal(orgHandle)); + const openDevPortal = (devPortal: DevPortal) => + navigate(routes.devportalDetail(orgHandle, devPortal.id)); const confirmDelete = () => { if (!toDelete) return; @@ -466,6 +458,7 @@ export function DevPortalPage() { devPortal={devPortal} key={devPortal.id} onDelete={setToDelete} + onOpen={openDevPortal} /> ))} diff --git a/portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx b/portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx new file mode 100644 index 0000000000..07aaf911fc --- /dev/null +++ b/portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx @@ -0,0 +1,125 @@ +/* + * 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. + */ + +import { useState } from 'react'; +import { + Box, + FormControl, + FormLabel, + IconButton, + InputAdornment, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { Eye, EyeOff } from '@wso2/oxygen-ui-icons-react'; + +type IdpCredentialsFieldsProps = { + stsTokenUrl: string; + onStsTokenUrlChange: (value: string) => void; + clientId: string; + onClientIdChange: (value: string) => void; + clientSecret: string; + onClientSecretChange: (value: string) => void; +}; + +/** + * Grouped STS token URL / client ID / client secret inputs shown once + * `authType` is `idp_client_credentials` — shared by the create and + * detail/edit devportal pages so the group never drifts between the two. + */ +export function IdpCredentialsFields({ + stsTokenUrl, + onStsTokenUrlChange, + clientId, + onClientIdChange, + clientSecret, + onClientSecretChange, +}: IdpCredentialsFieldsProps) { + const [secretVisible, setSecretVisible] = useState(false); + + return ( + + + IDP CLIENT CREDENTIALS + + + + STS token URL + onStsTokenUrlChange(event.target.value)} + placeholder="https://idp.example.com/oauth2/token" + value={stsTokenUrl} + /> + + + + Client ID + onClientIdChange(event.target.value)} + value={clientId} + /> + + + + Client secret + onClientSecretChange(event.target.value)} + slotProps={{ + input: { + endAdornment: ( + + setSecretVisible((v) => !v)} + size="small" + > + {secretVisible ? : } + + + ), + }, + }} + type={secretVisible ? 'text' : 'password'} + value={clientSecret} + /> + + + + ); +} diff --git a/portals/api-control-plane/src/features/devportal/devPortalDisplay.ts b/portals/api-control-plane/src/features/devportal/devPortalDisplay.ts new file mode 100644 index 0000000000..54d6bbddb6 --- /dev/null +++ b/portals/api-control-plane/src/features/devportal/devPortalDisplay.ts @@ -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. + */ + +import type { DevPortalAuthType, DevPortalWorkflowStatus } from '../../types/domain'; + +/** Shared across the list, create, and detail/edit pages so the three never drift. */ +export const AUTH_TYPE_OPTIONS: { value: DevPortalAuthType; label: string }[] = [ + { value: 'local', label: 'Local' }, + { value: 'idp_client_credentials', label: 'IdP Client Credentials' }, +]; + +export const AUTH_LABEL: Record = { + local: 'Local', + idp_client_credentials: 'IdP Client Credentials', +}; + +export const STATUS_LABEL: Record = { + pending: 'Pending', + active: 'Active', + failed: 'Failed', +}; + +export const STATUS_COLOR: Record = { + pending: 'warning.main', + active: 'success.main', + failed: 'error.main', +}; + +export const STATUS_CHIP_COLOR: Record< + DevPortalWorkflowStatus, + 'warning' | 'success' | 'error' +> = { + pending: 'warning', + active: 'success', + failed: 'error', +}; diff --git a/portals/api-control-plane/src/routes/AppRoutes.tsx b/portals/api-control-plane/src/routes/AppRoutes.tsx index 410e5f81ca..762a72d78d 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.tsx @@ -70,6 +70,11 @@ const DevPortalCreatePage = lazy(() => default: m.DevPortalCreatePage, })) ); +const DevPortalDetailPage = lazy(() => + import('../features/devportal/DevPortalDetailPage').then((m) => ({ + default: m.DevPortalDetailPage, + })) +); const ProjectHomePage = lazy(() => import('../features/projects/ProjectHomePage').then((m) => ({ default: m.ProjectHomePage, @@ -139,6 +144,10 @@ export function AppRoutes() { path={routes.newDevportal()} element={} /> + } + /> } /> } /> } /> diff --git a/portals/api-control-plane/src/routes/paths.ts b/portals/api-control-plane/src/routes/paths.ts index 27806fd336..bf29586362 100644 --- a/portals/api-control-plane/src/routes/paths.ts +++ b/portals/api-control-plane/src/routes/paths.ts @@ -38,6 +38,10 @@ export const routes = { `/organizations/${orgHandle}/devportal`, newDevportal: (orgHandle = ':orgHandle') => `/organizations/${orgHandle}/devportal/new`, + devportalDetail: ( + orgHandle = ':orgHandle', + devPortalId = ':devPortalId' + ) => `/organizations/${orgHandle}/devportal/${devPortalId}`, projectHome: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => `/organizations/${orgHandle}/projects/${projectHandler}/home`, apis: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => diff --git a/portals/api-control-plane/src/types/domain.ts b/portals/api-control-plane/src/types/domain.ts index a5819620ac..848617fd7c 100644 --- a/portals/api-control-plane/src/types/domain.ts +++ b/portals/api-control-plane/src/types/domain.ts @@ -276,6 +276,18 @@ export type CreateDevPortalInput = { clientSecret?: string; }; +/** `handle` is set at creation and not editable afterwards. */ +export type UpdateDevPortalInput = { + name: string; + url: string; + authType: DevPortalAuthType; + description?: string; + /** Required when authType is 'idp_client_credentials'. */ + stsTokenUrl?: string; + clientId?: string; + clientSecret?: string; +}; + /** * How the API definition is sourced on create. `scratch` builds an empty proxy; * the import variants create from an OpenAPI definition (URL or uploaded file). From 44ea907d2e98f86eccc128c6b5fded38b7646051 Mon Sep 17 00:00:00 2001 From: pranavansu Date: Mon, 10 Aug 2026 17:24:56 +0530 Subject: [PATCH 06/33] Fix menu interaction in DevPortalCard to prevent unintended opens --- .../src/features/devportal/DevPortalPage.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx index 3aa8f5abf4..cc8a3a4154 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx @@ -16,7 +16,7 @@ * under the License. */ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { alpha, Box, @@ -115,6 +115,13 @@ function DevPortalCard({ const [copied, setCopied] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); const statusColor = STATUS_COLOR[devPortal.workflowStatus]; + // MUI's Menu closes on an outside click via a document-level listener that + // doesn't reliably block the same click from also reaching this card's + // onClick underneath it — dismissing the menu by clicking just off it would + // otherwise also open the devportal. mousedown always fires before click, + // so capturing "was the menu open" there is a deterministic guard + // regardless of exactly when the menu's own close logic runs. + const wasMenuOpenRef = useRef(false); const copyUrl = (event: React.MouseEvent) => { event.stopPropagation(); @@ -146,7 +153,16 @@ function DevPortalCard({ return ( onOpen(devPortal)} + onClick={() => { + if (wasMenuOpenRef.current) { + wasMenuOpenRef.current = false; + return; + } + onOpen(devPortal); + }} + onMouseDown={() => { + wasMenuOpenRef.current = menuAnchor !== null; + }} sx={{ bgcolor: 'background.paper', border: '1px solid', From be6a7cbe1ca3cdca08ffca658037884cc90feec7 Mon Sep 17 00:00:00 2001 From: pranavansu Date: Mon, 10 Aug 2026 22:00:01 +0530 Subject: [PATCH 07/33] Validate URLs in Dev Portal forms and provide user feedback for invalid entries --- .../src/features/devportal/DevPortalCreatePage.tsx | 9 ++++++++- .../src/features/devportal/DevPortalDetailPage.tsx | 13 ++++++++++--- .../src/features/devportal/IdpCredentialsFields.tsx | 5 +++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx index 938446435d..d79b4fa7cd 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx @@ -35,6 +35,7 @@ import { useCreateDevPortal } from '../../api/hooks/useMvpQueries'; import { useNotifications } from '../../components/Notifications'; import { routes } from '../../routes/paths'; import type { DevPortalAuthType } from '../../types/domain'; +import { isValidUrl } from '../apis/develop/developEdit'; import { AUTH_TYPE_OPTIONS } from './devPortalDisplay'; import { IdpCredentialsFields } from './IdpCredentialsFields'; @@ -71,16 +72,18 @@ export function DevPortalCreatePage() { }; const handleValid = HANDLE_PATTERN.test(handle); + const urlValid = url.trim() !== '' && isValidUrl(url); const isIdpAuth = authType === 'idp_client_credentials'; const idpFieldsValid = !isIdpAuth || (stsTokenUrl.trim() !== '' && + isValidUrl(stsTokenUrl) && clientId.trim() !== '' && clientSecret.trim() !== ''); const canSubmit = displayName.trim() !== '' && handleValid && - url.trim() !== '' && + urlValid && idpFieldsValid && !createDevPortal.isPending; @@ -194,6 +197,10 @@ export function DevPortalCreatePage() { URL setUrl(event.target.value)} placeholder="https://devportal.example.com" value={url} diff --git a/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx index 53e7c98a7a..f13618b442 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx @@ -41,6 +41,7 @@ import { ErrorState, LoadingState } from '../../components/StateViews'; import { routes } from '../../routes/paths'; import type { DevPortal, DevPortalAuthType } from '../../types/domain'; import { relativeTime } from '../../utils/relativeTime'; +import { isValidUrl } from '../apis/develop/developEdit'; import { AUTH_TYPE_OPTIONS, STATUS_CHIP_COLOR, @@ -99,15 +100,17 @@ export function DevPortalDetailPage() { // when switching into idp from a different auth type is there no existing // credential to fall back to, so all three are required in that case. const switchingToIdp = isIdpAuth && devPortal.authType !== 'idp_client_credentials'; + const urlValid = url.trim() !== '' && isValidUrl(url); const stsTokenUrlEntered = stsTokenUrl.trim() !== ''; + const stsTokenUrlValid = !stsTokenUrlEntered || isValidUrl(stsTokenUrl); const clientIdEntered = clientId.trim() !== ''; const clientSecretEntered = clientSecret.trim() !== ''; const anyCredentialEntered = stsTokenUrlEntered || clientIdEntered || clientSecretEntered; const idpFieldsValid = !isIdpAuth || - !switchingToIdp || - (stsTokenUrlEntered && clientIdEntered && clientSecretEntered); + (stsTokenUrlValid && + (!switchingToIdp || (stsTokenUrlEntered && clientIdEntered && clientSecretEntered))); // Gate on the fields actually having changed from the loaded record — not // just "is currently valid" — otherwise Save starts enabled on page load // with zero edits. Only compare once this record's fields have been seeded @@ -123,7 +126,7 @@ export function DevPortalDetailPage() { const canSave = isDirty && name.trim() !== '' && - url.trim() !== '' && + urlValid && idpFieldsValid && !updateDevPortal.isPending; @@ -249,6 +252,10 @@ export function DevPortalDetailPage() { URL setUrl(event.target.value)} placeholder="https://devportal.example.com" value={url} diff --git a/portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx b/portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx index 07aaf911fc..5c11abe482 100644 --- a/portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx +++ b/portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx @@ -29,6 +29,8 @@ import { } from '@wso2/oxygen-ui'; import { Eye, EyeOff } from '@wso2/oxygen-ui-icons-react'; +import { isValidUrl } from '../apis/develop/developEdit'; + type IdpCredentialsFieldsProps = { stsTokenUrl: string; onStsTokenUrlChange: (value: string) => void; @@ -52,6 +54,7 @@ export function IdpCredentialsFields({ onClientSecretChange, }: IdpCredentialsFieldsProps) { const [secretVisible, setSecretVisible] = useState(false); + const stsTokenUrlInvalid = stsTokenUrl.trim() !== '' && !isValidUrl(stsTokenUrl); return ( STS token URL onStsTokenUrlChange(event.target.value)} placeholder="https://idp.example.com/oauth2/token" value={stsTokenUrl} From 99f4bf66944a20dbc6c196a5e77be1654e68fce2 Mon Sep 17 00:00:00 2001 From: pranavansu Date: Mon, 10 Aug 2026 22:07:04 +0530 Subject: [PATCH 08/33] Add stsTokenUrl and clientId to DevPortal type and update related functionality --- portals/api-control-plane/src/api/adapters.ts | 2 + .../src/api/devportal/devPortalClient.ts | 13 +++-- .../devportal/DevPortalDetailPage.tsx | 57 ++++++++++--------- portals/api-control-plane/src/types/domain.ts | 7 +++ 4 files changed, 47 insertions(+), 32 deletions(-) diff --git a/portals/api-control-plane/src/api/adapters.ts b/portals/api-control-plane/src/api/adapters.ts index d01ed14d58..b5bdb1c101 100644 --- a/portals/api-control-plane/src/api/adapters.ts +++ b/portals/api-control-plane/src/api/adapters.ts @@ -340,6 +340,8 @@ export const toDevPortal = (value: unknown): DevPortal => { workflowStatus: asDevPortalWorkflowStatus(source.workflowStatus), authType: asDevPortalAuthType(source.authType), createdAt: asOptionalString(source.createdAt), + stsTokenUrl: asOptionalString(source.stsTokenUrl), + clientId: asOptionalString(source.clientId), }; }; diff --git a/portals/api-control-plane/src/api/devportal/devPortalClient.ts b/portals/api-control-plane/src/api/devportal/devPortalClient.ts index 3c3b60aa30..3b1a19b8b9 100644 --- a/portals/api-control-plane/src/api/devportal/devPortalClient.ts +++ b/portals/api-control-plane/src/api/devportal/devPortalClient.ts @@ -44,9 +44,9 @@ export async function getDevPortal(id: string): Promise { export async function createDevPortal( input: CreateDevPortalInput ): Promise { - // Picked explicitly (not `...input`) so idp_client_credentials secrets - // (stsTokenUrl/clientId/clientSecret) never end up on the stored/returned - // record — those are write-only, forwarded to the real backend once it exists. + // Picked explicitly (not `...input`) so `clientSecret` — the one genuinely + // write-only field — never ends up on the stored/returned record. + // stsTokenUrl/clientId are not secret and are stored/returned normally. const devPortal: DevPortal = { id: input.handle, name: input.name, @@ -54,6 +54,8 @@ export async function createDevPortal( description: input.description, url: input.url, authType: input.authType, + stsTokenUrl: input.stsTokenUrl, + clientId: input.clientId, workflowStatus: 'pending', createdAt: new Date().toISOString(), }; @@ -69,14 +71,15 @@ export async function updateDevPortal( if (index < 0) { throw new ApiError('Devportal not found', 'NOT_FOUND', 404); } - // Picked explicitly, same reasoning as createDevPortal: idp_client_credentials - // secrets are write-only and never end up on the stored/returned record. + // Same reasoning as createDevPortal: only clientSecret is excluded. const updated: DevPortal = { ...devPortals[index], name: input.name, description: input.description, url: input.url, authType: input.authType, + stsTokenUrl: input.stsTokenUrl, + clientId: input.clientId, }; devPortals[index] = updated; return toDevPortal(updated); diff --git a/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx index f13618b442..300f9dc613 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalDetailPage.tsx @@ -74,8 +74,11 @@ export function DevPortalDetailPage() { setDescription(devPortal.description || ''); setUrl(devPortal.url || ''); setAuthType(devPortal.authType); - setStsTokenUrl(''); - setClientId(''); + // stsTokenUrl/clientId aren't secret and are returned by the backend, so + // they seed from the record like any other field. clientSecret is the + // one write-only field — it always seeds blank. + setStsTokenUrl(devPortal.stsTokenUrl || ''); + setClientId(devPortal.clientId || ''); setClientSecret(''); setSeededId(devPortal.id); }, [devPortalQuery.data, seededId]); @@ -92,25 +95,23 @@ export function DevPortalDetailPage() { const devPortal: DevPortal = devPortalQuery.data; const isIdpAuth = authType === 'idp_client_credentials'; - // Credentials are never returned by the backend (write-only), so an - // already-idp devportal always reloads with these three fields blank. - // Once idp is already the active auth type, each field updates - // independently — leaving one blank keeps its existing value, so changing - // just the secret (say) doesn't require re-entering the other two. Only - // when switching into idp from a different auth type is there no existing - // credential to fall back to, so all three are required in that case. + // stsTokenUrl/clientId aren't secret — they're stored/returned and behave + // like any other required field once idp is active. clientSecret is the + // one write-only field: it's never returned, so blank means "keep the + // existing secret" whenever one already exists — only when switching into + // idp from a different auth type is there no existing secret to fall back + // to, so it's required in that case. const switchingToIdp = isIdpAuth && devPortal.authType !== 'idp_client_credentials'; const urlValid = url.trim() !== '' && isValidUrl(url); const stsTokenUrlEntered = stsTokenUrl.trim() !== ''; - const stsTokenUrlValid = !stsTokenUrlEntered || isValidUrl(stsTokenUrl); + const stsTokenUrlValid = stsTokenUrlEntered && isValidUrl(stsTokenUrl); const clientIdEntered = clientId.trim() !== ''; const clientSecretEntered = clientSecret.trim() !== ''; - const anyCredentialEntered = - stsTokenUrlEntered || clientIdEntered || clientSecretEntered; const idpFieldsValid = !isIdpAuth || (stsTokenUrlValid && - (!switchingToIdp || (stsTokenUrlEntered && clientIdEntered && clientSecretEntered))); + clientIdEntered && + (!switchingToIdp || clientSecretEntered)); // Gate on the fields actually having changed from the loaded record — not // just "is currently valid" — otherwise Save starts enabled on page load // with zero edits. Only compare once this record's fields have been seeded @@ -122,7 +123,9 @@ export function DevPortalDetailPage() { description !== (devPortal.description || '') || url !== (devPortal.url || '') || authType !== devPortal.authType || - anyCredentialEntered); + stsTokenUrl !== (devPortal.stsTokenUrl || '') || + clientId !== (devPortal.clientId || '') || + clientSecretEntered); const canSave = isDirty && name.trim() !== '' && @@ -137,19 +140,19 @@ export function DevPortalDetailPage() { url: url.trim(), authType, description: description || undefined, - // Only the fields the user actually typed into are sent — an - // untouched field must never overwrite the existing value with ''. - ...(isIdpAuth && stsTokenUrlEntered - ? { stsTokenUrl: stsTokenUrl.trim() } + ...(isIdpAuth + ? { + stsTokenUrl: stsTokenUrl.trim(), + clientId: clientId.trim(), + // Only sent when the user actually typed a new one — blank + // must never overwrite the existing secret with ''. + ...(clientSecretEntered ? { clientSecret } : {}), + } : {}), - ...(isIdpAuth && clientIdEntered ? { clientId: clientId.trim() } : {}), - ...(isIdpAuth && clientSecretEntered ? { clientSecret } : {}), }, { onSuccess: (updated) => { notify(`Devportal "${updated.name}" updated.`, 'success'); - setStsTokenUrl(''); - setClientId(''); setClientSecret(''); }, onError: (error) => @@ -166,8 +169,8 @@ export function DevPortalDetailPage() { setDescription(devPortal.description || ''); setUrl(devPortal.url || ''); setAuthType(devPortal.authType); - setStsTokenUrl(''); - setClientId(''); + setStsTokenUrl(devPortal.stsTokenUrl || ''); + setClientId(devPortal.clientId || ''); setClientSecret(''); }; @@ -233,9 +236,9 @@ export function DevPortalDetailPage() { {!switchingToIdp && ( - Credentials are never displayed after saving. Leave a - field blank to keep its existing value — fill in only - the ones you want to change. + Client secret is never displayed after saving — leave + it blank to keep the existing one, or enter a new + value to replace it. )} Date: Mon, 10 Aug 2026 22:16:54 +0530 Subject: [PATCH 09/33] Rename form labels for clarity in DevPortal creation and detail pages --- .../src/features/devportal/DevPortalCreatePage.tsx | 4 ++-- .../src/features/devportal/DevPortalDetailPage.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx index d79b4fa7cd..f8fd371794 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx @@ -134,7 +134,7 @@ export function DevPortalCreatePage() { - Display name + Name onDisplayNameChange(event.target.value)} placeholder="Production Devportal" @@ -143,7 +143,7 @@ export function DevPortalCreatePage() { - Name + Identifier - Handle: {devPortal.handle} + Identifier: {devPortal.handle} {devPortal.createdAt && ( Date: Mon, 10 Aug 2026 23:04:16 +0530 Subject: [PATCH 10/33] Add identifier editing and view toggle functionality in DevPortal creation and listing --- .../devportal/DevPortalCreatePage.tsx | 28 +- .../src/features/devportal/DevPortalPage.tsx | 253 ++++++++++++++++-- 2 files changed, 264 insertions(+), 17 deletions(-) diff --git a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx index f8fd371794..1a07a0ac3e 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx @@ -22,13 +22,17 @@ import { Button, FormControl, FormLabel, + IconButton, + InputAdornment, MenuItem, PageContent, PageTitle, Select, Stack, TextField, + Tooltip, } from '@wso2/oxygen-ui'; +import { Pencil } from '@wso2/oxygen-ui-icons-react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { useCreateDevPortal } from '../../api/hooks/useMvpQueries'; @@ -57,6 +61,11 @@ export function DevPortalCreatePage() { const [displayName, setDisplayName] = useState(''); const [handle, setHandle] = useState(''); const [handleEdited, setHandleEdited] = useState(false); + // Auto-derived from Name and locked by default; the identifier is + // permanent once the devportal is created, so editing it directly is an + // explicit, deliberate action rather than something you fall into while + // typing the Name. + const [handleLocked, setHandleLocked] = useState(true); const [description, setDescription] = useState(''); const [authType, setAuthType] = useState( AUTH_TYPE_OPTIONS[0].value @@ -145,13 +154,30 @@ export function DevPortalCreatePage() { Identifier { setHandleEdited(true); setHandle(event.target.value); }} placeholder="prod-devportal" + slotProps={{ + input: { + endAdornment: handleLocked && ( + + + setHandleLocked(false)} + size="small" + > + + + + + ), + }, + }} value={handle} /> diff --git a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx index cc8a3a4154..be9f2baa87 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx @@ -21,6 +21,7 @@ import { alpha, Box, Button, + Card, IconButton, InputAdornment, ListItemIcon, @@ -31,6 +32,8 @@ import { PageTitle, Stack, TextField, + ToggleButton, + ToggleButtonGroup, Tooltip, Typography, } from '@wso2/oxygen-ui'; @@ -39,6 +42,8 @@ import { Clock, Copy, Globe, + LayoutGrid, + List, MoreVertical, Plus, Search, @@ -60,6 +65,8 @@ import { relativeTime } from '../../utils/relativeTime'; import type { DevPortal } from '../../types/domain'; import { AUTH_LABEL, STATUS_COLOR, STATUS_LABEL } from './devPortalDisplay'; +type ViewMode = 'grid' | 'list'; + /** A small KPI summary tile. */ function StatCard({ label, @@ -339,6 +346,188 @@ function DevPortalCard({ ); } +function DevPortalRow({ + devPortal, + onOpen, + onDelete, +}: { + devPortal: DevPortal; + onOpen: (devPortal: DevPortal) => void; + onDelete?: (devPortal: DevPortal) => void; +}) { + const [menuAnchor, setMenuAnchor] = useState(null); + const statusColor = STATUS_COLOR[devPortal.workflowStatus]; + // Same click-away guard as DevPortalCard — see the comment there. + const wasMenuOpenRef = useRef(false); + + const closeMenu = (event?: React.MouseEvent) => { + event?.stopPropagation(); + setMenuAnchor(null); + }; + + return ( + { + if (wasMenuOpenRef.current) { + wasMenuOpenRef.current = false; + return; + } + onOpen(devPortal); + }} + onMouseDown={() => { + wasMenuOpenRef.current = menuAnchor !== null; + }} + sx={{ + alignItems: 'center', + borderBottom: '1px solid', + borderColor: 'divider', + cursor: 'pointer', + display: 'flex', + gap: 2, + px: 2.5, + py: 1.75, + transition: 'background-color 250ms', + '&:hover': { bgcolor: 'action.hover' }, + '&:last-of-type': { borderBottom: 0 }, + }} + > + + + + + + {devPortal.name} + + + {devPortal.url || devPortal.handle} + + + + + + {AUTH_LABEL[devPortal.authType]} + + + + + + {STATUS_LABEL[devPortal.workflowStatus]} + + + + + + {devPortal.createdAt ? relativeTime(devPortal.createdAt) : '—'} + + + {onDelete && ( + <> + { + event.stopPropagation(); + setMenuAnchor(event.currentTarget); + }} + size="small" + sx={{ ml: { md: 0, xs: 'auto' } }} + > + + + closeMenu()} + open={Boolean(menuAnchor)} + > + { + closeMenu(event); + onDelete(devPortal); + }} + sx={{ color: 'error.main' }} + > + + + + Delete + + + + )} + + ); +} + +/** Compact row layout for devportals — the list-view counterpart of the card grid. */ +function DevPortalListView({ + devPortals, + onOpen, + onDelete, +}: { + devPortals: DevPortal[]; + onOpen: (devPortal: DevPortal) => void; + onDelete?: (devPortal: DevPortal) => void; +}) { + return ( + + {devPortals.map((devPortal) => ( + + ))} + + ); +} + export function DevPortalPage() { const { orgHandle = '' } = useParams(); const navigate = useNavigate(); @@ -346,6 +535,7 @@ export function DevPortalPage() { const devPortalsQuery = useDevPortals(); const deleteDevPortalMutation = useDeleteDevPortal(); const [search, setSearch] = useState(''); + const [view, setView] = useState('grid'); const [toDelete, setToDelete] = useState(null); const provision = () => navigate(routes.newDevportal(orgHandle)); @@ -439,29 +629,54 @@ export function DevPortalPage() { {/* Toolbar */} - setSearch(event.target.value)} - placeholder="Search Dev Portal" - size="small" - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, + + > + setSearch(event.target.value)} + placeholder="Search Dev Portal" + size="small" + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ flex: 1, maxWidth: 420, minWidth: 240 }} + value={search} + /> + { + if (value) setView(value); + }} + size="small" + sx={{ ml: 'auto' }} + value={view} + > + + + + + + + + {filtered.length === 0 ? ( - ) : ( + ) : view === 'grid' ? ( ))} + ) : ( + )} )} From 8eecbbe68d46acd582a427c2a8866c40025905da Mon Sep 17 00:00:00 2001 From: pranavansu Date: Tue, 11 Aug 2026 11:33:45 +0530 Subject: [PATCH 11/33] Add helper text for handle input validation in DevPortal creation --- .../src/features/devportal/DevPortalCreatePage.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx index 1a07a0ac3e..9f01d12633 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx @@ -156,6 +156,11 @@ export function DevPortalCreatePage() { { setHandleEdited(true); setHandle(event.target.value); From 4c754cd5b85833fba63705a2ce0e0b4fbb55ed23 Mon Sep 17 00:00:00 2001 From: pranavansu Date: Tue, 11 Aug 2026 11:42:50 +0530 Subject: [PATCH 12/33] Make identifier field read-only when locked in DevPortal creation --- .../src/features/devportal/DevPortalCreatePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx index 9f01d12633..223b334179 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx +++ b/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx @@ -154,7 +154,6 @@ export function DevPortalCreatePage() { Identifier ), + readOnly: handleLocked, }, }} value={handle} From 9b88830cec88322bf8782eb85f8d2a5d6f771ab8 Mon Sep 17 00:00:00 2001 From: pranavansu Date: Thu, 13 Aug 2026 14:11:26 +0530 Subject: [PATCH 13/33] Refactored devportal to api-portal in the newly added UI --- .../src/api/ApiClientProvider.tsx | 22 +-- portals/api-control-plane/src/api/adapters.ts | 30 +-- .../apiPortalClient.ts} | 64 +++--- .../src/api/hooks/useMvpQueries.ts | 56 +++--- .../api-control-plane/src/api/mocks/data.ts | 4 +- portals/api-control-plane/src/api/mvpApi.ts | 12 +- .../ApiPortalCreatePage.tsx} | 46 ++--- .../ApiPortalDetailPage.tsx} | 120 ++++++------ .../ApiPortalPage.tsx} | 182 +++++++++--------- .../IdpCredentialsFields.tsx | 2 +- .../apiPortalDisplay.ts} | 12 +- .../src/navigation/navigationRegistry.tsx | 8 +- .../src/routes/AppRoutes.tsx | 28 +-- portals/api-control-plane/src/routes/paths.ts | 14 +- portals/api-control-plane/src/types/domain.ts | 24 +-- 15 files changed, 312 insertions(+), 312 deletions(-) rename portals/api-control-plane/src/api/{devportal/devPortalClient.ts => apiportal/apiPortalClient.ts} (55%) rename portals/api-control-plane/src/features/{devportal/DevPortalCreatePage.tsx => apiportal/ApiPortalCreatePage.tsx} (85%) rename portals/api-control-plane/src/features/{devportal/DevPortalDetailPage.tsx => apiportal/ApiPortalDetailPage.tsx} (74%) rename portals/api-control-plane/src/features/{devportal/DevPortalPage.tsx => apiportal/ApiPortalPage.tsx} (80%) rename portals/api-control-plane/src/features/{devportal => apiportal}/IdpCredentialsFields.tsx (98%) rename portals/api-control-plane/src/features/{devportal/devPortalDisplay.ts => apiportal/apiPortalDisplay.ts} (78%) diff --git a/portals/api-control-plane/src/api/ApiClientProvider.tsx b/portals/api-control-plane/src/api/ApiClientProvider.tsx index 7f4f171479..73c030ac3c 100644 --- a/portals/api-control-plane/src/api/ApiClientProvider.tsx +++ b/portals/api-control-plane/src/api/ApiClientProvider.tsx @@ -21,26 +21,26 @@ import { createContext, type ReactNode, useContext } from 'react'; import { createApi, createApiKey, - createDevPortal, + createApiPortal, createGateway, createGatewayToken, createProject, deleteApi, - deleteDevPortal, + deleteApiPortal, deleteGatewayDeployment, deleteProject, deployApi, getApi, getApiDetail, getApiProxy, - getDevPortal, + getApiPortal, getGateway, getOrganization, getProject, listApiKeys, listApis, listDeployments, - listDevPortals, + listApiPortals, listEnvironments, listGatewayDeployments, listGateways, @@ -50,7 +50,7 @@ import { revokeApiKey, undeployGatewayDeployment, updateApi, - updateDevPortal, + updateApiPortal, } from './mvpApi'; import { getPolicyDefinition, @@ -84,12 +84,12 @@ export const realApiClient = { getGateway, createGateway, createGatewayToken, - // dev portals - listDevPortals, - getDevPortal, - createDevPortal, - updateDevPortal, - deleteDevPortal, + // API Portals + listApiPortals, + getApiPortal, + createApiPortal, + updateApiPortal, + deleteApiPortal, // gateway deployments (the deploy path) listGatewayDeployments, deployApi, diff --git a/portals/api-control-plane/src/api/adapters.ts b/portals/api-control-plane/src/api/adapters.ts index b5bdb1c101..72a83653f2 100644 --- a/portals/api-control-plane/src/api/adapters.ts +++ b/portals/api-control-plane/src/api/adapters.ts @@ -22,9 +22,9 @@ import type { ApiKind, ApiStatus, Deployment, - DevPortal, - DevPortalAuthType, - DevPortalWorkflowStatus, + ApiPortal, + ApiPortalAuthType, + ApiPortalWorkflowStatus, Environment, Gateway, GatewayDeployment, @@ -301,44 +301,44 @@ export const toGateway = (value: unknown): Gateway => { }; }; -const DEV_PORTAL_AUTH_TYPES: DevPortalAuthType[] = [ +const DEV_PORTAL_AUTH_TYPES: ApiPortalAuthType[] = [ 'local', 'idp_client_credentials', ]; -const asDevPortalAuthType = (value: unknown): DevPortalAuthType => { +const asApiPortalAuthType = (value: unknown): ApiPortalAuthType => { const normalized = asString(value).toLowerCase(); - return DEV_PORTAL_AUTH_TYPES.includes(normalized as DevPortalAuthType) - ? (normalized as DevPortalAuthType) + return DEV_PORTAL_AUTH_TYPES.includes(normalized as ApiPortalAuthType) + ? (normalized as ApiPortalAuthType) : DEV_PORTAL_AUTH_TYPES[0]; }; -const DEV_PORTAL_WORKFLOW_STATUSES: DevPortalWorkflowStatus[] = [ +const DEV_PORTAL_WORKFLOW_STATUSES: ApiPortalWorkflowStatus[] = [ 'pending', 'active', 'failed', ]; -const asDevPortalWorkflowStatus = (value: unknown): DevPortalWorkflowStatus => { +const asApiPortalWorkflowStatus = (value: unknown): ApiPortalWorkflowStatus => { const normalized = asString(value).toLowerCase(); return DEV_PORTAL_WORKFLOW_STATUSES.includes( - normalized as DevPortalWorkflowStatus + normalized as ApiPortalWorkflowStatus ) - ? (normalized as DevPortalWorkflowStatus) + ? (normalized as ApiPortalWorkflowStatus) : DEV_PORTAL_WORKFLOW_STATUSES[0]; }; -export const toDevPortal = (value: unknown): DevPortal => { +export const toApiPortal = (value: unknown): ApiPortal => { const source = asRecord(value); - const name = asString(source.name, 'unknown-devportal'); + const name = asString(source.name, 'unknown-api-portal'); return { id: asString(source.id, name), name, handle: asString(source.handle, name), description: asOptionalString(source.description), url: asOptionalString(source.url), - workflowStatus: asDevPortalWorkflowStatus(source.workflowStatus), - authType: asDevPortalAuthType(source.authType), + workflowStatus: asApiPortalWorkflowStatus(source.workflowStatus), + authType: asApiPortalAuthType(source.authType), createdAt: asOptionalString(source.createdAt), stsTokenUrl: asOptionalString(source.stsTokenUrl), clientId: asOptionalString(source.clientId), diff --git a/portals/api-control-plane/src/api/devportal/devPortalClient.ts b/portals/api-control-plane/src/api/apiportal/apiPortalClient.ts similarity index 55% rename from portals/api-control-plane/src/api/devportal/devPortalClient.ts rename to portals/api-control-plane/src/api/apiportal/apiPortalClient.ts index 3b1a19b8b9..f12670319f 100644 --- a/portals/api-control-plane/src/api/devportal/devPortalClient.ts +++ b/portals/api-control-plane/src/api/apiportal/apiPortalClient.ts @@ -17,37 +17,37 @@ */ import type { - CreateDevPortalInput, - DevPortal, - UpdateDevPortalInput, + CreateApiPortalInput, + ApiPortal, + UpdateApiPortalInput, } from '../../types/domain'; -import { toDevPortal } from '../adapters'; -import { devPortals } from '../mocks/data'; +import { toApiPortal } from '../adapters'; +import { apiPortals } from '../mocks/data'; import { ApiError } from '../types/errors'; /** - * Devportal management has no platform-api backend yet (console-only feature + * API Portal management has no platform-api backend yet (console-only feature * for now), so this always operates on the in-memory mock store — unlike * gatewayClient, there is no real REST endpoint to call. Swap this for a real - * client (platformGet/platformPost against DevPortalResponse) once + * client (platformGet/platformPost against ApiPortalResponse) once * platform-api adds one. */ -export async function listDevPortals(): Promise { - return devPortals.map(toDevPortal); +export async function listApiPortals(): Promise { + return apiPortals.map(toApiPortal); } -export async function getDevPortal(id: string): Promise { - const found = devPortals.find((item) => item.id === id); - return found ? toDevPortal(found) : undefined; +export async function getApiPortal(id: string): Promise { + const found = apiPortals.find((item) => item.id === id); + return found ? toApiPortal(found) : undefined; } -export async function createDevPortal( - input: CreateDevPortalInput -): Promise { +export async function createApiPortal( + input: CreateApiPortalInput +): Promise { // Picked explicitly (not `...input`) so `clientSecret` — the one genuinely // write-only field — never ends up on the stored/returned record. // stsTokenUrl/clientId are not secret and are stored/returned normally. - const devPortal: DevPortal = { + const apiPortal: ApiPortal = { id: input.handle, name: input.name, handle: input.handle, @@ -59,21 +59,21 @@ export async function createDevPortal( workflowStatus: 'pending', createdAt: new Date().toISOString(), }; - devPortals.push(devPortal); - return toDevPortal(devPortal); + apiPortals.push(apiPortal); + return toApiPortal(apiPortal); } -export async function updateDevPortal( +export async function updateApiPortal( id: string, - input: UpdateDevPortalInput -): Promise { - const index = devPortals.findIndex((item) => item.id === id); + input: UpdateApiPortalInput +): Promise { + const index = apiPortals.findIndex((item) => item.id === id); if (index < 0) { - throw new ApiError('Devportal not found', 'NOT_FOUND', 404); + throw new ApiError('API Portal not found', 'NOT_FOUND', 404); } - // Same reasoning as createDevPortal: only clientSecret is excluded. - const updated: DevPortal = { - ...devPortals[index], + // Same reasoning as createApiPortal: only clientSecret is excluded. + const updated: ApiPortal = { + ...apiPortals[index], name: input.name, description: input.description, url: input.url, @@ -81,14 +81,14 @@ export async function updateDevPortal( stsTokenUrl: input.stsTokenUrl, clientId: input.clientId, }; - devPortals[index] = updated; - return toDevPortal(updated); + apiPortals[index] = updated; + return toApiPortal(updated); } -export async function deleteDevPortal(id: string): Promise { - const index = devPortals.findIndex((item) => item.id === id); +export async function deleteApiPortal(id: string): Promise { + const index = apiPortals.findIndex((item) => item.id === id); if (index < 0) { - throw new ApiError('Devportal not found', 'NOT_FOUND', 404); + throw new ApiError('API Portal not found', 'NOT_FOUND', 404); } - devPortals.splice(index, 1); + apiPortals.splice(index, 1); } diff --git a/portals/api-control-plane/src/api/hooks/useMvpQueries.ts b/portals/api-control-plane/src/api/hooks/useMvpQueries.ts index 9d96f98a4b..1b6db93358 100644 --- a/portals/api-control-plane/src/api/hooks/useMvpQueries.ts +++ b/portals/api-control-plane/src/api/hooks/useMvpQueries.ts @@ -25,14 +25,14 @@ import type { ApiDetail, CreateApiInput, CreateApiKeyInput, - CreateDevPortalInput, + CreateApiPortalInput, CreateGatewayInput, CreateProjectInput, DeployApiInput, - DevPortal, + ApiPortal, GatewayDeployment, Project, - UpdateDevPortalInput, + UpdateApiPortalInput, } from '../../types/domain'; import { useApiClient } from '../ApiClientProvider'; @@ -61,9 +61,9 @@ export const queryKeys = { gateways: (orgHandle: string) => ['gateways', orgHandle] as const, gateway: (orgHandle: string, gatewayId: string) => ['gateway', orgHandle, gatewayId] as const, - devPortals: (orgHandle: string) => ['devPortals', orgHandle] as const, - devPortal: (orgHandle: string, devPortalId: string) => - ['devPortal', orgHandle, devPortalId] as const, + apiPortals: (orgHandle: string) => ['apiPortals', orgHandle] as const, + apiPortal: (orgHandle: string, apiPortalId: string) => + ['apiPortal', orgHandle, apiPortalId] as const, }; /** @@ -538,80 +538,80 @@ export const useCreateGatewayToken = ( }); }; -export const useDevPortals = (orgHandleArg?: string) => { +export const useApiPortals = (orgHandleArg?: string) => { const client = useApiClient(); const { orgHandle } = useScopeArgs(orgHandleArg); return useQuery({ - queryKey: queryKeys.devPortals(orgHandle || ''), + queryKey: queryKeys.apiPortals(orgHandle || ''), queryFn: () => { if (!orgHandle) { - throw new Error('orgHandle is required to list dev portals'); + throw new Error('orgHandle is required to list API Portals'); } - return client.listDevPortals(); + return client.listApiPortals(); }, enabled: !!orgHandle, }); }; -export const useDevPortal = (orgHandleArg?: string, devPortalId?: string) => { +export const useApiPortal = (orgHandleArg?: string, apiPortalId?: string) => { const client = useApiClient(); const { orgHandle } = useScopeArgs(orgHandleArg); return useQuery({ - queryKey: queryKeys.devPortal(orgHandle || '', devPortalId || ''), + queryKey: queryKeys.apiPortal(orgHandle || '', apiPortalId || ''), queryFn: () => { - if (!devPortalId) { - throw new Error('devPortalId is required to fetch a dev portal'); + if (!apiPortalId) { + throw new Error('apiPortalId is required to fetch an API Portal'); } - return client.getDevPortal(devPortalId); + return client.getApiPortal(apiPortalId); }, - enabled: !!orgHandle && !!devPortalId, + enabled: !!orgHandle && !!apiPortalId, }); }; -export const useCreateDevPortal = (orgHandleArg?: string) => { +export const useCreateApiPortal = (orgHandleArg?: string) => { const client = useApiClient(); const queryClient = useQueryClient(); const { orgHandle = '' } = useScopeArgs(orgHandleArg); return useMutation({ - mutationFn: (input: CreateDevPortalInput) => client.createDevPortal(input), + mutationFn: (input: CreateApiPortalInput) => client.createApiPortal(input), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: queryKeys.devPortals(orgHandle), + queryKey: queryKeys.apiPortals(orgHandle), }); }, }); }; -export const useUpdateDevPortal = ( +export const useUpdateApiPortal = ( orgHandleArg?: string, - devPortalId = '' + apiPortalId = '' ) => { const client = useApiClient(); const queryClient = useQueryClient(); const { orgHandle = '' } = useScopeArgs(orgHandleArg); return useMutation({ - mutationFn: (input: UpdateDevPortalInput) => - client.updateDevPortal(devPortalId, input), + mutationFn: (input: UpdateApiPortalInput) => + client.updateApiPortal(apiPortalId, input), onSuccess: (updated) => { queryClient.invalidateQueries({ - queryKey: queryKeys.devPortal(orgHandle, updated.id), + queryKey: queryKeys.apiPortal(orgHandle, updated.id), }); queryClient.invalidateQueries({ - queryKey: queryKeys.devPortals(orgHandle), + queryKey: queryKeys.apiPortals(orgHandle), }); }, }); }; -export const useDeleteDevPortal = (orgHandleArg?: string) => { +export const useDeleteApiPortal = (orgHandleArg?: string) => { const client = useApiClient(); const queryClient = useQueryClient(); const { orgHandle = '' } = useScopeArgs(orgHandleArg); return useMutation({ - mutationFn: (devPortal: DevPortal) => client.deleteDevPortal(devPortal.id), + mutationFn: (apiPortal: ApiPortal) => client.deleteApiPortal(apiPortal.id), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: queryKeys.devPortals(orgHandle), + queryKey: queryKeys.apiPortals(orgHandle), }); }, }); diff --git a/portals/api-control-plane/src/api/mocks/data.ts b/portals/api-control-plane/src/api/mocks/data.ts index 8eb57ae8e3..e91cb7e28e 100644 --- a/portals/api-control-plane/src/api/mocks/data.ts +++ b/portals/api-control-plane/src/api/mocks/data.ts @@ -20,7 +20,7 @@ import type { ApiProxy, Api, Deployment, - DevPortal, + ApiPortal, Environment, Gateway, Organization, @@ -126,7 +126,7 @@ export const gateways: Gateway[] = [ }, ]; -export const devPortals: DevPortal[] = []; +export const apiPortals: ApiPortal[] = []; export const apiProxies: ApiProxy[] = [ { diff --git a/portals/api-control-plane/src/api/mvpApi.ts b/portals/api-control-plane/src/api/mvpApi.ts index eb0211646d..d24ab1f03c 100644 --- a/portals/api-control-plane/src/api/mvpApi.ts +++ b/portals/api-control-plane/src/api/mvpApi.ts @@ -45,12 +45,12 @@ export { listGateways, } from './gateways/gatewayClient'; export { - createDevPortal, - deleteDevPortal, - getDevPortal, - listDevPortals, - updateDevPortal, -} from './devportal/devPortalClient'; + createApiPortal, + deleteApiPortal, + getApiPortal, + listApiPortals, + updateApiPortal, +} from './apiportal/apiPortalClient'; export { listEnvironments } from './environments/environmentClient'; export { getOrganization, diff --git a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.tsx similarity index 85% rename from portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx rename to portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.tsx index 223b334179..76ff6ecdb4 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalCreatePage.tsx +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.tsx @@ -35,12 +35,12 @@ import { import { Pencil } from '@wso2/oxygen-ui-icons-react'; import { Link, useNavigate, useParams } from 'react-router-dom'; -import { useCreateDevPortal } from '../../api/hooks/useMvpQueries'; +import { useCreateApiPortal } from '../../api/hooks/useMvpQueries'; import { useNotifications } from '../../components/Notifications'; import { routes } from '../../routes/paths'; -import type { DevPortalAuthType } from '../../types/domain'; +import type { ApiPortalAuthType } from '../../types/domain'; import { isValidUrl } from '../apis/develop/developEdit'; -import { AUTH_TYPE_OPTIONS } from './devPortalDisplay'; +import { AUTH_TYPE_OPTIONS } from './apiPortalDisplay'; import { IdpCredentialsFields } from './IdpCredentialsFields'; const HANDLE_PATTERN = /^[a-z0-9-]{3,64}$/; @@ -52,22 +52,22 @@ const slugify = (value: string) => .replace(/^-+|-+$/g, '') .slice(0, 64); -export function DevPortalCreatePage() { +export function ApiPortalCreatePage() { const { orgHandle = '' } = useParams(); const navigate = useNavigate(); const { notify } = useNotifications(); - const createDevPortal = useCreateDevPortal(); + const createApiPortal = useCreateApiPortal(); const [displayName, setDisplayName] = useState(''); const [handle, setHandle] = useState(''); const [handleEdited, setHandleEdited] = useState(false); // Auto-derived from Name and locked by default; the identifier is - // permanent once the devportal is created, so editing it directly is an + // permanent once the API Portal is created, so editing it directly is an // explicit, deliberate action rather than something you fall into while // typing the Name. const [handleLocked, setHandleLocked] = useState(true); const [description, setDescription] = useState(''); - const [authType, setAuthType] = useState( + const [authType, setAuthType] = useState( AUTH_TYPE_OPTIONS[0].value ); const [url, setUrl] = useState(''); @@ -94,10 +94,10 @@ export function DevPortalCreatePage() { handleValid && urlValid && idpFieldsValid && - !createDevPortal.isPending; + !createApiPortal.isPending; const submit = () => { - createDevPortal.mutate( + createApiPortal.mutate( { name: displayName, handle, @@ -113,15 +113,15 @@ export function DevPortalCreatePage() { : {}), }, { - onSuccess: (devPortal) => { - notify(`Devportal "${devPortal.name}" provisioned.`, 'success'); - navigate(routes.devportal(orgHandle)); + onSuccess: (apiPortal) => { + notify(`API Portal "${apiPortal.name}" provisioned.`, 'success'); + navigate(routes.apiPortal(orgHandle)); }, onError: (error) => notify( error instanceof Error ? error.message - : 'Failed to provision devportal', + : 'Failed to provision API Portal', 'error' ), } @@ -131,10 +131,10 @@ export function DevPortalCreatePage() { return ( - - Back to Dev Portal + + Back to API Portal - Provision a devportal + Provision an API Portal Register a developer portal, then connect it to the platform. @@ -146,7 +146,7 @@ export function DevPortalCreatePage() { Name onDisplayNameChange(event.target.value)} - placeholder="Production Devportal" + placeholder="Production API Portal" value={displayName} /> @@ -164,7 +164,7 @@ export function DevPortalCreatePage() { setHandleEdited(true); setHandle(event.target.value); }} - placeholder="prod-devportal" + placeholder="prod-api-portal" slotProps={{ input: { endAdornment: handleLocked && ( @@ -201,7 +201,7 @@ export function DevPortalCreatePage() { Authentication - setAuthType(event.target.value as DevPortalAuthType) + setAuthType(event.target.value as ApiPortalAuthType) } size="small" value={authType} @@ -260,21 +260,21 @@ export function DevPortalDetailPage() { url !== '' && !isValidUrl(url) ? 'Enter a valid URL' : undefined } onChange={(event) => setUrl(event.target.value)} - placeholder="https://devportal.example.com" + placeholder="https://api-portal.example.com" value={url} /> @@ -291,21 +291,21 @@ export function DevPortalDetailPage() { - Identifier: {devPortal.handle} + Identifier: {apiPortal.handle} - {devPortal.createdAt && ( + {apiPortal.createdAt && ( - Created {relativeTime(devPortal.createdAt)} + Created {relativeTime(apiPortal.createdAt)} )} diff --git a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalPage.tsx similarity index 80% rename from portals/api-control-plane/src/features/devportal/DevPortalPage.tsx rename to portals/api-control-plane/src/features/apiportal/ApiPortalPage.tsx index be9f2baa87..631ace48fd 100644 --- a/portals/api-control-plane/src/features/devportal/DevPortalPage.tsx +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalPage.tsx @@ -52,7 +52,7 @@ import { } from '@wso2/oxygen-ui-icons-react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useDeleteDevPortal, useDevPortals } from '../../api/hooks/useMvpQueries'; +import { useDeleteApiPortal, useApiPortals } from '../../api/hooks/useMvpQueries'; import { ConfirmDialog } from '../../components/ConfirmDialog'; import { useNotifications } from '../../components/Notifications'; import { @@ -62,8 +62,8 @@ import { } from '../../components/StateViews'; import { routes } from '../../routes/paths'; import { relativeTime } from '../../utils/relativeTime'; -import type { DevPortal } from '../../types/domain'; -import { AUTH_LABEL, STATUS_COLOR, STATUS_LABEL } from './devPortalDisplay'; +import type { ApiPortal } from '../../types/domain'; +import { AUTH_LABEL, STATUS_COLOR, STATUS_LABEL } from './apiPortalDisplay'; type ViewMode = 'grid' | 'list'; @@ -110,30 +110,30 @@ function StatCard({ ); } -function DevPortalCard({ - devPortal, +function ApiPortalCard({ + apiPortal, onOpen, onDelete, }: { - devPortal: DevPortal; - onOpen: (devPortal: DevPortal) => void; - onDelete?: (devPortal: DevPortal) => void; + apiPortal: ApiPortal; + onOpen: (apiPortal: ApiPortal) => void; + onDelete?: (apiPortal: ApiPortal) => void; }) { const [copied, setCopied] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); - const statusColor = STATUS_COLOR[devPortal.workflowStatus]; + const statusColor = STATUS_COLOR[apiPortal.workflowStatus]; // MUI's Menu closes on an outside click via a document-level listener that // doesn't reliably block the same click from also reaching this card's // onClick underneath it — dismissing the menu by clicking just off it would - // otherwise also open the devportal. mousedown always fires before click, + // otherwise also open the API Portal. mousedown always fires before click, // so capturing "was the menu open" there is a deterministic guard // regardless of exactly when the menu's own close logic runs. const wasMenuOpenRef = useRef(false); const copyUrl = (event: React.MouseEvent) => { event.stopPropagation(); - if (!devPortal.url) return; - navigator.clipboard?.writeText(devPortal.url).catch(() => undefined); + if (!apiPortal.url) return; + navigator.clipboard?.writeText(apiPortal.url).catch(() => undefined); setCopied(true); setTimeout(() => setCopied(false), 1400); }; @@ -165,7 +165,7 @@ function DevPortalCard({ wasMenuOpenRef.current = false; return; } - onOpen(devPortal); + onOpen(apiPortal); }} onMouseDown={() => { wasMenuOpenRef.current = menuAnchor !== null; @@ -205,9 +205,9 @@ function DevPortalCard({ - {devPortal.name} + {apiPortal.name} - {devPortal.url && ( + {apiPortal.url && ( - {devPortal.url} + {apiPortal.url} {onDelete && ( <> - + { event.stopPropagation(); setMenuAnchor(event.currentTarget); @@ -259,7 +259,7 @@ function DevPortalCard({ { closeMenu(event); - onDelete(devPortal); + onDelete(apiPortal); }} sx={{ color: 'error.main' }} > @@ -273,7 +273,7 @@ function DevPortalCard({ )} - {devPortal.description && ( + {apiPortal.description && ( - {devPortal.description} + {apiPortal.description} )} @@ -300,7 +300,7 @@ function DevPortalCard({ }} > - {AUTH_LABEL[devPortal.authType]} + {AUTH_LABEL[apiPortal.authType]} @@ -325,10 +325,10 @@ function DevPortalCard({ }} /> - {STATUS_LABEL[devPortal.workflowStatus]} + {STATUS_LABEL[apiPortal.workflowStatus]} - {devPortal.createdAt && ( + {apiPortal.createdAt && ( - {relativeTime(devPortal.createdAt)} + {relativeTime(apiPortal.createdAt)} )} @@ -346,18 +346,18 @@ function DevPortalCard({ ); } -function DevPortalRow({ - devPortal, +function ApiPortalRow({ + apiPortal, onOpen, onDelete, }: { - devPortal: DevPortal; - onOpen: (devPortal: DevPortal) => void; - onDelete?: (devPortal: DevPortal) => void; + apiPortal: ApiPortal; + onOpen: (apiPortal: ApiPortal) => void; + onDelete?: (apiPortal: ApiPortal) => void; }) { const [menuAnchor, setMenuAnchor] = useState(null); - const statusColor = STATUS_COLOR[devPortal.workflowStatus]; - // Same click-away guard as DevPortalCard — see the comment there. + const statusColor = STATUS_COLOR[apiPortal.workflowStatus]; + // Same click-away guard as ApiPortalCard — see the comment there. const wasMenuOpenRef = useRef(false); const closeMenu = (event?: React.MouseEvent) => { @@ -372,7 +372,7 @@ function DevPortalRow({ wasMenuOpenRef.current = false; return; } - onOpen(devPortal); + onOpen(apiPortal); }} onMouseDown={() => { wasMenuOpenRef.current = menuAnchor !== null; @@ -410,7 +410,7 @@ function DevPortalRow({ - {devPortal.name} + {apiPortal.name} - {devPortal.url || devPortal.handle} + {apiPortal.url || apiPortal.handle} - {AUTH_LABEL[devPortal.authType]} + {AUTH_LABEL[apiPortal.authType]} - {STATUS_LABEL[devPortal.workflowStatus]} + {STATUS_LABEL[apiPortal.workflowStatus]} - {devPortal.createdAt ? relativeTime(devPortal.createdAt) : '—'} + {apiPortal.createdAt ? relativeTime(apiPortal.createdAt) : '—'} {onDelete && ( <> { event.stopPropagation(); setMenuAnchor(event.currentTarget); @@ -488,7 +488,7 @@ function DevPortalRow({ { closeMenu(event); - onDelete(devPortal); + onDelete(apiPortal); }} sx={{ color: 'error.main' }} > @@ -504,22 +504,22 @@ function DevPortalRow({ ); } -/** Compact row layout for devportals — the list-view counterpart of the card grid. */ -function DevPortalListView({ - devPortals, +/** Compact row layout for API Portals — the list-view counterpart of the card grid. */ +function ApiPortalListView({ + apiPortals, onOpen, onDelete, }: { - devPortals: DevPortal[]; - onOpen: (devPortal: DevPortal) => void; - onDelete?: (devPortal: DevPortal) => void; + apiPortals: ApiPortal[]; + onOpen: (apiPortal: ApiPortal) => void; + onDelete?: (apiPortal: ApiPortal) => void; }) { return ( - {devPortals.map((devPortal) => ( - ( + @@ -528,23 +528,23 @@ function DevPortalListView({ ); } -export function DevPortalPage() { +export function ApiPortalPage() { const { orgHandle = '' } = useParams(); const navigate = useNavigate(); const { notify } = useNotifications(); - const devPortalsQuery = useDevPortals(); - const deleteDevPortalMutation = useDeleteDevPortal(); + const apiPortalsQuery = useApiPortals(); + const deleteApiPortalMutation = useDeleteApiPortal(); const [search, setSearch] = useState(''); const [view, setView] = useState('grid'); - const [toDelete, setToDelete] = useState(null); + const [toDelete, setToDelete] = useState(null); - const provision = () => navigate(routes.newDevportal(orgHandle)); - const openDevPortal = (devPortal: DevPortal) => - navigate(routes.devportalDetail(orgHandle, devPortal.id)); + const provision = () => navigate(routes.newApiPortal(orgHandle)); + const openApiPortal = (apiPortal: ApiPortal) => + navigate(routes.apiPortalDetail(orgHandle, apiPortal.id)); const confirmDelete = () => { if (!toDelete) return; - deleteDevPortalMutation.mutate(toDelete, { + deleteApiPortalMutation.mutate(toDelete, { onSuccess: () => { notify(`Deleted "${toDelete.name}".`, 'success'); setToDelete(null); @@ -557,29 +557,29 @@ export function DevPortalPage() { }); }; - const devPortals = useMemo( - () => devPortalsQuery.data || [], - [devPortalsQuery.data] + const apiPortals = useMemo( + () => apiPortalsQuery.data || [], + [apiPortalsQuery.data] ); const filtered = useMemo(() => { const term = search.trim().toLowerCase(); - if (!term) return devPortals; - return devPortals.filter((devPortal) => - [devPortal.name, devPortal.handle, devPortal.url] + if (!term) return apiPortals; + return apiPortals.filter((apiPortal) => + [apiPortal.name, apiPortal.handle, apiPortal.url] .filter(Boolean) .some((field) => field!.toLowerCase().includes(term)) ); - }, [devPortals, search]); + }, [apiPortals, search]); - const activeCount = devPortals.filter( - (devPortal) => devPortal.workflowStatus === 'active' + const activeCount = apiPortals.filter( + (apiPortal) => apiPortal.workflowStatus === 'active' ).length; return ( - Dev Portal + API Portal Provision and manage the developer portal for your organization. @@ -590,21 +590,21 @@ export function DevPortalPage() { sx={{ borderRadius: 5 }} variant="contained" > - Provision Devportal + Provision API Portal - {devPortalsQuery.isLoading ? ( - - ) : devPortalsQuery.error ? ( - - ) : devPortals.length === 0 ? ( + {apiPortalsQuery.isLoading ? ( + + ) : apiPortalsQuery.error ? ( + + ) : apiPortals.length === 0 ? ( ) : ( @@ -618,8 +618,8 @@ export function DevPortalPage() { > setSearch(event.target.value)} - placeholder="Search Dev Portal" + placeholder="Search API Portal" size="small" slotProps={{ input: { @@ -674,7 +674,7 @@ export function DevPortalPage() { {filtered.length === 0 ? ( ) : view === 'grid' ? ( - {filtered.map((devPortal) => ( - ( + ))} ) : ( - )} @@ -708,16 +708,16 @@ export function DevPortalPage() { confirmLabel="Delete" confirmPhrase={toDelete?.name ?? ''} destructive - loading={deleteDevPortalMutation.isPending} + loading={deleteApiPortalMutation.isPending} message={ toDelete - ? `This permanently deletes the devportal "${toDelete.name}". This action is irreversible.` + ? `This permanently deletes the API Portal "${toDelete.name}". This action is irreversible.` : '' } onCancel={() => setToDelete(null)} onConfirm={confirmDelete} open={toDelete !== null} - title="Delete devportal" + title="Delete API Portal" /> ); diff --git a/portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx b/portals/api-control-plane/src/features/apiportal/IdpCredentialsFields.tsx similarity index 98% rename from portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx rename to portals/api-control-plane/src/features/apiportal/IdpCredentialsFields.tsx index 5c11abe482..1819ec3a06 100644 --- a/portals/api-control-plane/src/features/devportal/IdpCredentialsFields.tsx +++ b/portals/api-control-plane/src/features/apiportal/IdpCredentialsFields.tsx @@ -43,7 +43,7 @@ type IdpCredentialsFieldsProps = { /** * Grouped STS token URL / client ID / client secret inputs shown once * `authType` is `idp_client_credentials` — shared by the create and - * detail/edit devportal pages so the group never drifts between the two. + * detail/edit API Portal pages so the group never drifts between the two. */ export function IdpCredentialsFields({ stsTokenUrl, diff --git a/portals/api-control-plane/src/features/devportal/devPortalDisplay.ts b/portals/api-control-plane/src/features/apiportal/apiPortalDisplay.ts similarity index 78% rename from portals/api-control-plane/src/features/devportal/devPortalDisplay.ts rename to portals/api-control-plane/src/features/apiportal/apiPortalDisplay.ts index 54d6bbddb6..3db74147a6 100644 --- a/portals/api-control-plane/src/features/devportal/devPortalDisplay.ts +++ b/portals/api-control-plane/src/features/apiportal/apiPortalDisplay.ts @@ -16,33 +16,33 @@ * under the License. */ -import type { DevPortalAuthType, DevPortalWorkflowStatus } from '../../types/domain'; +import type { ApiPortalAuthType, ApiPortalWorkflowStatus } from '../../types/domain'; /** Shared across the list, create, and detail/edit pages so the three never drift. */ -export const AUTH_TYPE_OPTIONS: { value: DevPortalAuthType; label: string }[] = [ +export const AUTH_TYPE_OPTIONS: { value: ApiPortalAuthType; label: string }[] = [ { value: 'local', label: 'Local' }, { value: 'idp_client_credentials', label: 'IdP Client Credentials' }, ]; -export const AUTH_LABEL: Record = { +export const AUTH_LABEL: Record = { local: 'Local', idp_client_credentials: 'IdP Client Credentials', }; -export const STATUS_LABEL: Record = { +export const STATUS_LABEL: Record = { pending: 'Pending', active: 'Active', failed: 'Failed', }; -export const STATUS_COLOR: Record = { +export const STATUS_COLOR: Record = { pending: 'warning.main', active: 'success.main', failed: 'error.main', }; export const STATUS_CHIP_COLOR: Record< - DevPortalWorkflowStatus, + ApiPortalWorkflowStatus, 'warning' | 'success' | 'error' > = { pending: 'warning', diff --git a/portals/api-control-plane/src/navigation/navigationRegistry.tsx b/portals/api-control-plane/src/navigation/navigationRegistry.tsx index 883d176672..414bbf1635 100644 --- a/portals/api-control-plane/src/navigation/navigationRegistry.tsx +++ b/portals/api-control-plane/src/navigation/navigationRegistry.tsx @@ -62,15 +62,15 @@ export const navigationRegistry: NavigationDefinition[] = [ match: (pathname) => /\/organizations\/[^/]+\/gateways(\/[^/]+)?$/.test(pathname), }, { - id: 'devportal', - label: 'Dev Portal', + id: 'api-portal', + label: 'API Portal', level: 'organization', order: 35, icon: , to: ({ params }) => - params.orgHandle ? routes.devportal(params.orgHandle) : undefined, + params.orgHandle ? routes.apiPortal(params.orgHandle) : undefined, match: (pathname) => - /\/organizations\/[^/]+\/devportal(\/[^/]+)?$/.test(pathname), + /\/organizations\/[^/]+\/api-portal(\/[^/]+)?$/.test(pathname), }, { id: 'project-home', diff --git a/portals/api-control-plane/src/routes/AppRoutes.tsx b/portals/api-control-plane/src/routes/AppRoutes.tsx index 762a72d78d..37e19cd229 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.tsx @@ -60,19 +60,19 @@ const GatewayDetailPage = lazy(() => default: m.GatewayDetailPage, })) ); -const DevPortalPage = lazy(() => - import('../features/devportal/DevPortalPage').then((m) => ({ - default: m.DevPortalPage, +const ApiPortalPage = lazy(() => + import('../features/apiportal/ApiPortalPage').then((m) => ({ + default: m.ApiPortalPage, })) ); -const DevPortalCreatePage = lazy(() => - import('../features/devportal/DevPortalCreatePage').then((m) => ({ - default: m.DevPortalCreatePage, +const ApiPortalCreatePage = lazy(() => + import('../features/apiportal/ApiPortalCreatePage').then((m) => ({ + default: m.ApiPortalCreatePage, })) ); -const DevPortalDetailPage = lazy(() => - import('../features/devportal/DevPortalDetailPage').then((m) => ({ - default: m.DevPortalDetailPage, +const ApiPortalDetailPage = lazy(() => + import('../features/apiportal/ApiPortalDetailPage').then((m) => ({ + default: m.ApiPortalDetailPage, })) ); const ProjectHomePage = lazy(() => @@ -139,14 +139,14 @@ export function AppRoutes() { } /> } /> } /> - } /> + } /> } + path={routes.newApiPortal()} + element={} /> } + path={routes.apiPortalDetail()} + element={} /> } /> } /> diff --git a/portals/api-control-plane/src/routes/paths.ts b/portals/api-control-plane/src/routes/paths.ts index bf29586362..1a8abd257f 100644 --- a/portals/api-control-plane/src/routes/paths.ts +++ b/portals/api-control-plane/src/routes/paths.ts @@ -34,14 +34,14 @@ export const routes = { `/organizations/${orgHandle}/gateways/new`, gateway: (orgHandle = ':orgHandle', gatewayId = ':gatewayId') => `/organizations/${orgHandle}/gateways/${gatewayId}`, - devportal: (orgHandle = ':orgHandle') => - `/organizations/${orgHandle}/devportal`, - newDevportal: (orgHandle = ':orgHandle') => - `/organizations/${orgHandle}/devportal/new`, - devportalDetail: ( + apiPortal: (orgHandle = ':orgHandle') => + `/organizations/${orgHandle}/api-portal`, + newApiPortal: (orgHandle = ':orgHandle') => + `/organizations/${orgHandle}/api-portal/new`, + apiPortalDetail: ( orgHandle = ':orgHandle', - devPortalId = ':devPortalId' - ) => `/organizations/${orgHandle}/devportal/${devPortalId}`, + apiPortalId = ':apiPortalId' + ) => `/organizations/${orgHandle}/api-portal/${apiPortalId}`, projectHome: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => `/organizations/${orgHandle}/projects/${projectHandler}/home`, apis: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => diff --git a/portals/api-control-plane/src/types/domain.ts b/portals/api-control-plane/src/types/domain.ts index a862b8babd..0a5050f916 100644 --- a/portals/api-control-plane/src/types/domain.ts +++ b/portals/api-control-plane/src/types/domain.ts @@ -246,21 +246,21 @@ export type GatewayToken = { message?: string; }; -/** How the platform authenticates to a Developer Portal instance. */ -export type DevPortalAuthType = 'local' | 'idp_client_credentials'; +/** How the platform authenticates to an API Portal instance. */ +export type ApiPortalAuthType = 'local' | 'idp_client_credentials'; -/** Provisioning state of a devportal (platform-api DevPortalResponse.workflowStatus). */ -export type DevPortalWorkflowStatus = 'pending' | 'active' | 'failed'; +/** Provisioning state of an API Portal (platform-api ApiPortalResponse.workflowStatus). */ +export type ApiPortalWorkflowStatus = 'pending' | 'active' | 'failed'; -/** Maps 1:1 to platform-api's DevPortalResponse schema. */ -export type DevPortal = { +/** Maps 1:1 to platform-api's ApiPortalResponse schema. */ +export type ApiPortal = { id: string; name: string; handle: string; description?: string; url?: string; - workflowStatus: DevPortalWorkflowStatus; - authType: DevPortalAuthType; + workflowStatus: ApiPortalWorkflowStatus; + authType: ApiPortalAuthType; createdAt?: string; /** * Set when authType is 'idp_client_credentials'. Not secret — safe to @@ -271,11 +271,11 @@ export type DevPortal = { clientId?: string; }; -export type CreateDevPortalInput = { +export type CreateApiPortalInput = { name: string; handle: string; url: string; - authType: DevPortalAuthType; + authType: ApiPortalAuthType; description?: string; /** Required when authType is 'idp_client_credentials'. */ stsTokenUrl?: string; @@ -284,10 +284,10 @@ export type CreateDevPortalInput = { }; /** `handle` is set at creation and not editable afterwards. */ -export type UpdateDevPortalInput = { +export type UpdateApiPortalInput = { name: string; url: string; - authType: DevPortalAuthType; + authType: ApiPortalAuthType; description?: string; /** Required when authType is 'idp_client_credentials'. */ stsTokenUrl?: string; From 0c8b90c677fc0ab1c3a39b69d7f6daea6fcf8a63 Mon Sep 17 00:00:00 2001 From: pranavansu Date: Thu, 13 Aug 2026 14:29:17 +0530 Subject: [PATCH 14/33] Rename references from 'Devportal' to 'API Portal' in OverviewTab and ProgressBanner components --- .../src/features/apis/overview/OverviewTab.test.tsx | 2 +- .../src/features/apis/overview/ProgressBanner.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/portals/api-control-plane/src/features/apis/overview/OverviewTab.test.tsx b/portals/api-control-plane/src/features/apis/overview/OverviewTab.test.tsx index cde5bb3f0a..86f4ed0060 100644 --- a/portals/api-control-plane/src/features/apis/overview/OverviewTab.test.tsx +++ b/portals/api-control-plane/src/features/apis/overview/OverviewTab.test.tsx @@ -128,7 +128,7 @@ describe('OverviewTab', () => { expect(screen.getByRole('button', { name: 'Deploy' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Test' })).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Publish to Devportal' }) + screen.getByRole('button', { name: 'Publish to API Portal' }) ).toBeInTheDocument(); }); diff --git a/portals/api-control-plane/src/features/apis/overview/ProgressBanner.tsx b/portals/api-control-plane/src/features/apis/overview/ProgressBanner.tsx index eea52daab5..c811f2e6a5 100644 --- a/portals/api-control-plane/src/features/apis/overview/ProgressBanner.tsx +++ b/portals/api-control-plane/src/features/apis/overview/ProgressBanner.tsx @@ -58,7 +58,7 @@ type ProgressStep = { * Create → always done (the API record exists) * Deploy → live on a gateway, or staged/published * Test → staged (PENDING) or published (ACTIVE) - * Publish → published to the dev portal (ACTIVE) + * Publish → published to the API Portal (ACTIVE) * The steps double as the navigation the old Deploy/Test/Manage buttons gave. */ export function ProgressBanner({ @@ -96,7 +96,7 @@ export function ProgressBanner({ }, { key: 'publish', - label: 'Publish to Devportal', + label: 'Publish to API Portal', Icon: Globe, complete: published, onClick: () => From 00ec7d10793393ce6ead07f1009263575e1dcf74 Mon Sep 17 00:00:00 2001 From: pranavansu Date: Thu, 13 Aug 2026 14:35:38 +0530 Subject: [PATCH 15/33] Update references from 'developer portal' to 'API Portal' in various components --- .../src/features/apiportal/ApiPortalCreatePage.tsx | 2 +- .../src/features/apiportal/ApiPortalPage.tsx | 4 ++-- .../api-control-plane/src/features/settings/SettingsPage.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.tsx index 76ff6ecdb4..ae21a65064 100644 --- a/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.tsx +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.tsx @@ -136,7 +136,7 @@ export function ApiPortalCreatePage() { Provision an API Portal - Register a developer portal, then connect it to the platform. + Register an API Portal, then connect it to the platform. diff --git a/portals/api-control-plane/src/features/apiportal/ApiPortalPage.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalPage.tsx index 631ace48fd..a503ac0f81 100644 --- a/portals/api-control-plane/src/features/apiportal/ApiPortalPage.tsx +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalPage.tsx @@ -581,7 +581,7 @@ export function ApiPortalPage() { API Portal - Provision and manage the developer portal for your organization. + Provision and manage the API Portal for your organization. + setMenuAnchor(event.currentTarget)} + size="small" + > + + + setMenuAnchor(null)} + open={Boolean(menuAnchor)} + > + { + setMenuAnchor(null); + setConfirmDelete(true); + }} + sx={{ color: 'error.main' }} + > + + + + Delete + + + + - {/* Editable form */} + {/* Overview */} - API Portal settings + Overview - Update the API Portal's details and authentication. + Read-only view of the registered API Portal. Use Edit to change + any field. - - - Name - setName(event.target.value)} - value={name} - /> - + + - - Description (optional) - setDescription(event.target.value)} - value={description} + {apiPortal.description && ( + - - - - Authentication - - - - {isIdpAuth && ( - - {!switchingToIdp && ( - - Client secret is never displayed after saving — leave - it blank to keep the existing one, or enter a new - value to replace it. - - )} - - )} - - URL - setUrl(event.target.value)} - placeholder="https://api-portal.example.com" - value={url} - /> - + + } + /> - - - - + + IDP CLIENT CREDENTIALS + + + + + + + + ) : ( + + Platform API mints its own signed JWT with its configured + key to authenticate to this portal. + + )} - {/* Details */} + {/* Details rail */} - + Details - + - - Identifier: {apiPortal.handle} - - {apiPortal.createdAt && ( - - Created {relativeTime(apiPortal.createdAt)} - - )} + + + {apiPortal.createdAt && ( + + )} + {apiPortal.updatedAt && ( + + )} + + + setConfirmDelete(false)} + onConfirm={doDelete} + open={confirmDelete} + title="Delete API Portal" + /> ); } diff --git a/portals/api-control-plane/src/features/apiportal/ApiPortalEditPage.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalEditPage.tsx new file mode 100644 index 0000000000..d2896cf23b --- /dev/null +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalEditPage.tsx @@ -0,0 +1,267 @@ +/* + * 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. + */ + +import { useEffect, useState } from 'react'; +import { + Box, + Button, + Card, + CardContent, + FormControl, + FormLabel, + MenuItem, + PageContent, + PageTitle, + Select, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { Link, useNavigate, useParams } from 'react-router-dom'; + +import { useApiPortal, useUpdateApiPortal } from '../../api/hooks/useMvpQueries'; +import { useNotifications } from '../../components/Notifications'; +import { ErrorState, LoadingState } from '../../components/StateViews'; +import { routes } from '../../routes/paths'; +import type { + ApiPortal, + ApiPortalAuthType, + UpdateApiPortalInput, +} from '../../types/domain'; +import { isValidUrl } from '../apis/develop/developEdit'; +import { AUTH_TYPE_OPTIONS } from './apiPortalDisplay'; +import { IdpCredentialsFields } from './IdpCredentialsFields'; + +export function ApiPortalEditPage() { + const { orgHandle = '', apiPortalId = '' } = useParams(); + const navigate = useNavigate(); + const { notify } = useNotifications(); + const apiPortalQuery = useApiPortal(orgHandle, apiPortalId); + const updateApiPortal = useUpdateApiPortal(orgHandle, apiPortalId); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [url, setUrl] = useState(''); + const [authType, setAuthType] = useState('local'); + const [stsTokenUrl, setStsTokenUrl] = useState(''); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [seededId, setSeededId] = useState(); + + // Seed the editable fields once per loaded record. A poll/refetch after + // save must not clobber in-progress edits, so this only re-seeds when the + // API Portal id itself changes (i.e. navigating to a different one). + useEffect(() => { + if (!apiPortalQuery.data || apiPortalQuery.data.id === seededId) return; + const apiPortal = apiPortalQuery.data; + setName(apiPortal.name); + setDescription(apiPortal.description || ''); + setUrl(apiPortal.url || ''); + setAuthType(apiPortal.authType); + // stsTokenUrl/clientId aren't secret and are returned by the backend, so + // they seed from the record like any other field. clientSecret is the + // one write-only field — it always seeds blank. + setStsTokenUrl(apiPortal.authConfig?.stsTokenUrl || ''); + setClientId(apiPortal.authConfig?.clientId || ''); + setClientSecret(''); + setSeededId(apiPortal.id); + }, [apiPortalQuery.data, seededId]); + + if (apiPortalQuery.isLoading) { + return ; + } + if (apiPortalQuery.error) { + return ; + } + if (!apiPortalQuery.data) { + return ; + } + + const apiPortal: ApiPortal = apiPortalQuery.data; + const isOAuth2 = authType === 'oauth2'; + // stsTokenUrl/clientId aren't secret — they're stored/returned and behave + // like any other required field once oauth2 is active. clientSecret is the + // one write-only field: it's never returned, so blank means "keep the + // existing secret" whenever one already exists — only when switching into + // oauth2 from a different auth type is there no existing secret to fall + // back to, so it's required in that case. + const switchingToOAuth2 = isOAuth2 && apiPortal.authType !== 'oauth2'; + const urlValid = url.trim() !== '' && isValidUrl(url); + const stsTokenUrlEntered = stsTokenUrl.trim() !== ''; + const stsTokenUrlValid = stsTokenUrlEntered && isValidUrl(stsTokenUrl); + const clientIdEntered = clientId.trim() !== ''; + const clientSecretEntered = clientSecret.trim() !== ''; + const idpFieldsValid = + !isOAuth2 || + (stsTokenUrlValid && + clientIdEntered && + (!switchingToOAuth2 || clientSecretEntered)); + const isDirty = + seededId === apiPortal.id && + (name !== apiPortal.name || + description !== (apiPortal.description || '') || + url !== (apiPortal.url || '') || + authType !== apiPortal.authType || + stsTokenUrl !== (apiPortal.authConfig?.stsTokenUrl || '') || + clientId !== (apiPortal.authConfig?.clientId || '') || + clientSecretEntered); + const canSave = + isDirty && + name.trim() !== '' && + urlValid && + idpFieldsValid && + !updateApiPortal.isPending; + + const goBackToDetail = () => + navigate(routes.apiPortalDetail(orgHandle, apiPortal.id)); + + const save = () => { + const basePayload = { + name: name.trim(), + url: url.trim(), + description: description || undefined, + }; + const input: UpdateApiPortalInput = isOAuth2 + ? { + ...basePayload, + authType: 'oauth2', + authConfig: { + stsTokenUrl: stsTokenUrl.trim(), + clientId: clientId.trim(), + // Only sent when the user actually typed a new one — blank + // must never overwrite the existing secret with ''. + ...(clientSecretEntered ? { clientSecret } : {}), + }, + } + : { ...basePayload, authType: 'local' }; + updateApiPortal.mutate(input, { + onSuccess: (updated) => { + notify(`API Portal "${updated.name}" updated.`, 'success'); + navigate(routes.apiPortalDetail(orgHandle, updated.id)); + }, + onError: (error) => + notify( + error instanceof Error ? error.message : 'Failed to update API Portal', + 'error' + ), + }); + }; + + return ( + + + + Back to {apiPortal.name} + + Edit API Portal + + Update details and outbound authentication for {apiPortal.name}. + + + + + + + + + Name + setName(event.target.value)} + value={name} + /> + + + + Description (optional) + setDescription(event.target.value)} + value={description} + /> + + + + URL + setUrl(event.target.value)} + placeholder="https://api-portal.example.com" + value={url} + /> + + + + Authentication + + + + {isOAuth2 && ( + + {!switchingToOAuth2 && ( + + Client secret is never displayed after saving — leave it + blank to keep the existing one, or enter a new value to + replace it. + + )} + + + )} + + + + + + + + + + + ); +} diff --git a/portals/api-control-plane/src/routes/AppRoutes.tsx b/portals/api-control-plane/src/routes/AppRoutes.tsx index 6d771e05fb..9f3a583bf6 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.tsx @@ -76,6 +76,11 @@ const ApiPortalDetailPage = lazy(() => default: m.ApiPortalDetailPage, })) ); +const ApiPortalEditPage = lazy(() => + import('../features/apiportal/ApiPortalEditPage').then((m) => ({ + default: m.ApiPortalEditPage, + })) +); const ProjectHomePage = lazy(() => import('../features/projects/ProjectHomePage').then((m) => ({ default: m.ProjectHomePage, @@ -151,6 +156,10 @@ export function AppRoutes() { path={routes.apiPortalDetail()} element={} /> + } + /> )} } /> diff --git a/portals/api-control-plane/src/routes/paths.ts b/portals/api-control-plane/src/routes/paths.ts index 1a8abd257f..ffa9f8c8a4 100644 --- a/portals/api-control-plane/src/routes/paths.ts +++ b/portals/api-control-plane/src/routes/paths.ts @@ -42,6 +42,10 @@ export const routes = { orgHandle = ':orgHandle', apiPortalId = ':apiPortalId' ) => `/organizations/${orgHandle}/api-portal/${apiPortalId}`, + apiPortalEdit: ( + orgHandle = ':orgHandle', + apiPortalId = ':apiPortalId' + ) => `/organizations/${orgHandle}/api-portal/${apiPortalId}/edit`, projectHome: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => `/organizations/${orgHandle}/projects/${projectHandler}/home`, apis: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => From 2e3e009bac42261d92afe2d114bd98c14a8c701f Mon Sep 17 00:00:00 2001 From: dushaniw Date: Tue, 18 Aug 2026 02:38:38 +0530 Subject: [PATCH 32/33] Bypass BFF auth when VITE_USE_MOCK_API=true for local UI review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, a mock-mode dev session still tries to hit /api/session and /api/login on the BFF — neither of which is running when the SPA is used standalone. The provider now short-circuits hydrate() to a synthetic MOCK_USER (name / email / org) when useMockApi() is true so the login page never appears during local UI review. No effect on real deployments: useMockApi() returns false unless VITE_USE_MOCK_API is set, which only happens in local Vite dev. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/features/auth/AuthProvider.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/portals/api-control-plane/src/features/auth/AuthProvider.tsx b/portals/api-control-plane/src/features/auth/AuthProvider.tsx index a652e44b66..5b05cdfce7 100644 --- a/portals/api-control-plane/src/features/auth/AuthProvider.tsx +++ b/portals/api-control-plane/src/features/auth/AuthProvider.tsx @@ -18,6 +18,7 @@ import { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useMockApi } from '../../api/shared/apiClientUtils'; import { runtimeConfig } from '../../config/runtime'; import { CSRF_HEADER, CSRF_HEADER_VALUE } from './authConstants'; import { AuthStateContext } from './AuthStateContext'; @@ -28,6 +29,16 @@ const LOGIN_URL = '/api/login'; const LOGOUT_URL = '/api/logout'; const OIDC_LOGIN_URL = '/api/auth/login'; +// Synthetic session used in mock-mode (VITE_USE_MOCK_API=true). The SPA never +// contacts a real BFF in this mode, so the AuthProvider hydrates from this +// object rather than /api/session. Local UI review only — real deployments +// leave VITE_USE_MOCK_API unset. +const MOCK_USER: AuthUser = { + name: 'Mock Admin', + email: 'admin@example.dev', + org: { id: 'org-1', name: 'API Platform Demo', handle: 'api-platform-demo' }, +}; + type SessionResponse = { authenticated: boolean; user?: AuthUser; @@ -65,6 +76,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [error, setError] = useState(); const hydrate = useCallback(async () => { + if (useMockApi()) { + setUser(MOCK_USER); + setStatus('authenticated'); + return; + } try { const response = await fetch(SESSION_URL, { credentials: 'same-origin' }); const body = (await response.json().catch(() => ({}))) as SessionResponse; From 7184e3d4fbd0a6d6afd59325a978d808a154d337 Mon Sep 17 00:00:00 2001 From: dushaniw Date: Thu, 20 Aug 2026 09:40:52 +0530 Subject: [PATCH 33/33] Address PR #3244 review comments (a11y, metadata guard, edit workflow status, nav match) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five follow-ups from Copilot's review of the previous commits: 1. aria-label on both copy IconButtons (ApiPortalDetailPage's CopyableInline and ApiPortalPage's list card). Tooltip text alone doesn't reliably provide an accessible name — screen readers announced these as unlabeled. 2. Reject arrays in toApiPortalMetadata. `typeof [] === 'object'` was letting an array through as metadata, whose numeric keys / length would then read as properties by downstream consumers. 3. Add a "Workflow status" Select to ApiPortalEditPage. The user story says platform admins want to see AND update workflowStatus, and UpdateApiPortalInput.workflowStatus is already accepted by the mock client; the edit page just had no control for it. Included in dirty tracking and the update payload. 4. Widen the API Portal nav-match regex to cover /api-portal/:id/edit (and any future subpath). The nav item now stays highlighted while editing a portal instead of losing its active state. Co-Authored-By: Claude Opus 4.7 (1M context) --- portals/api-control-plane/src/api/adapters.ts | 5 ++- .../apiportal/ApiPortalDetailPage.tsx | 2 +- .../features/apiportal/ApiPortalEditPage.tsx | 33 ++++++++++++++++++- .../src/features/apiportal/ApiPortalPage.tsx | 1 + .../src/navigation/navigationRegistry.tsx | 5 ++- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/portals/api-control-plane/src/api/adapters.ts b/portals/api-control-plane/src/api/adapters.ts index 7936620cdb..ed91a25454 100644 --- a/portals/api-control-plane/src/api/adapters.ts +++ b/portals/api-control-plane/src/api/adapters.ts @@ -340,7 +340,10 @@ const toApiPortalAuthConfig = ( const toApiPortalMetadata = ( value: unknown ): ApiPortalMetadata | undefined => { - if (!value || typeof value !== 'object') return undefined; + // Reject arrays explicitly — `typeof [] === 'object'` would otherwise + // let an array through, and its numeric keys / `length` would then be + // read as metadata properties by downstream consumers. + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const source = value as ApiPortalMetadata; return Object.keys(source).length > 0 ? source : undefined; }; diff --git a/portals/api-control-plane/src/features/apiportal/ApiPortalDetailPage.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalDetailPage.tsx index e97d3f3bfb..7bc298b789 100644 --- a/portals/api-control-plane/src/features/apiportal/ApiPortalDetailPage.tsx +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalDetailPage.tsx @@ -74,7 +74,7 @@ function CopyableInline({ value }: { value: string }) { {value} - + {copied ? : } diff --git a/portals/api-control-plane/src/features/apiportal/ApiPortalEditPage.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalEditPage.tsx index d2896cf23b..1f14283c2b 100644 --- a/portals/api-control-plane/src/features/apiportal/ApiPortalEditPage.tsx +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalEditPage.tsx @@ -41,12 +41,19 @@ import { routes } from '../../routes/paths'; import type { ApiPortal, ApiPortalAuthType, + ApiPortalWorkflowStatus, UpdateApiPortalInput, } from '../../types/domain'; import { isValidUrl } from '../apis/develop/developEdit'; -import { AUTH_TYPE_OPTIONS } from './apiPortalDisplay'; +import { AUTH_TYPE_OPTIONS, STATUS_LABEL } from './apiPortalDisplay'; import { IdpCredentialsFields } from './IdpCredentialsFields'; +const WORKFLOW_STATUS_OPTIONS: ApiPortalWorkflowStatus[] = [ + 'pending', + 'active', + 'failed', +]; + export function ApiPortalEditPage() { const { orgHandle = '', apiPortalId = '' } = useParams(); const navigate = useNavigate(); @@ -57,6 +64,8 @@ export function ApiPortalEditPage() { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [url, setUrl] = useState(''); + const [workflowStatus, setWorkflowStatus] = + useState('pending'); const [authType, setAuthType] = useState('local'); const [stsTokenUrl, setStsTokenUrl] = useState(''); const [clientId, setClientId] = useState(''); @@ -72,6 +81,7 @@ export function ApiPortalEditPage() { setName(apiPortal.name); setDescription(apiPortal.description || ''); setUrl(apiPortal.url || ''); + setWorkflowStatus(apiPortal.workflowStatus); setAuthType(apiPortal.authType); // stsTokenUrl/clientId aren't secret and are returned by the backend, so // they seed from the record like any other field. clientSecret is the @@ -116,6 +126,7 @@ export function ApiPortalEditPage() { (name !== apiPortal.name || description !== (apiPortal.description || '') || url !== (apiPortal.url || '') || + workflowStatus !== apiPortal.workflowStatus || authType !== apiPortal.authType || stsTokenUrl !== (apiPortal.authConfig?.stsTokenUrl || '') || clientId !== (apiPortal.authConfig?.clientId || '') || @@ -135,6 +146,7 @@ export function ApiPortalEditPage() { name: name.trim(), url: url.trim(), description: description || undefined, + workflowStatus, }; const input: UpdateApiPortalInput = isOAuth2 ? { @@ -209,6 +221,25 @@ export function ApiPortalEditPage() { /> + + Workflow status + + + Authentication