diff --git a/apps/csm-portal/microapp/package.json b/apps/csm-portal/microapp/package.json
index 8664a2a26c..0df8f6ddd0 100644
--- a/apps/csm-portal/microapp/package.json
+++ b/apps/csm-portal/microapp/package.json
@@ -19,9 +19,9 @@
"@mui/lab": "7.0.1-beta.20",
"@mui/material": "7.3.6",
"@tanstack/react-query": "5.90.12",
- "@wso2/oxygen-ui": "0.2.1",
- "@wso2/oxygen-ui-charts-react": "0.4.0",
- "@wso2/oxygen-ui-icons-react": "0.2.1",
+ "@wso2/oxygen-ui": "0.6.1",
+ "@wso2/oxygen-ui-charts-react": "0.6.1",
+ "@wso2/oxygen-ui-icons-react": "0.6.1",
"axios": "1.13.2",
"dayjs": "1.11.19",
"dompurify": "3.4.0",
@@ -57,8 +57,5 @@
"typescript-eslint": "8.46.4",
"vite": "7.1.7",
"vite-tsconfig-paths": "5.1.4"
- },
- "allowScripts": {
- "esbuild@0.25.12": true
}
}
diff --git a/apps/csm-portal/microapp/src/App.tsx b/apps/csm-portal/microapp/src/App.tsx
new file mode 100644
index 0000000000..cbe6b4aaaf
--- /dev/null
+++ b/apps/csm-portal/microapp/src/App.tsx
@@ -0,0 +1,48 @@
+// 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 { useLayoutEffect } from "react";
+import { HashRouter as Router, Route, Routes } from "react-router-dom";
+import MainLayout from "@components/layout/MainLayout";
+import { requestDeviceSafeAreaInsets } from "@components/microapp-bridge";
+import SupportPage from "@pages/SupportPage";
+import CaseDetailPage from "@pages/CaseDetailPage";
+
+export default function App() {
+ useLayoutEffect(() => {
+ requestDeviceSafeAreaInsets((data) => {
+ if (!data?.insets) return;
+
+ const { top, right, bottom, left } = data.insets;
+ const root = document.documentElement;
+ root.style.setProperty("--safe-top", `${top}px`);
+ root.style.setProperty("--safe-right", `${right}px`);
+ root.style.setProperty("--safe-bottom", `${bottom}px`);
+ root.style.setProperty("--safe-left", `${left}px`);
+ });
+ }, []);
+
+ return (
+
+
+ }>
+ } />
+ } />
+
+
+
+ );
+}
diff --git a/apps/csm-portal/microapp/src/components/common/ErrorBoundary.tsx b/apps/csm-portal/microapp/src/components/common/ErrorBoundary.tsx
new file mode 100644
index 0000000000..443804bbe1
--- /dev/null
+++ b/apps/csm-portal/microapp/src/components/common/ErrorBoundary.tsx
@@ -0,0 +1,63 @@
+// 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.
+
+// refer: https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
+import * as React from "react";
+
+interface ErrorBoundaryProps {
+ children: React.ReactNode;
+ fallback: React.ReactNode | ((error: unknown, resetErrorBoundary: () => void) => React.ReactNode);
+ onError?: (error: unknown) => void;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: unknown;
+}
+
+export class ErrorBoundary extends React.Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: unknown, info: React.ErrorInfo): void {
+ console.error(error, info.componentStack);
+ this.props.onError?.(error);
+ }
+
+ resetErrorBoundary = () => {
+ this.setState({ hasError: false, error: null });
+ };
+
+ render(): React.ReactNode {
+ const { hasError, error } = this.state;
+ const { fallback, children } = this.props;
+
+ if (hasError && error) {
+ if (typeof fallback === "function") {
+ return fallback(error, this.resetErrorBoundary);
+ }
+ return fallback;
+ }
+
+ return children;
+ }
+}
diff --git a/apps/csm-portal/microapp/src/components/layout/MainLayout.tsx b/apps/csm-portal/microapp/src/components/layout/MainLayout.tsx
new file mode 100644
index 0000000000..db112bc486
--- /dev/null
+++ b/apps/csm-portal/microapp/src/components/layout/MainLayout.tsx
@@ -0,0 +1,30 @@
+// 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 } from "@wso2/oxygen-ui";
+import { Outlet } from "react-router-dom";
+import { TabBar } from "./TabBar";
+
+export default function MainLayout() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/apps/csm-portal/microapp/src/components/layout/TabBar.tsx b/apps/csm-portal/microapp/src/components/layout/TabBar.tsx
new file mode 100644
index 0000000000..b251e110ec
--- /dev/null
+++ b/apps/csm-portal/microapp/src/components/layout/TabBar.tsx
@@ -0,0 +1,62 @@
+// 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 { useLayoutEffect, useRef } from "react";
+import { Link, useLocation } from "react-router-dom";
+import { BottomNavigation, BottomNavigationAction, Box } from "@wso2/oxygen-ui";
+import { Headset } from "@wso2/oxygen-ui-icons-react";
+
+export function TabBar() {
+ const ref = useRef(null);
+ const location = useLocation();
+ const isSupportActive = location.pathname === "/" || location.pathname.startsWith("/cases/");
+
+ useLayoutEffect(() => {
+ if (!ref.current) return;
+
+ const observer = new ResizeObserver(([entry]) => {
+ document.documentElement.style.setProperty("--tab-bar-height", `${entry.contentRect.height}px`);
+ });
+
+ observer.observe(ref.current);
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+
+ }
+ disableRipple
+ />
+
+
+ );
+}
diff --git a/apps/csm-portal/microapp/src/components/microapp-bridge/index.ts b/apps/csm-portal/microapp/src/components/microapp-bridge/index.ts
new file mode 100644
index 0000000000..f6ede93413
--- /dev/null
+++ b/apps/csm-portal/microapp/src/components/microapp-bridge/index.ts
@@ -0,0 +1,272 @@
+// 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 { Logger } from "@utils/logger";
+import { ErrorMessages } from "@utils/constants";
+import { Topic, type EdgeInsets, type LogLevel, type TopicType } from "./types";
+
+type Callback = (data?: T) => void;
+
+// Bridge event topics used for communication between the main app and micro apps.
+const TOPIC = {
+ TOKEN: "token",
+ QR_REQUEST: "qr_request",
+ SAVE_LOCAL_DATA: "save_local_data",
+ GET_LOCAL_DATA: "get_local_data",
+ ALERT: "alert",
+ CONFIRM_ALERT: "confirm_alert",
+ TOTP: "totp",
+ OPEN_URL: "open_url",
+ MICRO_APP_VERSION: "micro_app_version",
+};
+
+export interface BrowserConfiguration {
+ url: string;
+ presentationStyle: string;
+ dismissButtonStyle?: string;
+}
+
+declare global {
+ interface Window {
+ nativebridge?: {
+ requestToken: () => void;
+ resolveToken: (token: string) => void;
+ requestIdToken: () => void;
+ resolveIdToken: (token: string) => void;
+ resolveDeviceSafeAreaInsets?: (data: { insets: EdgeInsets }) => void;
+ resolveConfirmAlert: (action: string) => void;
+ resolveSaveLocalData: () => void;
+ rejectSaveLocalData: (error: string) => void;
+ resolveGetLocalData: (encodedData: { value?: string }) => void;
+ rejectGetLocalData: (error: string) => void;
+ resolveOpenUrl?: () => void;
+ rejectOpenUrl?: (error: string) => void;
+ requestMicroAppVersion: () => void;
+ resolveMicroAppVersion: (version: string) => void;
+ rejectMicroAppVersion: (error: string) => void;
+ };
+ ReactNativeWebView?: {
+ postMessage: (message: string) => void;
+ };
+ }
+}
+
+export const getAccessTokenFromBridge = (callback: Callback): void => {
+ if (window.nativebridge) {
+ window.nativebridge.requestToken();
+ window.nativebridge.resolveToken = (token: string) => {
+ callback(token);
+ };
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ callback();
+ }
+};
+
+// Function to get token from React Native
+export const getToken = (callback: Callback): void => {
+ if (window.nativebridge) {
+ window.nativebridge.requestIdToken();
+ window.nativebridge.resolveIdToken = (token: string) => {
+ callback(token);
+ };
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ callback();
+ }
+};
+
+// Function to show alert in React Native
+export const showAlert = (title: string, message: string, buttonText: string): void => {
+ if (window.nativebridge && window.ReactNativeWebView) {
+ const alertData = JSON.stringify({
+ topic: TOPIC.ALERT,
+ data: { title, message, buttonText },
+ });
+
+ window.ReactNativeWebView.postMessage(alertData);
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ }
+};
+
+// Function to show confirm alert in React Native
+export const showConfirmAlert = (
+ title: string,
+ message: string,
+ confirmButtonText: string,
+ cancelButtonText: string,
+ confirmCallback: () => void,
+ cancelCallback: () => void,
+): void => {
+ if (window.nativebridge && window.ReactNativeWebView) {
+ const confirmData = JSON.stringify({
+ topic: TOPIC.CONFIRM_ALERT,
+ data: { title, message, confirmButtonText, cancelButtonText },
+ });
+
+ window.ReactNativeWebView.postMessage(confirmData);
+
+ // Handling response from React Native side
+ window.nativebridge.resolveConfirmAlert = (action: string) => {
+ if (action === "confirm") {
+ confirmCallback();
+ } else if (action === "cancel") {
+ cancelCallback();
+ }
+ };
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ }
+};
+
+// Save Local Data
+export const saveLocalData = (
+ key: string,
+ value: unknown,
+ callback: () => void,
+ failedToRespondCallback: (error: string) => void,
+): void => {
+ key = key.toString().replace(" ", "-").toLowerCase();
+ const encodedValue = btoa(JSON.stringify(value));
+
+ if (window.nativebridge && window.ReactNativeWebView) {
+ window.ReactNativeWebView.postMessage(
+ JSON.stringify({
+ topic: TOPIC.SAVE_LOCAL_DATA,
+ data: { key, value: encodedValue },
+ }),
+ );
+
+ window.nativebridge.resolveSaveLocalData = callback;
+ window.nativebridge.rejectSaveLocalData = (error: string) => failedToRespondCallback(error);
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ failedToRespondCallback(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ }
+};
+
+// Get Local Data
+export const getLocalData = (
+ key: string,
+ callback: (data: T | null) => void,
+ failedToRespondCallback: (error: string) => void,
+): void => {
+ key = key.toString().replace(" ", "-").toLowerCase();
+
+ if (window.nativebridge && window.ReactNativeWebView) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({ topic: TOPIC.GET_LOCAL_DATA, data: { key } }));
+
+ window.nativebridge.resolveGetLocalData = (encodedData: { value?: string }) => {
+ if (!encodedData.value) {
+ callback(null);
+ } else {
+ callback(JSON.parse(atob(encodedData.value)) as T);
+ }
+ };
+
+ window.nativebridge.rejectGetLocalData = (error: string) => failedToRespondCallback(error);
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ failedToRespondCallback(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ }
+};
+
+/**
+ * Trigger an action in the super app
+ * @param topic - The topic to trigger
+ * @param data - The data to send
+ */
+const triggerSuperAppAction = (topic: TopicType, data?: unknown): void => {
+ if (window.ReactNativeWebView) {
+ const messageData = JSON.stringify({
+ topic,
+ data,
+ });
+ window.ReactNativeWebView.postMessage(messageData);
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ }
+};
+
+/**
+ * Send a log message to the native side
+ * @param message - The message to send
+ * @param data - The data to send
+ * @param level - The level of the log
+ */
+export const sendNativeLog = (message?: string, data?: unknown, level: LogLevel = "debug"): void => {
+ if (window.nativebridge && window.ReactNativeWebView) {
+ triggerSuperAppAction(Topic.nativeLog, {
+ message,
+ data,
+ level,
+ });
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ }
+};
+
+/**
+ * Send a request to the native app to navigate back to the previous screen. I.e., close the webview.
+ */
+export const goToMyAppsScreen = (): void => {
+ if (window.nativebridge) {
+ triggerSuperAppAction(Topic.navigateToMyApps);
+ }
+};
+
+/**
+ * Request the device safe area insets from the native app
+ * @param callback - The callback to receive the device safe area insets
+ */
+export const requestDeviceSafeAreaInsets = (callback: Callback<{ insets: EdgeInsets }>): void => {
+ if (window.nativebridge) {
+ triggerSuperAppAction(Topic.deviceSafeAreaInsets);
+ window.nativebridge.resolveDeviceSafeAreaInsets = (data) => {
+ callback(data);
+ };
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE + " to fetch device safe area insets");
+ callback();
+ }
+};
+
+// Open URL in Browser
+export const openUrl = (config: BrowserConfiguration): void => {
+ if (window.nativebridge && window.ReactNativeWebView) {
+ const alertData = JSON.stringify({
+ topic: TOPIC.OPEN_URL,
+ data: { config },
+ });
+
+ window.ReactNativeWebView.postMessage(alertData);
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ }
+};
+
+export const getVersion = (callback: Callback): void => {
+ if (window.nativebridge) {
+ triggerSuperAppAction(Topic.version);
+ window.nativebridge.resolveMicroAppVersion = (version) => {
+ callback(version);
+ };
+ } else {
+ Logger.error(ErrorMessages.NATIVE_BRIDGE_NOT_AVAILABLE);
+ callback();
+ }
+};
diff --git a/apps/csm-portal/microapp/src/components/microapp-bridge/types.ts b/apps/csm-portal/microapp/src/components/microapp-bridge/types.ts
new file mode 100644
index 0000000000..cae9122ddb
--- /dev/null
+++ b/apps/csm-portal/microapp/src/components/microapp-bridge/types.ts
@@ -0,0 +1,42 @@
+// 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.
+
+export const Topic = {
+ token: "token",
+ nativeLog: "native_log",
+ navigateToMyApps: "close_webview",
+ saveLocalData: "save_local_data",
+ getLocalData: "get_local_data",
+ deviceSafeAreaInsets: "device_safe_area_insets",
+ deleteLocalData: "delete_local_data",
+ openUrl: "open_url",
+ scheduleLocalNotification: "scheduling_local_notification",
+ cancelLocalNotification: "cancelling_local_notification",
+ clearAllLocalNotifications: "clearing_all_local_notifications",
+ qrRequest: "qr_request",
+ version: "micro_app_version",
+} as const;
+
+export type TopicType = (typeof Topic)[keyof typeof Topic];
+
+export type LogLevel = "error" | "warn" | "info" | "debug";
+
+export interface EdgeInsets {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+}
diff --git a/apps/csm-portal/microapp/src/components/support/CaseCard.tsx b/apps/csm-portal/microapp/src/components/support/CaseCard.tsx
new file mode 100644
index 0000000000..dd81119f13
--- /dev/null
+++ b/apps/csm-portal/microapp/src/components/support/CaseCard.tsx
@@ -0,0 +1,93 @@
+// 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 { Card, Skeleton, Stack, Typography, pxToRem, useTheme } from "@wso2/oxygen-ui";
+import { ChevronRight, Clock4 } from "@wso2/oxygen-ui-icons-react";
+import { Link } from "react-router-dom";
+import type { CaseSummary } from "@src/types";
+import { fromNow } from "@utils/dateTime";
+import { SeverityChip, StatusChip } from "./Chips";
+import { TYPE_CONFIG } from "./config";
+
+export function CaseCard({ item }: { item: CaseSummary }) {
+ const theme = useTheme();
+ const { icon: Icon, color } = TYPE_CONFIG[item.type] ?? TYPE_CONFIG.case;
+
+ return (
+
+
+
+
+
+
+ {item.number}
+
+
+
+
+
+
+ {item.subject}
+
+
+
+
+
+
+
+
+
+
+
+ Updated {fromNow(item.updatedOn)}
+
+
+
+ {item.assignedEngineer?.name ?? "Unassigned"}
+
+
+
+
+ );
+}
+
+export function CaseCardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/csm-portal/microapp/src/components/support/Chips.tsx b/apps/csm-portal/microapp/src/components/support/Chips.tsx
new file mode 100644
index 0000000000..d5a947de67
--- /dev/null
+++ b/apps/csm-portal/microapp/src/components/support/Chips.tsx
@@ -0,0 +1,54 @@
+// 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 { alpha, Chip, type ChipProps } from "@wso2/oxygen-ui";
+import type { CaseSeverity, CaseState } from "@src/types";
+import { SEVERITY_CHIP_COLOR_CONFIG, SEVERITY_LABELS, STATE_CHIP_COLOR_CONFIG, STATE_LABELS } from "./config";
+
+function ColoredChip({
+ color,
+ label,
+ ...props
+}: Omit & { color: NonNullable; label: string }) {
+ return (
+ ({ bgcolor: alpha(theme.palette[color].light, 0.1), color: theme.palette[color].light })
+ : undefined
+ }
+ />
+ );
+}
+
+export function StatusChip({ state, ...props }: { state: CaseState } & Omit) {
+ return (
+
+ );
+}
+
+export function SeverityChip({ severity, ...props }: { severity: CaseSeverity } & Omit) {
+ return (
+
+ );
+}
diff --git a/apps/csm-portal/microapp/src/components/support/EmptyState.tsx b/apps/csm-portal/microapp/src/components/support/EmptyState.tsx
new file mode 100644
index 0000000000..b12377deda
--- /dev/null
+++ b/apps/csm-portal/microapp/src/components/support/EmptyState.tsx
@@ -0,0 +1,29 @@
+// 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 { Stack, Typography } from "@wso2/oxygen-ui";
+import { Inbox } from "@wso2/oxygen-ui-icons-react";
+
+export function EmptyState({ message = "Nothing to show here." }: { message?: string }) {
+ return (
+
+
+
+ {message}
+
+
+ );
+}
diff --git a/apps/csm-portal/microapp/src/components/support/ErrorState.tsx b/apps/csm-portal/microapp/src/components/support/ErrorState.tsx
new file mode 100644
index 0000000000..ac7eba1364
--- /dev/null
+++ b/apps/csm-portal/microapp/src/components/support/ErrorState.tsx
@@ -0,0 +1,32 @@
+// 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 { Button, Stack, Typography } from "@wso2/oxygen-ui";
+import { TriangleAlert } from "@wso2/oxygen-ui-icons-react";
+
+export function ErrorState({ onRetry }: { onRetry: () => void }) {
+ return (
+
+
+
+ Something went wrong. Please try again.
+
+
+
+ );
+}
diff --git a/apps/csm-portal/microapp/src/components/support/config.tsx b/apps/csm-portal/microapp/src/components/support/config.tsx
new file mode 100644
index 0000000000..bee2895fa3
--- /dev/null
+++ b/apps/csm-portal/microapp/src/components/support/config.tsx
@@ -0,0 +1,93 @@
+// 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 { colors, type ChipProps } from "@wso2/oxygen-ui";
+import { Briefcase, Megaphone, OctagonAlert, Settings, Shield, type LucideIcon } from "@wso2/oxygen-ui-icons-react";
+import type { CaseSeverity, CaseState, CaseType } from "@src/types";
+
+export const TABS: CaseType[] = ["case", "service_request", "security_report_analysis", "engagement", "announcement"];
+
+export const TYPE_CONFIG: Record = {
+ case: { icon: OctagonAlert, color: colors.red[500] },
+ service_request: { icon: Settings, color: colors.purple[500] },
+ security_report_analysis: { icon: Shield, color: colors.orange[500] },
+ engagement: { icon: Briefcase, color: colors.grey[600] },
+ announcement: { icon: Megaphone, color: colors.blue[500] },
+};
+
+export const TAB_CONFIG: Record = {
+ case: {
+ title: "Cases",
+ subtitle: "Standard support cases",
+ emptyMessage: "No cases found.",
+ },
+ service_request: {
+ title: "Service Requests",
+ subtitle: "Catalog-based service requests",
+ emptyMessage: "No service requests found.",
+ },
+ security_report_analysis: {
+ title: "Security Reports",
+ subtitle: "Security report analyses",
+ emptyMessage: "No security report analyses found.",
+ },
+ engagement: {
+ title: "Engagements",
+ subtitle: "Ongoing engagements",
+ emptyMessage: "No engagements found.",
+ },
+ announcement: {
+ title: "Announcements",
+ subtitle: "Broadcast announcements",
+ emptyMessage: "No announcements found.",
+ },
+};
+
+export const STATE_LABELS: Record = {
+ open: "Open",
+ work_in_progress: "In Progress",
+ waiting_on_wso2: "Waiting on WSO2",
+ awaiting_info: "Awaiting Info",
+ reopened: "Reopened",
+ solution_proposed: "Solution Proposed",
+ closed: "Closed",
+};
+
+export const STATE_CHIP_COLOR_CONFIG: Record> = {
+ open: "info",
+ work_in_progress: "primary",
+ waiting_on_wso2: "warning",
+ awaiting_info: "warning",
+ reopened: "error",
+ solution_proposed: "success",
+ closed: "default",
+};
+
+export const SEVERITY_LABELS: Record = {
+ catastrophic: "Catastrophic (S0)",
+ critical: "Critical (S1)",
+ high: "High (S2)",
+ medium: "Medium (S3)",
+ low: "Low (S4)",
+};
+
+export const SEVERITY_CHIP_COLOR_CONFIG: Record> = {
+ catastrophic: "error",
+ critical: "error",
+ high: "warning",
+ medium: "info",
+ low: "default",
+};
diff --git a/apps/csm-portal/microapp/src/config/config.ts b/apps/csm-portal/microapp/src/config/config.ts
new file mode 100644
index 0000000000..fbcd8506a1
--- /dev/null
+++ b/apps/csm-portal/microapp/src/config/config.ts
@@ -0,0 +1,43 @@
+// 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.
+
+declare global {
+ interface Window {
+ config: {
+ CLIENT_ID: string;
+ SIGN_IN_REDIRECT_URL: string;
+ SIGN_OUT_REDIRECT_URL: string;
+ ASGARDEO_BASE_URL: string;
+ IS_MICROAPP: boolean;
+ BACKEND_BASE_URL: string;
+ };
+ }
+}
+
+export const CLIENT_ID = window.config.CLIENT_ID;
+export const SIGN_IN_REDIRECT_URL = window.config.SIGN_IN_REDIRECT_URL;
+export const SIGN_OUT_REDIRECT_URL = window.config.SIGN_OUT_REDIRECT_URL;
+export const ASGARDEO_BASE_URL = window.config.ASGARDEO_BASE_URL;
+export const IS_MICROAPP = window.config.IS_MICROAPP;
+
+// TODO: Uncomment and update the `baseUrl` variable when the backend URL configuration is available and in use.
+// const baseUrl = window.config.BACKEND_BASE_URL;
+
+export const serviceUrls = {
+ serviceUrls: {
+ // TODO: Add service URLs here as needed for future implementation.
+ },
+};
diff --git a/apps/csm-portal/microapp/src/config/endpoints.ts b/apps/csm-portal/microapp/src/config/endpoints.ts
new file mode 100644
index 0000000000..a2341bd27b
--- /dev/null
+++ b/apps/csm-portal/microapp/src/config/endpoints.ts
@@ -0,0 +1,27 @@
+// 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.
+
+export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
+
+if (!BACKEND_URL) {
+ throw new Error("VITE_BACKEND_URL is not defined");
+}
+
+export const USERS_ME_ENDPOINT = "/users/me";
+
+export const CASES_SEARCH_ENDPOINT = "/cases/search";
+export const CASE_DETAILS_ENDPOINT = (id: string) => `/cases/${id}`;
+export const CASE_COMMENTS_SEARCH_ENDPOINT = (id: string) => `/cases/${id}/comments/search`;
diff --git a/apps/csm-portal/microapp/src/index.css b/apps/csm-portal/microapp/src/index.css
index 1fad9a5fc7..775532e897 100644
--- a/apps/csm-portal/microapp/src/index.css
+++ b/apps/csm-portal/microapp/src/index.css
@@ -15,3 +15,37 @@ KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
*/
+
+@import url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap");
+
+* {
+ font-family: "Plus Jakarta Sans", sans-serif;
+ font-optical-sizing: auto;
+}
+
+html {
+ font-size: 15px; /* base font size */
+}
+
+html,
+body {
+ margin: 0;
+ height: 100%;
+ overscroll-behavior: none;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+body::-webkit-scrollbar {
+ display: none;
+}
+
+@keyframes gentlePulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.8; }
+}
+
+@keyframes stepIn {
+ from { opacity: 0; transform: translateX(-6px); }
+ to { opacity: 1; transform: translateX(0); }
+}
diff --git a/apps/csm-portal/microapp/src/main.tsx b/apps/csm-portal/microapp/src/main.tsx
index 61cae150d8..529cb37966 100644
--- a/apps/csm-portal/microapp/src/main.tsx
+++ b/apps/csm-portal/microapp/src/main.tsx
@@ -14,14 +14,38 @@
// specific language governing permissions and limitations
// under the License.
-import React from "react";
+import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { OxygenUIThemeProvider } from "@wso2/oxygen-ui";
+import axios from "axios";
+import App from "@src/App";
+import theme from "./theme";
+import "@src/index.css";
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: (failureCount, error) => {
+ if (axios.isAxiosError(error)) {
+ const status = error.response?.status;
+ if (status && status >= 400 && status < 500) return false;
+ }
+ return failureCount < 3;
+ },
+ },
+ },
+});
const container = document.getElementById("root");
-if (!container) throw new Error("'Root container missing'");
-const root = createRoot(container);
-root.render(
-
- CSM Microapp
- ,
+if (!container) throw new Error("Root container missing");
+
+createRoot(container).render(
+
+
+
+
+
+
+ ,
);
diff --git a/apps/csm-portal/microapp/src/pages/CaseDetailPage.tsx b/apps/csm-portal/microapp/src/pages/CaseDetailPage.tsx
new file mode 100644
index 0000000000..38fe320f51
--- /dev/null
+++ b/apps/csm-portal/microapp/src/pages/CaseDetailPage.tsx
@@ -0,0 +1,183 @@
+// 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 { Suspense, type ReactNode } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { Divider, IconButton, Skeleton, Stack, Typography, pxToRem } from "@wso2/oxygen-ui";
+import { ArrowLeft } from "@wso2/oxygen-ui-icons-react";
+import { useQueryErrorResetBoundary, useSuspenseQuery } from "@tanstack/react-query";
+import { cases } from "@src/services/cases";
+import type { CaseDetail, Comment } from "@src/types";
+import { ErrorBoundary } from "@components/common/ErrorBoundary";
+import { SeverityChip, StatusChip } from "@components/support/Chips";
+import { ErrorState } from "@components/support/ErrorState";
+import { TYPE_CONFIG } from "@components/support/config";
+import { formatDate } from "@utils/dateTime";
+
+export default function CaseDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+
+ return (
+
+
+ navigate(-1)} size="small" aria-label="Go back">
+
+
+ Case Details
+
+
+
+ }>
+
+
+
+
+ );
+}
+
+function CaseDetailContent({ id }: { id: string }) {
+ const { data: caseDetail } = useSuspenseQuery(cases.get(id));
+ const { data: comments } = useSuspenseQuery(cases.comments(id));
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function CaseSummarySection({ caseDetail }: { caseDetail: CaseDetail }) {
+ const { icon: Icon, color } = TYPE_CONFIG[caseDetail.type ?? "case"] ?? TYPE_CONFIG.case;
+
+ return (
+
+
+
+
+ {caseDetail.number}
+ {caseDetail.wso2Id ? ` · ${caseDetail.wso2Id}` : ""}
+
+
+
+ {caseDetail.subject}
+
+
+
+
+
+
+
+ {caseDetail.description}
+
+
+ );
+}
+
+function CaseMetadataSection({ caseDetail }: { caseDetail: CaseDetail }) {
+ return (
+
+
+
+
+
+
+
+
+
+ {caseDetail.closedOn && }
+
+ );
+}
+
+function DetailRow({ label, value }: { label: string; value?: string | null }) {
+ if (!value) return null;
+
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function CaseCommentsSection({ comments }: { comments: Comment[] }) {
+ return (
+
+ Comments
+
+ {comments.length === 0 && (
+
+ No comments yet.
+
+ )}
+
+ {comments.map((comment) => (
+
+
+
+ {comment.createdBy}
+
+
+ {formatDate(comment.createdOn)}
+
+
+
+ {comment.content}
+
+
+ ))}
+
+ );
+}
+
+function CaseDetailSkeleton() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function CaseDetailErrorBoundary({ children }: { children: ReactNode }) {
+ const { reset } = useQueryErrorResetBoundary();
+
+ return (
+ (
+ {
+ reset();
+ resetErrorBoundary();
+ }}
+ />
+ )}
+ >
+ {children}
+
+ );
+}
diff --git a/apps/csm-portal/microapp/src/pages/SupportPage.tsx b/apps/csm-portal/microapp/src/pages/SupportPage.tsx
new file mode 100644
index 0000000000..3d2089dd01
--- /dev/null
+++ b/apps/csm-portal/microapp/src/pages/SupportPage.tsx
@@ -0,0 +1,111 @@
+// 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 { Suspense, type ReactNode } from "react";
+import { useSearchParams } from "react-router-dom";
+import { Stack, Tab, Tabs, Typography } from "@wso2/oxygen-ui";
+import { useQueryErrorResetBoundary, useSuspenseQuery } from "@tanstack/react-query";
+import { cases } from "@src/services/cases";
+import type { CaseType } from "@src/types";
+import { ErrorBoundary } from "@components/common/ErrorBoundary";
+import { CaseCard, CaseCardSkeleton } from "@components/support/CaseCard";
+import { EmptyState } from "@components/support/EmptyState";
+import { ErrorState } from "@components/support/ErrorState";
+import { TABS, TAB_CONFIG } from "@components/support/config";
+
+export default function SupportPage() {
+ const [searchParams, setSearchParams] = useSearchParams();
+ // Derived directly from the URL (not local state) so browser back/forward navigation that
+ // changes ?tab= is reflected immediately, instead of showing a stale tab.
+ const tab = TABS.find((t) => t === searchParams.get("tab")) ?? "case";
+
+ const handleTabChange = (value: CaseType) => {
+ setSearchParams(
+ (prev) => {
+ prev.set("tab", value);
+ return prev;
+ },
+ { replace: true },
+ );
+ };
+
+ return (
+
+ Support
+
+ handleTabChange(value)}>
+ {TABS.map((t) => (
+
+ ))}
+
+
+
+ }>
+
+
+
+
+ );
+}
+
+function CaseListContent({ type }: { type: CaseType }) {
+ const { data } = useSuspenseQuery(
+ cases.all({
+ filters: { types: [type] },
+ sortBy: { field: "updatedOn", order: "desc" },
+ pagination: { limit: 20 },
+ }),
+ );
+
+ if (data.items.length === 0) return ;
+
+ return (
+
+ {data.items.map((item) => (
+
+ ))}
+
+ );
+}
+
+function CaseListSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+function CaseListErrorBoundary({ children }: { children: ReactNode }) {
+ const { reset } = useQueryErrorResetBoundary();
+
+ return (
+ (
+ {
+ reset();
+ resetErrorBoundary();
+ }}
+ />
+ )}
+ >
+ {children}
+
+ );
+}
diff --git a/apps/csm-portal/microapp/src/services/apiClient.ts b/apps/csm-portal/microapp/src/services/apiClient.ts
new file mode 100644
index 0000000000..9471fa9763
--- /dev/null
+++ b/apps/csm-portal/microapp/src/services/apiClient.ts
@@ -0,0 +1,178 @@
+// 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 axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
+import { Logger } from "@utils/logger";
+import { getAccessToken, refreshToken } from "./auth";
+import { BACKEND_URL } from "@config/endpoints";
+
+interface RetryableRequestConfig extends InternalAxiosRequestConfig {
+ _retry?: boolean;
+}
+
+// Variables/constants
+let isRefreshing = false;
+let failedQueue: {
+ resolve: (value: unknown) => void;
+ reject: (reason?: unknown) => void;
+}[] = [];
+
+// Holds the refresh token promise
+let refreshTokenPromise: Promise | null = null;
+
+// axios instance
+const apiClient = axios.create({
+ baseURL: BACKEND_URL,
+});
+
+/**
+ * Request Interceptor
+ */
+apiClient.interceptors.request.use(
+ async (config: InternalAxiosRequestConfig) => {
+ // Log the outgoing request. Deliberately omits config.headers — on a retried request
+ // (see the 401 handler below) it already carries the prior Authorization/x-user-id-token
+ // values at this point, and this log forwards to the native bridge via sendNativeLog.
+ Logger.info(`Making ${config.method?.toUpperCase()} request to: ${config.baseURL || ""}${config.url || ""}`, {
+ url: config.url,
+ method: config.method,
+ baseURL: config.baseURL,
+ fullURL: `${config.baseURL || ""}${config.url || ""}`,
+ });
+
+ // Use a singleton promise for token refresh
+ if (!refreshTokenPromise) {
+ refreshTokenPromise = refreshToken().finally(() => {
+ // Reset the promise once it's resolved or rejected
+ refreshTokenPromise = null;
+ });
+ }
+
+ try {
+ const idToken = await refreshTokenPromise;
+ const accessToken = getAccessToken();
+ if (idToken && accessToken) {
+ config.headers.Authorization = `Bearer ${accessToken}`;
+ config.headers["x-user-id-token"] = idToken;
+ config.headers["Content-Type"] = "application/json";
+ config.headers["X-CSM-Correlation-ID"] = crypto.randomUUID();
+ } else {
+ Logger.warn("No token available for request");
+ }
+ } catch (error) {
+ Logger.error("Failed to get token for request", error);
+ return Promise.reject(error);
+ }
+
+ return config;
+ },
+ (error) => {
+ Logger.error("Request interceptor error", error);
+ return Promise.reject(error);
+ },
+);
+
+/**
+ * Response Interceptor
+ */
+const processQueue = (error: unknown, token: string | null = null) => {
+ failedQueue.forEach((prom) => {
+ if (error) {
+ prom.reject(error);
+ } else {
+ prom.resolve(token);
+ }
+ });
+
+ failedQueue = [];
+};
+
+apiClient.interceptors.response.use(
+ (response) => {
+ // Any status code within the range of 2xx causes this function to trigger. Logs only the
+ // response's size, not its content — response bodies here can carry user PII (email, phone)
+ // and case content, and this log forwards to the native bridge via sendNativeLog.
+ Logger.info(`Successful response from ${response.config.method?.toUpperCase()} ${response.config.url}`, {
+ status: response.status,
+ statusText: response.statusText,
+ url: response.config.url,
+ dataSize: response.data ? JSON.stringify(response.data).length : 0,
+ });
+ return response;
+ },
+ async (error: AxiosError) => {
+ if (axios.isCancel(error)) {
+ return Promise.reject(error);
+ }
+ Logger.error(`API request failed`, {
+ status: error.response?.status,
+ statusText: error.response?.statusText,
+ url: error.config?.url,
+ method: error.config?.method,
+ message: error.message,
+ });
+
+ // error.config can be undefined for some Axios errors (e.g. ones raised before a request
+ // config is fully attached), and the 401-retry logic below needs a config to retry against.
+ const originalRequest = error.config as RetryableRequestConfig | undefined;
+ if (!originalRequest) {
+ return Promise.reject(error);
+ }
+
+ if (error.response?.status === 401 && !originalRequest._retry) {
+ Logger.warn("Received 401 unauthorized, attempting token refresh");
+
+ if (isRefreshing) {
+ Logger.info("Token refresh already in progress, queuing request");
+ return new Promise((resolve, reject) => {
+ failedQueue.push({ resolve, reject });
+ })
+ .then((token) => {
+ originalRequest.headers["Authorization"] = "Bearer " + token;
+ return apiClient(originalRequest);
+ })
+ .catch((err) => {
+ return Promise.reject(err);
+ });
+ }
+
+ originalRequest._retry = true;
+ isRefreshing = true;
+
+ try {
+ Logger.info("Attempting to refresh access token");
+ await refreshToken();
+ const newAccessToken = getAccessToken();
+ apiClient.defaults.headers.common["Authorization"] = `Bearer ${newAccessToken}`;
+ originalRequest.headers["Authorization"] = `Bearer ${newAccessToken}`;
+ processQueue(null, newAccessToken);
+ Logger.info("Token refresh successful, retrying original request");
+ return apiClient(originalRequest);
+ } catch (refreshError) {
+ Logger.error("Token refresh failed", refreshError);
+ processQueue(refreshError, null);
+ console.error("Token refresh failed:", refreshError);
+ return Promise.reject(refreshError);
+ } finally {
+ isRefreshing = false;
+ }
+ }
+
+ return Promise.reject(error);
+ },
+);
+
+export default apiClient;
diff --git a/apps/csm-portal/microapp/src/services/auth.ts b/apps/csm-portal/microapp/src/services/auth.ts
new file mode 100644
index 0000000000..3ee57fa8a8
--- /dev/null
+++ b/apps/csm-portal/microapp/src/services/auth.ts
@@ -0,0 +1,170 @@
+// 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 { jwtDecode } from "jwt-decode";
+
+import { getAccessTokenFromBridge, getToken } from "@components/microapp-bridge";
+import { LocalStorageKeys } from "@utils/constants";
+import { Logger } from "@utils/logger";
+import { useUserStore, type User } from "../store/user";
+
+// Token Payload
+interface TokenPayload {
+ email?: string;
+ name?: string;
+ groups?: string[];
+ given_name: string;
+ family_name: string;
+}
+
+// These would be your actual token storage functions
+export const getAccessToken = (): string | null => localStorage.getItem(LocalStorageKeys.accessToken);
+export const setAccessToken = (token: string): void => localStorage.setItem(LocalStorageKeys.accessToken, token);
+export const getIdToken = (): string | null => localStorage.getItem(LocalStorageKeys.idToken);
+export const setIdToken = (token: string): void => localStorage.setItem(LocalStorageKeys.idToken, token);
+
+// Refresh early rather than right at expiry, to cover in-flight request latency.
+const TOKEN_EXPIRY_BUFFER_MS = 60_000;
+
+// apiClient calls refreshToken() on every request; without this check that means a native-bridge
+// round trip (plus a full user-store re-init) per request even when the current token is still
+// valid for a while. Treat undecodable/expired-soon tokens as needing refresh, same as a missing
+// token, so this fails toward refreshing rather than toward silently reusing a stale token.
+function isTokenExpiringSoon(token: string | null): boolean {
+ if (!token) return true;
+ try {
+ const { exp } = jwtDecode<{ exp?: number }>(token);
+ if (!exp) return true;
+ return exp * 1000 - Date.now() < TOKEN_EXPIRY_BUFFER_MS;
+ } catch {
+ return true;
+ }
+}
+
+/**
+ * A function to refresh the token.
+ * This is a simplified version of the logic in the original `handleRequestWithNewToken`.
+ * Skips the native-bridge round trip when the current ID token is still valid for a while,
+ * and only fetches a new token and updates storage when it's missing or near-expiry.
+ */
+export const refreshToken = (): Promise => {
+ const currentIdToken = getIdToken();
+ if (!isTokenExpiringSoon(currentIdToken)) {
+ return Promise.resolve(currentIdToken as string);
+ }
+
+ const idTokenPromise = new Promise((resolve, reject) => {
+ getToken((token) => (token ? resolve(token) : reject("ID Token failed")));
+ });
+
+ const accessTokenPromise = new Promise((resolve, reject) => {
+ getAccessTokenFromBridge((token) => (token ? resolve(token) : reject("Access Token failed")));
+ });
+
+ return Promise.all([idTokenPromise, accessTokenPromise])
+ .then(([newIdToken, newAccessToken]) => {
+ setIdToken(newIdToken);
+ setAccessToken(newAccessToken);
+
+ try {
+ initializeUserFromToken();
+ Logger.info("User information updated after full token refresh");
+ } catch (error) {
+ Logger.warn("Failed to update user information", error);
+ }
+
+ return newIdToken;
+ })
+ .catch((error) => {
+ Logger.error("Failed to refresh tokens", error);
+ throw error;
+ });
+};
+
+/**
+ * Checks if the user belongs to a given group or groups based on the ID token.
+ * This is a direct replacement for `handleCheckGroups`.
+ * @param groupNames - A single group name or an array of group names.
+ * @returns boolean - True if the user is in at least one of the required groups.
+ */
+export const checkUserGroups = (groupNames: string | string[]): boolean => {
+ const token = getIdToken();
+
+ if (!token) {
+ Logger.error("ID token not found for group check.");
+ return false;
+ }
+
+ try {
+ const decoded = jwtDecode(token);
+ const userGroups = decoded.groups ?? [];
+ const requiredGroups = Array.isArray(groupNames) ? groupNames : [groupNames];
+
+ return requiredGroups.some((group) => userGroups.includes(group));
+ } catch (error) {
+ Logger.error("Failed to decode ID token for group check.", error);
+ return false;
+ }
+};
+
+/**
+ * Decodes the ID token and extracts user information.
+ * Stores the user information in the Zustand user store.
+ * @returns User object or null if token is invalid
+ */
+export const decodeTokenAndStoreUser = (): User | null => {
+ try {
+ const token = getIdToken();
+
+ if (!token) {
+ Logger.error("ID token not found for user decoding.");
+ useUserStore.getState().clearUser();
+ return null;
+ }
+
+ const decoded = jwtDecode(token);
+
+ // Extract user information from token
+ const user: User = {
+ email: decoded.email || "",
+ name: `${decoded.given_name || ""} ${decoded.family_name || ""}`,
+ };
+
+ useUserStore.getState().setUser(user);
+ return user;
+ } catch (error) {
+ Logger.error("Failed to decode token and store user information", error);
+ useUserStore.getState().clearUser();
+ return null;
+ }
+};
+
+/**
+ * Initializes user data from stored token.
+ * Should be called when the app starts or when a new token is received.
+ */
+export const initializeUserFromToken = (): void => {
+ useUserStore.getState().setLoading(true);
+
+ try {
+ decodeTokenAndStoreUser();
+ } catch (error) {
+ Logger.error("Failed to initialize user from token", error);
+ useUserStore.getState().clearUser();
+ } finally {
+ useUserStore.getState().setLoading(false);
+ }
+};
diff --git a/apps/csm-portal/microapp/src/services/cases.ts b/apps/csm-portal/microapp/src/services/cases.ts
new file mode 100644
index 0000000000..a769543a54
--- /dev/null
+++ b/apps/csm-portal/microapp/src/services/cases.ts
@@ -0,0 +1,77 @@
+// 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 { queryOptions } from "@tanstack/react-query";
+import { CASES_SEARCH_ENDPOINT, CASE_COMMENTS_SEARCH_ENDPOINT, CASE_DETAILS_ENDPOINT } from "@config/endpoints";
+import type {
+ CaseCommentSearchResponseDto,
+ CaseSearchPayloadDto,
+ CaseSearchResponseDto,
+ CaseViewDto,
+} from "@src/types";
+import { toCaseDetail, toCaseSummary, toComment, type CaseDetail, type CaseSummary, type Comment } from "@src/types";
+import apiClient from "./apiClient";
+
+export interface CaseSearchResult {
+ items: CaseSummary[];
+ total: number;
+ limit: number;
+ offset: number;
+ hasMore: boolean;
+}
+
+const getAllCases = async (payload: CaseSearchPayloadDto = {}): Promise => {
+ const { data } = await apiClient.post(CASES_SEARCH_ENDPOINT, payload);
+ return {
+ items: data.cases.map(toCaseSummary),
+ total: data.total,
+ limit: data.limit,
+ offset: data.offset,
+ hasMore: data.hasMore,
+ };
+};
+
+const getCase = async (id: string): Promise => {
+ const { data } = await apiClient.get(CASE_DETAILS_ENDPOINT(id));
+ return toCaseDetail(data);
+};
+
+const getCaseComments = async (id: string): Promise => {
+ const { data } = await apiClient.post(CASE_COMMENTS_SEARCH_ENDPOINT(id), {
+ pagination: { limit: 50 },
+ });
+ return data.comments.map(toComment);
+};
+
+export const cases = {
+ all: (payload: CaseSearchPayloadDto = {}) =>
+ queryOptions({
+ queryKey: ["cases", payload],
+ queryFn: () => getAllCases(payload),
+ }),
+
+ get: (id: string) =>
+ queryOptions({
+ queryKey: ["case", id],
+ queryFn: () => getCase(id),
+ }),
+
+ comments: (id: string) =>
+ queryOptions({
+ queryKey: ["case", id, "comments"],
+ queryFn: () => getCaseComments(id),
+ }),
+};
diff --git a/apps/csm-portal/microapp/src/store/user.ts b/apps/csm-portal/microapp/src/store/user.ts
new file mode 100644
index 0000000000..562593f390
--- /dev/null
+++ b/apps/csm-portal/microapp/src/store/user.ts
@@ -0,0 +1,50 @@
+// 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 { create } from "zustand";
+
+export interface User {
+ email: string;
+ name: string;
+}
+
+export interface UserStore {
+ user: User | null;
+ isLoading: boolean;
+
+ setUser: (user: User) => void;
+ clearUser: () => void;
+ setLoading: (loading: boolean) => void;
+}
+
+export const useUserStore = create((set) => ({
+ user: null,
+ isLoading: false,
+
+ setUser: (user: User) =>
+ set({
+ user,
+ isLoading: false,
+ }),
+
+ clearUser: () =>
+ set({
+ user: null,
+ isLoading: false,
+ }),
+
+ setLoading: (loading: boolean) => set({ isLoading: loading }),
+}));
diff --git a/apps/csm-portal/microapp/src/theme/index.ts b/apps/csm-portal/microapp/src/theme/index.ts
new file mode 100644
index 0000000000..8e7fa4aeb9
--- /dev/null
+++ b/apps/csm-portal/microapp/src/theme/index.ts
@@ -0,0 +1,25 @@
+// 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 { OxygenTheme } from "@wso2/oxygen-ui/styles/Themes/OxygenThemeBase";
+import { AcrylicOrangeTheme, extendTheme } from "@wso2/oxygen-ui";
+import { typography } from "@theme/typography";
+
+const theme = extendTheme(AcrylicOrangeTheme, {
+ typography,
+}) as OxygenTheme;
+
+export default theme;
diff --git a/apps/csm-portal/microapp/src/theme/typography.ts b/apps/csm-portal/microapp/src/theme/typography.ts
new file mode 100644
index 0000000000..cb1583436e
--- /dev/null
+++ b/apps/csm-portal/microapp/src/theme/typography.ts
@@ -0,0 +1,59 @@
+// 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 { pxToRem } from "@wso2/oxygen-ui";
+
+export const typography = {
+ h1: {
+ fontSize: pxToRem(36),
+ },
+ h2: {
+ fontSize: pxToRem(28),
+ },
+ h3: {
+ fontSize: pxToRem(24),
+ },
+ h4: {
+ fontSize: pxToRem(20),
+ },
+ h5: {
+ fontSize: pxToRem(18),
+ },
+ h6: {
+ fontSize: pxToRem(16),
+ },
+ subtitle1: {
+ fontSize: pxToRem(14),
+ },
+ subtitle2: {
+ fontSize: pxToRem(12),
+ },
+ body1: {
+ fontSize: pxToRem(14),
+ },
+ body2: {
+ fontSize: pxToRem(13),
+ },
+ button: {
+ fontSize: pxToRem(14),
+ },
+ caption: {
+ fontSize: pxToRem(11),
+ },
+ overline: {
+ fontSize: pxToRem(8),
+ },
+};
diff --git a/apps/csm-portal/microapp/src/types/case.dto.ts b/apps/csm-portal/microapp/src/types/case.dto.ts
new file mode 100644
index 0000000000..a93ba3cc85
--- /dev/null
+++ b/apps/csm-portal/microapp/src/types/case.dto.ts
@@ -0,0 +1,184 @@
+// 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.
+
+export type CaseType = "case" | "service_request" | "security_report_analysis" | "engagement" | "announcement";
+
+export type CaseSeverity = "catastrophic" | "critical" | "high" | "medium" | "low";
+
+export type CaseState =
+ | "open"
+ | "work_in_progress"
+ | "waiting_on_wso2"
+ | "awaiting_info"
+ | "reopened"
+ | "solution_proposed"
+ | "closed";
+
+export type CaseWorkState = "ongoing" | "paused" | null;
+
+export type CaseIssueType =
+ | "error"
+ | "partial_outage"
+ | "performance_degradation"
+ | "question"
+ | "security_or_compliance"
+ | "total_outage";
+
+export interface EntityRefDto {
+ id: string;
+ name: string;
+}
+
+export interface UserRefDto {
+ id: string;
+ displayName: string;
+ userId: string;
+ email: string;
+}
+
+export interface UserIdEmailRefDto {
+ id: string;
+ email: string;
+}
+
+export interface AssignedEngineerRefDto {
+ id: string;
+ name: string;
+ email: string | null;
+}
+
+export interface CaseNumberRefDto {
+ id: string;
+ number: string;
+}
+
+export interface AccountRefDto {
+ id: string;
+ name: string;
+ type: string;
+}
+
+export interface DeployedProductRefDto {
+ id: string;
+ displayName: string;
+}
+
+export interface CaseSearchFiltersDto {
+ types?: CaseType[];
+ searchQuery?: string;
+ projectIds?: string[];
+ deploymentIds?: string[];
+ states?: CaseState[];
+ severities?: CaseSeverity[];
+ issueTypes?: CaseIssueType[];
+ assignedUserIds?: string[];
+ createdByMe?: boolean;
+}
+
+export interface CaseSearchPayloadDto {
+ filters?: CaseSearchFiltersDto;
+ sortBy?: {
+ field?: "createdOn" | "updatedOn" | "severity" | "state";
+ order?: "asc" | "desc";
+ };
+ pagination?: {
+ offset?: number;
+ limit?: number;
+ };
+}
+
+export interface CaseSearchViewDto {
+ id: string;
+ number: string;
+ wso2Id: string;
+ subject: string;
+ description: string;
+ severity: CaseSeverity;
+ issueType: CaseIssueType;
+ state: CaseState;
+ workState: CaseWorkState;
+ type: string;
+ createdOn?: string;
+ updatedOn?: string;
+ closedOn: string | null;
+ createdBy: UserIdEmailRefDto;
+ project: EntityRefDto;
+ deployment: EntityRefDto | null;
+ deployedProduct: EntityRefDto | null;
+ product: EntityRefDto | null;
+ assignedEngineer: AssignedEngineerRefDto | null;
+ parentCase: CaseNumberRefDto | null;
+ relatedCase: CaseNumberRefDto | null;
+ account: AccountRefDto | null;
+}
+
+export interface CaseSearchResponseDto {
+ cases: CaseSearchViewDto[];
+ total: number;
+ limit: number;
+ offset: number;
+ hasMore: boolean;
+}
+
+export interface CaseViewDto {
+ id: string;
+ number: string;
+ wso2Id: string;
+ subject: string;
+ description: string;
+ severity: CaseSeverity;
+ issueType: CaseIssueType;
+ state: CaseState;
+ workState: CaseWorkState;
+ type: string | null;
+ engagementType: string | null;
+ createdOn?: string;
+ updatedOn?: string;
+ closedOn: string | null;
+ createdBy: UserRefDto;
+ project: EntityRefDto;
+ deployment: EntityRefDto | null;
+ deployedProduct: DeployedProductRefDto | null;
+ product: EntityRefDto | null;
+ catalog: EntityRefDto | null;
+ catalogItem: EntityRefDto | null;
+ assignedTeam: EntityRefDto | null;
+ conversation: EntityRefDto | null;
+ assignedEngineer: AssignedEngineerRefDto | null;
+ parentCase: CaseNumberRefDto | null;
+ relatedCase: CaseNumberRefDto | null;
+ account: AccountRefDto | null;
+ nextStates: CaseState[];
+}
+
+export type CaseCommentType = "work_note" | "comment" | "activity";
+
+export interface CaseCommentDto {
+ id: string;
+ caseId: string;
+ type: CaseCommentType;
+ content: string;
+ createdBy: string;
+ createdOn: string;
+}
+
+export interface CaseCommentSearchResponseDto {
+ comments: CaseCommentDto[];
+ total: number;
+ limit: number;
+ offset: number;
+ hasMore: boolean;
+}
diff --git a/apps/csm-portal/microapp/src/types/case.model.ts b/apps/csm-portal/microapp/src/types/case.model.ts
new file mode 100644
index 0000000000..e749efa48b
--- /dev/null
+++ b/apps/csm-portal/microapp/src/types/case.model.ts
@@ -0,0 +1,160 @@
+// 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 {
+ AccountRefDto,
+ AssignedEngineerRefDto,
+ CaseCommentDto,
+ CaseCommentType,
+ CaseNumberRefDto,
+ CaseSearchViewDto,
+ CaseSeverity,
+ CaseState,
+ CaseType,
+ CaseViewDto,
+ CaseWorkState,
+ DeployedProductRefDto,
+ EntityRefDto,
+ UserIdEmailRefDto,
+ UserRefDto,
+} from "./case.dto";
+import { parseBackendTimestamp, parseOptionalBackendTimestamp } from "@utils/dateTime";
+
+export interface CaseSummary {
+ id: string;
+ number: string;
+ wso2Id: string;
+ subject: string;
+ description: string;
+ severity: CaseSeverity;
+ state: CaseState;
+ workState: CaseWorkState;
+ type: CaseType;
+ createdOn: Date;
+ updatedOn: Date;
+ closedOn: Date | null;
+ createdBy: UserIdEmailRefDto;
+ project: EntityRefDto;
+ deployment: EntityRefDto | null;
+ product: EntityRefDto | null;
+ assignedEngineer: AssignedEngineerRefDto | null;
+ account: AccountRefDto | null;
+ parentCase: CaseNumberRefDto | null;
+ relatedCase: CaseNumberRefDto | null;
+}
+
+export interface CaseDetail {
+ id: string;
+ number: string;
+ wso2Id: string;
+ subject: string;
+ description: string;
+ severity: CaseSeverity;
+ state: CaseState;
+ workState: CaseWorkState;
+ type: CaseType | null;
+ engagementType: string | null;
+ createdOn: Date;
+ updatedOn: Date;
+ closedOn: Date | null;
+ createdBy: UserRefDto;
+ project: EntityRefDto;
+ deployment: EntityRefDto | null;
+ deployedProduct: DeployedProductRefDto | null;
+ product: EntityRefDto | null;
+ assignedEngineer: AssignedEngineerRefDto | null;
+ account: AccountRefDto | null;
+ parentCase: CaseNumberRefDto | null;
+ relatedCase: CaseNumberRefDto | null;
+ nextStates: CaseState[];
+}
+
+export interface Comment {
+ id: string;
+ caseId: string;
+ type: CaseCommentType;
+ content: string;
+ createdBy: string;
+ createdOn: Date;
+}
+
+export function toCaseSummary(dto: CaseSearchViewDto): CaseSummary {
+ return {
+ id: dto.id,
+ number: dto.number,
+ wso2Id: dto.wso2Id,
+ subject: dto.subject,
+ description: dto.description,
+ severity: dto.severity,
+ state: dto.state,
+ workState: dto.workState,
+ type: dto.type as CaseType,
+ // The search/detail views don't always populate updatedOn (e.g. a case that hasn't been
+ // touched since creation); fall back to createdOn rather than showing an invalid time.
+ createdOn: parseBackendTimestamp(dto.createdOn ?? ""),
+ updatedOn: parseBackendTimestamp(dto.updatedOn ?? dto.createdOn ?? ""),
+ closedOn: parseOptionalBackendTimestamp(dto.closedOn),
+ createdBy: dto.createdBy,
+ project: dto.project,
+ deployment: dto.deployment,
+ product: dto.product,
+ assignedEngineer: dto.assignedEngineer,
+ account: dto.account,
+ parentCase: dto.parentCase,
+ relatedCase: dto.relatedCase,
+ };
+}
+
+export function toCaseDetail(dto: CaseViewDto): CaseDetail {
+ return {
+ id: dto.id,
+ number: dto.number,
+ wso2Id: dto.wso2Id,
+ subject: dto.subject,
+ description: dto.description,
+ severity: dto.severity,
+ state: dto.state,
+ workState: dto.workState,
+ type: dto.type as CaseType | null,
+ engagementType: dto.engagementType,
+ // The search/detail views don't always populate updatedOn (e.g. a case that hasn't been
+ // touched since creation); fall back to createdOn rather than showing an invalid time.
+ createdOn: parseBackendTimestamp(dto.createdOn ?? ""),
+ updatedOn: parseBackendTimestamp(dto.updatedOn ?? dto.createdOn ?? ""),
+ closedOn: parseOptionalBackendTimestamp(dto.closedOn),
+ createdBy: dto.createdBy,
+ project: dto.project,
+ deployment: dto.deployment,
+ deployedProduct: dto.deployedProduct,
+ product: dto.product,
+ assignedEngineer: dto.assignedEngineer,
+ account: dto.account,
+ parentCase: dto.parentCase,
+ relatedCase: dto.relatedCase,
+ nextStates: dto.nextStates,
+ };
+}
+
+export function toComment(dto: CaseCommentDto): Comment {
+ return {
+ id: dto.id,
+ caseId: dto.caseId,
+ type: dto.type,
+ content: dto.content,
+ createdBy: dto.createdBy,
+ createdOn: parseBackendTimestamp(dto.createdOn),
+ };
+}
diff --git a/apps/csm-portal/microapp/src/types/index.ts b/apps/csm-portal/microapp/src/types/index.ts
new file mode 100644
index 0000000000..34262ce215
--- /dev/null
+++ b/apps/csm-portal/microapp/src/types/index.ts
@@ -0,0 +1,18 @@
+// 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.
+
+export * from "./case.dto";
+export * from "./case.model";
diff --git a/apps/csm-portal/microapp/src/utils/ApiError.ts b/apps/csm-portal/microapp/src/utils/ApiError.ts
new file mode 100644
index 0000000000..86d3eb1506
--- /dev/null
+++ b/apps/csm-portal/microapp/src/utils/ApiError.ts
@@ -0,0 +1,57 @@
+// 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 { isAxiosError } from "axios";
+
+interface ApiErrorMessageBody {
+ message?: string;
+}
+
+export class ApiError extends Error {
+ public readonly status: number;
+ public readonly statusText: string;
+
+ constructor(status: number, statusText: string, message?: string) {
+ super(message ?? `${status} ${statusText}`);
+ this.name = "ApiError";
+ this.status = status;
+ this.statusText = statusText;
+ }
+}
+
+export function toApiError(error: unknown, fallbackMessage: string): Error {
+ if (error instanceof ApiError) {
+ return error;
+ }
+
+ if (isAxiosError(error)) {
+ const status = error.response?.status ?? 500;
+ const statusText = error.response?.statusText ?? "Internal Server Error";
+ const apiMessage = error.response?.data?.message;
+ return new ApiError(status, statusText, apiMessage ?? fallbackMessage);
+ }
+
+ return error instanceof Error ? error : new Error(fallbackMessage);
+}
+
+export function getApiErrorMessage(error: unknown): string | undefined {
+ if (!(error instanceof ApiError)) {
+ return undefined;
+ }
+
+ const defaultMessage = `${error.status} ${error.statusText}`;
+ return error.message !== defaultMessage ? error.message : undefined;
+}
diff --git a/apps/csm-portal/microapp/src/utils/constants.ts b/apps/csm-portal/microapp/src/utils/constants.ts
new file mode 100644
index 0000000000..08d12db084
--- /dev/null
+++ b/apps/csm-portal/microapp/src/utils/constants.ts
@@ -0,0 +1,24 @@
+// 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.
+
+export const ErrorMessages = {
+ NATIVE_BRIDGE_NOT_AVAILABLE: "Native bridge is not available",
+};
+
+export const LocalStorageKeys = {
+ accessToken: "accessToken",
+ idToken: "idToken",
+};
diff --git a/apps/csm-portal/microapp/src/utils/dateTime.ts b/apps/csm-portal/microapp/src/utils/dateTime.ts
new file mode 100644
index 0000000000..b4d8b4635e
--- /dev/null
+++ b/apps/csm-portal/microapp/src/utils/dateTime.ts
@@ -0,0 +1,61 @@
+// 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 dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
+
+dayjs.extend(relativeTime);
+
+export const formatDate = (date: Date): string =>
+ Number.isNaN(date.getTime()) ? "—" : dayjs(date).format("MMM D, YYYY h:mm A");
+
+// dayjs renders an Invalid Date as "a month ago" rather than erroring, which silently masks
+// unparseable timestamps as a plausible-looking value instead of surfacing the bug.
+export const fromNow = (date: Date): string => (Number.isNaN(date.getTime()) ? "—" : dayjs(date).fromNow());
+
+// The backend is ServiceNow-sourced and its timestamps are UTC but carry no zone marker,
+// in one of a few different shapes depending on which SN API served the value (e.g.
+// "2026-06-08 10:15:00", "06/08/2026 10:15:00", or "2026-06-08T10:15:00" — no Z/offset on
+// any of them). `new Date()` treats a zone-less date-time string as local time, so parsing
+// it directly silently shifts it by the browser's UTC offset (or fails to parse at all,
+// depending on runtime). Normalize to an explicit UTC ISO string before handing it to
+// `Date` so the parsed instant is correct regardless of the viewer's timezone.
+function normalizeBackendTimestamp(raw: string): string {
+ const spaceSeparated = /^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})(\.\d+)?$/.exec(raw);
+ if (spaceSeparated) {
+ const [, yyyy, mm, dd, hh, mi, ss, fractional = ""] = spaceSeparated;
+ return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}T${hh.padStart(2, "0")}:${mi.padStart(2, "0")}:${ss.padStart(2, "0")}${fractional}Z`;
+ }
+
+ const mdy = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})(\.\d+)?$/.exec(raw);
+ if (mdy) {
+ const [, mm, dd, yyyy, hh, mi, ss, fractional = ""] = mdy;
+ return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}T${hh.padStart(2, "0")}:${mi.padStart(2, "0")}:${ss.padStart(2, "0")}${fractional}Z`;
+ }
+
+ const tSeparated = /^(\d{4})-(\d{1,2})-(\d{1,2})T(\d{1,2}):(\d{1,2}):(\d{1,2})(\.\d+)?$/.exec(raw);
+ if (tSeparated) {
+ const [, yyyy, mm, dd, hh, mi, ss, fractional = ""] = tSeparated;
+ return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}T${hh.padStart(2, "0")}:${mi.padStart(2, "0")}:${ss.padStart(2, "0")}${fractional}Z`;
+ }
+
+ return raw;
+}
+
+export const parseBackendTimestamp = (raw: string): Date => new Date(normalizeBackendTimestamp(raw));
+
+export const parseOptionalBackendTimestamp = (raw: string | null | undefined): Date | null =>
+ raw ? parseBackendTimestamp(raw) : null;
diff --git a/apps/csm-portal/microapp/src/utils/logger.ts b/apps/csm-portal/microapp/src/utils/logger.ts
new file mode 100644
index 0000000000..612fc8ce7f
--- /dev/null
+++ b/apps/csm-portal/microapp/src/utils/logger.ts
@@ -0,0 +1,37 @@
+// 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 { sendNativeLog } from "@components/microapp-bridge";
+
+/**
+ * Logger class for logging messages to the React Native DevTools
+ */
+export class Logger {
+ // Info/Basic Logs
+ static info(message: string, data?: unknown) {
+ sendNativeLog(message, data, "info");
+ }
+
+ // Error Logs
+ static error(message: string, data?: unknown) {
+ sendNativeLog(message, data, "error");
+ }
+
+ // Warning Logs
+ static warn(message: string, data?: unknown) {
+ sendNativeLog(message, data, "warn");
+ }
+}