Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion platform-api/src/internal/service/devportal_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ func (s *DevPortalService) UpdateDevPortal(uuid, orgUUID string, req *dto.Update
if req.Hostname != nil {
devPortal.Hostname = *req.Hostname
}
if req.APIKey != nil {
if req.APIKey != nil && strings.Trim(*req.APIKey, "*") != "" {
devPortal.APIKey = *req.APIKey
}
if req.HeaderKeyName != nil {
Expand Down
21 changes: 14 additions & 7 deletions portals/management-portal/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { Box, Button, Typography } from "@mui/material";
import React from 'react';
import { Box, Button, Typography } from '@mui/material';

type Props = {
children: React.ReactNode;
Expand Down Expand Up @@ -34,21 +34,28 @@ class ErrorBoundary extends React.Component<Props, State> {
};

renderFallback() {
const { fallbackTitle = "Something went wrong", fallbackMessage = "An unexpected error occurred. You can retry or navigate away." } = this.props;
const {
fallbackTitle = 'Something went wrong',
fallbackMessage = 'An unexpected error occurred. You can retry or navigate away.',
} = this.props;

return (
<Box sx={{ p: 4, textAlign: "center" }} role="alert">
<Box sx={{ p: 4, textAlign: 'center' }} role="alert">
<Typography variant="h5" gutterBottom>
{fallbackTitle}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{fallbackMessage}
</Typography>
<Box sx={{ display: "flex", justifyContent: "center", gap: 2 }}>
<Button variant="contained" onClick={this.handleRetry} sx={{ textTransform: "none" }}>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2 }}>
<Button
variant="contained"
onClick={this.handleRetry}
sx={{ textTransform: 'none' }}
>
Retry
</Button>
<Button variant="outlined" href="/" sx={{ textTransform: "none" }}>
<Button variant="outlined" href="/" sx={{ textTransform: 'none' }}>
Home
</Button>
</Box>
Expand Down
24 changes: 21 additions & 3 deletions portals/management-portal/src/constants/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const PORTAL_CONSTANTS = {
DEFAULT_VISIBILITY_LABEL: 'Private',
DEFAULT_HEADER_KEY_NAME: 'x-wso2-api-key',
DEFAULT_LOGO_ALT: 'Portal logo',
API_KEY_MASK: '**********',

// Portal types
PORTAL_TYPES: {
Expand Down Expand Up @@ -36,8 +37,23 @@ export const PORTAL_CONSTANTS = {
PORTAL_ACTIVATED: 'Developer Portal activated successfully.',
ACTIVATION_FAILED: 'Failed to activate Developer Portal.',
CREATION_FAILED: 'Failed to create Developer Portal.',
UPDATE_FAILED: 'Failed to update Developer Portal.',
FETCH_DEVPORTALS_FAILED: 'Failed to fetch devportals',
CREATE_DEVPORTAL_FAILED: 'Failed to create devportal',
UPDATE_DEVPORTAL_FAILED: 'Failed to update devportal',
DELETE_DEVPORTAL_FAILED: 'Failed to delete devportal',
FETCH_PORTAL_DETAILS_FAILED: 'Failed to fetch portal details',
ACTIVATE_DEVPORTAL_FAILED: 'Failed to activate devportal',
PUBLISH_FAILED: 'Failed to publish',
NO_PORTAL_SELECTED: 'No portal selected',
PROVIDE_API_NAME_AND_URL: 'Please provide API Name and Production URL',
PUBLISH_THEME_FAILED: 'Failed to publish theme',
PROMO_ACTION_FAILED: 'Promo action failed',
REFRESH_PUBLICATIONS_FAILED: 'Failed to refresh publications after publish',
API_PUBLISH_CONTEXT_ERROR: 'useApiPublishing must be used within an ApiPublishProvider',
LOADING_ERROR: 'An error occurred while loading developer portals.',
URL_NOT_AVAILABLE: 'Portal URL is not available until the portal is activated',
URL_NOT_AVAILABLE:
'Portal URL is not available until the portal is activated',
OPEN_PORTAL_URL: 'Open portal URL',
} as const,

Expand All @@ -52,5 +68,7 @@ export const PORTAL_CONSTANTS = {
} as const;

// Type helpers
export type PortalType = typeof PORTAL_CONSTANTS.PORTAL_TYPES[keyof typeof PORTAL_CONSTANTS.PORTAL_TYPES];
export type PortalMode = typeof PORTAL_CONSTANTS.MODES[keyof typeof PORTAL_CONSTANTS.MODES];
export type PortalType =
(typeof PORTAL_CONSTANTS.PORTAL_TYPES)[keyof typeof PORTAL_CONSTANTS.PORTAL_TYPES];
export type PortalMode =
(typeof PORTAL_CONSTANTS.MODES)[keyof typeof PORTAL_CONSTANTS.MODES];
176 changes: 101 additions & 75 deletions portals/management-portal/src/context/ApiPublishContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,159 +5,183 @@ import {
useEffect,
useMemo,
useState,
useRef,
type ReactNode,
} from "react";
} from 'react';

import {
useApiPublishApi,
type UnpublishResponse,
type ApiPublicationWithPortal,
type ApiPublishPayload,
type PublishResponse,
} from "../hooks/apiPublish";
import { useOrganization } from "./OrganizationContext";

type ApiPublishContextValue = {
publishedApis: Record<string, ApiPublicationWithPortal[]>; // keyed by apiId
import { useOrganization } from './OrganizationContext';

/* -------------------------------------------------------------------------- */
/* Type Definitions */
/* -------------------------------------------------------------------------- */

export type ApiPublishContextValue = {
publishedApis: ApiPublicationWithPortal[];
loading: boolean;
error: string | null;

refreshPublishedApis: (apiId: string) => Promise<ApiPublicationWithPortal[]>;
publishApiToDevPortal: (apiId: string, payload: ApiPublishPayload) => Promise<PublishResult | void>;
publishApiToDevPortal: (
apiId: string,
payload: ApiPublishPayload
) => Promise<PublishResponse | void>;
unpublishApiFromDevPortal: (
apiId: string,
devPortalId: string
) => Promise<UnpublishResponse>;
getPublishStatus: (apiId: string, devPortalId: string) => ApiPublicationWithPortal | undefined;
getPublication: (
devPortalId: string
) => ApiPublicationWithPortal | undefined;
clearPublishedApis: () => void;
};

// The publish hook returns a PublishResponse type; expose that as the context result
type PublishResult = PublishResponse;
type ApiPublishProviderProps = { children: ReactNode };

const ApiPublishContext = createContext<ApiPublishContextValue | undefined>(undefined);
/* -------------------------------------------------------------------------- */
/* Context */
/* -------------------------------------------------------------------------- */

type ApiPublishProviderProps = {
children: ReactNode;
};
const ApiPublishContext = createContext<ApiPublishContextValue | undefined>(
undefined
);

/* -------------------------------------------------------------------------- */
/* Provider */
/* -------------------------------------------------------------------------- */

export const ApiPublishProvider = ({ children }: ApiPublishProviderProps) => {
const { organization, loading: organizationLoading } = useOrganization();
const { organization, loading: orgLoading } = useOrganization();
const {
fetchPublications,
publishApiToDevPortal: publishRequest,
unpublishApiFromDevPortal: unpublishRequest,
} = useApiPublishApi();

const [publishedApis, setPublishedApis] = useState<
ApiPublicationWithPortal[]
>([]);
const [loading, setLoading] = useState(false);

const { fetchPublications, publishApiToDevPortal: publishRequest, unpublishApiFromDevPortal: unpublishRequest } =
useApiPublishApi();
// Ref to track previous organization to avoid unnecessary clears
const prevOrgRef = useRef<string | undefined>(undefined);

const [publishedApis, setPublishedApis] = useState<Record<string, ApiPublicationWithPortal[]>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/* ------------------------------- API Actions ------------------------------ */

/** Refresh publications for a specific API */
const refreshPublishedApis = useCallback(
async (apiId: string) => {
setLoading(true);
setError(null);

try {
const publishedList = await fetchPublications(apiId);
setPublishedApis((prev) => ({ ...prev, [apiId]: publishedList }));
return publishedList;
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch published APIs";
setError(message);
throw err;
const list = await fetchPublications(apiId);
setPublishedApis(list);
return list;
} finally {
setLoading(false);
}
},
[fetchPublications]
);

/** Publish API to devportal */
const publishApiToDevPortal = useCallback(
async (apiId: string, payload: ApiPublishPayload): Promise<PublishResult | void> => {
setError(null);
async (apiId: string, payload: ApiPublishPayload) => {
setLoading(true);

try {
const res = await publishRequest(apiId, payload);
const response = await publishRequest(apiId, payload);

// If backend returned a publication or reference, attempt to refresh/merge
// Refresh state after a successful publish
try {
await refreshPublishedApis(apiId);
} catch {
// swallow — refresh failure will be exposed via error state already
// Publish succeeded but refresh failed - user may need to manually refresh
console.warn('Failed to refresh publications after publish');
}

return res;
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to publish API to devportal";
setError(message);
throw err;
return response;
} finally {
setLoading(false);
}
},
[publishRequest, refreshPublishedApis]
);


const getPublishStatus = useCallback(
(apiId: string, devPortalId: string) => {
const apiPublished = publishedApis[apiId] || [];
return apiPublished.find(p => p.uuid === devPortalId);
},
[publishedApis]
);

/** Unpublish API from devportal */
const unpublishApiFromDevPortal = useCallback(
async (apiId: string, devPortalId: string) => {
setError(null);
setLoading(true);

try {
const result = await unpublishRequest(apiId, devPortalId);

// Update local state: remove the devPortal entry from publishedApis[apiId]
setPublishedApis((prev) => {
const list = prev[apiId] ?? [];
const nextList = list.filter((p) => p.uuid !== devPortalId);
return { ...prev, [apiId]: nextList };
});
// Update local state
setPublishedApis((prev) => prev.filter((p) => p.uuid !== devPortalId));

return result;
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to unpublish API from devportal";
setError(message);
throw err;
} finally {
setLoading(false);
}
},
[unpublishRequest]
);

// Clear published APIs when organization changes (including when switching between orgs)
/** Get publish status for specific (apiId, devPortalId) */
const getPublication = useCallback(
(devPortalId: string) => {
return publishedApis.find((p) => p.uuid === devPortalId);
},
[publishedApis]
);

/** Clear published APIs */
const clearPublishedApis = useCallback(() => {
setPublishedApis([]);
}, []);

/* ----------------------------- Organization Switch ----------------------------- */

useEffect(() => {
if (organizationLoading) return;
// Always clear when org changes, not just when it becomes null
// This prevents data leakage when switching between organizations
setPublishedApis({});
setLoading(false);
}, [organization, organizationLoading]);
if (orgLoading) return;

const currentOrgId = organization?.id;
const prevOrgId = prevOrgRef.current;

// Only clear data if organization actually changed
if (currentOrgId !== prevOrgId) {
setPublishedApis([]);
setLoading(false);
prevOrgRef.current = currentOrgId;
}
}, [organization, orgLoading]);

/* -------------------------------- Context Value ------------------------------- */

const value = useMemo<ApiPublishContextValue>(
() => ({
publishedApis,
loading,
error,
refreshPublishedApis,
publishApiToDevPortal,
unpublishApiFromDevPortal,
getPublishStatus,
getPublication,
clearPublishedApis,
}),
[
publishedApis,
loading,
error,
refreshPublishedApis,
publishApiToDevPortal,
unpublishApiFromDevPortal,
getPublishStatus,
getPublication,
clearPublishedApis,
]
);

Expand All @@ -168,12 +192,14 @@ export const ApiPublishProvider = ({ children }: ApiPublishProviderProps) => {
);
};

export const useApiPublishing = () => {
const context = useContext(ApiPublishContext);
/* -------------------------------------------------------------------------- */
/* Consumer Hook */
/* -------------------------------------------------------------------------- */

if (!context) {
throw new Error("useApiPublishing must be used within an ApiPublishProvider");
export const useApiPublishing = () => {
const ctx = useContext(ApiPublishContext);
if (!ctx) {
throw new Error('useApiPublishing must be used within an ApiPublishProvider');
}

return context;
};
return ctx;
};
Loading