Skip to content
Open
Show file tree
Hide file tree
Changes from all 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