diff --git a/apps/demo-app/.env-cmdrc.json b/apps/demo-app/.env-cmdrc.json index 3dd97fe90..631131185 100644 --- a/apps/demo-app/.env-cmdrc.json +++ b/apps/demo-app/.env-cmdrc.json @@ -6,9 +6,18 @@ "REACT_APP_ENVIRONMENT": "local", "REACT_APP_LIVE_CONFIG_ID": "demo-app-development", "REACT_APP_USE_LOCAL_CONFIG": "true", - "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", + "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/", + "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", "REACT_APP_AUTH_CLIENT_ID": "O7geYcPM6zEJrHw0WvQVzSIzw4WzrAtH", + "REACT_APP_API_DOMAIN": "api.preview.monk.ai/v1", + "REACT_APP_THUMBNAIL_DOMAIN": "api.preview.monk.ai/image_resize", + + "REACT_APP_AUTH_DOMAIN_US": "acv-staging.us.auth0.com", + "REACT_APP_AUTH_CLIENT_ID_US": "0tIBBTLd4uP52jtF2PcXawWYxW12mUfZ", + "REACT_APP_API_DOMAIN_US": "monk-us-core-api.gateway.staging.acvauctions.com/v1", + "REACT_APP_THUMBNAIL_DOMAIN_US": "monk-us-image.gateway.staging.acvauctions.com/image_resize", + "REACT_APP_SENTRY_DSN": "https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720", "REACT_APP_SENTRY_DEBUG": "true", "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai" @@ -16,36 +25,72 @@ "development": { "REACT_APP_ENVIRONMENT": "development", "REACT_APP_LIVE_CONFIG_ID": "demo-app-development", - "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", + "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/", + "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", "REACT_APP_AUTH_CLIENT_ID": "O7geYcPM6zEJrHw0WvQVzSIzw4WzrAtH", + "REACT_APP_API_DOMAIN": "api.preview.monk.ai/v1", + "REACT_APP_THUMBNAIL_DOMAIN": "api.preview.monk.ai/image_resize", + + "REACT_APP_AUTH_DOMAIN_US": "acv-staging.us.auth0.com", + "REACT_APP_AUTH_CLIENT_ID_US": "0tIBBTLd4uP52jtF2PcXawWYxW12mUfZ", + "REACT_APP_API_DOMAIN_US": "monk-us-core-api.gateway.staging.acvauctions.com/v1", + "REACT_APP_THUMBNAIL_DOMAIN_US": "monk-us-image.gateway.staging.acvauctions.com/image_resize", + "REACT_APP_SENTRY_DSN": "https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720", "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai" }, "staging": { "REACT_APP_ENVIRONMENT": "staging", "REACT_APP_LIVE_CONFIG_ID": "demo-app-staging", - "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", + "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/", + "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", "REACT_APP_AUTH_CLIENT_ID": "O7geYcPM6zEJrHw0WvQVzSIzw4WzrAtH", + "REACT_APP_API_DOMAIN": "api.preview.monk.ai/v1", + "REACT_APP_THUMBNAIL_DOMAIN": "api.preview.monk.ai/image_resize", + + "REACT_APP_AUTH_DOMAIN_US": "acv-staging.us.auth0.com", + "REACT_APP_AUTH_CLIENT_ID_US": "0tIBBTLd4uP52jtF2PcXawWYxW12mUfZ", + "REACT_APP_API_DOMAIN_US": "monk-us-core-api.gateway.staging.acvauctions.com/v1", + "REACT_APP_THUMBNAIL_DOMAIN_US": "monk-us-image.gateway.staging.acvauctions.com/image_resize", + "REACT_APP_SENTRY_DSN": "https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720", "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai" }, "preview": { "REACT_APP_ENVIRONMENT": "preview", "REACT_APP_LIVE_CONFIG_ID": "demo-app-preview", - "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", + "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/", + "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", "REACT_APP_AUTH_CLIENT_ID": "O7geYcPM6zEJrHw0WvQVzSIzw4WzrAtH", + "REACT_APP_API_DOMAIN": "api.preview.monk.ai/v1", + "REACT_APP_THUMBNAIL_DOMAIN": "api.preview.monk.ai/image_resize", + + "REACT_APP_AUTH_DOMAIN_US": "acv-staging.us.auth0.com", + "REACT_APP_AUTH_CLIENT_ID_US": "0tIBBTLd4uP52jtF2PcXawWYxW12mUfZ", + "REACT_APP_API_DOMAIN_US": "monk-us-core-api.gateway.staging.acvauctions.com/v1", + "REACT_APP_THUMBNAIL_DOMAIN_US": "monk-us-image.gateway.staging.acvauctions.com/image_resize", + "REACT_APP_SENTRY_DSN": "https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720", "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai" }, "backend-staging-qa": { "REACT_APP_ENVIRONMENT": "backend-staging-qa", "REACT_APP_LIVE_CONFIG_ID": "demo-app-backend-staging-qa", - "REACT_APP_AUTH_DOMAIN": "idp.staging.monk.ai", + "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/", + "REACT_APP_AUTH_DOMAIN": "idp.staging.monk.ai", "REACT_APP_AUTH_CLIENT_ID": "DAeZWqeeOfgItYBcQzFeFwSrlvmUdN7L", + "REACT_APP_API_DOMAIN": "api.preview.monk.ai/v1", + "REACT_APP_THUMBNAIL_DOMAIN": "api.staging.dev.monk.ai/image_resize", + + "REACT_APP_AUTH_DOMAIN_US": "acv-staging.us.auth0.com", + "REACT_APP_AUTH_CLIENT_ID_US": "0tIBBTLd4uP52jtF2PcXawWYxW12mUfZ", + "REACT_APP_API_DOMAIN_US": "monk-us-core-api.gateway.staging.acvauctions.com/v1", + "REACT_APP_THUMBNAIL_DOMAIN_US": "monk-us-image.gateway.staging.acvauctions.com/image_resize", + "REACT_APP_SENTRY_DSN": "https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720", "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.staging.monk.ai" } diff --git a/apps/demo-app/src/auth.ts b/apps/demo-app/src/auth.ts new file mode 100644 index 000000000..8253a18f0 --- /dev/null +++ b/apps/demo-app/src/auth.ts @@ -0,0 +1,26 @@ +import { AuthorizationParams } from '@auth0/auth0-react'; +import { getEnvOrThrow } from '@monkvision/common'; +import { AuthConfig } from '@monkvision/network'; + +export const AUTHORIZATION_PARAMS: AuthorizationParams = { + redirect_uri: window.location.origin, + audience: getEnvOrThrow('REACT_APP_AUTH_AUDIENCE'), + prompt: 'login', +}; + +export const authConfigs: AuthConfig[] = [ + { + domain: getEnvOrThrow('REACT_APP_AUTH_DOMAIN'), + clientId: getEnvOrThrow('REACT_APP_AUTH_CLIENT_ID'), + apiDomain: getEnvOrThrow('REACT_APP_API_DOMAIN'), + thumbnailDomain: getEnvOrThrow('REACT_APP_THUMBNAIL_DOMAIN'), + authorizationParams: AUTHORIZATION_PARAMS, + }, + { + domain: getEnvOrThrow('REACT_APP_AUTH_DOMAIN_US'), + clientId: getEnvOrThrow('REACT_APP_AUTH_CLIENT_ID_US'), + apiDomain: getEnvOrThrow('REACT_APP_API_DOMAIN_US'), + thumbnailDomain: getEnvOrThrow('REACT_APP_THUMBNAIL_DOMAIN_US'), + authorizationParams: AUTHORIZATION_PARAMS, + }, +]; diff --git a/apps/demo-app/src/components/App.tsx b/apps/demo-app/src/components/App.tsx index b86bf83c1..0719ff6d7 100644 --- a/apps/demo-app/src/components/App.tsx +++ b/apps/demo-app/src/components/App.tsx @@ -3,9 +3,11 @@ import { getEnvOrThrow, MonkProvider } from '@monkvision/common'; import { useTranslation } from 'react-i18next'; import { LiveConfigAppProvider } from '@monkvision/common-ui-web'; import { LiveConfig } from '@monkvision/types'; +import { getAuthConfig } from '@monkvision/network'; import { Page } from '../pages'; import * as config from '../local-config.json'; import { AppContainer } from './AppContainer'; +import { authConfigs } from '../auth'; const localConfig = process.env['REACT_APP_USE_LOCAL_CONFIG'] === 'true' @@ -20,6 +22,8 @@ export function App() { navigate(Page.CREATE_INSPECTION)} onFetchLanguage={(lang) => i18n.changeLanguage(lang)} lang={i18n.language} diff --git a/apps/demo-app/src/index.tsx b/apps/demo-app/src/index.tsx index 9a5832da1..a141b9663 100644 --- a/apps/demo-app/src/index.tsx +++ b/apps/demo-app/src/index.tsx @@ -1,28 +1,20 @@ import ReactDOM from 'react-dom'; import { MonitoringProvider } from '@monkvision/monitoring'; import { AnalyticsProvider } from '@monkvision/analytics'; -import { Auth0Provider } from '@auth0/auth0-react'; -import { getEnvOrThrow } from '@monkvision/common'; +import { AuthProvider } from '@monkvision/network'; import { sentryMonitoringAdapter } from './sentry'; import { posthogAnalyticsAdapter } from './posthog'; import { AppRouter } from './components'; +import { authConfigs } from './auth'; import './index.css'; import './i18n'; ReactDOM.render( - + - + , document.getElementById('root'), diff --git a/apps/demo-app/src/local-config.json b/apps/demo-app/src/local-config.json index 280228747..d31e33cf1 100644 --- a/apps/demo-app/src/local-config.json +++ b/apps/demo-app/src/local-config.json @@ -12,8 +12,6 @@ "createInspectionOptions": { "tasks": ["damage_detection", "wheel_analysis"] }, - "apiDomain": "api.preview.monk.ai/v1", - "thumbnailDomain": "api.preview.monk.ai/image_resize", "enableTutorial": "first_time_only", "enableSightTutorial": "modern", "startTasksOnComplete": true, diff --git a/configs/test-utils/src/__mocks__/@auth0/auth0-react.ts b/configs/test-utils/src/__mocks__/@auth0/auth0-react.ts deleted file mode 100644 index 3cae079dd..000000000 --- a/configs/test-utils/src/__mocks__/@auth0/auth0-react.ts +++ /dev/null @@ -1,8 +0,0 @@ -export = { - /* Actual exports */ - /* Mocks */ - useAuth0: jest.fn(() => ({ - getAccessTokenWithPopup: jest.fn(() => Promise.resolve('')), - logout: jest.fn(), - })), -}; diff --git a/configs/test-utils/src/__mocks__/@auth0/auth0-react.tsx b/configs/test-utils/src/__mocks__/@auth0/auth0-react.tsx new file mode 100644 index 000000000..438164121 --- /dev/null +++ b/configs/test-utils/src/__mocks__/@auth0/auth0-react.tsx @@ -0,0 +1,14 @@ +const mockAuth0Provider = ({ children, ...props }: any) => { + (global as any).__auth0ProviderLastProps = props; + return <>{children}; +}; + +export = { + /* Actual exports */ + /* Mocks */ + useAuth0: jest.fn(() => ({ + getAccessTokenWithPopup: jest.fn(() => Promise.resolve('')), + logout: jest.fn(), + })), + Auth0Provider: mockAuth0Provider, +}; diff --git a/configs/test-utils/src/__mocks__/@monkvision/common.tsx b/configs/test-utils/src/__mocks__/@monkvision/common.tsx index c647ef190..407b451a0 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/common.tsx +++ b/configs/test-utils/src/__mocks__/@monkvision/common.tsx @@ -39,6 +39,7 @@ const { email, getVehicleModel, getAspectRatio, + MonkSearchParam, } = jest.requireActual('@monkvision/common'); export = { @@ -71,6 +72,7 @@ export = { email, getVehicleModel, getAspectRatio, + MonkSearchParam, /* Mocks */ useMonkTheme: jest.fn(() => createTheme()), @@ -148,4 +150,5 @@ export = { requestCompassPermission: jest.fn(() => Promise.resolve()), })), useSafeTimeout: jest.fn(() => jest.fn()), + useMonkSearchParams: jest.fn(() => ({ get: jest.fn(() => null) })), }; diff --git a/configs/test-utils/src/__mocks__/@monkvision/network.ts b/configs/test-utils/src/__mocks__/@monkvision/network.ts index bbb35ecec..9d720e5af 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/network.ts +++ b/configs/test-utils/src/__mocks__/@monkvision/network.ts @@ -31,6 +31,8 @@ export = { decodeMonkJwt: jest.fn((str) => str), isUserAuthorized: jest.fn(() => true), isTokenExpired: jest.fn(() => false), + isTokenValid: jest.fn(() => true), + getAuthConfig: jest.fn(), useAuth: jest.fn(() => ({ authToken: null, login: jest.fn(() => Promise.resolve('')), diff --git a/documentation/src/utils/schemas.ts b/documentation/src/utils/schemas.ts index a7fcbba89..08c22c22e 100644 --- a/documentation/src/utils/schemas.ts +++ b/documentation/src/utils/schemas.ts @@ -9,6 +9,7 @@ import { MileageUnit, MonkApiPermission, PhotoCaptureSightGuidelinesOption, + PhotoCaptureSightTutorialOption, PhotoCaptureTutorialOption, SteeringWheelPosition, TaskName, @@ -269,44 +270,57 @@ export const CreateInspectionDiscriminatedUnionSchema = z.discriminatedUnion( const domainsByEnv = { staging: { - api: 'api.staging.monk.ai/v1', - thumbnail: 'api.staging.monk.ai/image_resize', + api: ['api.staging.monk.ai/v1', 'monk-us-core-api.gateway.dev.acvauctions.com/v1'], + thumbnail: [ + 'api.staging.monk.ai/image_resize', + 'monk-us-image.gateway.dev.acvauctions.com/image_resize', + ], }, preview: { - api: 'api.preview.monk.ai/v1', - thumbnail: 'api.preview.monk.ai/image_resize', + api: ['api.preview.monk.ai/v1', 'monk-us-core-api.gateway.staging.acvauctions.com/v1'], + thumbnail: [ + 'api.preview.monk.ai/image_resize', + 'monk-us-image.gateway.staging.acvauctions.com/image_resize', + ], }, production: { - api: 'api.monk.ai/v1', - thumbnail: 'api.monk.ai/image_resize', + api: ['api.monk.ai/v1', 'monk-us-core-api.gateway.acvauctions.com/v1'], + thumbnail: ['api.monk.ai/image_resize', 'monk-us-image.gateway.acvauctions.com/image_resize'], }, }; -const apiDomains = Object.values(domainsByEnv).map((env) => env.api) as [string, ...string[]]; -const thumbnailDomains = Object.values(domainsByEnv).map((env) => env.thumbnail) as [ +const apiDomains = [...new Set(Object.values(domainsByEnv).flatMap((env) => env.api))] as [ string, ...string[], ]; +const thumbnailDomains = [ + ...new Set(Object.values(domainsByEnv).flatMap((env) => env.thumbnail)), +] as [string, ...string[]]; export const DomainsSchema = z .object({ - apiDomain: z.enum(apiDomains), - thumbnailDomain: z.enum(thumbnailDomains), + apiDomain: z.enum(apiDomains).optional(), + thumbnailDomain: z.enum(thumbnailDomains).optional(), }) .refine( (data) => { - const apiEnv = Object.values(domainsByEnv).find((env) => env.api === data.apiDomain); - const thumbnailEnv = Object.values(domainsByEnv).find( - (env) => env.thumbnail === data.thumbnailDomain, + const { apiDomain, thumbnailDomain } = data; + + if (!apiDomain || !thumbnailDomain) { + return true; + } + + const apiEnv = Object.values(domainsByEnv).find((env) => env.api.includes(apiDomain)); + const thumbnailEnv = Object.values(domainsByEnv).find((env) => + env.thumbnail.includes(thumbnailDomain), ); - return !!apiEnv && apiEnv === thumbnailEnv; + + return Boolean(apiEnv && thumbnailEnv && apiEnv === thumbnailEnv); }, - (data) => ({ - message: `The selected thumbnailDomain must correspond to the selected apiDomain. Please use the corresponding thumbnailDomain: ${ - thumbnailDomains[apiDomains.indexOf(data.apiDomain)] - }`, + { + message: 'The selected thumbnailDomain must correspond to the selected apiDomain.', path: ['thumbnailDomain'], - }), + }, ); export const LiveConfigSchema = z @@ -329,8 +343,8 @@ export const LiveConfigSchema = z sightGuidelines: z.array(SightGuidelineSchema).optional(), enableTutorial: z.nativeEnum(PhotoCaptureTutorialOption).optional(), allowSkipTutorial: z.boolean().optional(), - enableSightTutorial: z.boolean().optional(), - sightTutorial: z.array(SightGuidelineSchema).optional(), + enableSightTutorial: z.nativeEnum(PhotoCaptureSightTutorialOption).optional(), + sightTutorial: z.array(SightTutorialSchema).optional(), defaultVehicleType: z.nativeEnum(VehicleType), allowManualLogin: z.boolean(), allowVehicleTypeSelection: z.boolean(), diff --git a/packages/common-ui-web/src/components/LiveConfigAppProvider/LiveConfigAppProvider.tsx b/packages/common-ui-web/src/components/LiveConfigAppProvider/LiveConfigAppProvider.tsx index 5644b4bf2..f6d88b523 100644 --- a/packages/common-ui-web/src/components/LiveConfigAppProvider/LiveConfigAppProvider.tsx +++ b/packages/common-ui-web/src/components/LiveConfigAppProvider/LiveConfigAppProvider.tsx @@ -13,6 +13,24 @@ import { styles } from './LiveConfigAppProvider.styles'; import { Spinner } from '../Spinner'; import { Button } from '../Button'; +function validateRequiredDomains( + apiDomain: string | undefined, + thumbnailDomain: string | undefined, + configId: string, +): Error | null { + if (!apiDomain) { + return new Error( + `Missing required apiDomain: provide it as a LiveConfigAppProvider prop or define it in the JSON live config file: "${configId}".`, + ); + } + if (!thumbnailDomain) { + return new Error( + `Missing required thumbnailDomain: provide it as a LiveConfigAppProvider prop or define it in the JSON live config file: "${configId}".`, + ); + } + return null; +} + /** * Props accepted by the LiveConfigAppProvider component. */ @@ -21,6 +39,14 @@ export interface LiveConfigAppProviderProps extends Omit { loading.onSuccess(); - setConfig(result); + const finalApiDomain = result.apiDomain ?? apiDomain; + const finalThumbnailDomain = result.thumbnailDomain ?? thumbnailDomain; + + const missingDomainError = validateRequiredDomains( + finalApiDomain, + finalThumbnailDomain, + id, + ); + if (missingDomainError) { + handleError(missingDomainError); + loading.onError(); + return; + } + + setConfig({ ...result, apiDomain: finalApiDomain, thumbnailDomain: finalThumbnailDomain }); }, onReject: (err) => { handleError(err); diff --git a/packages/common-ui-web/test/components/LiveConfigAppProvider.test.tsx b/packages/common-ui-web/test/components/LiveConfigAppProvider.test.tsx index e2eb5c1fa..1d7ef8246 100644 --- a/packages/common-ui-web/test/components/LiveConfigAppProvider.test.tsx +++ b/packages/common-ui-web/test/components/LiveConfigAppProvider.test.tsx @@ -24,7 +24,11 @@ describe('LiveConfigAppProvider component', () => { }); it('should fetch the live config and pass it to the MonkAppStateProvider component', async () => { - const config = { hello: 'world' }; + const config = { + hello: 'world', + apiDomain: 'test-domain', + thumbnailDomain: 'test-thumbmail-domain', + }; (MonkApi.getLiveConfig as jest.Mock).mockImplementationOnce(() => Promise.resolve(config)); const id = 'test-id-test'; const { unmount } = render(); @@ -36,10 +40,54 @@ describe('LiveConfigAppProvider component', () => { unmount(); }); + it('should fetch the live config, ensure apiDomain is set, and pass it to MonkAppStateProvider', async () => { + const config = { hello: 'world' }; + const liveConfigAppProviderPropsMock = { + id: 'test-id-test', + apiDomain: 'test-domain', + thumbnailDomain: 'test-thumbmail-domain', + }; + (MonkApi.getLiveConfig as jest.Mock).mockImplementationOnce(() => Promise.resolve(config)); + const { unmount } = render(); + + await waitFor(() => { + expectPropsOnChildMock(MonkAppStateProvider, { + config: { + ...config, + apiDomain: liveConfigAppProviderPropsMock.apiDomain, + thumbnailDomain: liveConfigAppProviderPropsMock.thumbnailDomain, + }, + }); + }); + + unmount(); + }); + + it('should display an error message with a retry button in case of no apiDomain found', async () => { + const config = { hello: 'world' }; + (MonkApi.getLiveConfig as jest.Mock).mockImplementationOnce(() => Promise.resolve(config)); + const id = 'test-id-test'; + const { unmount } = render(); + + expect(MonkAppStateProvider).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.getByTestId('error-msg')).not.toBeNull(); + expectPropsOnChildMock(Button, { children: 'Retry', onClick: expect.any(Function) }); + }); + + unmount(); + }); + it('should pass down the props and children to the MonkAppStateProvider component', async () => { const onFetchAuthToken = jest.fn(); const onFetchLanguage = jest.fn(); const children = 'test-children'; + const config = { + hello: 'world', + apiDomain: 'test-domain', + thumbnailDomain: 'test-thumbmail-domain', + }; + (MonkApi.getLiveConfig as jest.Mock).mockImplementationOnce(() => Promise.resolve(config)); const { unmount } = render( { expect(screen.queryByTestId(spinnerTestId)).not.toBeNull(); expect(MonkAppStateProvider).not.toHaveBeenCalled(); await act(async () => { - promise.resolve({}); + promise.resolve({ apiDomain: 'test-domain', thumbnailDomain: 'test-thumbmail-domain' }); await promise; }); expect(screen.queryByTestId(spinnerTestId)).toBeNull(); @@ -93,7 +141,9 @@ describe('LiveConfigAppProvider component', () => { expectPropsOnChildMock(Button, { children: 'Retry', onClick: expect.any(Function) }); }); const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; - (MonkApi.getLiveConfig as jest.Mock).mockImplementationOnce(() => Promise.resolve({})); + (MonkApi.getLiveConfig as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ apiDomain: 'test-domain', thumbnailDomain: 'test-thumbmail-domain' }), + ); act(() => { onClick(); }); @@ -107,7 +157,11 @@ describe('LiveConfigAppProvider component', () => { }); it('should not fetch the live config and return the local config if it is used', async () => { - const localConfig = { hello: 'world' } as unknown as LiveConfig; + const localConfig = { + hello: 'world', + apiDomain: 'test-domain', + thumbnailDomain: 'test-thumbmail-domain', + } as unknown as LiveConfig; const id = 'test-id-test'; const { unmount } = render(); diff --git a/packages/common/src/apps/searchParams.ts b/packages/common/src/apps/searchParams.ts index d32f34043..9f51009b2 100644 --- a/packages/common/src/apps/searchParams.ts +++ b/packages/common/src/apps/searchParams.ts @@ -49,6 +49,10 @@ export enum MonkSearchParam { * @see SteeringWheelPosition */ STEERING_WHEEL = 's', + /** + * Search parameter used to specify the Auth0 Client ID. + */ + CLIENT_ID = 'c', } /** @@ -60,6 +64,7 @@ export type MonkSearchParamsGetter = { (param: MonkSearchParam.VEHICLE_TYPE): VehicleType | null; (param: MonkSearchParam.STEERING_WHEEL): SteeringWheelPosition | null; (param: MonkSearchParam.LANGUAGE): MonkLanguage | null; + (param: MonkSearchParam.CLIENT_ID): string | null; }; function validateParamValue( @@ -108,6 +113,8 @@ export function useMonkSearchParams({ availableVehicleTypes }: UseMonkSearchPara return validateParamValue(value, SteeringWheelPosition); case MonkSearchParam.LANGUAGE: return validateParamValue(value, monkLanguages); + case MonkSearchParam.CLIENT_ID: + return value; default: return null; } diff --git a/packages/common/test/apps/searchParams.test.ts b/packages/common/test/apps/searchParams.test.ts index 0624512f3..65a574c5e 100644 --- a/packages/common/test/apps/searchParams.test.ts +++ b/packages/common/test/apps/searchParams.test.ts @@ -162,5 +162,23 @@ describe('MonkSearchParams utils', () => { unmount(); }); + + it('should return a null client ID if it is not found in the search params', () => { + const { result, unmount } = renderHook(useMonkSearchParams); + + expect(result.current.get(MonkSearchParam.CLIENT_ID)).toBeNull(); + + unmount(); + }); + + it('should return the client ID if it is found in the search params', () => { + const clientId = 'test-id-test'; + mockSearchParams({ [MonkSearchParam.CLIENT_ID]: clientId }); + const { result, unmount } = renderHook(useMonkSearchParams); + + expect(result.current.get(MonkSearchParam.CLIENT_ID)).toEqual(clientId); + + unmount(); + }); }); }); diff --git a/packages/network/README.md b/packages/network/README.md index a0f2de6cc..b29ec0216 100644 --- a/packages/network/README.md +++ b/packages/network/README.md @@ -349,6 +349,39 @@ function MyAuthComponent() { } ``` +## AuthProvider + +### Description +This component is a Authentication provider that selects the appropriate Auth0 configuration based on URL parameters (MonkSearchParam.CLIENT_ID). + +### Example + +```tsx +import { AuthProvider } from '@monkvision/network'; + +const configs = [ + { + clientId: 'CID_EU', + domain: 'eu.auth0.com', + authorizationParams: { redirect_uri: 'https://eu.monk.ai' }, + }, + { + clientId: 'CID_US', + domain: 'us.auth0.com', + authorizationParams: { redirect_uri: 'https://us.monk.ai' }, + }, +]; + +function App() { + return ( + + ... + + ); +} +``` + + ## JWT Utils ### Token decoding You can decode Monk JWT token issued by Auth0 using the `decodeMonkJwt` util function provided by this package : @@ -374,7 +407,6 @@ console.log(isUserAuthorized(value, requiredPermissions)); // value can either be an auth token as a string or a decoded JWT payload ``` - ### isTokenExpired This utility function checks if an authorization token is expired or not. You can either pass an auth token to be decoded or the JWT payload directly. @@ -385,3 +417,39 @@ import { isTokenExpired } from '@monkvision/network'; console.log(isTokenExpired(value)); // value can either be an auth token as a string or a decoded JWT payload ``` + +### isTokenValid +This utility function checks if the stored auth token is valid for the given Auth0 Client ID. + +```typescript +import { isTokenValid } from '@monkvision/network'; + +console.log(isTokenValid(clientId)); +``` + +### getAuthConfig +This utility function that retrieves the appropriate AuthConfig based on the URL search params. +Priority: TOKEN parameter (decoded azp claim) > CLIENT_ID parameter > first config (default) + +```typescript +import { getAuthConfig } from '@monkvision/network'; +import { MonkSearchParam } from '@monkvision/common'; + +const configs = [ + { + clientId: 'CID_EU', + domain: 'eu.auth0.com', + authorizationParams: { redirect_uri: 'https://eu.monk.ai' }, + }, + { + clientId: 'CID_US', + domain: 'us.auth0.com', + authorizationParams: { redirect_uri: 'https://us.monk.ai' }, + }, +]; + +// Suppose the URL includes ?c=CID_US +const config = getAuthConfig(configs); +console.log(config); +// → returns the "us" configuration +``` diff --git a/packages/network/src/auth/authProvider.tsx b/packages/network/src/auth/authProvider.tsx new file mode 100644 index 000000000..108d4e7eb --- /dev/null +++ b/packages/network/src/auth/authProvider.tsx @@ -0,0 +1,53 @@ +import { PropsWithChildren, useEffect } from 'react'; +import { Auth0Provider, useAuth0 } from '@auth0/auth0-react'; +import { STORAGE_KEY_AUTH_TOKEN } from '@monkvision/common'; +import { getAuthConfig, isTokenValid } from './token'; +import { AuthConfig } from './authProvider.types'; + +function AuthValidator({ clientId }: { clientId: string }) { + const { logout } = useAuth0(); + + useEffect(() => { + const storedToken = localStorage.getItem(STORAGE_KEY_AUTH_TOKEN); + + if (!isTokenValid(clientId) && storedToken) { + localStorage.removeItem(STORAGE_KEY_AUTH_TOKEN); + logout({ logoutParams: { returnTo: window.location.href } }); + } + }, [clientId, logout]); + + return null; +} + +/** + * Props accepted by the AuthProvider component. + */ +export interface AuthProviderProps { + /** + * List of Auth0 configurations to choose from based on URL parameters. + */ + configs: AuthConfig[]; +} + +/** + * Authentication provider that selects the appropriate Auth0 configuration based on URL parameters. + */ +export function AuthProvider({ configs, children }: PropsWithChildren) { + const config = getAuthConfig(configs); + + if (!config) { + return <>{children}; + } + + return ( + + + {children} + + ); +} diff --git a/packages/network/src/auth/authProvider.types.ts b/packages/network/src/auth/authProvider.types.ts new file mode 100644 index 000000000..1c75f9f7d --- /dev/null +++ b/packages/network/src/auth/authProvider.types.ts @@ -0,0 +1,32 @@ +import { Context } from 'react'; +import { Auth0ContextInterface, AuthorizationParams } from '@auth0/auth0-react'; + +/** + * Auth0 authentication configuration. + */ +export interface AuthConfig { + /** + * Auth0 domain (e.g., "idp.monk.ai"). + */ + domain: string; + /** + * Auth0 client ID. + */ + clientId: string; + /** + * Authorization parameters for Auth0. + */ + authorizationParams: AuthorizationParams; + /** + * Base domain for API requests. + */ + apiDomain?: string; + /** + * Domain for thumbnail images (image_resize microservice). + */ + thumbnailDomain?: string; + /** + * Custom Auth0 context (optional). + */ + context?: Context; +} diff --git a/packages/network/src/auth/index.ts b/packages/network/src/auth/index.ts index 538b5c29a..9d4bbc9b4 100644 --- a/packages/network/src/auth/index.ts +++ b/packages/network/src/auth/index.ts @@ -1,2 +1,4 @@ export * from './token'; export * from './hooks'; +export * from './authProvider'; +export * from './authProvider.types'; diff --git a/packages/network/src/auth/token.ts b/packages/network/src/auth/token.ts index e8bb1b2b6..3e5a86e31 100644 --- a/packages/network/src/auth/token.ts +++ b/packages/network/src/auth/token.ts @@ -1,6 +1,8 @@ import { JwtPayload } from 'jsonwebtoken'; import { jwtDecode } from 'jwt-decode'; import { MonkApiPermission } from '@monkvision/types'; +import { STORAGE_KEY_AUTH_TOKEN, MonkSearchParam, zlibDecompress } from '@monkvision/common'; +import { AuthConfig } from './authProvider.types'; /** * The payload of the authentication token used with the Monk API. @@ -53,3 +55,32 @@ export function isTokenExpired(tokenOrPayload: MonkJwtPayload | string | null): typeof tokenOrPayload === 'object' ? tokenOrPayload : decodeMonkJwt(tokenOrPayload); return !payload.exp || Math.round(Date.now() / 1000) >= payload.exp; } + +/** + * Utility function that checks if the stored auth token is valid for the given Auth0 Client ID. + */ +export function isTokenValid(clientID: string): boolean { + const fetchedToken = localStorage.getItem(STORAGE_KEY_AUTH_TOKEN); + const fetchedClientId = fetchedToken ? decodeMonkJwt(fetchedToken).azp : null; + return fetchedClientId === clientID; +} + +/** + * Utility function that retrieves the appropriate AuthConfig based on the URL search params. + * Priority: TOKEN parameter (decoded azp claim) > CLIENT_ID parameter > first config (default) + */ +export function getAuthConfig(configs: AuthConfig[]): AuthConfig | undefined { + if (!configs.length) { + return undefined; + } + + const { searchParams } = new URL(window.location.href); + const tokenParam = searchParams.get(MonkSearchParam.TOKEN); + const clientIdParam = searchParams.get(MonkSearchParam.CLIENT_ID); + + const tokenClientId = tokenParam ? decodeMonkJwt(zlibDecompress(tokenParam)).azp : null; + const targetClientId = tokenClientId || clientIdParam; + + const matchingConfig = configs.find((config) => config.clientId === targetClientId); + return matchingConfig ?? configs[0]; +} diff --git a/packages/network/test/auth/authProvider.test.tsx b/packages/network/test/auth/authProvider.test.tsx new file mode 100644 index 000000000..caaa62db8 --- /dev/null +++ b/packages/network/test/auth/authProvider.test.tsx @@ -0,0 +1,102 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { STORAGE_KEY_AUTH_TOKEN } from '@monkvision/common'; +import { AuthConfig } from '../../src/auth/authProvider.types'; +import { useAuth0 } from '@auth0/auth0-react'; + +jest.mock('../../src/auth/token', () => ({ + getAuthConfig: jest.fn(), + isTokenValid: jest.fn(), +})); + +import { AuthProvider } from '../../src/auth/authProvider'; +import { getAuthConfig, isTokenValid } from '../../src/auth/token'; + +function createConfigs(): AuthConfig[] { + return [ + { + clientId: 'client-A', + domain: 'a.auth0.com', + authorizationParams: { redirect_uri: 'https://a.example.com' }, + context: undefined, + }, + { + clientId: 'client-B', + domain: 'b.auth0.com', + authorizationParams: { redirect_uri: 'https://b.example.com' }, + context: undefined, + }, + ]; +} + +describe('AuthProvider component', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + delete (global as any).__auth0ProviderLastProps; + }); + + it('should render children and pass correct props to Auth0Provider', () => { + const childTestId = 'child-test'; + const configs = createConfigs(); + (getAuthConfig as jest.Mock).mockReturnValue(configs[0]); + + render( + +
+ , + ); + + expect(screen.getByTestId(childTestId)).toBeInTheDocument(); + const lastProps = (global as any).__auth0ProviderLastProps; + expect(lastProps).toMatchObject(configs[0]); + }); + + it('should call logout and clear token when token is invalid', () => { + const childTestId = 'child-test'; + const configs = createConfigs(); + (getAuthConfig as jest.Mock).mockReturnValue(configs[1]); + (isTokenValid as jest.Mock).mockReturnValue(false); + + localStorage.setItem(STORAGE_KEY_AUTH_TOKEN, 'auth-token-test'); + + render( + +
+ , + ); + + const useAuth0Mock = (useAuth0 as jest.Mock).mock.results[0].value; + expect(localStorage.getItem(STORAGE_KEY_AUTH_TOKEN)).toBeNull(); + expect(useAuth0Mock.logout).toHaveBeenCalled(); + }); + + it('should not call logout when there is no token in localStorage', () => { + const childTestId = 'child-test'; + const configs = createConfigs(); + (getAuthConfig as jest.Mock).mockReturnValue(configs[1]); + (isTokenValid as jest.Mock).mockReturnValue(false); + + render( + +
+ , + ); + + const useAuth0Mock = (useAuth0 as jest.Mock).mock.results[0].value; + expect(useAuth0Mock.logout).not.toHaveBeenCalled(); + }); + + it('should update Auth0Provider props for different configs', () => { + const childTestId = 'child-test'; + const configs = createConfigs(); + (getAuthConfig as jest.Mock).mockReturnValue(configs[1]); + render( + +
+ , + ); + const lastProps = (global as any).__auth0ProviderLastProps; + expect(lastProps).toMatchObject(configs[1]); + }); +}); diff --git a/packages/network/test/auth/token.test.ts b/packages/network/test/auth/token.test.ts index 81abc0ac0..d8b526ff5 100644 --- a/packages/network/test/auth/token.test.ts +++ b/packages/network/test/auth/token.test.ts @@ -4,9 +4,18 @@ jest.mock('jwt-decode', () => ({ })), })); -import { MonkApiPermission } from '@monkvision/types'; import { jwtDecode } from 'jwt-decode'; -import { decodeMonkJwt, isTokenExpired, isUserAuthorized, MonkJwtPayload } from '../../src'; +import { MonkApiPermission } from '@monkvision/types'; +import { MonkSearchParam, STORAGE_KEY_AUTH_TOKEN, zlibDecompress } from '@monkvision/common'; +import { + decodeMonkJwt, + isTokenExpired, + isUserAuthorized, + MonkJwtPayload, + isTokenValid, + getAuthConfig, +} from '../../src'; +import { AuthConfig } from '../../src/auth/authProvider.types'; describe('Network package JWT utils', () => { afterEach(() => { @@ -120,4 +129,91 @@ describe('Network package JWT utils', () => { ).toBe(false); }); }); + + describe('isTokenValid function', () => { + beforeEach(() => { + localStorage.clear(); + (jwtDecode as jest.Mock).mockReset(); + }); + + it('should return false when no token is stored', () => { + expect(isTokenValid('client-123')).toBe(false); + }); + + it('should return true when stored token azp matches client ID', () => { + localStorage.setItem(STORAGE_KEY_AUTH_TOKEN, 'encoded-token'); + (jwtDecode as jest.Mock).mockImplementationOnce(() => ({ azp: 'client-123' })); + expect(isTokenValid('client-123')).toBe(true); + expect(jwtDecode).toHaveBeenCalledWith('encoded-token'); + }); + + it('should return false when stored token azp differs from client ID', () => { + localStorage.setItem(STORAGE_KEY_AUTH_TOKEN, 'encoded-token'); + (jwtDecode as jest.Mock).mockImplementationOnce(() => ({ azp: 'client-XYZ' })); + expect(isTokenValid('client-123')).toBe(false); + }); + }); + + describe('getAuthConfig function', () => { + beforeEach(() => { + jest.resetAllMocks(); + delete (window as any).location; + (window as any).location = { href: 'https://test.app' }; + }); + + const configs: AuthConfig[] = [ + { + clientId: 'client-A', + domain: 'a.auth0.com', + authorizationParams: { redirect_uri: 'https://a.example.com' }, + context: undefined, + }, + { + clientId: 'client-B', + domain: 'b.auth0.com', + authorizationParams: { redirect_uri: 'https://b.example.com' }, + context: undefined, + }, + ]; + + it('should return undefined when no configs are provided', () => { + const result = getAuthConfig([]); + expect(result).toBeUndefined(); + }); + + it('should return first config when no params are present', () => { + const result = getAuthConfig(configs); + expect(result).toBe(configs[0]); + }); + + it('should return matching config when CLIENT_ID param is present', () => { + ( + window as any + ).location.href = `https://test.app?${MonkSearchParam.CLIENT_ID}=${configs[1].clientId}`; + + const result = getAuthConfig(configs); + expect(result).toBe(configs[1]); + }); + + it('should return matching config from TOKEN param (decoded azp)', () => { + const fakeToken = 'compressed-token'; + const fakeTokenDecompressed = 'decoded-token'; + const fakeDecodedTokenClientId = { azp: configs[0].clientId }; + + (window as any).location.href = `https://test.app?${MonkSearchParam.TOKEN}=${fakeToken}`; + (zlibDecompress as jest.Mock).mockImplementationOnce(() => fakeTokenDecompressed); + (jwtDecode as jest.Mock).mockImplementationOnce(() => fakeDecodedTokenClientId); + + const result = getAuthConfig(configs); + expect(zlibDecompress).toHaveBeenCalledWith(fakeToken); + expect(jwtDecode).toHaveBeenCalledWith(fakeTokenDecompressed); + expect(result).toBe(configs[0]); + }); + + it('falls back to first config when no match found', () => { + (window as any).location.href = `https://test.app?${MonkSearchParam.CLIENT_ID}=nonexistent`; + const result = getAuthConfig(configs); + expect(result).toBe(configs[0]); + }); + }); }); diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 5855b8d35..58c187139 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -365,4 +365,12 @@ export type LiveConfig = (PhotoCaptureAppConfig | VideoCaptureAppConfig) & { * The description of the configuration. */ description: string; + /** + * Optional API domain override. + */ + apiDomain?: string; + /** + * Optional Thumbnail domain override. + */ + thumbnailDomain?: string; };