Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/csm-portal/webapp/public/config.js.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,15 @@ window.config = {
CSM_PORTAL_TOP_BANNER_ENABLED: false,
// Optional top banner rendered above the header (raw HTML string):
// CSM_PORTAL_TOP_BANNER_HTML: '<div style="background-color:#000;height:6.8rem"><a href="https://wso2.com/wso2con/" target="_blank"><img style="object-fit:cover;height:100%;width:100%" src="banner.png" role="presentation"></a></div>',

// Dismissible banner nudging engineers on a detected mobile phone browser
// toward the WSO2 Super App micro-app. Unlike the customer portal, this
// never blocks access -- engineers can dismiss it and keep using the web
// portal on a phone. Leave the store URLs unset to suppress the banner
// even when enabled (no download destination to offer).
CSM_PORTAL_MOBILE_APP_PROMPT_ENABLED: false,
// CSM_PORTAL_MOBILE_APP_IOS_STORE_URL: "https://apps.apple.com/app/id0000000000",
// CSM_PORTAL_MOBILE_APP_ANDROID_STORE_URL: "https://play.google.com/store/apps/details?id=com.example.app",
// Also show the banner on tablets, not just phones (default false).
// CSM_PORTAL_MOBILE_APP_INCLUDE_TABLETS: false,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// 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 "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DeviceType, MobileOs } from "@/types/mobileDevice";

const mockConfig = {
enabled: true,
iosStoreUrl: "https://apps.apple.com/app/example",
androidStoreUrl: "https://play.google.com/store/apps/details?id=example",
includeTablets: false,
};

vi.mock("@utils/deviceDetection", () => ({
detectMobileDevice: vi.fn(),
}));

vi.mock("@config/mobileAppConfig", () => ({
getMobileAppConfig: vi.fn(() => mockConfig),
getMobileAppStoreUrl: (os: string) =>
os === "ios" ? mockConfig.iosStoreUrl : mockConfig.androidStoreUrl,
}));

import { detectMobileDevice } from "@utils/deviceDetection";
import MobileAppBanner from "@components/mobile-app-banner/MobileAppBanner";

describe("MobileAppBanner", () => {
beforeEach(() => {
mockConfig.enabled = true;
mockConfig.iosStoreUrl = "https://apps.apple.com/app/example";
mockConfig.androidStoreUrl =
"https://play.google.com/store/apps/details?id=example";
mockConfig.includeTablets = false;
vi.stubGlobal("open", vi.fn());
});

afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});

it("renders nothing on desktop (no device detected)", () => {
vi.mocked(detectMobileDevice).mockReturnValue(null);

render(<MobileAppBanner />);

expect(screen.queryByText("Get the WSO2 Super App")).toBeNull();
});

it("renders nothing when the prompt is disabled even on a mobile device", () => {
mockConfig.enabled = false;
vi.mocked(detectMobileDevice).mockReturnValue({
os: MobileOs.Ios,
deviceType: DeviceType.Phone,
});

render(<MobileAppBanner />);

expect(screen.queryByText("Get the WSO2 Super App")).toBeNull();
});

it("renders nothing when no store URL is configured for the detected OS", () => {
mockConfig.iosStoreUrl = undefined as unknown as string;
vi.mocked(detectMobileDevice).mockReturnValue({
os: MobileOs.Ios,
deviceType: DeviceType.Phone,
});

render(<MobileAppBanner />);

expect(screen.queryByText("Get the WSO2 Super App")).toBeNull();
});

it("shows the banner on a detected mobile phone", () => {
vi.mocked(detectMobileDevice).mockReturnValue({
os: MobileOs.Android,
deviceType: DeviceType.Phone,
});

render(<MobileAppBanner />);

expect(screen.getByText("Get the WSO2 Super App")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /download/i })).toBeInTheDocument();
});

it("dismisses on close and stays hidden until re-mounted with a new visible state", () => {
vi.mocked(detectMobileDevice).mockReturnValue({
os: MobileOs.Ios,
deviceType: DeviceType.Phone,
});

render(<MobileAppBanner />);
expect(screen.getByText("Get the WSO2 Super App")).toBeInTheDocument();

const dismissButton = screen.getByRole("button", { name: /close/i });
fireEvent.click(dismissButton);

expect(screen.queryByText("Get the WSO2 Super App")).toBeNull();
});

