diff --git a/portals/api-control-plane/src/api/ApiClientProvider.tsx b/portals/api-control-plane/src/api/ApiClientProvider.tsx index 23aabbdcb3..73c030ac3c 100644 --- a/portals/api-control-plane/src/api/ApiClientProvider.tsx +++ b/portals/api-control-plane/src/api/ApiClientProvider.tsx @@ -21,22 +21,26 @@ import { createContext, type ReactNode, useContext } from 'react'; import { createApi, createApiKey, + createApiPortal, createGateway, createGatewayToken, createProject, deleteApi, + deleteApiPortal, deleteGatewayDeployment, deleteProject, deployApi, getApi, getApiDetail, getApiProxy, + getApiPortal, getGateway, getOrganization, getProject, listApiKeys, listApis, listDeployments, + listApiPortals, listEnvironments, listGatewayDeployments, listGateways, @@ -46,6 +50,7 @@ import { revokeApiKey, undeployGatewayDeployment, updateApi, + updateApiPortal, } from './mvpApi'; import { getPolicyDefinition, @@ -79,6 +84,12 @@ export const realApiClient = { getGateway, createGateway, createGatewayToken, + // 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 8126ce6d98..ed91a25454 100644 --- a/portals/api-control-plane/src/api/adapters.ts +++ b/portals/api-control-plane/src/api/adapters.ts @@ -22,6 +22,11 @@ import type { ApiKind, ApiStatus, Deployment, + ApiPortal, + ApiPortalAuthConfig, + ApiPortalAuthType, + ApiPortalMetadata, + ApiPortalWorkflowStatus, Environment, Gateway, GatewayDeployment, @@ -298,6 +303,70 @@ export const toGateway = (value: unknown): Gateway => { }; }; +const API_PORTAL_AUTH_TYPES: ApiPortalAuthType[] = ['local', 'oauth2']; + +const asApiPortalAuthType = (value: unknown): ApiPortalAuthType => { + const normalized = asString(value).toLowerCase(); + return API_PORTAL_AUTH_TYPES.includes(normalized as ApiPortalAuthType) + ? (normalized as ApiPortalAuthType) + : API_PORTAL_AUTH_TYPES[0]; +}; + +const API_PORTAL_WORKFLOW_STATUSES: ApiPortalWorkflowStatus[] = [ + 'pending', + 'active', + 'failed', +]; + +const asApiPortalWorkflowStatus = (value: unknown): ApiPortalWorkflowStatus => { + const normalized = asString(value).toLowerCase(); + return API_PORTAL_WORKFLOW_STATUSES.includes( + normalized as ApiPortalWorkflowStatus + ) + ? (normalized as ApiPortalWorkflowStatus) + : API_PORTAL_WORKFLOW_STATUSES[0]; +}; + +const toApiPortalAuthConfig = ( + value: unknown +): ApiPortalAuthConfig | undefined => { + const source = asRecord(value); + const stsTokenUrl = asOptionalString(source.stsTokenUrl); + const clientId = asOptionalString(source.clientId); + if (!stsTokenUrl && !clientId) return undefined; + return { stsTokenUrl, clientId }; +}; + +const toApiPortalMetadata = ( + value: unknown +): ApiPortalMetadata | 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; +}; + +export const toApiPortal = (value: unknown): ApiPortal => { + const source = asRecord(value); + 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: asApiPortalWorkflowStatus(source.workflowStatus), + authType: asApiPortalAuthType(source.authType), + authConfig: toApiPortalAuthConfig(source.authConfig), + metadata: toApiPortalMetadata(source.metadata), + createdAt: asOptionalString(source.createdAt), + updatedAt: asOptionalString(source.updatedAt), + organizationId: asOptionalString(source.organizationId), + }; +}; + const GATEWAY_DEPLOYMENT_STATUSES: GatewayDeploymentStatus[] = [ 'DEPLOYED', 'UNDEPLOYED', diff --git a/portals/api-control-plane/src/api/apiportal/apiPortalClient.ts b/portals/api-control-plane/src/api/apiportal/apiPortalClient.ts new file mode 100644 index 0000000000..4d734e9f87 --- /dev/null +++ b/portals/api-control-plane/src/api/apiportal/apiPortalClient.ts @@ -0,0 +1,172 @@ +/* + * 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 { + CreateApiPortalInput, + ApiPortal, + UpdateApiPortalInput, +} from '../../types/domain'; +import { toApiPortal } from '../adapters'; +import { apiPortals, organizations } from '../mocks/data'; +import { delay, useMockApi } from '../shared/apiClientUtils'; +import { ApiError } from '../types/errors'; + +/** + * API Portal management has no platform-api backend yet (console-only feature + * for now) — unlike gatewayClient, there is no real REST endpoint to call, so + * there's no usePlatformApi() branch. Every method still gates on + * useMockApi() so a non-mock deployment gets an explicit error/empty result + * instead of silently writing to (and reading back from) the in-memory mock + * store as if it were persisted. Swap the `!useMockApi()` branches for a real + * client (platformGet/platformPost against ApiPortalResponse) once + * platform-api adds one. + */ + +const requireOrganizationId = (orgHandle: string): string => { + const organization = organizations.find((item) => item.handle === orgHandle); + if (!organization) { + throw new ApiError('Organization not found', 'NOT_FOUND', 404); + } + return organization.id; +}; + +export async function listApiPortals(orgHandle: string): Promise { + if (!useMockApi()) { + return []; + } + await delay(); + const orgId = requireOrganizationId(orgHandle); + return apiPortals + .filter((item) => item.organizationId === orgId) + .map(toApiPortal); +} + +export async function getApiPortal( + orgHandle: string, + id: string +): Promise { + if (!useMockApi()) { + return undefined; + } + await delay(); + const orgId = requireOrganizationId(orgHandle); + const found = apiPortals.find( + (item) => item.id === id && item.organizationId === orgId + ); + return found ? toApiPortal(found) : undefined; +} + +export async function createApiPortal( + orgHandle: string, + input: CreateApiPortalInput +): Promise { + if (!useMockApi()) { + throw new ApiError('API Portal creation requires the platform API', 'UNKNOWN'); + } + await delay(); + const orgId = requireOrganizationId(orgHandle); + if (apiPortals.some((item) => item.organizationId === orgId && item.handle === input.handle)) { + throw new ApiError( + 'API Portal handle already exists in organization', + 'CONFLICT', + 409 + ); + } + // Picked explicitly (not `...input`) so `authConfig.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 now = new Date().toISOString(); + const apiPortal: ApiPortal = { + id: input.handle, + name: input.name, + handle: input.handle, + description: input.description, + url: input.url, + authType: input.authType, + authConfig: + input.authType === 'oauth2' + ? { + stsTokenUrl: input.authConfig.stsTokenUrl, + clientId: input.authConfig.clientId, + } + : undefined, + metadata: input.metadata, + workflowStatus: input.workflowStatus ?? 'pending', + createdAt: now, + updatedAt: now, + organizationId: orgId, + }; + apiPortals.push(apiPortal); + return toApiPortal(apiPortal); +} + +export async function updateApiPortal( + orgHandle: string, + id: string, + input: UpdateApiPortalInput +): Promise { + if (!useMockApi()) { + throw new ApiError('API Portal update requires the platform API', 'UNKNOWN'); + } + await delay(); + const orgId = requireOrganizationId(orgHandle); + const index = apiPortals.findIndex( + (item) => item.id === id && item.organizationId === orgId + ); + if (index < 0) { + throw new ApiError('API Portal not found', 'NOT_FOUND', 404); + } + // Same reasoning as createApiPortal: only authConfig.clientSecret is excluded. + const updated: ApiPortal = { + ...apiPortals[index], + name: input.name, + description: input.description, + url: input.url, + authType: input.authType, + authConfig: + input.authType === 'oauth2' + ? { + stsTokenUrl: input.authConfig.stsTokenUrl, + clientId: input.authConfig.clientId, + } + : undefined, + metadata: input.metadata ?? apiPortals[index].metadata, + workflowStatus: input.workflowStatus ?? apiPortals[index].workflowStatus, + updatedAt: new Date().toISOString(), + }; + apiPortals[index] = updated; + return toApiPortal(updated); +} + +export async function deleteApiPortal( + orgHandle: string, + id: string +): Promise { + if (!useMockApi()) { + throw new ApiError('API Portal deletion requires the platform API', 'UNKNOWN'); + } + await delay(); + const orgId = requireOrganizationId(orgHandle); + const index = apiPortals.findIndex( + (item) => item.id === id && item.organizationId === orgId + ); + if (index < 0) { + throw new ApiError('API Portal not found', 'NOT_FOUND', 404); + } + 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 9295e34a5e..86bc43f426 100644 --- a/portals/api-control-plane/src/api/hooks/useMvpQueries.ts +++ b/portals/api-control-plane/src/api/hooks/useMvpQueries.ts @@ -25,11 +25,14 @@ import type { ApiDetail, CreateApiInput, CreateApiKeyInput, + CreateApiPortalInput, CreateGatewayInput, CreateProjectInput, DeployApiInput, + ApiPortal, GatewayDeployment, Project, + UpdateApiPortalInput, } from '../../types/domain'; import { useApiClient } from '../ApiClientProvider'; @@ -58,6 +61,9 @@ export const queryKeys = { gateways: (orgHandle: string) => ['gateways', orgHandle] as const, gateway: (orgHandle: string, gatewayId: string) => ['gateway', orgHandle, gatewayId] as const, + apiPortals: (orgHandle: string) => ['apiPortals', orgHandle] as const, + apiPortal: (orgHandle: string, apiPortalId: string) => + ['apiPortal', orgHandle, apiPortalId] as const, }; /** @@ -532,6 +538,97 @@ export const useCreateGatewayToken = ( }); }; +export const useApiPortals = (orgHandleArg?: string) => { + const client = useApiClient(); + const { orgHandle } = useScopeArgs(orgHandleArg); + return useQuery({ + queryKey: queryKeys.apiPortals(orgHandle || ''), + queryFn: () => { + if (!orgHandle) { + throw new Error('orgHandle is required to list API Portals'); + } + return client.listApiPortals(orgHandle); + }, + enabled: !!orgHandle, + }); +}; + +export const useApiPortal = (orgHandleArg?: string, apiPortalId?: string) => { + const client = useApiClient(); + const { orgHandle } = useScopeArgs(orgHandleArg); + return useQuery({ + queryKey: queryKeys.apiPortal(orgHandle || '', apiPortalId || ''), + queryFn: () => { + if (!orgHandle) { + throw new Error('orgHandle is required to fetch an API Portal'); + } + if (!apiPortalId) { + throw new Error('apiPortalId is required to fetch an API Portal'); + } + return client.getApiPortal(orgHandle, apiPortalId); + }, + enabled: !!orgHandle && !!apiPortalId, + }); +}; + +export const useCreateApiPortal = (orgHandleArg?: string) => { + const client = useApiClient(); + const queryClient = useQueryClient(); + const { orgHandle = '' } = useScopeArgs(orgHandleArg); + return useMutation({ + mutationFn: (input: CreateApiPortalInput) => + client.createApiPortal(orgHandle, input), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.apiPortals(orgHandle), + }); + }, + }); +}; + +export const useUpdateApiPortal = ( + orgHandleArg?: string, + apiPortalId = '' +) => { + const client = useApiClient(); + const queryClient = useQueryClient(); + const { orgHandle = '' } = useScopeArgs(orgHandleArg); + return useMutation({ + mutationFn: (input: UpdateApiPortalInput) => + client.updateApiPortal(orgHandle, apiPortalId, input), + onSuccess: (updated) => { + // Write the response into the cache synchronously so callers reading + // this query (e.g. a dirty-state check) see the saved values + // immediately, rather than waiting on the background refetch below. + queryClient.setQueryData( + queryKeys.apiPortal(orgHandle, updated.id), + updated + ); + queryClient.invalidateQueries({ + queryKey: queryKeys.apiPortal(orgHandle, updated.id), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.apiPortals(orgHandle), + }); + }, + }); +}; + +export const useDeleteApiPortal = (orgHandleArg?: string) => { + const client = useApiClient(); + const queryClient = useQueryClient(); + const { orgHandle = '' } = useScopeArgs(orgHandleArg); + return useMutation({ + mutationFn: (apiPortal: ApiPortal) => + client.deleteApiPortal(orgHandle, apiPortal.id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.apiPortals(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..e91cb7e28e 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, + ApiPortal, Environment, Gateway, Organization, @@ -125,6 +126,8 @@ export const gateways: Gateway[] = [ }, ]; +export const apiPortals: ApiPortal[] = []; + 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..d24ab1f03c 100644 --- a/portals/api-control-plane/src/api/mvpApi.ts +++ b/portals/api-control-plane/src/api/mvpApi.ts @@ -44,6 +44,13 @@ export { getGateway, listGateways, } from './gateways/gatewayClient'; +export { + createApiPortal, + deleteApiPortal, + getApiPortal, + listApiPortals, + updateApiPortal, +} from './apiportal/apiPortalClient'; export { listEnvironments } from './environments/environmentClient'; export { getOrganization, diff --git a/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.test.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.test.tsx new file mode 100644 index 0000000000..e31fab7fb7 --- /dev/null +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.test.tsx @@ -0,0 +1,264 @@ +/* + * 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 { Route, Routes } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders, screen, waitFor } from '../../test/utils'; + +// Capture the create mutation so we can assert the submitted input and drive +// success/error callbacks the same way the real mutation would invoke them. +const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() })); +vi.mock('../../api/hooks/useMvpQueries', () => ({ + useCreateApiPortal: () => ({ mutate, isPending: false }), +})); + +import { ApiPortalCreatePage } from './ApiPortalCreatePage'; + +const ORG = 'acme'; +const ROUTE = `/organizations/${ORG}/api-portal/new`; + +function renderPage() { + return renderWithProviders( + + } + /> + API Portal List} + /> + , + { route: ROUTE } + ); +} + +function submitButton() { + return screen.getByRole('button', { name: 'Provision API Portal' }); +} + +function getFieldInput(labelText: string): HTMLInputElement { + return screen.getByLabelText(labelText) as HTMLInputElement; +} + +describe('ApiPortalCreatePage', () => { + beforeEach(() => vi.clearAllMocks()); + + it('disables submit until name, identifier, and URL are all valid', async () => { + const { user } = renderPage(); + expect(submitButton()).toBeDisabled(); + + await user.type( + screen.getByPlaceholderText('Production API Portal'), + 'Production API Portal' + ); + // Identifier auto-derives from the name, but the URL is still missing. + expect(submitButton()).toBeDisabled(); + + await user.type( + screen.getByPlaceholderText('https://api-portal.example.com'), + 'https://api-portal.example.com' + ); + expect(submitButton()).toBeEnabled(); + }); + + it('auto-derives a slugified, locked identifier from the name', async () => { + const { user } = renderPage(); + await user.type( + screen.getByPlaceholderText('Production API Portal'), + 'Prod API Portal!!' + ); + + const identifierField = screen.getByPlaceholderText( + 'prod-api-portal' + ) as HTMLInputElement; + expect(identifierField.value).toBe('prod-api-portal'); + expect(identifierField).toHaveAttribute('readonly'); + }); + + it('rejects an invalid identifier once unlocked', async () => { + const { user } = renderPage(); + await user.type( + screen.getByPlaceholderText('Production API Portal'), + 'AB' + ); + + await user.click(screen.getByRole('button', { name: 'Edit identifier' })); + const identifierField = screen.getByPlaceholderText('prod-api-portal'); + await user.clear(identifierField); + await user.type(identifierField, 'AB'); + + expect( + screen.getByText('Lowercase letters, numbers, hyphens only; 3–64 chars.') + ).toBeInTheDocument(); + expect(submitButton()).toBeDisabled(); + }); + + it('rejects an invalid URL', async () => { + const { user } = renderPage(); + await user.type( + screen.getByPlaceholderText('https://api-portal.example.com'), + 'not-a-url' + ); + expect(screen.getByText('Enter a valid URL')).toBeInTheDocument(); + expect(submitButton()).toBeDisabled(); + }); + + it('requires IdP fields before enabling submit when IdP auth is selected', async () => { + const { user } = renderPage(); + await user.type( + screen.getByPlaceholderText('Production API Portal'), + 'Production API Portal' + ); + await user.type( + screen.getByPlaceholderText('https://api-portal.example.com'), + 'https://api-portal.example.com' + ); + expect(submitButton()).toBeEnabled(); + + await user.click(screen.getByRole('combobox')); + await user.click( + screen.getByRole('option', { name: 'OAuth 2.0 Client Credentials' }) + ); + + // Local auth was valid, but switching to OAuth2 requires its own fields. + expect(submitButton()).toBeDisabled(); + + await user.type( + screen.getByPlaceholderText('https://idp.example.com/oauth2/token'), + 'https://idp.example.com/oauth2/token' + ); + expect(submitButton()).toBeDisabled(); + + await user.type(getFieldInput('Client ID'), 'client-123'); + expect(submitButton()).toBeDisabled(); + + await user.type(getFieldInput('Client secret'), 'shh'); + expect(submitButton()).toBeEnabled(); + }); + + it('submits a local-auth CreateApiPortalInput', async () => { + const { user } = renderPage(); + await user.type( + screen.getByPlaceholderText('Production API Portal'), + 'Production API Portal' + ); + await user.type( + screen.getByPlaceholderText('https://api-portal.example.com'), + ' https://api-portal.example.com ' + ); + + await user.click(submitButton()); + + expect(mutate).toHaveBeenCalledWith( + { + name: 'Production API Portal', + handle: 'production-api-portal', + url: 'https://api-portal.example.com', + description: undefined, + authType: 'local', + }, + expect.any(Object) + ); + }); + + it('submits an OAuth2 CreateApiPortalInput with trimmed fields', async () => { + const { user } = renderPage(); + await user.type( + screen.getByPlaceholderText('Production API Portal'), + 'Production API Portal' + ); + await user.type( + screen.getByPlaceholderText('https://api-portal.example.com'), + 'https://api-portal.example.com' + ); + + await user.click(screen.getByRole('combobox')); + await user.click( + screen.getByRole('option', { name: 'OAuth 2.0 Client Credentials' }) + ); + await user.type( + screen.getByPlaceholderText('https://idp.example.com/oauth2/token'), + ' https://idp.example.com/oauth2/token ' + ); + await user.type(getFieldInput('Client ID'), ' client-123 '); + await user.type(getFieldInput('Client secret'), 'shh'); + + await user.click(submitButton()); + + expect(mutate).toHaveBeenCalledWith( + { + name: 'Production API Portal', + handle: 'production-api-portal', + url: 'https://api-portal.example.com', + description: undefined, + authType: 'oauth2', + authConfig: { + stsTokenUrl: 'https://idp.example.com/oauth2/token', + clientId: 'client-123', + clientSecret: 'shh', + }, + }, + expect.any(Object) + ); + }); + + it('navigates to the API Portal list on success', async () => { + mutate.mockImplementation((_input, { onSuccess }) => + onSuccess({ id: 'portal-1', name: 'Production API Portal' }) + ); + const { user } = renderPage(); + await user.type( + screen.getByPlaceholderText('Production API Portal'), + 'Production API Portal' + ); + await user.type( + screen.getByPlaceholderText('https://api-portal.example.com'), + 'https://api-portal.example.com' + ); + + await user.click(submitButton()); + + await waitFor(() => + expect(screen.getByText('API Portal List')).toBeInTheDocument() + ); + }); + + it('notifies and stays on the page on failure', async () => { + mutate.mockImplementation((_input, { onError }) => + onError(new Error('Handle already in use')) + ); + const { user } = renderPage(); + await user.type( + screen.getByPlaceholderText('Production API Portal'), + 'Production API Portal' + ); + await user.type( + screen.getByPlaceholderText('https://api-portal.example.com'), + 'https://api-portal.example.com' + ); + + await user.click(submitButton()); + + await screen.findByText('Handle already in use'); + expect( + screen.getByPlaceholderText('Production API Portal') + ).toBeInTheDocument(); + }); +}); diff --git a/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.tsx new file mode 100644 index 0000000000..3507d49f6d --- /dev/null +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalCreatePage.tsx @@ -0,0 +1,268 @@ +/* + * 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, + 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 { useCreateApiPortal } from '../../api/hooks/useMvpQueries'; +import { useNotifications } from '../../components/Notifications'; +import { routes } from '../../routes/paths'; +import type { ApiPortalAuthType, CreateApiPortalInput } from '../../types/domain'; +import { isValidUrl } from '../apis/develop/developEdit'; +import { AUTH_TYPE_OPTIONS } from './apiPortalDisplay'; +import { IdpCredentialsFields } from './IdpCredentialsFields'; + +const HANDLE_PATTERN = /^[a-z0-9-]{3,64}$/; + +const slugify = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) + .replace(/^-+|-+$/g, ''); + +export function ApiPortalCreatePage() { + const { orgHandle = '' } = useParams(); + const navigate = useNavigate(); + const { notify } = useNotifications(); + const createApiPortal = useCreateApiPortal(orgHandle); + + 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 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( + AUTH_TYPE_OPTIONS[0].value + ); + const [url, setUrl] = useState(''); + const [stsTokenUrl, setStsTokenUrl] = useState(''); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + + const onDisplayNameChange = (value: string) => { + setDisplayName(value); + if (!handleEdited) setHandle(slugify(value)); + }; + + const handleValid = HANDLE_PATTERN.test(handle); + const urlValid = url.trim() !== '' && isValidUrl(url); + const isOAuth2 = authType === 'oauth2'; + const idpFieldsValid = + !isOAuth2 || + (stsTokenUrl.trim() !== '' && + isValidUrl(stsTokenUrl) && + clientId.trim() !== '' && + clientSecret.trim() !== ''); + const canSubmit = + displayName.trim() !== '' && + handleValid && + urlValid && + idpFieldsValid && + !createApiPortal.isPending; + + const submit = () => { + const basePayload = { + name: displayName, + handle, + url: url.trim(), + description: description || undefined, + }; + const input: CreateApiPortalInput = isOAuth2 + ? { + ...basePayload, + authType: 'oauth2', + authConfig: { + stsTokenUrl: stsTokenUrl.trim(), + clientId: clientId.trim(), + clientSecret, + }, + } + : { ...basePayload, authType: 'local' }; + createApiPortal.mutate(input, { + onSuccess: (apiPortal) => { + notify(`API Portal "${apiPortal.name}" provisioned.`, 'success'); + navigate(routes.apiPortal(orgHandle)); + }, + onError: (error) => + notify( + error instanceof Error + ? error.message + : 'Failed to provision API Portal', + 'error' + ), + }); + }; + + return ( + + + + Back to API Portal + + Provision an API Portal + + Register an API Portal, then connect it to the platform. + + + + + + + Name + onDisplayNameChange(event.target.value)} + placeholder="Production API Portal" + value={displayName} + /> + + + + Identifier + { + setHandleEdited(true); + setHandle(event.target.value); + }} + placeholder="prod-api-portal" + slotProps={{ + input: { + endAdornment: handleLocked && ( + + + setHandleLocked(false)} + size="small" + > + + + + + ), + readOnly: handleLocked, + }, + }} + value={handle} + /> + + + + + Description (optional) + + setDescription(event.target.value)} + value={description} + /> + + + + Authentication + + + + {isOAuth2 && ( + + )} + + + URL + setUrl(event.target.value)} + placeholder="https://api-portal.example.com" + value={url} + /> + + + + + + + + + + ); +} diff --git a/portals/api-control-plane/src/features/apiportal/ApiPortalDetailPage.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalDetailPage.tsx new file mode 100644 index 0000000000..7bc298b789 --- /dev/null +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalDetailPage.tsx @@ -0,0 +1,338 @@ +/* + * 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, + Card, + CardContent, + Chip, + Grid, + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + PageContent, + PageTitle, + Stack, + Tooltip, + Typography, +} from '@wso2/oxygen-ui'; +import { + Check, + Copy, + MoreVertical, + Pencil, + Trash2, +} from '@wso2/oxygen-ui-icons-react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; + +import { useApiPortal, useDeleteApiPortal } from '../../api/hooks/useMvpQueries'; +import { ConfirmDialog } from '../../components/ConfirmDialog'; +import { useNotifications } from '../../components/Notifications'; +import { ErrorState, LoadingState } from '../../components/StateViews'; +import { routes } from '../../routes/paths'; +import type { ApiPortal } from '../../types/domain'; +import { relativeTime } from '../../utils/relativeTime'; +import { + AUTH_LABEL, + STATUS_CHIP_COLOR, + STATUS_LABEL, +} from './apiPortalDisplay'; + +function CopyableInline({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + window.setTimeout(() => setCopied(false), 1400); + } catch { + /* clipboard blocked — swallow rather than surface an error toast */ + } + }; + return ( + + + {value} + + + + {copied ? : } + + + + ); +} + +function ReadOnlyRow({ + label, + value, + copyable, + monospace, +}: { + label: string; + value: React.ReactNode; + copyable?: string; + monospace?: boolean; +}) { + return ( + + + {label} + + {copyable !== undefined ? ( + + ) : monospace ? ( + + {value} + + ) : ( + {value} + )} + + ); +} + +export function ApiPortalDetailPage() { + const { orgHandle = '', apiPortalId = '' } = useParams(); + const navigate = useNavigate(); + const { notify } = useNotifications(); + const apiPortalQuery = useApiPortal(orgHandle, apiPortalId); + const deleteApiPortal = useDeleteApiPortal(orgHandle); + + const [menuAnchor, setMenuAnchor] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + + if (apiPortalQuery.isLoading) { + return ; + } + if (apiPortalQuery.error) { + return ; + } + if (!apiPortalQuery.data) { + return ; + } + + const apiPortal: ApiPortal = apiPortalQuery.data; + const isOAuth2 = apiPortal.authType === 'oauth2'; + + const goEdit = () => + navigate(routes.apiPortalEdit(orgHandle, apiPortal.id)); + + const doDelete = () => { + deleteApiPortal.mutate(apiPortal, { + onSuccess: () => { + notify(`Deleted "${apiPortal.name}".`, 'success'); + navigate(routes.apiPortal(orgHandle)); + }, + onError: (error) => + notify( + error instanceof Error ? error.message : 'Delete failed', + 'error' + ), + }); + }; + + return ( + + + + Back to API Portal + + {apiPortal.name} + + {apiPortal.url || apiPortal.handle} + + + + + setMenuAnchor(event.currentTarget)} + size="small" + > + + + setMenuAnchor(null)} + open={Boolean(menuAnchor)} + > + { + setMenuAnchor(null); + setConfirmDelete(true); + }} + sx={{ color: 'error.main' }} + > + + + + Delete + + + + + + + + {/* Overview */} + + + + + Overview + + + Read-only view of the registered API Portal. Use Edit to change + any field. + + + + + + {apiPortal.description && ( + + )} + + + } + /> + + {isOAuth2 ? ( + + + IDP CLIENT CREDENTIALS + + + + + + + + ) : ( + + Platform API mints its own signed JWT with its configured + key to authenticate to this portal. + + )} + + + + + + {/* Details rail */} + + + + + Details + + + + + + + {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..1f14283c2b --- /dev/null +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalEditPage.tsx @@ -0,0 +1,298 @@ +/* + * 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, + ApiPortalWorkflowStatus, + UpdateApiPortalInput, +} from '../../types/domain'; +import { isValidUrl } from '../apis/develop/developEdit'; +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(); + 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 [workflowStatus, setWorkflowStatus] = + useState('pending'); + 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 || ''); + 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 + // 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 || '') || + workflowStatus !== apiPortal.workflowStatus || + 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, + workflowStatus, + }; + 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} + /> + + + + Workflow status + + + + + 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/features/apiportal/ApiPortalPage.tsx b/portals/api-control-plane/src/features/apiportal/ApiPortalPage.tsx new file mode 100644 index 0000000000..b5b655772c --- /dev/null +++ b/portals/api-control-plane/src/features/apiportal/ApiPortalPage.tsx @@ -0,0 +1,785 @@ +/* + * 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 { useMemo, useRef, useState } from 'react'; +import { + alpha, + Box, + Button, + Card, + IconButton, + InputAdornment, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + PageContent, + PageTitle, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from '@wso2/oxygen-ui'; +import { + Check, + Clock, + Copy, + Globe, + LayoutGrid, + List, + MoreVertical, + Plus, + Search, + ShieldCheck, + Trash2, +} from '@wso2/oxygen-ui-icons-react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { useDeleteApiPortal, useApiPortals } from '../../api/hooks/useMvpQueries'; +import { ConfirmDialog } from '../../components/ConfirmDialog'; +import { useNotifications } from '../../components/Notifications'; +import { + EmptyState, + ErrorState, + LoadingState, +} from '../../components/StateViews'; +import { routes } from '../../routes/paths'; +import { relativeTime } from '../../utils/relativeTime'; +import type { ApiPortal } from '../../types/domain'; +import { AUTH_LABEL, STATUS_COLOR, STATUS_LABEL } from './apiPortalDisplay'; + +type ViewMode = 'grid' | 'list'; + +/** A small KPI summary tile. */ +function StatCard({ + label, + value, + dotColor, +}: { + label: string; + value: number; + dotColor: string; +}) { + return ( + + + + + {label} + + + + {value} + + + ); +} + +function ApiPortalCard({ + apiPortal, + onOpen, + onDelete, + openMenuId, + onMenuOpenChange, +}: { + apiPortal: ApiPortal; + onOpen: (apiPortal: ApiPortal) => void; + onDelete?: (apiPortal: ApiPortal) => void; + openMenuId: string | null; + onMenuOpenChange: (id: string | null) => void; +}) { + const [copied, setCopied] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(null); + 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 a card's + // onClick underneath it — dismissing a menu by clicking elsewhere would + // otherwise also open whichever API Portal was clicked, including a + // different card than the one whose menu was open. mousedown always fires + // before click, so capturing the page-level "was some menu open" state + // 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 (!apiPortal.url) return; + navigator.clipboard + ?.writeText(apiPortal.url) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1400); + }) + .catch(() => undefined); + }; + + const closeMenu = (event?: React.MouseEvent) => { + event?.stopPropagation(); + setMenuAnchor(null); + onMenuOpenChange(null); + }; + + 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 ( + { + if (wasMenuOpenRef.current) { + wasMenuOpenRef.current = false; + return; + } + onOpen(apiPortal); + }} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen(apiPortal); + } + }} + onMouseDown={() => { + wasMenuOpenRef.current = openMenuId !== null; + }} + role="button" + tabIndex={0} + 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)', + }, + '&:focus-visible': { + outline: (t) => `2px solid ${t.palette.primary.main}`, + outlineOffset: 2, + }, + }} + > + + + + + + + {apiPortal.name} + + {apiPortal.url && ( + + + {apiPortal.url} + + + + {copied ? : } + + + + )} + + {onDelete && ( + <> + + { + event.stopPropagation(); + setMenuAnchor(event.currentTarget); + onMenuOpenChange(apiPortal.id); + }} + size="small" + sx={{ alignSelf: 'flex-start', flex: 'none' }} + > + + + + closeMenu()} + open={Boolean(menuAnchor)} + > + { + closeMenu(event); + onDelete(apiPortal); + }} + sx={{ color: 'error.main' }} + > + + + + Delete + + + + )} + + + {apiPortal.description && ( + + {apiPortal.description} + + )} + + + alpha(t.palette.primary.main, 0.14), + borderColor: (t) => alpha(t.palette.primary.main, 0.3), + color: 'primary.main', + fontWeight: 600, + }} + > + + {AUTH_LABEL[apiPortal.authType]} + + + + + + + + {STATUS_LABEL[apiPortal.workflowStatus]} + + + {apiPortal.createdAt && ( + + + + {relativeTime(apiPortal.createdAt)} + + + )} + + + ); +} + +function ApiPortalRow({ + apiPortal, + onOpen, + onDelete, + openMenuId, + onMenuOpenChange, +}: { + apiPortal: ApiPortal; + onOpen: (apiPortal: ApiPortal) => void; + onDelete?: (apiPortal: ApiPortal) => void; + openMenuId: string | null; + onMenuOpenChange: (id: string | null) => void; +}) { + const [menuAnchor, setMenuAnchor] = useState(null); + 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) => { + event?.stopPropagation(); + setMenuAnchor(null); + onMenuOpenChange(null); + }; + + return ( + { + if (wasMenuOpenRef.current) { + wasMenuOpenRef.current = false; + return; + } + onOpen(apiPortal); + }} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen(apiPortal); + } + }} + onMouseDown={() => { + wasMenuOpenRef.current = openMenuId !== null; + }} + role="button" + tabIndex={0} + 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 }, + '&:focus-visible': { + outline: (t) => `2px solid ${t.palette.primary.main}`, + outlineOffset: 2, + }, + }} + > + + + + + + {apiPortal.name} + + + {apiPortal.url || apiPortal.handle} + + + + + + {AUTH_LABEL[apiPortal.authType]} + + + + + + {STATUS_LABEL[apiPortal.workflowStatus]} + + + + + + {apiPortal.createdAt ? relativeTime(apiPortal.createdAt) : '—'} + + + {onDelete && ( + <> + { + event.stopPropagation(); + setMenuAnchor(event.currentTarget); + onMenuOpenChange(apiPortal.id); + }} + size="small" + sx={{ ml: { md: 0, xs: 'auto' } }} + > + + + closeMenu()} + open={Boolean(menuAnchor)} + > + { + closeMenu(event); + onDelete(apiPortal); + }} + sx={{ color: 'error.main' }} + > + + + + Delete + + + + )} + + ); +} + +/** Compact row layout for API Portals — the list-view counterpart of the card grid. */ +function ApiPortalListView({ + apiPortals, + onOpen, + onDelete, + openMenuId, + onMenuOpenChange, +}: { + apiPortals: ApiPortal[]; + onOpen: (apiPortal: ApiPortal) => void; + onDelete?: (apiPortal: ApiPortal) => void; + openMenuId: string | null; + onMenuOpenChange: (id: string | null) => void; +}) { + return ( + + {apiPortals.map((apiPortal) => ( + + ))} + + ); +} + +export function ApiPortalPage() { + const { orgHandle = '' } = useParams(); + const navigate = useNavigate(); + const { notify } = useNotifications(); + const apiPortalsQuery = useApiPortals(); + const deleteApiPortalMutation = useDeleteApiPortal(); + const [search, setSearch] = useState(''); + const [view, setView] = useState('grid'); + const [toDelete, setToDelete] = useState(null); + // Shared across every card/row so opening one item's actions menu, then + // clicking a DIFFERENT item, dismisses the menu instead of also opening + // that item — see the click-away guard comment in ApiPortalCard. + const [openMenuId, setOpenMenuId] = useState(null); + + const provision = () => navigate(routes.newApiPortal(orgHandle)); + const openApiPortal = (apiPortal: ApiPortal) => + navigate(routes.apiPortalDetail(orgHandle, apiPortal.id)); + + const confirmDelete = () => { + if (!toDelete) return; + deleteApiPortalMutation.mutate(toDelete, { + onSuccess: () => { + notify(`Deleted "${toDelete.name}".`, 'success'); + setToDelete(null); + }, + onError: (error) => + notify( + error instanceof Error ? error.message : 'Delete failed', + 'error' + ), + }); + }; + + const apiPortals = useMemo( + () => apiPortalsQuery.data || [], + [apiPortalsQuery.data] + ); + + const filtered = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) return apiPortals; + return apiPortals.filter((apiPortal) => + [apiPortal.name, apiPortal.handle, apiPortal.url] + .filter(Boolean) + .some((field) => field!.toLowerCase().includes(term)) + ); + }, [apiPortals, search]); + + const activeCount = apiPortals.filter( + (apiPortal) => apiPortal.workflowStatus === 'active' + ).length; + + return ( + + + API Portal + + Provision and manage the API Portal for your organization. + + + + + + + {apiPortalsQuery.isLoading ? ( + + ) : apiPortalsQuery.error ? ( + + ) : apiPortals.length === 0 ? ( + + ) : ( + + {/* KPI summary */} + + + + + + {/* Toolbar */} + + setSearch(event.target.value)} + placeholder="Search API 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' ? ( + + {filtered.map((apiPortal) => ( + + ))} + + ) : ( + + )} + + )} + + setToDelete(null)} + onConfirm={confirmDelete} + open={toDelete !== null} + title="Delete API Portal" + /> + + ); +} diff --git a/portals/api-control-plane/src/features/apiportal/IdpCredentialsFields.tsx b/portals/api-control-plane/src/features/apiportal/IdpCredentialsFields.tsx new file mode 100644 index 0000000000..83078aec21 --- /dev/null +++ b/portals/api-control-plane/src/features/apiportal/IdpCredentialsFields.tsx @@ -0,0 +1,133 @@ +/* + * 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'; + +import { isValidUrl } from '../apis/develop/developEdit'; + +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 `oauth2` — shared by the create and detail/edit API Portal + * pages so the group never drifts between the two. + */ +export function IdpCredentialsFields({ + stsTokenUrl, + onStsTokenUrlChange, + clientId, + onClientIdChange, + clientSecret, + onClientSecretChange, +}: IdpCredentialsFieldsProps) { + const [secretVisible, setSecretVisible] = useState(false); + const stsTokenUrlInvalid = stsTokenUrl.trim() !== '' && !isValidUrl(stsTokenUrl); + + 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/apiportal/apiPortalDisplay.ts b/portals/api-control-plane/src/features/apiportal/apiPortalDisplay.ts new file mode 100644 index 0000000000..8e68ba025b --- /dev/null +++ b/portals/api-control-plane/src/features/apiportal/apiPortalDisplay.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 { 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: ApiPortalAuthType; label: string }[] = [ + { value: 'local', label: 'Local' }, + { value: 'oauth2', label: 'OAuth 2.0 Client Credentials' }, +]; + +export const AUTH_LABEL: Record = { + local: 'Local', + oauth2: 'OAuth 2.0 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< + ApiPortalWorkflowStatus, + 'warning' | 'success' | 'error' +> = { + pending: 'warning', + active: 'success', + failed: 'error', +}; 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: () => 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; diff --git a/portals/api-control-plane/src/features/settings/SettingsPage.tsx b/portals/api-control-plane/src/features/settings/SettingsPage.tsx index 7bf121d030..847784f947 100644 --- a/portals/api-control-plane/src/features/settings/SettingsPage.tsx +++ b/portals/api-control-plane/src/features/settings/SettingsPage.tsx @@ -32,7 +32,7 @@ export function SettingsPage() { Advanced organization admin settings, governance, marketplace, and - developer portal configuration are intentionally excluded from the + API Portal configuration are intentionally excluded from the MVP replacement app. diff --git a/portals/api-control-plane/src/navigation/navigationRegistry.tsx b/portals/api-control-plane/src/navigation/navigationRegistry.tsx index 7b728cddd6..6d50c790ce 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, @@ -26,6 +27,7 @@ import { Terminal, } from '@wso2/oxygen-ui-icons-react'; +import { useMockApi as isMockApiEnabled } from '../api/shared/apiClientUtils'; import { routes } from '../routes/paths'; import type { NavigationDefinition } from './navigationTypes'; @@ -60,6 +62,23 @@ export const navigationRegistry: NavigationDefinition[] = [ params.orgHandle ? routes.gateways(params.orgHandle) : undefined, match: (pathname) => /\/organizations\/[^/]+\/gateways(\/[^/]+)?$/.test(pathname), }, + { + id: 'api-portal', + label: 'API Portal', + level: 'organization', + order: 35, + icon: , + // API Portal has no platform-api backend yet — see apiPortalClient.ts. + // Hide the nav entry outside mock mode until a real backend exists. + isVisible: () => isMockApiEnabled(), + to: ({ params }) => + params.orgHandle ? routes.apiPortal(params.orgHandle) : undefined, + match: (pathname) => + // Matches the list (…/api-portal), detail (…/api-portal/:id), and edit + // (…/api-portal/:id/edit) routes — the nav item stays active across all + // three so a user editing a portal still sees where they are in the tree. + /\/organizations\/[^/]+\/api-portal(\/.*)?$/.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..9f3a583bf6 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.tsx @@ -28,6 +28,7 @@ import { SessionExpiredPage, UnauthorizedPage, } from '../features/system/SystemPages'; +import { useMockApi } from '../api/shared/apiClientUtils'; import { ConsoleScopeProvider } from '../scope/ConsoleScopeProvider'; import AppLayout from '../layouts/AppLayout'; import { ProtectedRoute } from './ProtectedRoute'; @@ -60,6 +61,26 @@ const GatewayDetailPage = lazy(() => default: m.GatewayDetailPage, })) ); +const ApiPortalPage = lazy(() => + import('../features/apiportal/ApiPortalPage').then((m) => ({ + default: m.ApiPortalPage, + })) +); +const ApiPortalCreatePage = lazy(() => + import('../features/apiportal/ApiPortalCreatePage').then((m) => ({ + default: m.ApiPortalCreatePage, + })) +); +const ApiPortalDetailPage = lazy(() => + import('../features/apiportal/ApiPortalDetailPage').then((m) => ({ + 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, @@ -124,6 +145,23 @@ export function AppRoutes() { } /> } /> } /> + {useMockApi() && ( + <> + } /> + } + /> + } + /> + } + /> + + )} } /> } /> } /> diff --git a/portals/api-control-plane/src/routes/paths.ts b/portals/api-control-plane/src/routes/paths.ts index 313aad8df6..ffa9f8c8a4 100644 --- a/portals/api-control-plane/src/routes/paths.ts +++ b/portals/api-control-plane/src/routes/paths.ts @@ -34,6 +34,18 @@ export const routes = { `/organizations/${orgHandle}/gateways/new`, gateway: (orgHandle = ':orgHandle', gatewayId = ':gatewayId') => `/organizations/${orgHandle}/gateways/${gatewayId}`, + apiPortal: (orgHandle = ':orgHandle') => + `/organizations/${orgHandle}/api-portal`, + newApiPortal: (orgHandle = ':orgHandle') => + `/organizations/${orgHandle}/api-portal/new`, + apiPortalDetail: ( + 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') => diff --git a/portals/api-control-plane/src/types/domain.ts b/portals/api-control-plane/src/types/domain.ts index 6d3d31bdf3..820dbaa0e6 100644 --- a/portals/api-control-plane/src/types/domain.ts +++ b/portals/api-control-plane/src/types/domain.ts @@ -246,6 +246,105 @@ export type GatewayToken = { message?: string; }; +/** How the platform authenticates to an API Portal instance. */ +export type ApiPortalAuthType = 'local' | 'oauth2'; + +/** Provisioning state of an API Portal (platform-api ApiPortalResponse.workflowStatus). */ +export type ApiPortalWorkflowStatus = 'pending' | 'active' | 'failed'; + +/** + * Outbound-auth material Platform API uses when calling the portal admin API. + * Shape depends on the portal's authType: + * - 'local' → empty. + * - 'oauth2' → stsTokenUrl + clientId (clientSecret is write-only, never + * returned in responses, so it's intentionally absent from this type). + */ +export type ApiPortalAuthConfig = { + stsTokenUrl?: string; + clientId?: string; +}; + +/** + * Free-form pass-through metadata for the portal instance (e.g. cloud-side + * OIDC endpoints written by the cloud plugin). Platform API stores and + * returns this as-is; it does not participate in outbound authentication. + */ +export type ApiPortalMetadata = Record; + +/** Maps 1:1 to platform-api's ApiPortalResponse schema. */ +export type ApiPortal = { + id: string; + name: string; + handle: string; + description?: string; + url?: string; + workflowStatus: ApiPortalWorkflowStatus; + authType: ApiPortalAuthType; + authConfig?: ApiPortalAuthConfig; + metadata?: ApiPortalMetadata; + createdAt?: string; + updatedAt?: string; + organizationId?: string; +}; + +/** + * `workflowStatus` on create is restricted to 'pending' or 'active'; a portal + * is never created in a failed state. 'active' additionally requires a + * non-empty url — platform-api rejects the request otherwise. + */ +export type CreateApiPortalInput = + | { + name: string; + handle: string; + url: string; + authType: 'local'; + description?: string; + workflowStatus?: 'pending' | 'active'; + metadata?: ApiPortalMetadata; + } + | { + name: string; + handle: string; + url: string; + authType: 'oauth2'; + description?: string; + workflowStatus?: 'pending' | 'active'; + authConfig: { + stsTokenUrl: string; + clientId: string; + clientSecret: string; + }; + metadata?: ApiPortalMetadata; + }; + +/** + * `handle` is set at creation and not editable afterwards. clientSecret stays + * optional even for 'oauth2' — it's write-only and never returned, so + * omitting it means "keep the existing secret". + */ +export type UpdateApiPortalInput = + | { + name: string; + url: string; + authType: 'local'; + description?: string; + workflowStatus?: ApiPortalWorkflowStatus; + metadata?: ApiPortalMetadata; + } + | { + name: string; + url: string; + authType: 'oauth2'; + description?: string; + workflowStatus?: ApiPortalWorkflowStatus; + authConfig: { + stsTokenUrl: string; + clientId: string; + clientSecret?: string; + }; + metadata?: ApiPortalMetadata; + }; + /** * 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).