diff --git a/user-interface/src/lib/humble/broadcast-channel-humble.ts b/user-interface/src/lib/humble/broadcast-channel-humble.ts new file mode 100644 index 000000000..eb803acdd --- /dev/null +++ b/user-interface/src/lib/humble/broadcast-channel-humble.ts @@ -0,0 +1,19 @@ +export class BroadcastChannelHumble { + private channel: BroadcastChannel; + + constructor(channelName: string) { + this.channel = new BroadcastChannel(channelName); + } + + onMessage(handler: (event: MessageEvent) => void) { + this.channel.onmessage = handler; + } + + postMessage(message: unknown) { + this.channel.postMessage(message); + } + + close() { + this.channel.close(); + } +} diff --git a/user-interface/src/login/Login.tsx b/user-interface/src/login/Login.tsx index a7e9857a7..0b41b5262 100644 --- a/user-interface/src/login/Login.tsx +++ b/user-interface/src/login/Login.tsx @@ -21,6 +21,7 @@ import ApiConfiguration from '@/configuration/apiConfiguration'; import { CamsUser } from '@common/cams/users'; import { CamsSession } from '@common/cams/session'; import { SUPERUSER } from '@common/cams/test-utilities/mock-user'; +import { initializeBroadcastLogout } from '@/login/broadcast-logout'; export type LoginProps = PropsWithChildren & { provider?: LoginProvider; @@ -42,6 +43,7 @@ export function Login(props: LoginProps): React.ReactNode { addApiAfterHook(http401Hook); initializeInactiveLogout(); + initializeBroadcastLogout(); const session: CamsSession | null = LocalStorage.getSession(); if (session) { diff --git a/user-interface/src/login/SessionEnd.tsx b/user-interface/src/login/SessionEnd.tsx index 7a3bb376a..150f9756a 100644 --- a/user-interface/src/login/SessionEnd.tsx +++ b/user-interface/src/login/SessionEnd.tsx @@ -5,6 +5,7 @@ import Button from '@/lib/components/uswds/Button'; import { LocalStorage } from '@/lib/utils/local-storage'; import { LOGIN_PATH, LOGOUT_SESSION_END_PATH } from './login-library'; import { BlankPage } from './BlankPage'; +import { broadcastLogout } from '@/login/broadcast-logout'; export function SessionEnd() { const location = useLocation(); @@ -16,6 +17,7 @@ export function SessionEnd() { LocalStorage.removeSession(); LocalStorage.removeAck(); + broadcastLogout(); useEffect(() => { if (location.pathname !== LOGOUT_SESSION_END_PATH) { diff --git a/user-interface/src/login/broadcast-logout.test.ts b/user-interface/src/login/broadcast-logout.test.ts new file mode 100644 index 000000000..4282e50e8 --- /dev/null +++ b/user-interface/src/login/broadcast-logout.test.ts @@ -0,0 +1,48 @@ +import { BroadcastChannelHumble } from '@/lib/humble/broadcast-channel-humble'; +import { + broadcastLogout, + handleLogoutBroadcast, + initializeBroadcastLogout, +} from '@/login/broadcast-logout'; + +describe('Broadcast Logout', () => { + test('should handle broadcast-logout properly', () => { + const postMessageSpy = vi + .spyOn(BroadcastChannelHumble.prototype, 'postMessage') + .mockReturnValue(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + let onMessageFn: Function = vi.fn(); + const onMessageSpy = vi + .spyOn(BroadcastChannelHumble.prototype, 'onMessage') + .mockImplementation((arg) => { + onMessageFn = arg; + }); + + const closeSpy = vi.spyOn(BroadcastChannelHumble.prototype, 'close').mockReturnValue(); + + Object.defineProperty(global, 'window', Object.create(window)); + global.window.location = { + host: 'some-host', + protocol: 'http:', + assign: function (_url: string | URL): void {}, + } as unknown as Location; + const assignSpy = vi.spyOn(global.window.location, 'assign'); + const expectedUrl = + global.window.location.protocol + '//' + global.window.location.host + '/logout'; + + initializeBroadcastLogout(); + broadcastLogout(); + + // Validate broadcastLogout function + expect(postMessageSpy).toHaveBeenCalledWith('Logout all windows'); + + // Validate initializeBroadcastLogout function + expect(onMessageSpy).toHaveBeenCalledWith(handleLogoutBroadcast); + expect(onMessageFn).toEqual(handleLogoutBroadcast); + + // Validate handleLogout function + onMessageFn(); + expect(closeSpy).toHaveBeenCalled(); + expect(assignSpy).toHaveBeenCalledWith(expectedUrl); + }); +}); diff --git a/user-interface/src/login/broadcast-logout.ts b/user-interface/src/login/broadcast-logout.ts new file mode 100644 index 000000000..2c836acec --- /dev/null +++ b/user-interface/src/login/broadcast-logout.ts @@ -0,0 +1,20 @@ +import { BroadcastChannelHumble } from '@/lib/humble/broadcast-channel-humble'; +import { LOGOUT_PATH } from '@/login/login-library'; + +let channel: BroadcastChannelHumble; + +export function handleLogoutBroadcast() { + const { host, protocol } = window.location; + const logoutUri = protocol + '//' + host + LOGOUT_PATH; + window.location.assign(logoutUri); + channel?.close(); +} + +export function initializeBroadcastLogout() { + channel = new BroadcastChannelHumble('CAMS_logout'); + channel.onMessage(handleLogoutBroadcast); +} + +export function broadcastLogout() { + channel?.postMessage('Logout all windows'); +} diff --git a/user-interface/vitest.config.mts b/user-interface/vitest.config.mts index d43a5f88b..e3200b264 100644 --- a/user-interface/vitest.config.mts +++ b/user-interface/vitest.config.mts @@ -27,6 +27,7 @@ export default defineConfig({ '**/data-verification/consolidation/ConsolidationOrderAccordionView.tsx', '**/data-verification/consolidation/*Mock.ts', '**/*.d.ts', + '**/**humble.ts', ...coverageConfigDefaults.exclude, ], thresholds: {