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"); + } +}