it("opens the store URL via window.open when the download action is used", () => {
vi.mocked(detectMobileDevice).mockReturnValue({
os: MobileOs.Ios,
deviceType: DeviceType.Phone,
});

render(<MobileAppBanner />);
fireEvent.click(screen.getByRole("button", { name: /download/i }));

expect(window.open).toHaveBeenCalledWith(
"https://apps.apple.com/app/example",
"_blank",
"noopener,noreferrer",
);
});

it("suppresses the banner entirely for a javascript: store URL, rather than rendering a dead Download button", () => {
mockConfig.iosStoreUrl = "javascript:alert('xss')";
vi.mocked(detectMobileDevice).mockReturnValue({
os: MobileOs.Ios,
deviceType: DeviceType.Phone,
});

render(<MobileAppBanner />);

expect(screen.queryByText("Get the WSO2 Super App")).toBeNull();
expect(screen.queryByRole("button", { name: /download/i })).toBeNull();
expect(window.open).not.toHaveBeenCalled();
});

it("suppresses the banner for a store URL that fails to parse at all", () => {
mockConfig.iosStoreUrl = "http://";
vi.mocked(detectMobileDevice).mockReturnValue({
os: MobileOs.Ios,
deviceType: DeviceType.Phone,
});

render(<MobileAppBanner />);

expect(screen.queryByText("Get the WSO2 Super App")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// 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 {
Alert,
AlertTitle,
Box,
Button,
Collapse,
IconButton,
Stack,
} from "@wso2/oxygen-ui";
import { X } from "@wso2/oxygen-ui-icons-react";
import { useEffect, useMemo, useState, type JSX } from "react";
import {
getMobileAppConfig,
getMobileAppStoreUrl,
} from "@config/mobileAppConfig";
import { MobileOs, type MobileDeviceInfo } from "@/types/mobileDevice";
import { detectMobileDevice } from "@utils/deviceDetection";

const OS_LABELS: Record<MobileOs, string> = {
[MobileOs.Ios]: "iOS",
[MobileOs.Android]: "Android",
};

/**
* Validates a configured store URL and returns its normalized `http(s)` form,
* or `undefined` when it's missing or unsupported (e.g. a `javascript:`/
* `data:` URI, or a string that doesn't parse as a URL at all). Used both to
* decide whether the banner should show at all and as the actual value
* `window.open` navigates to -- a config value that fails this check must
* never render a Download button that silently does nothing on click.
*/
function resolveDownloadUrl(storeUrl: string | undefined): string | undefined {
if (!storeUrl) return undefined;
try {
const parsed = new URL(storeUrl, window.location.origin);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined;
return parsed.toString();
} catch {
return undefined;
}
}

/**
* MobileAppBanner component.
*
* Dismissible banner nudging CS engineers on a detected mobile phone (and
* optionally tablet) browser toward the WSO2 Super App micro-app, without
* blocking access -- engineers can dismiss it and keep using the web portal
* on a phone, unlike the customer portal's full-page mobile gate.
*
* Built directly on `Alert` (not the higher-level `NotificationBanner`):
* `NotificationBanner`/MUI `Alert` only auto-renders its own close icon when
* no custom `action` node is supplied, so a banner that also needs a
* "Download" action button must pack both into `action` itself -- see
* `ErrorBanner.tsx` for the same pattern already established in this app.
*
* @returns {JSX.Element | null} The MobileAppBanner component.
*/
export default function MobileAppBanner(): JSX.Element | null {
const mobileAppConfig = useMemo(() => getMobileAppConfig(), []);
const device = useMemo<MobileDeviceInfo | null>(
() =>
detectMobileDevice({ includeTablets: mobileAppConfig.includeTablets }),
[mobileAppConfig.includeTablets],
);

const storeUrl = device
? getMobileAppStoreUrl(device.os, mobileAppConfig)
: undefined;
const downloadUrl = resolveDownloadUrl(storeUrl);

const visible = mobileAppConfig.enabled && device !== null && !!downloadUrl;

// State for the banner dismissal.
const [dismissed, setDismissed] = useState<boolean>(false);

// Reset the dismissed state when the visibility configuration changes to true.
useEffect(() => {
if (visible) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset dismissal when banner is re-shown
setDismissed(false);
}
}, [visible]);

if (!visible || dismissed || !device || !downloadUrl) {
return null;
}

const osLabel = OS_LABELS[device.os];

const handleDownload = (): void => {
window.open(downloadUrl, "_blank", "noopener,noreferrer");
};

return (
<Collapse in>
<Alert
severity="info"
variant="filled"
action={
<Stack direction="row" spacing={0.5} alignItems="center">
<Button
color="inherit"
size="small"
onClick={handleDownload}
sx={{ fontWeight: 600, textDecoration: "underline" }}
>
Download
</Button>
<IconButton
size="small"
color="inherit"
onClick={() => setDismissed(true)}
aria-label="Close"
>
<X size={16} />
</IconButton>
</Stack>
}
>
<AlertTitle sx={{ mb: 0 }}>Get the WSO2 Super App</AlertTitle>
<Box component="span">
{`This portal isn't optimized for ${osLabel} browsers. CSM Portal is also available as a micro-app inside the WSO2 Super App for a better mobile experience.`}
</Box>
</Alert>
</Collapse>
);
}
11 changes: 11 additions & 0 deletions apps/csm-portal/webapp/src/config/authConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ declare global {
CSM_PORTAL_ANNOUNCEMENT_BANNER_VISIBLE?: boolean;
CSM_PORTAL_ANNOUNCEMENT_BANNER_STORAGE_KEY?: string;
CSM_PORTAL_ANNOUNCEMENT_BANNER_HTML?: string;
/**
* Dismissible banner nudging CS engineers on a detected mobile phone
* (and optionally tablet) browser toward the WSO2 Super App micro-app
* instead of the full web portal. Unlike the customer portal's
* equivalent, this never blocks access -- engineers may need emergency
* mobile access. See `mobileAppConfig.ts`.
*/
CSM_PORTAL_MOBILE_APP_PROMPT_ENABLED?: boolean;
CSM_PORTAL_MOBILE_APP_IOS_STORE_URL?: string;
CSM_PORTAL_MOBILE_APP_ANDROID_STORE_URL?: string;
CSM_PORTAL_MOBILE_APP_INCLUDE_TABLETS?: boolean;
};
}
}
Expand Down
56 changes: 56 additions & 0 deletions apps/csm-portal/webapp/src/config/mobileAppConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// 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 { MobileOs } from "@/types/mobileDevice";

