diff --git a/.env b/.env index 1ad1e2a5..f0d6a092 100644 --- a/.env +++ b/.env @@ -2,18 +2,41 @@ # for deploy enviroment configurations, see config.json # Base URL path for the enviroment -PUBLIC_URL = '/dev/' +PUBLIC_URL='/' # Name of enviroment for build -REACT_APP_KBASE_ENV=ci-europa +# This is used for selection of a config stanza. +REACT_APP_KBASE_ENV='ci-europa' + # Domain of enviroment for build -REACT_APP_KBASE_DOMAIN=ci-europa.kbase.us +REACT_APP_KBASE_DOMAIN='ci-europa.kbase.us' + # The following must be a subdomain of REACT_APP_KBASE_DOMAIN -REACT_APP_KBASE_LEGACY_DOMAIN=legacy.ci-europa.kbase.us +REACT_APP_KBASE_LEGACY_DOMAIN='legacy.ci-europa.kbase.us' + +# The base path - the first part of the pathname - for the url used to load kbase-ui +# This must match how kbase-ui is proxied; in KBase environments, this must match +# the path established for kbase-ui; in development, it must match however the +# kbase-ui development proxy is configured. +REACT_APP_KBASE_LEGACY_BASE_PATH='' # Backup cookie name and domain, empty if unused by enviroment -REACT_APP_KBASE_BACKUP_COOKIE_NAME = 'test_kbase_backup_session' -REACT_APP_KBASE_BACKUP_COOKIE_DOMAIN = 'localhost' +REACT_APP_KBASE_BACKUP_COOKIE_NAME='' +REACT_APP_KBASE_BACKUP_COOKIE_DOMAIN='' + +# +# For the local dev server environment +# +EXTEND_ESLINT='true' +SKIP_PREFLIGHT_CHECK='true' -EXTEND_ESLINT=true -SKIP_PREFLIGHT_CHECK=true +# Whether the dev server is running in coordination with kbase-ui and under the +# kbase-ui proxy. This is necessary when running the dev server inside a +# container and behind a proxy, because in that case we want the base url (e.g. set in +# common/api/index.ts) to be the KBase deployment domain (e.g. ci.kbase.us) and +# not localhost:3000, set when running a local, unproxied dev server. +# In other words, `process.env.NODE_ENV` equal to "development" means that the +# development server is running and `process.env.REACT_APP_KBASE_UI_DEV` set to "true" +# means that this development server is running inside a container and coordinated with +# the kbase-ui development proxy. +REACT_APP_KBASE_UI_DEV='true' \ No newline at end of file diff --git a/config.json b/config.json index debb01e8..0a892963 100644 --- a/config.json +++ b/config.json @@ -3,45 +3,52 @@ "appdev": { "domain": "appdev.kbase.us", "legacy": "legacy.appdev.kbase.us", + "legacy_base_path": "", "public_url": "/" }, - "ci": { "domain": "ci.kbase.us", "legacy": "legacy.ci.kbase.us", + "legacy_base_path": "", "public_url": "/" }, - "ci-europa": { "domain": "ci-europa.kbase.us", "legacy": "legacy.ci-europa.kbase.us", + "legacy_base_path": "", "public_url": "/" }, - "narrative-dev": { "domain": "narrative-dev.kbase.us", "legacy": "legacy.narrative-dev.kbase.us", + "legacy_base_path": "", "public_url": "/" }, - "narrative2": { "domain": "narrative2.kbase.us", "legacy": "legacy.narrative2.kbase.us", + "legacy_base_path": "", "public_url": "/", - "backup_cookie": { "name": "kbase_session_backup", "domain": ".kbase.us" } + "backup_cookie": { + "name": "kbase_session_backup", + "domain": ".kbase.us" + } }, - "next": { "domain": "next.kbase.us", "legacy": "legacy.next.kbase.us", + "legacy_base_path": "", "public_url": "/" }, - "production": { "domain": "narrative.kbase.us", "legacy": "legacy.narrative.kbase.us", + "legacy_base_path": "", "public_url": "/", - "backup_cookie": { "name": "kbase_session_backup", "domain": ".kbase.us" } + "backup_cookie": { + "name": "kbase_session_backup", + "domain": ".kbase.us" + } } } } diff --git a/public/load-narrative.html b/public/load-narrative.html new file mode 100644 index 00000000..513f7e39 --- /dev/null +++ b/public/load-narrative.html @@ -0,0 +1,40 @@ + + + + + + KBase Narrative Interface + + + + + + + + \ No newline at end of file diff --git a/scripts/build_deploy.sh b/scripts/build_deploy.sh index dd769042..2ef8644c 100755 --- a/scripts/build_deploy.sh +++ b/scripts/build_deploy.sh @@ -3,10 +3,11 @@ # Here we are using bash "here strings" IFS=$'\n' read -d '' -r -a enviromentsConfig <<< "$(jq -r '.environments | keys[] as $k - | [($k), (.[$k]["domain"]) , (.[$k]["legacy"]) , (.[$k]["public_url"]) , (.[$k]["backup_cookie"]["name"]) , (.[$k]["backup_cookie"]["domain"])] - | join(" ")' config.json)" + | [($k), (.[$k]["domain"]) , (.[$k]["legacy"]) , (.[$k]["legacy_base_path"]) , (.[$k]["public_url"]) , (.[$k]["backup_cookie"]["name"]) , (.[$k]["backup_cookie"]["domain"])] + | join("|")' config.json)" for enviro in "${enviromentsConfig[@]}"; do + IFS="|" read -a envConf <<< "$enviro" echo "Building static files for enviroment \"${envConf[0]}\"..."; @@ -14,9 +15,10 @@ for enviro in "${enviromentsConfig[@]}"; do REACT_APP_KBASE_ENV="${envConf[0]}" \ REACT_APP_KBASE_DOMAIN="${envConf[1]}" \ REACT_APP_KBASE_LEGACY_DOMAIN="${envConf[2]}" \ - PUBLIC_URL="${envConf[3]}" \ - REACT_APP_KBASE_BACKUP_COOKIE_NAME="${envConf[4]}" \ - REACT_APP_KBASE_BACKUP_COOKIE_DOMAIN="${envConf[5]}" \ + REACT_APP_KBASE_LEGACY_BASE_PATH="${envConf[3]}" \ + PUBLIC_URL="${envConf[4]}" \ + REACT_APP_KBASE_BACKUP_COOKIE_NAME="${envConf[5]}" \ + REACT_APP_KBASE_BACKUP_COOKIE_DOMAIN="${envConf[6]}" \ npm run build && \ echo "Built static files for enviroment \"${envConf[0]}\"."; done diff --git a/scripts/nginx.conf.tmpl b/scripts/nginx.conf.tmpl index 36520cff..72323262 100644 --- a/scripts/nginx.conf.tmpl +++ b/scripts/nginx.conf.tmpl @@ -1,9 +1,18 @@ server { - listen 0.0.0.0:8080; + listen 0.0.0.0:8080; - location / { - root /deploy/__ENVIRONMENT__; - index index.html; - try_files $uri $uri/ /index.html =404; - } + root /deploy/__ENVIRONMENT__; + + # First try a physical file. + # If not found, go to fallback. + location ~ / { + try_files $uri $uri/ @fallback; + } + + # Fallback location is the web app itself. + location @fallback { + # We don't want the browser to cache the + add_header Cache-Control "no-store"; + try_files /index.html =404; + } } diff --git a/src/app/App.tsx b/src/app/App.tsx index c2679a36..986eb820 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,7 +7,6 @@ import { FC, useEffect } from 'react'; import { isInsideIframe } from '../common'; import { useAppDispatch, useAppSelector } from '../common/hooks'; import { authInitialized, authUsername } from '../features/auth/authSlice'; -import { useTokenCookie } from '../features/auth/hooks'; import LeftNavBar from '../features/layout/LeftNavBar'; import TopBar from '../features/layout/TopBar'; import ErrorPage from '../features/layout/ErrorPage'; @@ -17,12 +16,24 @@ import { ModalDialog } from '../features/layout/Modal'; import { useLoggedInProfileUser } from '../features/profile/profileSlice'; import Routes from './Routes'; import classes from './App.module.scss'; +import { + useInitializeAuthStateFromCookie, + useSyncAuthStateFromCookie, + useSyncCookieFromAuthState, +} from '../features/auth/hooks'; const useInitApp = () => { const dispatch = useAppDispatch(); - // Pulls token from cookie, syncs cookie to auth state - useTokenCookie( + // Only used to bootstrap auth from the auth cookie, if any, present in the browser. + useInitializeAuthStateFromCookie('kbase_session'); + + // Updates app auth state from cookie changes after app startup. + useSyncAuthStateFromCookie('kbase_session'); + + // Ensures the primary auth cookie is set correctly according to app auth state. If a + // backup cookie is configured, ensures that it mirrors the primary auth cookie. + useSyncCookieFromAuthState( 'kbase_session', process.env.REACT_APP_KBASE_BACKUP_COOKIE_NAME, process.env.REACT_APP_KBASE_BACKUP_COOKIE_DOMAIN diff --git a/src/app/Routes.test.tsx b/src/app/Routes.test.tsx index afe0031f..5779d5fa 100644 --- a/src/app/Routes.test.tsx +++ b/src/app/Routes.test.tsx @@ -2,12 +2,12 @@ import { render } from '@testing-library/react'; import { Provider } from 'react-redux'; import { - Route, Routes as RRRoutes, + Route, MemoryRouter as Router, } from 'react-router-dom'; import { TokenInfo } from '../features/auth/authSlice'; -import { LEGACY_BASE_ROUTE } from '../features/legacy/Legacy'; +import { LEGACY_BASE_ROUTE } from '../features/legacy/constants'; import { Authed, HashRouteRedirect, @@ -76,7 +76,10 @@ describe('Routing Utils', () => { } /> - } /> + } + /> } /> @@ -92,7 +95,10 @@ describe('Routing Utils', () => { } /> - } /> + } + /> } /> diff --git a/src/app/Routes.tsx b/src/app/Routes.tsx index 7016f74e..42107e93 100644 --- a/src/app/Routes.tsx +++ b/src/app/Routes.tsx @@ -6,8 +6,7 @@ import { useLocation, } from 'react-router-dom'; -import Legacy, { LEGACY_BASE_ROUTE } from '../features/legacy/Legacy'; -import { Fallback } from '../features/legacy/IFrameFallback'; +import Legacy from '../features/legacy/Legacy'; import Navigator, { navigatorPath, navigatorPathWithCategory, @@ -25,8 +24,10 @@ import { useFilteredParams, usePageTracking, } from '../common/hooks'; +import { LEGACY_BASE_ROUTE } from '../features/legacy/constants'; +import FallbackNotFound from '../common/components/FallbackNotFound'; -export const LOGIN_ROUTE = '/legacy/login'; +export const LOGIN_ROUTE = `${LEGACY_BASE_ROUTE()}/login`; export const ROOT_REDIRECT_ROUTE = '/narratives'; const Routes: FC = () => { @@ -34,7 +35,18 @@ const Routes: FC = () => { usePageTracking(); return ( - } /> + {/* The legacy route without any path element goes to the default location (probably the + Narratives Navigator) + Note that this replaces the previous behavior, in which the kbase-ui would receive + an empty path, and issue a navigation to /fallback, which would in turn redirect + to /narratives. However, this technique is more direct. */} + } + /> + {/* Otherwise, legacy routes go to the Legacy component. See the catch-alls at the end for + handling of kbase-ui hash routes. */} + } /> } />} @@ -77,22 +89,29 @@ const Routes: FC = () => { } /> - {/* IFrame Fallback Routes */} + {/* IFrame Fallback Routes + When kbase-ui is called with a hashpath which is not handled, + it navigates to + `/fallback/{hashpath}?{params}` + where `{hashpath}` is the original hash path provided to kbase-ui in the iframe + and `{params}` is the original params provided as well. + + This can be a way to handle: + - simple errant urls + - extant paths in kbase-ui which have been replaced with the equivalent functionality + in Europa + */} + {/* The fallback issued with no path is equivalent to calling kbase-ui + with no navigation; this simply cannot happen any longer. + TODO: try removing the narratives route + */} '/narratives'} />} - /> - `/narrative/${params.wsId}`} - /> - } + element={} /> - null} />} /> + + } /> } /> @@ -120,7 +139,10 @@ export const HashRouteRedirect = () => { const location = useLocation(); if (location.hash) return ( - + ); return ; }; diff --git a/src/common/api/authService.ts b/src/common/api/authService.ts index d7c3249a..e32c2e5e 100644 --- a/src/common/api/authService.ts +++ b/src/common/api/authService.ts @@ -98,6 +98,9 @@ export const authApi = baseApi.injectEndpoints({ revokeToken: builder.mutation({ query: (tokenId) => authService({ + headers: { + Accept: 'application/json', + }, url: encode`/tokens/revoke/${tokenId}`, method: 'DELETE', }), diff --git a/src/common/api/index.ts b/src/common/api/index.ts index 9a61e3c3..e6cfea69 100644 --- a/src/common/api/index.ts +++ b/src/common/api/index.ts @@ -1,10 +1,13 @@ import { kbaseBaseQuery } from './utils/kbaseBaseQuery'; import { createApi } from '@reduxjs/toolkit/query/react'; +import { isLocalDevelopment } from '..'; -const baseUrl = - process.env.NODE_ENV === 'development' - ? 'http://localhost:3000/' - : `https://${process.env.REACT_APP_KBASE_DOMAIN}`; +// If the running in development with kbase-ui, use the configured KBase domain, +// otherwise use localhost with port 3000. +// Note that the latter case implies development directly on the development host. +const baseUrl = isLocalDevelopment() + ? 'http://localhost:3000/' + : `https://${process.env.REACT_APP_KBASE_DOMAIN}/`; export const baseApi = createApi({ reducerPath: 'combinedApi', diff --git a/src/common/index.ts b/src/common/index.ts index adb97f6b..79c698b4 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -2,3 +2,15 @@ export const isInsideIframe: (w: Window) => boolean = (w) => Boolean(w) && Boolean(w.top) && w !== w.top; + +/** + * Determine whether the app is running in a locally hosted development mode. + * + * @returns Whether the app is running in a locally hosted development server. + */ +export function isLocalDevelopment() { + return ( + process.env.NODE_ENV === 'development' && + process.env.REACT_APP_KBASE_UI_DEV !== 'true' + ); +} diff --git a/src/common/testUtils.ts b/src/common/testUtils.ts index c8209b76..1198d652 100644 --- a/src/common/testUtils.ts +++ b/src/common/testUtils.ts @@ -1,8 +1,9 @@ // Some commonly used test values export const WAIT_FOR_TIMEOUT = 1000; export const WAIT_FOR_INTERVAL = 100; -export const CHANNEL_ID = 'my_channel_id'; export const UI_ORIGIN = 'http://localhost'; +export const SEND_CHANNEL_ID = 'TEST_SEND_CHANNEL'; +export const RECEIVE_CHANNEL_ID = 'TEST_RECEIVE_CHANNEL'; /** * Send a window message in the format supported by SendMessage and ReceiveMessage. @@ -42,6 +43,17 @@ export interface SendMessageOptions { targetOrigin?: string; } +/** + * Returns a function for sending window messages from the given `fromWindow` to the + * given `toWindow`. + * + * Handy in tests involving messages, as each instance of sending a message does not + * need to bother with specifying the windows. + * + * @param fromWindow Window from which the message should be considered sent + * @param toWindow Window to which the message is directed + * @returns + */ export function makeWindowMessageSender(fromWindow: Window, toWindow: Window) { return ( name: string, @@ -67,3 +79,43 @@ export function genericRawPostMessage(data: unknown, origin?: string) { new MessageEvent('message', { source: window, origin, data }) ); } + +/** + * A structure representing all of the supported `process.env` environment variables + * found in both the development `.env`, the `scripts/build_deploy.sh` build script, and + * consumed within the Europa codebase. + */ +export interface ProcessEnv { + domain?: string; + uiDev?: string; + legacyDomain?: string; + legacyBasePath?: string; + backupCookieName?: string; + backupCookieDomain?: string; + commit?: string; +} + +/** + * Sets `process.env` environment variables to allow tests to simulate different + * configuration scenarios. + * + * @param param0 A structure providing properties matching supporte environment + * variables used to populate the environment variables. + */ +export function setProcessEnv({ + domain, + uiDev, + legacyDomain, + legacyBasePath, + backupCookieName, + backupCookieDomain, + commit, +}: ProcessEnv) { + process.env.REACT_APP_KBASE_DOMAIN = domain || ''; + process.env.REACT_APP_KBASE_UI_DEV = uiDev || ''; + process.env.REACT_APP_KBASE_LEGACY_DOMAIN = legacyDomain || ''; + process.env.REACT_APP_KBASE_LEGACY_BASE_PATH = legacyBasePath || ''; + process.env.REACT_APP_KBASE_BACKUP_COOKIE_NAME = backupCookieName || ''; + process.env.REACT_APP_KBASE_BACKUP_COOKIE_DOMAIN = backupCookieDomain || ''; + process.env.REACT_APP_COMMIT = commit || ''; +} diff --git a/src/features/auth/authSlice.test.tsx b/src/features/auth/authSlice.test.tsx index 06f89016..bf91e6a0 100644 --- a/src/features/auth/authSlice.test.tsx +++ b/src/features/auth/authSlice.test.tsx @@ -6,7 +6,14 @@ import { createTestStore } from '../../app/store'; import { authFromToken, revokeToken } from '../../common/api/authService'; import * as cookies from '../../common/cookie'; import { TokenInfo } from './authSlice'; -import { useTokenCookie, useTryAuthFromToken } from './hooks'; +import { + useAuthenticateFromToken, + useInitializeAuthStateFromCookie, + useSyncAuthStateFromCookie, + useSyncCookieFromAuthState, +} from './hooks'; + +export const DEFAULT_AUTH_COOKIE_NAME = 'kbase_session'; let testStore = createTestStore({}); describe('authSlice', () => { @@ -14,26 +21,51 @@ describe('authSlice', () => { testStore = createTestStore({}); }); - test('useTryAuthFromToken sets auth token and username', async () => { + // TODO: add onAuthResolved to tests + + test('useAuthenticateFromToken sets auth token and username', async () => { const mock = jest.spyOn(authFromToken, 'useQuery'); + // Note this only works after the app is initialized + const testStore = createTestStore({ + auth: { + initialized: true, + }, + }); + + const testToken = 'BBBBBB'; mock.mockImplementation(() => { return { isSuccess: true, + isLoading: false, + originalArgs: testToken, data: { user: 'someUser' }, } as unknown as ReturnType; // Assert mocked response type }); + const Component = () => { - useTryAuthFromToken('some token'); + const { authenticate } = useAuthenticateFromToken(); + + // Simulates an external event being received. + window.setTimeout(() => { + authenticate({ + token: testToken, + onAuthResolved: () => { + return; + }, + }); + }, 100); + return <>; }; + render( ); + await waitFor(() => { - // token gets normalized to uppercase - expect(testStore.getState().auth.token).toBe('SOME TOKEN'); + expect(testStore.getState().auth.token).toBe(testToken); expect(testStore.getState().auth.username).toBe('someUser'); }); mock.mockClear(); @@ -48,7 +80,18 @@ describe('authSlice', () => { } as unknown as ReturnType; // Assert mocked response type }); const Component = () => { - useTryAuthFromToken('some token'); + const { authenticate } = useAuthenticateFromToken(); + + // setToken({ token: 'some token' }); + window.setTimeout(() => { + authenticate({ + token: 'some token', + onAuthResolved: () => { + return; + }, + }); + }, 100); + return <>; }; render( @@ -103,7 +146,7 @@ describe('authSlice', () => { fetchMock.disableMocks(); }); - describe('useTokenCookie', () => { + describe('useAuthenticateFromToken custom hook', () => { let useCookieMock: jest.SpyInstance< ReturnType, Parameters @@ -113,32 +156,42 @@ describe('authSlice', () => { Parameters >; let mockCookieVal = ''; - const setTokenCookieMock = jest.fn(); + const setCookieTokenMock = jest.fn(); const clearTokenCookieMock = jest.fn(); beforeAll(() => { useCookieMock = jest.spyOn(cookies, 'useCookie'); useCookieMock.mockImplementation(() => [ mockCookieVal, - setTokenCookieMock, + setCookieTokenMock, clearTokenCookieMock, ]); consoleErrorMock = jest.spyOn(console, 'error'); consoleErrorMock.mockImplementation(() => undefined); }); beforeEach(() => { - setTokenCookieMock.mockClear(); + setCookieTokenMock.mockClear(); clearTokenCookieMock.mockClear(); consoleErrorMock.mockClear(); }); afterAll(() => { - setTokenCookieMock.mockRestore(); + setCookieTokenMock.mockRestore(); clearTokenCookieMock.mockRestore(); consoleErrorMock.mockRestore(); }); - test('useTokenCookie clears cookie if auth token is undefined', async () => { + test('clears cookie if auth token is undefined', async () => { const Component = () => { - useTokenCookie('kbase_session'); + useInitializeAuthStateFromCookie(DEFAULT_AUTH_COOKIE_NAME); + + // This will set the auth, which will initialize the app with the mock cookie + // val, which is an empty string, and results in an unauthenticated state. + useSyncAuthStateFromCookie(DEFAULT_AUTH_COOKIE_NAME); + + // And this custom hook causes the cookie to be set based on the auth state. + // When the auth state is moved to unauthenticated above, the change in auth + // state will percolate to this hook, which will then "clear" the cookie. + useSyncCookieFromAuthState(DEFAULT_AUTH_COOKIE_NAME); + return <>; }; render( @@ -152,7 +205,7 @@ describe('authSlice', () => { }); }); - test('useTokenCookie sets cookie if auth token exists with expiration', async () => { + test('sets cookie if auth token exists with expiration', async () => { const auth = { token: 'some-token', username: 'some-user', @@ -162,7 +215,15 @@ describe('authSlice', () => { initialized: true, }; const Component = () => { - useTokenCookie('kbase_session'); + // This will set the auth, which will initialize the app with the mock cookie + // val, which is as defined above. + useSyncAuthStateFromCookie('kbase_session'); + + // And this custom hook causes the cookie to be set based on the auth state. + // When the auth state is moved to authenticated above, the change in auth + // state will percolate to this hook, which will then set the cookie. + useSyncCookieFromAuthState('kbase_session'); + return <>; }; render( @@ -171,14 +232,17 @@ describe('authSlice', () => { ); await waitFor(() => { - expect(setTokenCookieMock).toHaveBeenCalledWith('some-token', { - expires: new Date(auth.tokenInfo.expires), - }); + expect(setCookieTokenMock).toHaveBeenCalledWith( + 'some-token', + expect.objectContaining({ + expires: new Date(auth.tokenInfo.expires), + }) + ); expect(consoleErrorMock).not.toHaveBeenCalled(); }); }); - test('useTokenCookie sets cookie in development mode', async () => { + test('sets cookie in development mode', async () => { const processEnv = process.env; process.env = { ...processEnv, NODE_ENV: 'development' }; const auth = { @@ -190,7 +254,8 @@ describe('authSlice', () => { initialized: true, }; const Component = () => { - useTokenCookie('kbase_session'); + useSyncAuthStateFromCookie('kbase_session'); + useSyncCookieFromAuthState('kbase_session'); return <>; }; render( @@ -199,15 +264,18 @@ describe('authSlice', () => { ); await waitFor(() => { - expect(setTokenCookieMock).toHaveBeenCalledWith('some-token', { - expires: new Date(auth.tokenInfo.expires), - }); + expect(setCookieTokenMock).toHaveBeenCalledWith( + 'some-token', + expect.objectContaining({ + expires: new Date(auth.tokenInfo.expires), + }) + ); expect(consoleErrorMock).not.toHaveBeenCalled(); }); process.env = processEnv; }); - test('useTokenCookie does nothing and `console.error`s if token is defined but tokenInfo.expires is not', async () => { + test('does nothing and `console.error`s if token is defined but tokenInfo.expires is not', async () => { const auth = { token: 'some-token', username: 'some-user', @@ -215,7 +283,8 @@ describe('authSlice', () => { initialized: true, }; const Component = () => { - useTokenCookie('kbase_session'); + useSyncAuthStateFromCookie('kbase_session'); + useSyncCookieFromAuthState('kbase_session'); return <>; }; @@ -228,11 +297,11 @@ describe('authSlice', () => { expect(consoleErrorMock).toHaveBeenCalledWith( 'Could not set token cookie, missing expire time' ); - expect(setTokenCookieMock).not.toHaveBeenCalled(); + expect(setCookieTokenMock).not.toHaveBeenCalled(); }); }); - test('useTokenCookie clears cookie for bad cookie token and empty auth state', async () => { + test('clears cookie for bad cookie token and empty auth state', async () => { const auth = { initialized: false }; mockCookieVal = 'AAAAAA'; const mock = jest.spyOn(authFromToken, 'useQuery'); @@ -241,10 +310,13 @@ describe('authSlice', () => { isSuccess: false, isError: true, isFetching: false, + originalArgs: mockCookieVal, } as unknown as ReturnType; // Assert mocked response type }); const Component = () => { - useTokenCookie('kbase_session'); + useInitializeAuthStateFromCookie(DEFAULT_AUTH_COOKIE_NAME); + useSyncAuthStateFromCookie(DEFAULT_AUTH_COOKIE_NAME); + useSyncCookieFromAuthState(DEFAULT_AUTH_COOKIE_NAME); return <>; }; render( @@ -253,13 +325,27 @@ describe('authSlice', () => { ); await waitFor(() => { - expect(setTokenCookieMock).not.toBeCalled(); + expect(setCookieTokenMock).not.toBeCalled(); expect(clearTokenCookieMock).toBeCalled(); }); mock.mockClear(); }); - test('useTokenCookie sets cookie for bad cookie token and defined auth state', async () => { + /** + * Hmm, the test did not seem to be correct, and I don't understand the situation it + * is supposed to simulate. + * + * TODO: make sure we understand this case! + * + * This simulates the case in which: + * - the auth has already been determined, and the token info stored (some-token) + * - a cookie is present in the browser (AAAAAA) + * - the first run of useSyncAuthSTateFromCookie will run the query below and get an error + * - this causes the auth to be unset + * - the first run of useSyncCookieFromAUthState notices a valid auth state and sets + * the cookie accordingly (some-token) + */ + test('sets cookie for bad cookie token and defined auth state', async () => { const auth = { token: 'some-token', username: 'some-user', @@ -275,10 +361,13 @@ describe('authSlice', () => { isSuccess: false, isError: true, isFetching: false, + originalArgs: mockCookieVal, } as unknown as ReturnType; // Assert mocked response type }); const Component = () => { - useTokenCookie('kbase_session'); + useInitializeAuthStateFromCookie(DEFAULT_AUTH_COOKIE_NAME); + useSyncAuthStateFromCookie(DEFAULT_AUTH_COOKIE_NAME); + useSyncCookieFromAuthState(DEFAULT_AUTH_COOKIE_NAME); return <>; }; render( @@ -287,25 +376,35 @@ describe('authSlice', () => { ); await waitFor(() => { - expect(setTokenCookieMock).toBeCalled(); - expect(clearTokenCookieMock).not.toBeCalled(); + expect(setCookieTokenMock).toBeCalled(); + expect(clearTokenCookieMock).toBeCalled(); }); mock.mockClear(); }); - test('useTokenCookie does not set cookie while awaiting auth response', async () => { + test('auth is initialized from cookie token if not initially initialized', async () => { + // Not initialized const auth = { initialized: false }; + + // But have a cookie mockCookieVal = 'AAAAAA'; + + // Here we simulate that the query is running for the first time. const mock = jest.spyOn(authFromToken, 'useQuery'); mock.mockImplementation(() => { return { isSuccess: false, isError: false, isFetching: true, + originalArgs: mockCookieVal, } as unknown as ReturnType; // Assert mocked response type }); + + // Here we simulate the hooks used in App.tsx const Component = () => { - useTokenCookie('kbase_session'); + useInitializeAuthStateFromCookie('kbase_session'); + useSyncAuthStateFromCookie('kbase_session'); + useSyncCookieFromAuthState('kbase_session'); return <>; }; const { rerender } = render( @@ -314,14 +413,17 @@ describe('authSlice', () => { ); await waitFor(() => { - expect(setTokenCookieMock).not.toBeCalled(); + expect(setCookieTokenMock).not.toBeCalled(); expect(clearTokenCookieMock).not.toBeCalled(); }); + + // Some time later, the token query succeeds... mock.mockImplementation(() => { return { isSuccess: true, isError: false, isFetching: false, + originalArgs: mockCookieVal, data: { user: 'someUser', expires: 10 }, } as unknown as ReturnType; // Assert mocked response type }); @@ -331,12 +433,75 @@ describe('authSlice', () => { ); await waitFor(() => { - expect(setTokenCookieMock).toBeCalledWith('AAAAAA', { - expires: new Date(10), - }); + expect(setCookieTokenMock).toBeCalledWith( + 'AAAAAA', + expect.objectContaining({ + expires: new Date(10), + }) + ); expect(clearTokenCookieMock).not.toBeCalled(); }); mock.mockClear(); }); + + /** + * Well, let us look at this. The query is only run when the cookie token changes, + * but that is not what triggers a cookie setting. Rather a the cookie is synced to + * auth state at all times; + */ + // test("useCookie's setCookieToken does not set cookie while awaiting auth response", async () => { + // const auth = { initialized: true }; + // mockCookieVal = 'AAAAAA'; + // const mock = jest.spyOn(authFromToken, 'useQuery'); + // mock.mockImplementation(() => { + // return { + // isSuccess: false, + // isError: false, + // isFetching: true, + // originalArgs: mockCookieVal, + // } as unknown as ReturnType<(typeof authFromToken)['useQuery']>; // Assert mocked response type + // }); + // const Component = () => { + // useInitializeAuthStateFromCookie('kbase_session'); + // useSyncAuthStateFromCookie('kbase_session'); + // useSyncCookieFromAuthState('kbase_session'); + // return <>; + // }; + // const { rerender } = render( + // + // + // + // ); + // await waitFor(() => { + // expect(setCookieTokenMock).not.toBeCalled(); + // expect(clearTokenCookieMock).not.toBeCalled(); + // }); + + // // Some time later, the token query succeeds... + // mock.mockImplementation(() => { + // return { + // isSuccess: true, + // isError: false, + // isFetching: false, + // originalArgs: mockCookieVal, + // data: { user: 'someUser', expires: 10 }, + // } as unknown as ReturnType<(typeof authFromToken)['useQuery']>; // Assert mocked response type + // }); + // rerender( + // + // + // + // ); + // await waitFor(() => { + // expect(setCookieTokenMock).toBeCalledWith( + // 'AAAAAA', + // expect.objectContaining({ + // expires: new Date(10), + // }) + // ); + // expect(clearTokenCookieMock).not.toBeCalled(); + // }); + // mock.mockClear(); + // }); }); }); diff --git a/src/features/auth/hooks.ts b/src/features/auth/hooks.ts index c1d6bf79..27a8def0 100644 --- a/src/features/auth/hooks.ts +++ b/src/features/auth/hooks.ts @@ -1,15 +1,16 @@ /* auth/hooks */ -import { useEffect } from 'react'; -import { authFromToken, getMe } from '../../common/api/authService'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import toast from 'react-hot-toast'; +import { resetStateAction } from '../../app/store'; +import { isLocalDevelopment } from '../../common'; +import { + authFromToken, + getMe, + revokeToken, +} from '../../common/api/authService'; import { useCookie } from '../../common/cookie'; import { useAppDispatch, useAppSelector } from '../../common/hooks'; -import { - authInitialized, - authToken, - setAuth, - setAuthMe, - normalizeToken, -} from './authSlice'; +import { authInitialized, authToken, setAuth, setAuthMe } from './authSlice'; export const useAuthMe = () => { const dispatch = useAppDispatch(); @@ -23,150 +24,509 @@ export const useAuthMe = () => { }, [authAPIQuery, dispatch]); }; +export type CookieOptions = Parameters[1]; + /** - * Initializes auth from a cookie, then continues to monitor and update that cookie as appropriate. + * Constructs cookie options appropriate for the useCookie hook, based on any options + * passed in, and the automatically generated domain. + * + * Helps avoid the boilerplate of automatically determined options. + * + * @param cookieOptions + * @returns */ -export const useTokenCookie = ( - cookieName: string, - backupCookieName?: string, - backupCookieDomain?: string -) => { +export function getCookieOptions( + cookieOptions: CookieOptions = {} +): CookieOptions { + if (!isLocalDevelopment()) { + cookieOptions.domain = `${process.env.REACT_APP_KBASE_DOMAIN}`; + } + + return cookieOptions; +} + +/** + * Ensure a token coming from the untrusted outside world is roughly safe and is of + * expected types. + * + * We don't want to validate the precise form of the token, but just want to establish + * that it is a nonempty, safe string or null. + * + * @param token + */ +export function scrubExternalToken(token: unknown): string | null { + if (typeof token !== 'string') { + return null; + } + if (token.length > 100) { + return null; + } + + if (token.length === 0) { + return null; + } + + return token; +} + +/** + * This hook is dedicated to ensuring that the app's authentication state is + * consistent with the current auth cookie in the browser. + * + * It achieves this by launching a query against the auth service to verify the + * token and fetch associated information, and updating the app state with this + * information when the query completes. + * + * The query is skipped if the token is absent. + * + * @param cookieName + */ +export const useInitializeAuthStateFromCookie = (cookieName: string) => { const dispatch = useAppDispatch(); - // Pull token from main cookie. If it exists, and differs from state, try it for auth. - const [cookieToken, setCookieToken, clearCookieToken] = useCookie( - cookieName, - process.env.NODE_ENV === 'development' - ? {} - : { domain: `.${process.env.REACT_APP_KBASE_DOMAIN}` } - ); + const appAuthInitialized = useAppSelector(authInitialized); + + const cookieOptions = getCookieOptions(); - const { isSuccess, isFetching, isUninitialized } = - useTryAuthFromToken(cookieToken); + const [rawCookieToken] = useCookie(cookieName, cookieOptions); - // Controls for backupCookie - const [backupCookieToken, setBackupCookieToken, clearBackupCookieToken] = - useCookie(backupCookieName, { domain: backupCookieDomain }); + const cookieToken = scrubExternalToken(rawCookieToken); // Pull token, expiration, and init info from auth state - const token = useAppSelector(authToken); - const expires = useAppSelector(({ auth }) => auth.tokenInfo?.expires); + const currentToken = useAppSelector(authToken) || null; + + const skip = appAuthInitialized || !cookieToken; + + const tokenQuery = authFromToken.useQuery(cookieToken || '', { skip }); + + /** + * Handles the case of a successful token query, in which case the app auth is set + * from the results. + * + * Note that this honors the redux query state flags + */ + useEffect(() => { + if (!tokenQuery.isSuccess) return; + if (tokenQuery.isFetching) return; + + dispatch( + setAuth({ + // Seems cleaner to take the token from the query args. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + token: tokenQuery.originalArgs!, + username: tokenQuery.data.user, + tokenInfo: tokenQuery.data, + }) + ); + }, [ + // variables + currentToken, + cookieToken, + tokenQuery.originalArgs, + tokenQuery.data, + tokenQuery.isSuccess, + tokenQuery.isFetching, + // const + dispatch, + ]); + + /** + * Handle case of no cookie token + */ + useEffect(() => { + if (cookieToken) return; + + dispatch(setAuth(null)); + }, [ + // volatile + cookieToken, + // stable + dispatch, + ]); + + /** + * Handle case of no error in query + */ + useEffect(() => { + if (!tokenQuery.isError) return; + + dispatch(setAuth(null)); + }, [ + // volatile + cookieToken, + tokenQuery.isError, + // stable + dispatch, + ]); +}; + +/** + * Responsible for + * + * @param cookieName + */ +export const useSyncAuthStateFromCookie = (cookieName: string) => { + const dispatch = useAppDispatch(); + const appAuthInitialized = useAppSelector(authInitialized); - // Initializes auth for states where useTryAuthFromToken does not set auth + const cookieOptions = getCookieOptions(); + + const [rawCookieToken] = useCookie(cookieName, cookieOptions); + + const cookieToken = scrubExternalToken(rawCookieToken); + + const lastCookieRef = useRef(cookieToken); + + const [isCookieChanged, setCookieChanged] = useState(false); + + /** + * Just keeps the last cookie ref updated. + */ useEffect(() => { - // If the cookieToken is present but it failed checks and wont be overwritten by a token in state, clear - if ( - cookieToken && - !isUninitialized && - !isFetching && - !isSuccess && - !token - ) { - dispatch(setAuth(null)); - clearCookieToken(); - // clear backup token too, if it exists - if (backupCookieName) clearBackupCookieToken(); - } - if (isFetching || appAuthInitialized) return; - if (!cookieToken) { - dispatch(setAuth(null)); - } else if (!isSuccess) { - dispatch(setAuth(null)); + if (cookieToken !== lastCookieRef.current) { + setCookieChanged(true); + lastCookieRef.current = cookieToken; } + }, [lastCookieRef, cookieToken, setCookieChanged]); + + // Pull token, expiration, and init info from auth state + const currentToken = useAppSelector(authToken) || null; + + // We only run the token query if the app is initialized, we have a cookie token, and + // it is different from auth (either a different token, or the app is + // unauthenticated.) + // Don't worry, another effect handles the case of a the auth cookie becoming absent. + const skip = + !appAuthInitialized || !cookieToken || cookieToken === currentToken; + + const tokenQuery = authFromToken.useQuery(cookieToken || '', { skip }); + + /** + * Handles the case of a successful token query, in which case the app auth is set + * from the results. + * + * Note that this honors the redux query state flags + */ + useEffect(() => { + const runEffect = + appAuthInitialized && tokenQuery.isSuccess && !tokenQuery.isFetching; + + if (!runEffect) return; + + dispatch( + setAuth({ + // oddly, the token is not returned in the request for token info + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + token: tokenQuery.originalArgs!, + username: tokenQuery.data.user, + tokenInfo: tokenQuery.data, + }) + ); }, [ - isFetching, + // volatile appAuthInitialized, + isCookieChanged, + currentToken, cookieToken, + tokenQuery.originalArgs, + tokenQuery.data, + tokenQuery.isSuccess, + tokenQuery.isFetching, + // stable + dispatch, + ]); + + /** + * Handle case of unsuccessful token query, which case the app auth is removed. + * + */ + useEffect(() => { + const runEffect = + appAuthInitialized && isCookieChanged && tokenQuery.isError; + + if (!runEffect) return; + + dispatch(setAuth(null)); + }, [ + // volatile + appAuthInitialized, + isCookieChanged, + tokenQuery.isError, + // stable + dispatch, + ]); + + /** + * Handle case of the cookie token being empty but the app is authenticated, + * in which case we ensure that the app becomes unauthenticated. + * + * Note that in this case the query is also skipped, but afaik there is no status for + * a skipped query. + */ + useEffect(() => { + const runEffect = + appAuthInitialized && isCookieChanged && !cookieToken && !!currentToken; + + if (!runEffect) return; + + dispatch(setAuth(null)); + }, [ + // volatile + isCookieChanged, + cookieToken, + currentToken, + appAuthInitialized, + // stable dispatch, - isSuccess, - isUninitialized, - clearCookieToken, - backupCookieName, - clearBackupCookieToken, - token, ]); +}; + +/** + * This hook is dedicated to ensuring that browser cookies are synchronized with auth state. + * + * Note that each effect handles a single case of the app's auth state. + * + * @param cookieName The canonical KBase auth cookie name, aka "kbase_session" + * @param backupCookieName The canonical KBase backup auth cookie name, aka "kbase_session_backup" + * @param backupCookieDomain The canonical KBase backup auth cookie domain, aka "kbase.us" + */ +export const useSyncCookieFromAuthState = ( + cookieName: string, + backupCookieName?: string, + backupCookieDomain?: string +) => { + const cookieOptions = getCookieOptions(); + + const [, setCookieToken, clearCookieToken] = useCookie( + cookieName, + cookieOptions + ); + + // We can omit considering the domain as optional for the backup cookie, as it should + // not be set in a localhost development scenario. + const [, setBackupCookieToken, clearBackupCookieToken] = useCookie( + backupCookieName, + { domain: backupCookieDomain } + ); + + // Pull token, expiration, and init info from auth state + const appAuthToken = useAppSelector(authToken); + const appAuthTokenExpires = useAppSelector( + ({ auth }) => auth.tokenInfo?.expires + ); + const appAuthInitialized = useAppSelector(authInitialized); - // Set the cookie according to the initialized auth state + /** + * Set the auth cookie if the app is authenticated. + */ useEffect(() => { - if (!appAuthInitialized) return; - if (token && expires) { - setCookieToken(token, { - expires: new Date(expires), + const runEffect = appAuthInitialized && appAuthToken && appAuthTokenExpires; + + if (!runEffect) return; + + const cookieOptions = getCookieOptions({ + expires: new Date(appAuthTokenExpires), + }); + + setCookieToken(appAuthToken, cookieOptions); + + if (backupCookieName) { + setBackupCookieToken(appAuthToken, { + domain: backupCookieDomain, + expires: new Date(appAuthTokenExpires), }); - } else if (token && !expires) { - // eslint-disable-next-line no-console - console.error('Could not set token cookie, missing expire time'); - } else if (!token) { - // Auth initialized but theres no valid token? Clear the cookie! - clearCookieToken(); - // clear backup token too, if it exists - if (backupCookieName) clearBackupCookieToken(); } }, [ + // volatile, may change appAuthInitialized, - token, - expires, - setCookieToken, - clearCookieToken, - clearBackupCookieToken, + appAuthToken, + appAuthTokenExpires, + // non-volatile, should never change. backupCookieName, + backupCookieDomain, + setCookieToken, + setBackupCookieToken, ]); - // If a backup cookie name is specified, set the backup cookie when the token changes + /** + * Issue error message to the console if token is set but expires is not. + * + * TODO: this isn't a valid use case; if it is, it should do more: + * - remove app authentication + * - ensure cookies are absent + */ useEffect(() => { - if ( - Boolean(backupCookieName) && - appAuthInitialized && - token && - backupCookieToken !== token - ) { - if (!expires) { - // eslint-disable-next-line no-console - console.error('Could not set backup token cookie, missing expire time'); - } else { - setBackupCookieToken(token, { - expires: new Date(expires), - }); - } + const runEffect = + appAuthInitialized && appAuthToken && !appAuthTokenExpires; + + if (!runEffect) return; + + // eslint-disable-next-line no-console + console.error('Could not set token cookie, missing expire time'); + }, [ + // volatile, may change + appAuthInitialized, + appAuthToken, + appAuthTokenExpires, + ]); + + /** + * If token is absent, ensure that the auth and backup auth cookies are absent too. + */ + useEffect(() => { + const runEffect = appAuthInitialized && !appAuthToken; + + if (!runEffect) return; + + clearCookieToken(); + + if (backupCookieName) { + clearBackupCookieToken(); } }, [ - backupCookieDomain, - backupCookieName, - backupCookieToken, - expires, + // volatile, may change appAuthInitialized, - setBackupCookieToken, - token, + appAuthToken, + // non-volatile, should never change. + backupCookieName, + clearCookieToken, + clearBackupCookieToken, ]); }; -export const useTryAuthFromToken = (token?: string) => { +/** + * Authentication request state is both sent by the useAuthenticateFromToken hook's + * setToken function, and the internal state retained. + */ +export interface AuthenticationRequest { + token: string; + onAuthResolved: () => void; +} + +/** + * This hook is dedicated to the legacy component setting app authentication state from + * a token received at the successful conclusion of sign in or sign up. + * + * @returns + */ +export const useAuthenticateFromToken = () => { + const [authenticationRequest, setRequest] = + useState(null); + const dispatch = useAppDispatch(); - const currentToken = useAppSelector(authToken); - const normToken = normalizeToken(token, ''); - const tokenQuery = authFromToken.useQuery(normToken, { - skip: !normToken, + const appAuthInitialized = useAppSelector(authInitialized); + + // Don't run in the initial case (empty token). + // Also, due to the design of useQuery, we must supply an empty string for the initial + // case in which there is not yet a token (sent by sign in), even though the query is + // never run. + const rawToken = authenticationRequest && authenticationRequest.token; + + const token = scrubExternalToken(rawToken); + + const tokenQuery = authFromToken.useQuery(token || '', { + skip: !token, }); + /** + * Sets the app auth state upon successful completion of the auth request (token query). + * + * All other cases are ignored. + */ useEffect(() => { - if (tokenQuery.isSuccess && normToken !== currentToken) { - dispatch( - setAuth({ - token: normToken, - username: tokenQuery.data.user, - tokenInfo: tokenQuery.data, - }) - ); - } + const runEffect = + appAuthInitialized && + tokenQuery.isSuccess && + !tokenQuery.isFetching && + authenticationRequest !== null; + + if (!runEffect) return; + + const { onAuthResolved } = authenticationRequest; + + dispatch( + setAuth({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + token: tokenQuery.originalArgs!, + username: tokenQuery.data.user, + tokenInfo: tokenQuery.data, + }) + ); + + onAuthResolved(); }, [ - currentToken, - dispatch, - normToken, + // variable + appAuthInitialized, + tokenQuery.originalArgs, tokenQuery.data, tokenQuery.isSuccess, + tokenQuery.isFetching, + authenticationRequest, + // static + dispatch, + ]); + + /** + * This function provided as the output of the hook to be used for submitting a token + * after sign in. + */ + const authenticate = useCallback( + (request: AuthenticationRequest) => { + setRequest(request); + }, + [setRequest] + ); + + return { authenticate }; +}; + +/** + * Given the returned "logout" function, revoke the current authentication. + * + * Note that as the logout function is in at least one case (legacy connection) called + * via an event handler, we use a synchronized token id to ensure it is available when + * the function is called outside of a hook, effect, or component context. + * + * @returns + */ +export const useLogout = () => { + const tokenId = useAppSelector(({ auth }) => auth.tokenInfo?.id); + const tokenIdRef = useRef(tokenId); + + // Synchronizes the token id ref + useEffect(() => { + tokenIdRef.current = tokenId; + }, [tokenId]); + + const [revoke] = revokeToken.useMutation(); + const dispatch = useAppDispatch(); + + const logout = useCallback(() => { + if (!tokenIdRef.current) return; + + revoke(tokenIdRef.current) + .unwrap() + .then(() => { + dispatch(resetStateAction()); + // setAuth(null) follow the state reset to initialize the page as un-Authed + dispatch(setAuth(null)); + toast('You have been signed out'); + }) + .catch((ex) => { + // Handles the case of double-invocation, which can occur with strict mode. + if (tokenIdRef.current) { + // eslint-disable-next-line no-console + console.error('Cannot log out', ex); + toast('Error, could not log out.'); + } + }); + }, [ + // stable + revoke, + dispatch, ]); - return tokenQuery; + return logout; }; diff --git a/src/features/layout/TopBar.tsx b/src/features/layout/TopBar.tsx index 9dc4e5d8..0ef6b14f 100644 --- a/src/features/layout/TopBar.tsx +++ b/src/features/layout/TopBar.tsx @@ -1,4 +1,3 @@ -import { FontAwesomeIcon as FAIcon } from '@fortawesome/react-fontawesome'; import { faBars, faEnvelope, @@ -17,20 +16,31 @@ import { faUser, faWrench, } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon as FAIcon } from '@fortawesome/react-fontawesome'; import { FC, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; +import { LOGIN_ROUTE } from '../../app/Routes'; +import { getUserProfile } from '../../common/api/userProfileApi'; import logo from '../../common/assets/logo/46_square.png'; import { Dropdown } from '../../common/components'; -import { useAppDispatch, useAppSelector } from '../../common/hooks'; -import { authUsername, setAuth } from '../auth/authSlice'; +import { useAppSelector } from '../../common/hooks'; +import { authUsername } from '../auth/authSlice'; +import { useLogout } from '../auth/hooks'; +import { NextRequestObject } from '../legacy/messageValidation'; import classes from './TopBar.module.scss'; -import { Link } from 'react-router-dom'; -import { getUserProfile } from '../../common/api/userProfileApi'; -import { revokeToken } from '../../common/api/authService'; -import { toast } from 'react-hot-toast'; -import { noOp } from '../common'; -import { resetStateAction } from '../../app/store'; + +/** + * A set of url pathname regular expressions which, when matching the current url + * pathname, cause the "next request" parameter to be omitted from login. + */ +const NEXT_REQUEST_BLACKLIST: Array = [ + /^\/legacy\/signedout/, + /^\/legacy\/auth2\/signedout/, + /^\/legacy\/login/, + /^\/fallback/, +]; export default function TopBar() { const username = useAppSelector(authUsername); @@ -47,7 +57,7 @@ export default function TopBar() {
- +
{username ? : } @@ -56,12 +66,45 @@ export default function TopBar() { ); } -const LoginPrompt: FC = () => ( - - - Sign In - -); +const LoginPrompt: FC = () => { + // We form a "next request", essentially a redirect back to the current location after + // sign in, except for a few bespoke pages, the login page and the signedout page. + const nextRequest: NextRequestObject | undefined = (() => { + const pathname = window.location.pathname; + if ( + NEXT_REQUEST_BLACKLIST.some((blacklistedPath) => { + return blacklistedPath.test(pathname); + }) + ) { + return; + } + return { + path: { + path: pathname, + type: 'europaui', + }, + }; + })(); + + const url = new URL(window.location.origin); + if (nextRequest) { + url.searchParams.set('nextrequest', JSON.stringify(nextRequest)); + } + url.pathname = LOGIN_ROUTE; + + // Sign in should be disabled on the sign-in page! The sign-in page may have a search + // param suffix, so we split it off. + return ( + + + Sign In + + ); +}; const UserMenu: FC = () => { const username = useAppSelector(authUsername); @@ -133,7 +176,11 @@ const UserMenu: FC = () => { } }} > -
+
@@ -142,30 +189,6 @@ const UserMenu: FC = () => { ); }; -const useLogout = () => { - const tokenId = useAppSelector(({ auth }) => auth.tokenInfo?.id); - const dispatch = useAppDispatch(); - const [revoke] = revokeToken.useMutation(); - const navigate = useNavigate(); - - if (!tokenId) return noOp; - - return () => { - revoke(tokenId) - .unwrap() - .then(() => { - dispatch(resetStateAction()); - // setAuth(null) follow the state reset to initialize the page as un-Authed - dispatch(setAuth(null)); - toast('You have been signed out'); - navigate('/legacy/auth2/signedout'); - }) - .catch(() => { - toast('Error, could not log out.'); - }); - }; -}; - const HamburgerMenu: FC = () => { const navigate = useNavigate(); return ( @@ -203,6 +226,11 @@ const HamburgerMenu: FC = () => { icon: , label: 'KBase Services Status', }, + { + value: '/legacy/orcidlink', + icon: , + label: 'KBase ORCID Link', + }, ], }, { @@ -274,13 +302,13 @@ const UserAvatar = () => { const PageTitle: FC = () => { const title = useAppSelector((state) => state.layout.pageTitle); return ( -
+
{title || ''}
); }; -const Enviroment: FC = () => { +const Environment: FC = () => { const env = useAppSelector((state) => state.layout.environment); if (env === 'production') return null; const icon = { diff --git a/src/features/legacy/CountdownClock.test.tsx b/src/features/legacy/CountdownClock.test.tsx new file mode 100644 index 00000000..033a123a --- /dev/null +++ b/src/features/legacy/CountdownClock.test.tsx @@ -0,0 +1,25 @@ +import { render, waitFor } from '@testing-library/react'; +import { WAIT_FOR_INTERVAL, WAIT_FOR_TIMEOUT } from '../../common/testUtils'; +import CountdownClock from './CountdownClock'; + +describe('CountdownClock Component', () => { + test('renders with minimal props to completion', async () => { + const { container } = render( + + ); + + await waitFor( + () => { + expect(container).toHaveTextContent('remaining'); + }, + { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + ); + + await waitFor( + () => { + expect(container).toHaveTextContent('DONE'); + }, + { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + ); + }); +}); diff --git a/src/features/legacy/CountdownClock.tsx b/src/features/legacy/CountdownClock.tsx new file mode 100644 index 00000000..29db7362 --- /dev/null +++ b/src/features/legacy/CountdownClock.tsx @@ -0,0 +1,67 @@ +/** + * A component to display the time left until a given duration has been met. + * + * In other words, a count-down to a given time limit. It is bespoke for the + * legacy component. It displays both the time remaining, the total time limit, + * and a progress bar. + */ +import { Box, Typography } from '@mui/material'; +import LinearProgress from '@mui/material/LinearProgress'; +import { useEffect, useState } from 'react'; + +export interface CountdownClockProps { + duration: number; + interval: number; + elapsed?: number; +} + +const CountdownClock = (props: CountdownClockProps) => { + const [currentTime, setCurrentTime] = useState(Date.now()); + + const [startTime] = useState(Date.now()); + + // Just keeps the clock ticking. + useEffect(() => { + const timer = window.setInterval(() => { + const now = Date.now(); + const elapsed = now - startTime; + if (elapsed > props.duration) { + window.clearInterval(timer); + } + setCurrentTime(now); + }, props.interval); + + return () => { + window.clearInterval(timer); + }; + }, [startTime, props.interval, props.duration, setCurrentTime]); + + const totalDurationInSeconds = props.duration / 1000; + + const elapsedInSeconds = + Math.round((currentTime - startTime) / 1000) + (props.elapsed || 0) / 1000; + + const elapsed = currentTime - startTime; + const isDone = elapsed >= props.duration; + + const message = (() => { + if (isDone) { + return `DONE - ${totalDurationInSeconds} seconds have elapsed`; + } + return `${ + totalDurationInSeconds - elapsedInSeconds + } seconds remaining until timeout`; + })(); + + return ( + + + {message} + + ); +}; + +export default CountdownClock; diff --git a/src/features/legacy/IFrameFallback.tsx b/src/features/legacy/IFrameFallback.tsx deleted file mode 100644 index c68d8a54..00000000 --- a/src/features/legacy/IFrameFallback.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { ComponentProps } from 'react'; -import { useParams, Navigate, Params } from 'react-router-dom'; -import { isInsideIframe } from '../../common'; -import PageNotFound from '../layout/PageNotFound'; - -/** - * 404s from the legacy site are redirected from legacy.DOMAIN/[some/path/here] to DOMAIN/fallback/[some/path/here] - * this component handles these fallback redirects as defined in Routes.tsx - */ -export const Fallback = ({ - redirect, - reload = false, -}: { - redirect: ( - params: Readonly> - ) => ComponentProps['to'] | null; - reload?: boolean; -}) => { - const params = useParams(); - const to = redirect(params); - - if (window.top && isInsideIframe(window)) { - // Not in top window, redirect top window to current location - window.top.location = window.location; - return

Redirecting...

; - } else if (to) { - if (reload) { - // redirect is specified and reload is TRUE, perform immediate JS href redirect - window.location.href = to.toString(); - return

Redirecting...

; - } else { - // redirect is specified and reload is FALSE, render router Navigate redirect - return ; - } - } else { - return ; - } -}; diff --git a/src/features/legacy/IFrameWrapper.module.scss b/src/features/legacy/IFrameWrapper.module.scss new file mode 100644 index 00000000..64db3142 --- /dev/null +++ b/src/features/legacy/IFrameWrapper.module.scss @@ -0,0 +1,15 @@ +@import "../../common/colors"; + +.main { + display: flex; + flex-flow: column nowrap; + height: 100%; + position: relative; + width: 100%; +} + +.iframe { + border: 0; + height: 100%; + width: 100%; +} diff --git a/src/features/legacy/IFrameWrapper.test.tsx b/src/features/legacy/IFrameWrapper.test.tsx new file mode 100644 index 00000000..a7f2f542 --- /dev/null +++ b/src/features/legacy/IFrameWrapper.test.tsx @@ -0,0 +1,1130 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { + MemoryRouter, + Route, + RouterProvider, + Routes, + createMemoryRouter, +} from 'react-router-dom'; +import { + UI_ORIGIN, + WAIT_FOR_INTERVAL, + WAIT_FOR_TIMEOUT, + makeWindowMessageSender, + RECEIVE_CHANNEL_ID, + SEND_CHANNEL_ID, +} from '../../common/testUtils'; +import IFrameWrapper, { IFrameWrapperProps } from './IFrameWrapper'; +import { LEGACY_BASE_ROUTE } from './constants'; +import { KBaseUILoggedinPayload } from './messageValidation'; +import * as utils from './utils'; + +/** + * Renders an iframe wrapper component with router support. + * + * @param props + * @returns + */ +function setupComponentWithRouting4(props: IFrameWrapperProps) { + const router = createMemoryRouter( + [ + { + path: `${LEGACY_BASE_ROUTE()}/*`, + element: , + }, + ], + { + initialEntries: [`${LEGACY_BASE_ROUTE()}/foo`], + initialIndex: 0, + } + ); + const { container } = render(); + + return { container, router }; +} + +/** + * Creates an IFrameWrapper component wrapped in a router. + * + * Used when we need to rerender with changed props. + * + * @param props + * @returns + */ + +// const WrappedIFrameWrapper = (props: IFrameWrapperProps) => { +// const router = createMemoryRouter( +// [ +// { +// path: `${LEGACY_BASE_ROUTE()}/*`, +// element: +// } +// ] +// , +// { +// initialEntries: [`${LEGACY_BASE_ROUTE()}/foo`], +// initialIndex: 0, +// }); + +// return ; +// } + +const WrappedIFrameWrapper = (props: IFrameWrapperProps) => { + return ( + + + } + /> + + + ); +}; + +/** + * Renders the WrappedIFrameWrapper above in situations in which we don't need to + * re-render the component, and would like the extra convenience of extracting the + * contentWindow, which requires some tedious assertions. + * + * @param props + * @returns + */ +const renderWrappedIFrameWrapper = (props: IFrameWrapperProps) => { + const { container } = render(); + + const iframe = container.querySelector('iframe'); + + // constrain iframe + if (iframe === null) { + throw new Error('Impossible - iframe is null'); + } + + expect(iframe).toHaveAttribute('title', 'kbase-ui Wrapper'); + if (iframe.contentWindow === null) { + throw new Error('Impossible - iframe contentWindow is null'); + } + + return { container, contentWindow: iframe.contentWindow }; +}; + +// Commented out as the test it supports is commented out. +// TODO: sort this out +// function renderIFrameWrapperWithRouting( +// props: IFrameWrapperProps, +// onNarratives: () => void +// ) { +// // TODO: ensure that we can test with a subdomain AND a path. +// const Narratives = ({ onNarratives }: { onNarratives: () => void }) => { +// onNarratives(); +// return
FOO
; +// }; +// const { container } = render( +// +// +// } +// /> +// } +// /> +// +// +// ); + +// const iframe = container.querySelector('iframe'); + +// // constrain iframe +// if (iframe === null) { +// throw new Error('Impossible - iframe is null'); +// } + +// expect(iframe).toHaveAttribute('title', 'kbase-ui Wrapper'); +// if (iframe.contentWindow === null) { +// throw new Error('Impossible - iframe contentWindow is null'); +// } + +// return { container, contentWindow: iframe.contentWindow }; +// } + +/** + * Simulates what Legacy.tsx would do to prepare props for IFrameWrapper + * @param propOverrides + * @returns + */ +function createIFrameProps( + propOverrides: Partial +): IFrameWrapperProps { + // Mock the browser location, as we use + const legacyPath: utils.LegacyPath = { + path: 'about', + }; + + // Create the props and the component. + // const channel = CHANNEL_ID; + // TODO: stand up a service here! + const legacyURL = new URL('http://legacy.localhost'); + + legacyURL.hash = `about$`; + + const token = null; + + const setTitle = + typeof propOverrides.setTitle !== 'undefined' + ? propOverrides.setTitle + : (_: string) => { + return; + }; + + const onLoggedIn = + typeof propOverrides.onLoggedIn !== 'undefined' + ? propOverrides.onLoggedIn + : (_: KBaseUILoggedinPayload) => { + return; + }; + + const onLogOut = + typeof propOverrides.onLogOut !== 'undefined' + ? propOverrides.onLogOut + : () => { + return; + }; + + const iframeProps: IFrameWrapperProps = { + receiveChannelId: RECEIVE_CHANNEL_ID, + sendChannelId: SEND_CHANNEL_ID, + legacyURL, + token, + legacyPath, + spyOnChannels: + typeof propOverrides.spyOnChannels === 'undefined' + ? false + : propOverrides.spyOnChannels, + setTitle, + onLoggedIn, + onLogOut, + }; + + return iframeProps; +} + +describe('IFrameWrapper Module', () => { + let infoLogSpy: jest.SpyInstance; + beforeEach(() => { + jest.resetAllMocks(); + infoLogSpy = jest.spyOn(console, 'info'); + }); + + // describe('channelSpy function', () => { + // test('logs to the console', () => { + // channelSpy( + // 'SEND', + // new ChannelMessage({ + // name: 'foo', + // payload: 'bar', + // envelope: { id: 'baz', channel: CHANNEL_ID }, + // }) + // ); + // expect(infoLogSpy).toHaveBeenCalled(); + // expect(infoLogSpy).toHaveBeenCalledWith( + // '[IFrameWrapper][spy][SEND]', + // CHANNEL_ID, + // 'foo' + // ); + // }); + // }); + + describe('IFrameWrapper Component', () => { + /** + * Ensure that the `kbase-ui.set-title` message is received and handled correctly. + */ + test('receives "kbase-ui.set-title" normally', async () => { + // Ensures that the legacy base url is the same as Europa. + jest.spyOn(utils, 'legacyBaseURL').mockReturnValue(new URL(UI_ORIGIN)); + + const EXPECTED_TITLE = 'my title'; + + let pageTitle: unknown = null; + const setTitle = (title: string) => { + pageTitle = title; + }; + + // Override the iframe props `setTitle` prop. + const iframeProps = createIFrameProps({ setTitle, spyOnChannels: true }); + + const { contentWindow } = renderWrappedIFrameWrapper(iframeProps); + + // let TEST_EUROPA_RECEIVE_CHANNEL = 'foo'; + // const TEST_KBASE_UI_RECEIVE_CHANNEL = 'kbase-ui-receive-channel'; + const sendMessage = makeWindowMessageSender(contentWindow, window); + + // Here we simulate kbase-ui message handling, which is carried out on the + // iframe's contentWindow. + // TODO: redo for the new connection message flow: + + // connect + // Europa KBase UI + // ----------------- ------------------ + // load kbase-ui + // send kbase-ui.connect({channel}) + // send europa.connect + // send kbase-ui.connected + + // If navigating ... + // send europa.authnavigate + + // Standard connect dance... + contentWindow.addEventListener('message', (messageEvent) => { + if ('envelope' in messageEvent.data) { + if ('channel' in messageEvent.data.envelope) { + if (messageEvent.data.envelope.channel !== SEND_CHANNEL_ID) { + return; + } + } + } + // We want to listen for all 'europa' messages here. + // Simulate reaction to the 'start' message, by sending 'started'. + if ('name' in messageEvent.data) { + // Handle messages to simulate kbase-ui + switch (messageEvent.data.name) { + case 'europa.connect': { + // This is the normal message after connect is received. + act(() => { + sendMessage('kbase-ui.connected', RECEIVE_CHANNEL_ID, {}); + }); + + // Then some time later, we are simulating a set-title event + // act(() => { + // sendMessage('kbase-ui.set-title', RECEIVE_CHANNEL_ID, { + // title: EXPECTED_TITLE, + // }); + // }); + // })(); + break; + } + case 'europa.authnavigate': + // Then some time later, we are simulating a set-title event + act(() => { + sendMessage('kbase-ui.set-title', RECEIVE_CHANNEL_ID, { + title: EXPECTED_TITLE, + }); + }); + + break; + } + } + }); + + // Simulates the initial 'kbase-ui.connect' message sent by kbase-ui when the app has + // loaded, and the Europa compatibility layer is ready to receive messages. + act(() => { + sendMessage('kbase-ui.connect', RECEIVE_CHANNEL_ID, { + channel: SEND_CHANNEL_ID, + }); + }); + + // If the message is received by the IFrameWrapper, it will have called the + // `setTitle` prop passed to it. + await waitFor( + () => { + expect(pageTitle).toEqual(EXPECTED_TITLE); + }, + { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + ); + }); + + /** + * The basic logic of the above test is replicated, but we install a spy on the send + * and receive channels, and then inspect the console.info log to ensure that the + * log entries for sending and receiving have been made. + */ + test('can receive "kbase-ui.set-title" with a sneaky spy', async () => { + jest.spyOn(utils, 'legacyBaseURL').mockReturnValue(new URL(UI_ORIGIN)); + + const EXPECTED_TITLE = 'my title'; + let pageTitle: unknown = null; + const setTitle = (title: string) => { + pageTitle = title; + }; + + const iframeProps = createIFrameProps({ setTitle, spyOnChannels: true }); + iframeProps.spyOnChannels = true; + + const { contentWindow } = renderWrappedIFrameWrapper(iframeProps); + + const sendMessage = makeWindowMessageSender(contentWindow, window); + + // Here we simulate kbase-ui message handling. + // Standard connect dance... + contentWindow.addEventListener('message', (messageEvent) => { + if ('envelope' in messageEvent.data) { + if ('channel' in messageEvent.data.envelope) { + if (messageEvent.data.envelope.channel !== SEND_CHANNEL_ID) { + return; + } + } + } + + // We want to listen for all 'europa' messages here. + // Simulate reaction to the 'start' message, by sending 'started'. + if ('name' in messageEvent.data) { + // Handle messages to simulate kbase-ui + switch (messageEvent.data.name) { + case 'europa.connect': { + // This is the normal message after connect is received. + act(() => { + sendMessage('kbase-ui.connected', RECEIVE_CHANNEL_ID, {}); + }); + break; + } + + case 'europa.authnavigate': + // Then some time later, we are simulating a set-title event + act(() => { + sendMessage('kbase-ui.set-title', RECEIVE_CHANNEL_ID, { + title: EXPECTED_TITLE, + }); + }); + + break; + } + } + }); + + // Simulates the initial 'kbase-ui.connect' message sent by kbase-ui when the app has + // loaded, and the Europa compatibility layer is ready to receive messages. + act(() => { + sendMessage('kbase-ui.connect', RECEIVE_CHANNEL_ID, { + channel: SEND_CHANNEL_ID, + }); + }); + + // If the message is received bi the IFrameWrapper, it will have called the + // `setTitle` prop passed to it. + await waitFor( + () => { + expect(pageTitle).toEqual('my title'); + expect(infoLogSpy).toHaveBeenNthCalledWith( + 1, + '[KBaseUIConnection][spy][RECV]', + RECEIVE_CHANNEL_ID, + 'kbase-ui.connect' + ); + expect(infoLogSpy).toHaveBeenNthCalledWith( + 2, + '[KBaseUIConnection][spy][SEND]', + SEND_CHANNEL_ID, + 'europa.connect' + ); + expect(infoLogSpy).toHaveBeenNthCalledWith( + 3, + '[KBaseUIConnection][spy][RECV]', + RECEIVE_CHANNEL_ID, + 'kbase-ui.connected' + ); + expect(infoLogSpy).toHaveBeenNthCalledWith( + 4, + '[KBaseUIConnection][spy][SEND]', + SEND_CHANNEL_ID, + 'europa.authnavigate' + ); + expect(infoLogSpy).toHaveBeenNthCalledWith( + 5, + '[KBaseUIConnection][spy][RECV]', + RECEIVE_CHANNEL_ID, + 'kbase-ui.set-title' + ); + }, + { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + ); + }); + + // /** + // * Ensures that the `kbase-ui.logout` message is received and handled properly. + // */ + test('Can receive "kbase-ui.logout"', async () => { + let logoutCalled = false; + const onLogOut = () => { + logoutCalled = true; + }; + jest.spyOn(utils, 'legacyBaseURL').mockReturnValue(new URL(UI_ORIGIN)); + + const iframeProps = createIFrameProps({ onLogOut }); + + const { contentWindow } = renderWrappedIFrameWrapper(iframeProps); + + const sendMessage = makeWindowMessageSender(contentWindow, window); + + // Here we simulate kbase-ui message handling. + // Standard connect dance... + contentWindow.addEventListener('message', (messageEvent) => { + if ('envelope' in messageEvent.data) { + if ('channel' in messageEvent.data.envelope) { + if (messageEvent.data.envelope.channel !== SEND_CHANNEL_ID) { + return; + } + } + } + // We want to listen for all 'europa' messages here. + // Simulate reaction to the 'start' message, by sending 'started'. + if ('name' in messageEvent.data) { + // Handle messages to simulate kbase-ui + switch (messageEvent.data.name) { + case 'europa.connect': { + // This is the normal message after connect is received. + act(() => { + sendMessage('kbase-ui.connected', RECEIVE_CHANNEL_ID, {}); + }); + // Then some time later, we are simulating a logout event. + break; + } + case 'europa.authnavigate': + // Then some time later, we are simulating a set-title event + act(() => { + act(() => { + sendMessage('kbase-ui.logout', RECEIVE_CHANNEL_ID, {}); + }); + }); + + break; + } + } + }); + + // Simulates the initial 'kbase-ui.connect' message sent by kbase-ui when the app has + // loaded, and the Europa compatibility layer is ready to receive messages. + act(() => { + sendMessage('kbase-ui.connect', RECEIVE_CHANNEL_ID, { + channel: SEND_CHANNEL_ID, + }); + }); + + await waitFor( + () => { + expect(logoutCalled).toEqual(true); + }, + { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + ); + }); + + // /** + // * Ensure thate the `kbase-ui.loggedin` message without a "nextRequest" is received + // * and handled properly. + // */ + // DISABLED - + // TypeError: Cannot read properties of null (reading '_origin') + // + // 116 | }; + // 117 | const message = new ChannelMessage({ name, payload, envelope }); + // > 118 | this.window.postMessage(message.toJSON(), this.targetOrigin); + // test('can receive "kbase-ui.loggedin" without nextRequest', async () => { + // let loggedInCalled: KBaseUILoggedinPayload | null = null; + // const onLoggedIn = (payload: OnLoggedInParams) => { + // loggedInCalled = payload; + // payload.onAuthResolved(); + // }; + + // jest.spyOn(utils, 'legacyBaseURL').mockReturnValue(new URL(UI_ORIGIN)); + + // const iframeProps = createIFrameProps({ onLoggedIn }); + + // let onNarrativesCalled = false; + // const onNarratives = () => { + // onNarrativesCalled = true; + // }; + + // const { contentWindow } = renderIFrameWrapperWithRouting( + // iframeProps, + // onNarratives + // ); + + // const TEST_CHANNEL_ID = 'test_channel'; + // const sendMessage = makeWindowMessageSender(contentWindow, window); + + // const loggedInPayload: KBaseUILoggedinPayload = { + // token: 'mytoken', + // expires: 123, + // }; + + // // Here we simulate kbase-ui message handling. + // // Standard connect dance... + // const messagesReceivedFromEuropa: Array = []; + // contentWindow.addEventListener('message', (messageEvent) => { + // // We want to listen for all 'europa' messages here. + // // Simulate reaction to the 'start' message, by sending 'started'. + // if ('name' in messageEvent.data) { + // if (messageEvent.data.name.startsWith('europa.')) { + // messagesReceivedFromEuropa.push(messageEvent.data); + // } + // // Handle messages to simulate kbase-ui + // switch (messageEvent.data.name) { + // case 'europa.connect': { + // // This is the normal message after connect is received. + // act(() => { + // sendMessage('kbase-ui.connected', TEST_CHANNEL_ID, {}); + // }); + // // Then some time later, we are simulating a logout event. + // act(() => { + // sendMessage( + // 'kbase-ui.loggedin', + // TEST_CHANNEL_ID, + // loggedInPayload + // ); + // }); + // break; + // } + // } + // } + // }); + + // // Simulates the initial 'kbase-ui.connect' message sent by kbase-ui when the app has + // // loaded, and the Europa compatibility layer is ready to receive messages. + // act(() => { + // sendMessage('kbase-ui.connect', iframeProps.channelId, { + // channel: TEST_CHANNEL_ID, + // }); + // }); + + // await waitFor( + // () => { + // expect(loggedInCalled).toHaveProperty('token', 'mytoken'); + // expect(loggedInCalled).toHaveProperty('expires', 123); + + // expect(messagesReceivedFromEuropa.length).toEqual(1); + + // const startMessage = messagesReceivedFromEuropa[0]; + // expect(startMessage).toHaveProperty('name', 'europa.connect'); + + // // expect(onNarrativesCalled).toBeTruthy(); + // }, + // { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + // ); + // }); + + // /** + // * Ensure that the `kbase-ui.loggedin` message with a nextRequest is received and + // * acted up on correctly. + // */ + // test('Can receive kbase-ui.loggedin with nextRequest', async () => { + // let loggedInCalled: KBaseUILoggedinPayload | null = null; + // const onLoggedIn = (payload: OnLoggedInParams) => { + // loggedInCalled = payload; + // payload.onAuthResolved(); + // }; + + // jest.spyOn(utils, 'legacyBaseURL').mockReturnValue(new URL(UI_ORIGIN)); + + // const iframeProps = createIFrameProps({ onLoggedIn }); + + // // const {container} = renderf(); + // const { contentWindow } = renderWrappedIFrameWrapper(iframeProps); + + // // const iframe = container.querySelector('iframe'); + + // // // constrain iframe + // // if (iframe === null) { + // // throw new Error('Impossible - iframe is null'); + // // } + + // // expect(iframe).toHaveAttribute('title', 'kbase-ui Wrapper'); + // // if (iframe.contentWindow === null) { + // // throw new Error('Impossible - iframe contentWindow is null'); + // // } + + // // After receiving the 'ready' message, Europa's legacy layer will have set up + // // message listeners for key kbase-ui events, such as 'kbase-ui.set-title', the + // // subject of this test. + // // So here we simulate kbase-ui sending this message. + // const loggedInPayload: KBaseUILoggedinPayload = { + // token: 'mytoken', + // expires: 123, + // nextRequest: { + // path: { + // path: 'foo', + // type: 'kbaseui', + // }, + // }, + // }; + + // // Here we simulate kbase-ui message handling. + // const messagesReceivedFromEuropa: Array = []; + // contentWindow.addEventListener('message', (messageEvent) => { + // // We want to listen for all 'europa' messages here. + // // Simulate reaction to the 'start' message, by sending 'started'. + // if ('name' in messageEvent.data) { + // // Save all the messages for inspection later. + // if (messageEvent.data.name.startsWith('europa.')) { + // messagesReceivedFromEuropa.push(messageEvent.data); + // } + // // Handle messages to simulate kbase-ui + // if (messageEvent.data.name === 'europa.start') { + // // This is the normal message after start is received. + // act(() => { + // sendFromKBaseUI('kbase-ui.started', iframeProps.channelId, {}); + // }); + + // // Then some time later, we are simulating a loggedin event. + // act(() => { + // sendFromKBaseUI( + // 'kbase-ui.loggedin', + // iframeProps.channelId, + // loggedInPayload + // ); + // }); + // } + // } + // }); + + // // Simulates the initial 'kbase-ui.ready' message sent by kbase-ui when the app has + // // loaded, and the Europa compatiblility layer is ready to receive messages. + // act(() => { + // sendFromKBaseUI('kbase-ui.ready', iframeProps.channelId, { + // ready: true, + // }); + // }); + + // await waitFor( + // () => { + // expect(loggedInCalled).toHaveProperty('token', 'mytoken'); + // expect(loggedInCalled).toHaveProperty('expires', 123); + + // expect(messagesReceivedFromEuropa.length).toEqual(3); + + // const startMessage = messagesReceivedFromEuropa[0]; + // expect(startMessage).toHaveProperty('name', 'europa.start'); + // expect(startMessage).toHaveProperty('payload.authToken', null); + + // const navigationMessage = messagesReceivedFromEuropa[1]; + // expect(navigationMessage).toHaveProperty('name', 'europa.navigation'); + + // const authenticatedMessage = messagesReceivedFromEuropa[2]; + // expect(authenticatedMessage).toHaveProperty( + // 'payload.token', + // 'mytoken' + // ); + // expect(authenticatedMessage).toHaveProperty( + // 'payload.nextRequest.path.path', + // 'foo' + // ); + // expect(authenticatedMessage).toHaveProperty( + // 'payload.nextRequest.path.type', + // 'kbaseui' + // ); + // }, + // { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + // ); + // }); + + // /** + // * Ensure that if kbase-ui does not send the `kbase-ui.ready` message within the + // * timeout limit, an error message is displayed. + // * + // */ + // test('displays error message if "ready" not received within timeout', async () => { + // // Ensures that the legacy base url is the same as Europa. + // jest.spyOn(utils, 'legacyBaseURL').mockReturnValue(new URL(UI_ORIGIN)); + + // jest.spyOn(constants, 'READY_WAITING_TIMEOUT').mockReturnValue(100); + + // const iframeProps = createIFrameProps({}); + + // const { container, contentWindow } = + // renderWrappedIFrameWrapper(iframeProps); + + // const TEST_CHANNEL_ID = 'test_channel'; + // const sendMessage = makeWindowMessageSender(contentWindow, window); + + // // Here we simulate kbase-ui message handling. + // // const messagesReceivedFromEuropa: Array = []; + // // contentWindow.addEventListener('message', (messageEvent) => { + // // // We want to listen for all 'europa' messages here. + // // // Simulate reaction to the 'start' message, by sending 'started'. + // // if ('name' in messageEvent.data) { + // // // Save all the messages for inspection later. + // // if (messageEvent.data.name.startsWith('europa.')) { + // // messagesReceivedFromEuropa.push(messageEvent.data); + // // } + // // } + // // }); + + // // Here we simulate kbase-ui message handling. + // // Standard connect dance... + // const messagesReceivedFromEuropa: Array = []; + // contentWindow.addEventListener('message', (messageEvent) => { + // // We want to listen for all 'europa' messages here. + // // Simulate reaction to the 'start' message, by sending 'started'. + // if ('name' in messageEvent.data) { + // if (messageEvent.data.name.startsWith('europa.')) { + // messagesReceivedFromEuropa.push(messageEvent.data); + // } + // // Handle messages to simulate kbase-ui + // switch (messageEvent.data.name) { + // case 'europa.connect': { + // // This is the normal message after connect is received. + // act(() => { + // sendMessage('kbase-ui.connected', TEST_CHANNEL_ID, {}); + // }); + // // Then some time later, we are simulating a logout event. + // act(() => { + // sendMessage('kbase-ui.logout', TEST_CHANNEL_ID, {}); + // }); + // break; + // } + // } + // } + // }); + + // // Simulates the initial 'kbase-ui.connect' message sent by kbase-ui when the app has + // // loaded, and the Europa compatibility layer is ready to receive messages. + // // act(() => { + // // sendMessage('kbase-ui.connect', iframeProps.channelId, { + // // channel: TEST_CHANNEL_ID, + // // }); + // // }); + + // // We DO NOT send the ready message, because we want to force a timeout. + + // // If the message is received bi the IFrameWrapper, it will have called the + // // `setTitle` prop passed to it. + // await waitFor( + // () => { + // expect(container).toHaveTextContent('Timed out after'); + // expect(container).toHaveTextContent( + // 'waiting for kbase-ui to be ready' + // ); + // }, + // { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + // ); + // }); + + // test('displays error message if "started" not received within timeout', async () => { + // // Ensures that the legacy base url is the same as Europa. + // jest.spyOn(utils, 'legacyBaseURL').mockReturnValue(new URL(UI_ORIGIN)); + + // jest.spyOn(constants, 'STARTED_WAITING_TIMEOUT').mockReturnValue(100); + + // const iframeProps = createIFrameProps({}); + + // const { container, contentWindow } = + // renderWrappedIFrameWrapper(iframeProps); + + // // Here we simulate kbase-ui message handling. + // const messagesReceivedFromEuropa: Array = []; + // contentWindow.addEventListener('message', (messageEvent) => { + // // We want to listen for all 'europa' messages here. + // // Simulate reaction to the 'start' message, by sending 'started'. + // if ('name' in messageEvent.data) { + // // Save all the messages for inspection later. + // if (messageEvent.data.name.startsWith('europa.')) { + // messagesReceivedFromEuropa.push(messageEvent.data); + // } + // } + // }); + + // // Simulates the initial 'kbase-ui.ready' message sent by kbase-ui when the app has + // // loaded, and the Europa compatiblility layer is ready to receive messages. + // act(() => { + // sendFromKBaseUI('kbase-ui.ready', iframeProps.channelId, { + // ready: true, + // }); + // }); + + // // We DO NOT send the ready message, because we want to force a timeout. + + // // If the message is received bi the IFrameWrapper, it will have called the + // // `setTitle` prop passed to it. + // await waitFor( + // () => { + // expect(container).toHaveTextContent('Timed out after'); + // expect(container).toHaveTextContent( + // 'waiting for kbase-ui to start up' + // ); + // }, + // { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + // ); + // }); + + test('responds correctly to kbase-ui.navigated - typical usage', async () => { + // Ensures that the legacy base url is the same as Europa. + jest.spyOn(utils, 'legacyBaseURL').mockReturnValue(new URL(UI_ORIGIN)); + + const iframeProps = createIFrameProps({}); + + const { container, router } = setupComponentWithRouting4(iframeProps); + + const iframe = container.querySelector('iframe'); + + // constrain iframe + if (iframe === null) { + throw new Error('Impossible - iframe is null'); + } + + expect(iframe).toHaveAttribute('title', 'kbase-ui Wrapper'); + if (iframe.contentWindow === null) { + throw new Error('Impossible - iframe contentWindow is null'); + } + + const contentWindow = iframe.contentWindow; + + // let TEST_EUROPA_RECEIVE_CHANNEL = 'foo'; + // const TEST_KBASE_UI_RECEIVE_CHANNEL = 'kbase-ui-receive-channel'; + const sendMessage = makeWindowMessageSender(contentWindow, window); + + // After receiving the 'ready' message, Europa's legacy layer will have set up + // message listeners for key kbase-ui events, such as 'kbase-ui.set-title', the + // subject of this test. + // So here we simulate kbase-ui sending this message. + + // Here we simulate kbase-ui message handling. + const messagesReceivedFromEuropa: Array = []; + contentWindow.addEventListener('message', (messageEvent) => { + if ('envelope' in messageEvent.data) { + if ('channel' in messageEvent.data.envelope) { + if (messageEvent.data.envelope.channel !== SEND_CHANNEL_ID) { + return; + } + } + } + + // We want to listen for all 'europa' messages here. + // Simulate reaction to the 'start' message, by sending 'started'. + if ('name' in messageEvent.data) { + // Save all the messages for inspection later. + if (messageEvent.data.name.startsWith('europa.')) { + messagesReceivedFromEuropa.push(messageEvent.data); + } + + // Handle messages to simulate kbase-ui + switch (messageEvent.data.name) { + case 'europa.connect': { + // Europa's connect contains the channel id it is now listening on. + // TEST_EUROPA_RECEIVE_CHANNEL = messageEvent.data.payload.channelId; + // This is the normal message after connect is received. + act(() => { + sendMessage('kbase-ui.connected', RECEIVE_CHANNEL_ID, {}); + }); + // Then some time later, we are simulating a navigation event + + break; + } + + case 'europa.authnavigate': + // Then some time later, we are simulating a set-title event + act(() => { + act(() => { + sendMessage('kbase-ui.navigated', RECEIVE_CHANNEL_ID, { + path: 'bar', + params: {}, + }); + }); + }); + + break; + } + } + }); + + // Simulates the initial 'kbase-ui.connect' message sent by kbase-ui when the app has + // loaded, and the Europa compatibility layer is ready to receive messages. + act(() => { + sendMessage('kbase-ui.connect', RECEIVE_CHANNEL_ID, { + channel: SEND_CHANNEL_ID, + }); + }); + + await waitFor( + async () => { + expect(router.state.location.pathname).toEqual('/legacy/bar'); + }, + { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + ); + }); + // }); + + test('responds correctly when the token changes from null to non-null', async () => { + // Ensures that the legacy base url is the same as Europa. + jest.spyOn(utils, 'legacyBaseURL').mockReturnValue(new URL(UI_ORIGIN)); + + const iframeProps = createIFrameProps({}); + + const { rerender, container } = render( + + ); + + const iframe = container.querySelector('iframe'); + + // constrain iframe + if (iframe === null) { + throw new Error('Impossible - iframe is null'); + } + + expect(iframe).toHaveAttribute('title', 'kbase-ui Wrapper'); + if (iframe.contentWindow === null) { + throw new Error('Impossible - iframe contentWindow is null'); + } + + const contentWindow = iframe.contentWindow; + // let TEST_EUROPA_RECEIVE_CHANNEL = 'foo'; + // const TEST_KBASE_UI_RECEIVE_CHANNEL = 'kbase-ui-receive-channel'; + const sendMessage = makeWindowMessageSender(contentWindow, window); + + // After receiving the 'ready' message, Europa's legacy layer will have set up + // message listeners for key kbase-ui events, such as 'kbase-ui.set-title', the + // subject of this test. + // So here we simulate kbase-ui sending this message. + + // Here we simulate kbase-ui message handling. + let europaAuthentication: string | null = null; + const messagesReceivedFromEuropa: Array = []; + contentWindow.addEventListener('message', (messageEvent) => { + if ('envelope' in messageEvent.data) { + if ('channel' in messageEvent.data.envelope) { + if (messageEvent.data.envelope.channel !== SEND_CHANNEL_ID) { + return; + } + } + } + + // We want to listen for all 'europa' messages here. + // Simulate reaction to the 'start' message, by sending 'started'. + if ('name' in messageEvent.data) { + // Save all the messages for inspection later. + if (messageEvent.data.name.startsWith('europa.')) { + messagesReceivedFromEuropa.push(messageEvent.data); + } + + // Handle messages to simulate kbase-ui + switch (messageEvent.data.name) { + case 'europa.connect': { + // TEST_EUROPA_RECEIVE_CHANNEL = messageEvent.data.payload.channelId; + + // This is the normal message after connect is received. + act(() => { + sendMessage('kbase-ui.connected', RECEIVE_CHANNEL_ID, {}); + + rerender(); + }); + break; + } + case 'europa.authenticated': { + europaAuthentication = messageEvent.data.payload.token; + break; + } + } + } + }); + + // Simulates the initial 'kbase-ui.connect' message sent by kbase-ui when the app has + // loaded, and the Europa compatibility layer is ready to receive messages. + act(() => { + sendMessage('kbase-ui.connect', RECEIVE_CHANNEL_ID, { + channel: SEND_CHANNEL_ID, + }); + }); + + // If the message is received bi the IFrameWrapper, it will have called the + // `setTitle` prop passed to it. + await waitFor( + async () => { + expect(europaAuthentication).toEqual('FOO'); + }, + { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + ); + }); + + test('responds correctly when the token changes from non-null to null', async () => { + // Ensures that the legacy base url is the same as Europa. + jest.spyOn(utils, 'legacyBaseURL').mockReturnValue(new URL(UI_ORIGIN)); + + const iframeProps = createIFrameProps({}); + + const { rerender, container } = render( + + ); + + const iframe = container.querySelector('iframe'); + + // constrain iframe + if (iframe === null) { + throw new Error('Impossible - iframe is null'); + } + + expect(iframe).toHaveAttribute('title', 'kbase-ui Wrapper'); + if (iframe.contentWindow === null) { + throw new Error('Impossible - iframe contentWindow is null'); + } + + const contentWindow = iframe.contentWindow; + + // let TEST_EUROPA_RECEIVE_CHANNEL = 'foo'; + // const TEST_KBASE_UI_RECEIVE_CHANNEL = 'kbase-ui-receive-channel'; + const sendMessage = makeWindowMessageSender(contentWindow, window); + + // After receiving the 'ready' message, Europa's legacy layer will have set up + // message listeners for key kbase-ui events, such as 'kbase-ui.set-title', the + // subject of this test. + // So here we simulate kbase-ui sending this message. + + // Here we simulate kbase-ui message handling. + let deauthticatedCalled = false; + const messagesReceivedFromEuropa: Array = []; + contentWindow.addEventListener('message', (messageEvent) => { + if ('envelope' in messageEvent.data) { + if ('channel' in messageEvent.data.envelope) { + if (messageEvent.data.envelope.channel !== SEND_CHANNEL_ID) { + return; + } + } + } + + // We want to listen for all 'europa' messages here. + // Simulate reaction to the 'start' message, by sending 'started'. + if ('name' in messageEvent.data) { + // Save all the messages for inspection later. + if (messageEvent.data.name.startsWith('europa.')) { + messagesReceivedFromEuropa.push(messageEvent.data); + } + + // Handle messages to simulate kbase-ui + switch (messageEvent.data.name) { + case 'europa.connect': { + // Europa's connect contains the channel id it is now listening on. + // TEST_EUROPA_RECEIVE_CHANNEL = messageEvent.data.payload.channelId; + + // This is the normal message after connect is received. + act(() => { + sendMessage('kbase-ui.connected', RECEIVE_CHANNEL_ID, {}); + rerender( + + ); + }); + break; + } + case 'europa.deauthenticated': + deauthticatedCalled = true; + break; + } + } + }); + + // Simulates the initial 'kbase-ui.connect' message sent by kbase-ui when the app has + // loaded, and the Europa compatibility layer is ready to receive messages. + act(() => { + sendMessage('kbase-ui.connect', RECEIVE_CHANNEL_ID, { + channel: SEND_CHANNEL_ID, + }); + }); + + // If the message is received bi the IFrameWrapper, it will have called the + // `setTitle` prop passed to it. + await waitFor( + async () => { + expect(deauthticatedCalled).toEqual(true); + }, + { timeout: WAIT_FOR_TIMEOUT, interval: WAIT_FOR_INTERVAL } + ); + }); + }); +}); diff --git a/src/features/legacy/IFrameWrapper.tsx b/src/features/legacy/IFrameWrapper.tsx new file mode 100644 index 00000000..dc4059c0 --- /dev/null +++ b/src/features/legacy/IFrameWrapper.tsx @@ -0,0 +1,615 @@ +import { faExclamation, faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Alert, AlertTitle, Box, Grow, Typography } from '@mui/material'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import LoadingOverlay from '../../common/components/OverlayContainer'; +import { + CONNECTION_MONITORING_INTERVAL, + CONNECTION_TIMEOUT, + CONNECTION_TIMEOUT_DELAY, +} from './constants'; +import CountdownClock from './CountdownClock'; +import classes from './IFrameWrapper.module.scss'; +import KBaseUIConnection from './KBaseUIConnection'; +import { KBaseUIRedirectPayload } from './messageValidation'; +import TimeoutMonitor, { TimeoutMonitorStateRunning } from './TimeoutMonitor'; +import { areParamsEqual, parseLegacyURL } from './utils'; + +export interface OnLoggedInParams { + token: string; + expires: number; + onAuthResolved: () => void; +} + +/** + * Props for the iframe wrapper component. + */ +export interface IFrameWrapperProps { + /** The unique channel id for the send and receive channels. It could be generated + * here as a uuid, but it provs useful, in testing at least, to be able to supply it*/ + sendChannelId: string; + receiveChannelId: string; + + /** The url to kbase-ui. It should be in the form expected by kbase-ui + * TODO: document the required form! + */ + legacyURL: URL; + + /** The url present in the browser when the legacy component was mounted. */ + legacyPath: LegacyPath; + + /** The kbase auth token, if any, currently active in Europa */ + token: string | null; + + /** Spy on sent messages; useful for debugging */ + spyOnChannels?: boolean; + + /** Sets the page title for the UI and the brower; called when kbase-ui.set-title is received */ + setTitle: (title: string) => void; + + /** */ + onLoggedIn: (payload: OnLoggedInParams) => void; + + /** */ + onLogOut: () => void; +} + +/** + * Captures all recognized states of the iframe wrapper component + * + * NONE - initial state + * CONNECTING - actively engaged in creating a connection to kbase-ui + * INITIALIZING - actively initializing kbase-ui through the connection + * CONNECTED - connection to kbase-ui established and initialized; normal operating state. + * ERROR - some error occurred while connecting + * + * The resulting interface "IFrameWrapperState" uses the "diamond" definition pattern + * I've found very useful for discriminated-union enabled structures. + * + * The "status" property is the discriminiator, and in this case represents the identity + * of the "state". + * + * Then we define an interface for each state. Define properties for information + * associated with that state. + * + * Then, ultimately, we create a single type out of the union of all the "state" + * interfaces. Because we have defined a state interface for each "status" enum value. + * Thus, when we, say, perform some rendering operation as a function of the state, we + * can use a switch..case to close over all possible states. + */ +export enum IFrameWrapperStatus { + NONE = 'NONE', + CONNECT = 'CONNECT', + CONNECTING = 'CONNECTING', + INITIALIZING = 'INITIALIZING', + CONNECTED = 'CONNECTED', + ERROR = 'ERROR', +} + +export interface IFrameWrapperStatBase { + status: IFrameWrapperStatus; +} + +export interface IFrameWrapperStateNone extends IFrameWrapperStatBase { + status: IFrameWrapperStatus.NONE; +} + +export interface IFrameWrapperStateConnecting extends IFrameWrapperStatBase { + status: IFrameWrapperStatus.CONNECTING; + connection: KBaseUIConnection; + limit: number; + elapsed: number; +} + +export interface IFrameWrapperStateInitializing extends IFrameWrapperStatBase { + status: IFrameWrapperStatus.INITIALIZING; + connection: KBaseUIConnection; +} + +export interface IFrameWrapperStateConnected extends IFrameWrapperStatBase { + status: IFrameWrapperStatus.CONNECTED; + connection: KBaseUIConnection; +} + +export interface IFrameWrapperStateError extends IFrameWrapperStatBase { + status: IFrameWrapperStatus.ERROR; + message: string; +} + +export type IFrameWrapperState = + | IFrameWrapperStateNone + | IFrameWrapperStateConnecting + | IFrameWrapperStateInitializing + | IFrameWrapperStateConnected + | IFrameWrapperStateError; + +export interface LegacyPath { + path: string; + params?: Record; +} + +export default function IFrameWrapper({ + sendChannelId, + receiveChannelId, + legacyURL, + legacyPath, + token, + setTitle, + onLoggedIn, + onLogOut, + spyOnChannels, +}: IFrameWrapperProps) { + // Overall state here is used to track progress of the connection, and hide until it + // is ready. + const [state, setState] = useState({ + status: IFrameWrapperStatus.NONE, + }); + + const legacyContentRef = useRef(null); + + const navigate = useNavigate(); + + const location = useLocation(); + + /** + * Create and set up the connection to kbase-ui. + * Only set the connection state property after everything is finished. + * That way we don't end up with the connection being used before it is ready... + */ + + function onRedirect({ url }: KBaseUIRedirectPayload): void { + window.open(url, '_self'); + } + + function onLostConnection(message: string) { + setState({ + status: IFrameWrapperStatus.ERROR, + message: message, + }); + } + + const syncedState = useRef(state); + syncedState.current = state; + + /** + * This effect is dedicated to creating the initial connection. + * + * It transitions from NONE, the initial state, to CONNECTING. + * + * Its jobs is to create the connection, and pass it to the CONNECTING state. + */ + useEffect(() => { + if (state.status !== IFrameWrapperStatus.NONE) { + return; + } + + // Should never occur, but required for type narrowing, so let us honor it for what + // it is. + if (!legacyContentRef.current || !legacyContentRef.current.contentWindow) { + return; + } + + const connection = new KBaseUIConnection({ + kbaseUIWindow: legacyContentRef.current.contentWindow, + kbaseUIOrigin: legacyURL.origin, + spyOnChannels, + sendChannelId, + receiveChannelId, + }); + + setState({ + status: IFrameWrapperStatus.CONNECTING, + connection, + limit: CONNECTION_TIMEOUT(), + elapsed: 0, + }); + }, [ + state, + legacyURL, + spyOnChannels, + setState, + sendChannelId, + receiveChannelId, + ]); + + /** + * This effect is dedicated to CONNECTING to kbase-ui. + * + * The connection code should only be run once, so we use a gatekeeper ref for that + * purpose. However, the effect is run may times during the CONNECTING phase, as it + * continually updates the CONNECTING state to reflect the timeout monitor's countdown + * torards timing out. + */ + + // Used as a gatekeeper so that we only execute the connection process the first time + // we enter CONNECTING state. + const connectingRef = useRef(false); + + useEffect(() => { + if (state.status !== IFrameWrapperStatus.CONNECTING) { + return; + } + + const doConnect = async () => { + const connection = state.connection; + connectingRef.current = true; + + let monitor: TimeoutMonitor | null = null; + try { + // We use a countdown timer, which will set the state to error if + // it is allowed to complete. + monitor = new TimeoutMonitor({ + timeout: CONNECTION_TIMEOUT(), + interval: CONNECTION_MONITORING_INTERVAL(), + onTimeout: (elapsed: number) => { + // connection.current && connection.current.disconnect(); + connection.disconnect(); + setState({ + status: IFrameWrapperStatus.ERROR, + message: `Connection to kbase-ui timed out after ${elapsed}ms`, + }); + }, + onInterval: (state: TimeoutMonitorStateRunning) => { + setState({ + status: IFrameWrapperStatus.CONNECTING, + connection, + limit: CONNECTION_TIMEOUT(), + elapsed: state.elapsed, + }); + }, + }); + monitor.start(); + + // await connectionRef.current.connect(); + await connection.connect(CONNECTION_TIMEOUT()); + + // So we stop the clock as soon as we are connected. + monitor.stop(); + + setState({ status: IFrameWrapperStatus.INITIALIZING, connection }); + } catch (ex) { + setState({ + status: IFrameWrapperStatus.ERROR, + message: ex instanceof Error ? ex.message : 'Unknown Error', + }); + } finally { + // Just to make sure, doesn't hurt. + if (monitor) { + monitor.stop(); + } + } + }; + + // We only set the connection in component state once it is fully complete. + // NB we need to use old-style promise chaining for useEffect. + if (!connectingRef.current) { + doConnect().catch((ex) => { + setState({ + status: IFrameWrapperStatus.ERROR, + message: ex instanceof Error ? ex.message : 'Unknown Error', + }); + }); + } + }, [state]); + + /** + * Dedicated to the INITIALIZING state. + * + * This state is just temporary, and exists to start the connection and perform the + * initial navigation and authentication. + * + */ + useEffect(() => { + if (state.status !== IFrameWrapperStatus.INITIALIZING) { + return; + } + + const connection = state.connection; + + connection.start({ + navigate, + setTitle, + onLoggedIn, + onLogOut, + onRedirect, + onLostConnection, + }); + + connection.authnavigate(token || null, legacyPath); + + setState({ status: IFrameWrapperStatus.CONNECTED, connection }); + }, [ + // dynamic + state, + legacyPath, + token, + // static + navigate, + setTitle, + onLoggedIn, + onLogOut, + setState, + ]); + + /** + * Just sets the title, based on the current state of loading kbase-ui. + * + * After kbase-ui is loaded, it will take over setting the app title. + */ + useEffect(() => { + const title = (() => { + switch (state.status) { + case IFrameWrapperStatus.NONE: + case IFrameWrapperStatus.CONNECTING: + return 'Loading App'; + case IFrameWrapperStatus.ERROR: + return 'Error'; + } + })(); + if (title) { + setTitle(title); + } + }, [state, setTitle]); + + /** + * This effect dedicated to the CONNECTED state and changes to the location - navigation. + * + * It provides most of the runtime monitoring the current location for changes in the url + * which would cause a navigation in kbase-ui. + * + * If such a change is detected, the "navigate" connection method is called, which + * sends a "europa.navigate" message to kbase-ui. + */ + + const parseLegacyPathFromURL = useCallback( + (url: URL) => { + // const url = new URL(window.location.origin); + url.pathname = location.pathname; + new URLSearchParams(location.search).forEach((value, key) => { + url.searchParams.set(key, value); + }); + + return parseLegacyURL(url); + }, + [location] + ); + + const url = new URL(window.location.origin); + const initialLegacyPath = parseLegacyPathFromURL(url); + const previousLegacyPathRef = useRef(initialLegacyPath); + + useEffect(() => { + if (state.status !== IFrameWrapperStatus.CONNECTED) { + return; + } + // Generate the legacy path from the current window location. + const url = new URL(window.location.origin); + const { path, params } = parseLegacyPathFromURL(url); + + // Handle transition from one location to another (i.e. navigation) + if ( + previousLegacyPathRef.current === null || + previousLegacyPathRef.current.path !== path || + !areParamsEqual(previousLegacyPathRef.current.params, params) + ) { + previousLegacyPathRef.current = { path, params }; + state.connection.navigate(path, params); + } + }, [location, state, parseLegacyPathFromURL]); + + /** + * This effect dedicated to CONNECTED state and token change. + * + * It monitors auth state for changes and sends the appropriate message to kbase-ui. + */ + const previousTokenRef = useRef(token); + useEffect(() => { + if (state.status !== IFrameWrapperStatus.CONNECTED) { + return; + } + const previousToken = previousTokenRef.current; + previousTokenRef.current = token; + + // Handle transition from unauthenticated to authenticated. + if (previousToken === null) { + if (token !== null) { + // Note no next request, as this is from the side effect of authentication + // happening outside of this session - e.g. logging in in a different window. + state.connection.authenticated(token); + } + } else if (token === null) { + // Handle transition from authenticated to unauthenticated. + state.connection.deauthenticated(); + } + }, [token, state, previousTokenRef]); + + /** + * This effect is dedicated to the CONNECTED state and exists in order to properly + * arrange the connection cleanup upon dismount. + * + * It works becomes the state is only updated once upon entering CONNECTED state, and + * thus the cleanup only runs once. + */ + useEffect(() => { + if (state.status !== IFrameWrapperStatus.CONNECTED) { + return; + } + + return () => { + state.connection.disconnect(); + }; + }, [state]); + + /** + * Renders a "loading overlay" - an absolutely positioned element stretching across + * it's container, and obscuring what is happening "underneath". The purppose is to + * provide a loading indicator, even as kbase-ui is loading underneath. + * + * It is enabled during "NONE" or "CONNECTING" state, and disabled upon "ERROR" or "CONNECTED". + * + * @param state + * @returns + */ + function renderLoadingOverlay(state: IFrameWrapperState) { + if (state.status === IFrameWrapperStatus.CONNECTED) { + return; + } + + const title = (() => { + switch (state.status) { + case IFrameWrapperStatus.NONE: + case IFrameWrapperStatus.CONNECTING: + return 'Loading App'; + case IFrameWrapperStatus.ERROR: + return 'Error'; + } + })(); + + const loadingMessage = (() => { + switch (state.status) { + case IFrameWrapperStatus.NONE: + return ( + + + Connecting... + + ); + case IFrameWrapperStatus.CONNECTING: + return ( +
+
+ + Connecting... +
+
+ CONNECTION_TIMEOUT_DELAY()} + unmountOnExit + > +
+ + + + {/*
*/} + + This is taking longer than expected. + + + We'll continue waiting{' '} + {Intl.NumberFormat('en-US', {}).format( + state.limit / 1000 + )}{' '} + seconds for load to complete. + + + You may try to reload the browser any time, in case it is + due to a temporary outage or slowdown. + + +
+
+
+
+ ); + case IFrameWrapperStatus.ERROR: + return ( + +

An error ocurred connecting to kbase-ui.

+

+ You may try reloading the browser to see if the problem has been + resolved. +

+ {state.message} +
+ ); + } + })(); + + const icon = (() => { + switch (state.status) { + case IFrameWrapperStatus.NONE: + case IFrameWrapperStatus.CONNECTING: + return false; + case IFrameWrapperStatus.ERROR: + return ( + + ); + } + })(); + + const severity = (() => { + switch (state.status) { + case IFrameWrapperStatus.NONE: + case IFrameWrapperStatus.CONNECTING: + return 'info'; + case IFrameWrapperStatus.ERROR: + return 'error'; + } + })(); + + function renderLoading() { + return ( +
+ + {title} + {loadingMessage} + +
+ ); + } + + return {renderLoading()}; + } + + return ( +
+ {renderLoadingOverlay(state)} +