export interface MobileAppConfig {
enabled: boolean;
iosStoreUrl?: string;
androidStoreUrl?: string;
includeTablets: boolean;
}

/**
* Reads mobile-app banner settings from window.config.
*
* @returns {MobileAppConfig} Resolved mobile app configuration.
*/
export function getMobileAppConfig(): MobileAppConfig {
const config = window.config;

return {
enabled: config?.CSM_PORTAL_MOBILE_APP_PROMPT_ENABLED ?? false,
iosStoreUrl: config?.CSM_PORTAL_MOBILE_APP_IOS_STORE_URL,
androidStoreUrl: config?.CSM_PORTAL_MOBILE_APP_ANDROID_STORE_URL,
includeTablets: config?.CSM_PORTAL_MOBILE_APP_INCLUDE_TABLETS ?? false,
};
}

/**
* Resolves the app-store URL for the given mobile OS.
*
* @param {MobileOs} os - Target mobile operating system.
* @param {MobileAppConfig} mobileAppConfig - Mobile app configuration.
* @returns {string | undefined} Store URL when configured.
*/
export function getMobileAppStoreUrl(
os: MobileOs,
mobileAppConfig: MobileAppConfig = getMobileAppConfig(),
): string | undefined {
return os === MobileOs.Ios
? mobileAppConfig.iosStoreUrl
: mobileAppConfig.androidStoreUrl;
}
Loading