diff --git a/package.json b/package.json index 63e63420..d4f4692b 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "size-limit": [ { "path": "dist/tsembed.es.js", - "limit": "32 kB" + "limit": "40 kB" } ], "scripts": { diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index a2ea29dd..5b8f5a69 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -31,6 +31,7 @@ import { DefaultAppInitData, ErrorDetailsTypes, EmbedErrorCodes, + NavigationPath, } from '../types'; import { executeAfterWait, @@ -130,6 +131,9 @@ const getMockAppInitPayload = (data: any) => { customVariablesForThirdPartyTools, interceptTimeout: undefined, interceptUrls: [], + allowedRoutes: [], + blockedRoutes: [], + accessDeniedMessage: '', }; return { type: EmbedEvent.APP_INIT, @@ -3845,6 +3849,272 @@ describe('Unit test case for ts embed', () => { }); }); }); + + describe('getDefaultAppInitData with BlockedAndAllowedRoutes', () => { + beforeEach(() => { + jest.spyOn(authInstance, 'doCookielessTokenAuth').mockResolvedValueOnce(true); + jest.spyOn(authService, 'verifyTokenService').mockResolvedValue(true); + init({ + thoughtSpotHost: 'tshost', + authType: AuthType.TrustedAuthTokenCookieless, + getAuthToken: () => Promise.resolve('test_auth_token1'), + }); + }); + + afterEach(() => { + baseInstance.reset(); + jest.clearAllMocks(); + }); + + test('should auto-generate allowedRoutes for LiveboardEmbed when user provides additional allowedRoutes', async () => { + const mockEmbedEventPayload = { + type: EmbedEvent.APP_INIT, + data: {}, + }; + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId: '33248a57-cc70-4e39-9199-fb5092283381', + routeBlocking: { + allowedRoutes: ['/custom/route'], + }, + }); + + liveboardEmbed.render(); + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); + }); + + await executeAfterWait(() => { + const appInitData = mockPort.postMessage.mock.calls[0][0].data; + + expect(appInitData.allowedRoutes).toContain( + '/embed/viz/33248a57-cc70-4e39-9199-fb5092283381', + ); + expect(appInitData.allowedRoutes).toContain( + '/insights/pinboard/33248a57-cc70-4e39-9199-fb5092283381', + ); + + expect(appInitData.allowedRoutes).toContain('/custom/route'); + expect(appInitData.allowedRoutes).toContain(NavigationPath.Login); + + expect(appInitData.blockedRoutes).toEqual([]); + }); + }); + + test('should return empty allowedRoutes when only blockedRoutes are provided', async () => { + const mockEmbedEventPayload = { + type: EmbedEvent.APP_INIT, + data: {}, + }; + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId: '33248a57-cc70-4e39-9199-fb5092283381', + routeBlocking: { + blockedRoutes: ['/admin', '/settings'], + }, + }); + + liveboardEmbed.render(); + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); + }); + + await executeAfterWait(() => { + const appInitData = mockPort.postMessage.mock.calls[0][0].data; + + expect(appInitData.allowedRoutes).toEqual([]); + expect(appInitData.blockedRoutes).toEqual(['/admin', '/settings']); + }); + }); + + test('should handle error when both blockedRoutes and allowedRoutes are provided', async () => { + const mockHandleError = jest.fn(); + const mockEmbedEventPayload = { + type: EmbedEvent.APP_INIT, + data: {}, + }; + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId: '33248a57-cc70-4e39-9199-fb5092283381', + routeBlocking: { + allowedRoutes: ['/home'], + blockedRoutes: ['/admin'], + }, + }); + + jest.spyOn(liveboardEmbed as any, 'handleError').mockImplementation(mockHandleError); + + liveboardEmbed.render(); + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); + }); + + await executeAfterWait(() => { + expect(mockHandleError).toHaveBeenCalledWith(expect.objectContaining({ + errorType: ErrorDetailsTypes.VALIDATION_ERROR, + message: ERROR_MESSAGE.CONFLICTING_ROUTES_CONFIG, + code: EmbedErrorCodes.CONFLICTING_ROUTES_CONFIG, + error: ERROR_MESSAGE.CONFLICTING_ROUTES_CONFIG, + } + )); + }); + }); + + test('should auto-generate routes for AppEmbed with pageId and merge with user allowedRoutes', async () => { + const mockEmbedEventPayload = { + type: EmbedEvent.APP_INIT, + data: {}, + }; + + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + pageId: 'home' as any, + routeBlocking: { + allowedRoutes: ['/custom/app/route'], + }, + }); + + appEmbed.render(); + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); + }); + + await executeAfterWait(() => { + const appInitData = mockPort.postMessage.mock.calls[0][0].data; + + expect(appInitData.allowedRoutes).toContain('/home'); + expect(appInitData.allowedRoutes).toContain('/custom/app/route'); + expect(appInitData.allowedRoutes).toContain(NavigationPath.Login); + }); + }); + + test('should include accessDeniedMessage in app init data when provided', async () => { + const mockEmbedEventPayload = { + type: EmbedEvent.APP_INIT, + data: {}, + }; + + const customMessage = + 'You do not have permission to access this page. Please contact your administrator.'; + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId: '33248a57-cc70-4e39-9199-fb5092283381', + routeBlocking: { + accessDeniedMessage: customMessage, + allowedRoutes: ['/dashboard'], + }, + }); + + liveboardEmbed.render(); + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); + }); + + await executeAfterWait(() => { + const appInitData = mockPort.postMessage.mock.calls[0][0].data; + expect(appInitData.accessDeniedMessage).toBe(customMessage); + expect(appInitData.allowedRoutes.length).toBeGreaterThan(0); + }); + }); + + test('should return error when blockedRoute conflicts with auto-generated route', async () => { + const mockHandleError = jest.fn(); + const mockEmbedEventPayload = { + type: EmbedEvent.APP_INIT, + data: {}, + }; + + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + pageId: 'home' as any, + routeBlocking: { + blockedRoutes: ['/home'], + }, + }); + + jest.spyOn(appEmbed as any, 'handleError').mockImplementation(mockHandleError); + + appEmbed.render(); + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); + }); + + await executeAfterWait(() => { + expect(mockHandleError).toHaveBeenCalledWith( + expect.objectContaining({ + errorType: ErrorDetailsTypes.VALIDATION_ERROR, + message: ERROR_MESSAGE.BLOCKING_COMPONENT_ROUTES, + code: EmbedErrorCodes.CONFLICTING_ROUTES_CONFIG, + error: ERROR_MESSAGE.BLOCKING_COMPONENT_ROUTES, + } + )); + }); + }); + + test('should handle empty routeBlocking object', async () => { + const mockEmbedEventPayload = { + type: EmbedEvent.APP_INIT, + data: {}, + }; + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId: '33248a57-cc70-4e39-9199-fb5092283381', + routeBlocking: {}, + }); + + liveboardEmbed.render(); + const mockPort: any = { + postMessage: jest.fn(), + }; + + await executeAfterWait(() => { + const iframe = getIFrameEl(); + postMessageToParent(iframe.contentWindow, mockEmbedEventPayload, mockPort); + }); + + await executeAfterWait(() => { + const appInitData = mockPort.postMessage.mock.calls[0][0].data; + expect(appInitData.allowedRoutes).toEqual([]); + expect(appInitData.blockedRoutes).toEqual([]); + expect(appInitData.accessDeniedMessage).toBe(''); + }); + }); + }); }); diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index f634c6b4..56a11681 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -36,6 +36,7 @@ import { isUndefined, } from '../utils'; import { getCustomActions } from '../utils/custom-actions'; +import { validateAndProcessRoutes } from '../utils/allowed-or-blocked-routes'; import { getThoughtSpotHost, URL_MAX_LENGTH, @@ -460,6 +461,26 @@ export class TsEmbed { error : { type: EmbedErrorCodes.CUSTOM_ACTION_VALIDATION, message: customActionsResult.errors } }); } + const blockedAndAllowedRoutesResult = validateAndProcessRoutes( + this.viewConfig?.routeBlocking, + { + embedComponentType: (this.viewConfig as any).embedComponentType || '', + liveboardId: (this.viewConfig as any).liveboardId, + vizId: (this.viewConfig as any).vizId, + activeTabId: (this.viewConfig as any).activeTabId, + pageId: (this.viewConfig as any).pageId, + path: (this.viewConfig as any).path, + }, + ); + if (blockedAndAllowedRoutesResult.hasError) { + const errorDetails = { + errorType: ErrorDetailsTypes.VALIDATION_ERROR, + message: blockedAndAllowedRoutesResult.errorMessage, + code: EmbedErrorCodes.CONFLICTING_ROUTES_CONFIG, + error : blockedAndAllowedRoutesResult.errorMessage, + }; + this.handleError(errorDetails); + } const baseInitData = { customisations: getCustomisations(this.embedConfig, this.viewConfig), authToken, @@ -479,6 +500,9 @@ export class TsEmbed { this.embedConfig.customVariablesForThirdPartyTools || {}, hiddenListColumns: this.viewConfig.hiddenListColumns || [], customActions: customActionsResult.actions, + allowedRoutes: blockedAndAllowedRoutesResult.allowedRoutes, + blockedRoutes: blockedAndAllowedRoutesResult.blockedRoutes, + accessDeniedMessage: blockedAndAllowedRoutesResult.errorMessage, ...getInterceptInitData(this.viewConfig), }; diff --git a/src/errors.ts b/src/errors.ts index a9e94dd4..f4dcc624 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -27,6 +27,9 @@ export const ERROR_MESSAGE = { HOST_EVENT_TYPE_UNDEFINED: 'Host event type is undefined', LOGIN_FAILED: 'Login failed', ERROR_PARSING_API_INTERCEPT_BODY: 'Error parsing api intercept body', + CONFLICTING_ROUTES_CONFIG: 'You cannot have both blockedRoutes and allowedRoutes set at the same time', + BLOCKING_PROTECTED_ROUTES: 'You cannot block the login or embed access denied page', + BLOCKING_COMPONENT_ROUTES: 'You cannot block a route that is being embedded. The path specified in AppEmbed configuration conflicts with blockedRoutes.', }; export const CUSTOM_ACTIONS_ERROR_MESSAGE = { diff --git a/src/index.ts b/src/index.ts index 10fa08ff..a90620e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,8 @@ import { CustomActionsPosition, CustomActionTarget, InterceptedApiType, + NavigationPath, + RouteBlocking, } from './types'; import { CustomCssVariables } from './css-variables'; import { SageEmbed, SageViewConfig } from './embed/sage'; @@ -156,6 +158,8 @@ export { CustomActionsPosition, CustomActionTarget, InterceptedApiType, + NavigationPath, + RouteBlocking, }; export { resetCachedAuthToken } from './authToken'; diff --git a/src/types.ts b/src/types.ts index 06f9cd20..945fb5de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1106,6 +1106,101 @@ export interface BaseViewConfig extends ApiInterceptFlags { * ``` */ customActions?: CustomAction[]; + + /** + * Route blocking configuration for the embedded app. + * + * Control which routes users can navigate to within the embedded application + * using either an allowlist (`allowedRoutes`) or a blocklist (`blockedRoutes`). + * + * **Important:** + * - `allowedRoutes` and `blockedRoutes` are mutually exclusive. Use only one at a time. + * - The path that the user initially embeds is always accessible, regardless of this configuration. + * + * Supported embed types: `AppEmbed`, `LiveboardEmbed`, `SageEmbed`, `SearchEmbed`, `SpotterAgentEmbed`, `SpotterEmbed`, `SearchBarEmbed` + * @version SDK: 1.45.0 | ThoughtSpot: 26.2.0.cl + * @example + * ```js + * const embed = new AppEmbed('#tsEmbed', { + * ... // other embed view config + * routeBlocking: { + * allowedRoutes: [Path.Home, Path.Search, Path.Liveboards] || blockedRoutes: [Path.Admin, Path.Settings], + * accessDeniedMessage: 'You do not have access to this page' + * } + * }) + * ``` + */ + routeBlocking?: { + /** + * Array of routes that are allowed to be accessed in the embedded app. + * When specified, navigation will be restricted to only these routes. + * All other routes will be blocked. + * Use Path.All to allow all routes without restrictions. + * + * **Important:** The path that the user initially embeds is always unblocked + * and accessible, regardless of the + * `allowedRoutes` or `blockedRoutes` configuration. + * + * Note: `allowedRoutes` and `blockedRoutes` are mutually exclusive. + * Use only one at a time. + * + * Supported embed types: `AppEmbed`, `LiveboardEmbed`, `SageEmbed`, `SearchEmbed`, `SpotterAgentEmbed`, `SpotterEmbed`, `SearchBarEmbed` + * @version SDK: 1.45.0 | ThoughtSpot: 26.2.0.cl + * @example + * + * // Allow only specific routes + *```js + * const embed = new AppEmbed('#tsEmbed', { + * ... // other embed view config + * allowedRoutes: [Path.Home, Path.Search, Path.Liveboards], + * accessDeniedMessage: 'You do not have access to this page' + * }) + *``` + **/ + allowedRoutes?: (NavigationPath | string)[]; + + /** + * Array of routes that are blocked from being accessed in the embedded app. + * When specified, all routes except these will be accessible. + * Use Path.All to block all routes. + * + * **Important:** The path that the user initially embeds is always unblocked + * and accessible, regardless of the + * `allowedRoutes` or `blockedRoutes` configuration. + * + * Note: `allowedRoutes` and `blockedRoutes` are mutually exclusive. + * Use only one at a time. + * + * Supported embed types: `AppEmbed`, `LiveboardEmbed`, `SageEmbed`, `SearchEmbed`, `SpotterAgentEmbed`, `SpotterEmbed`, `SearchBarEmbed` + * @version SDK: 1.45.0 | ThoughtSpot: 26.2.0.cl + * @example + * + * // Block specific routes + *```js + * const embed = new AppEmbed('#tsEmbed', { + * blockedRoutes: [Path.Home, Path.Search, Path.Liveboards], + * }) + *``` + **/ + blockedRoutes?: (NavigationPath | string)[]; + + /** + * Custom message to display when a user tries to access a blocked route + * or a route that is not in the allowedRoutes list. + * + * Supported embed types: `AppEmbed`, `LiveboardEmbed`, `SageEmbed`, `SearchEmbed`, `SpotterAgentEmbed`, `SpotterEmbed`, `SearchBarEmbed` + * @default 'Access Denied' + * @version SDK: 1.45.0 | ThoughtSpot: 26.2.0.cl + * @example + * + * + * const embed = new AppEmbed('#tsEmbed', { + * allowedRoutes: [Path.Home, Path.Liveboards], + * accessDeniedMessage: 'You do not have permission to access this page.' + * }) + **/ + accessDeniedMessage?: string; + }; } /** @@ -6257,6 +6352,9 @@ export enum ErrorDetailsTypes { * }); * */ export enum EmbedErrorCodes { + /** Conflicting routes configuration detected (e.g., both allowedRoutes and blockedRoutes specified) */ + CONFLICTING_ROUTES_CONFIG = 'CONFLICTING_ROUTES_CONFIG', + /** Worksheet ID not found or does not exist */ WORKSHEET_ID_NOT_FOUND = 'WORKSHEET_ID_NOT_FOUND', @@ -6376,6 +6474,9 @@ export interface DefaultAppInitData { customActions: CustomAction[]; interceptTimeout: number | undefined; interceptUrls: (string | InterceptedApiType)[]; + allowedRoutes: ( NavigationPath | string)[]; + blockedRoutes: (NavigationPath | string)[]; + accessDeniedMessage: string; } /** @@ -6396,6 +6497,163 @@ export enum InterceptedApiType { LiveboardData = 'LiveboardData', } +/** + * Routes/paths within the ThoughtSpot embedded application that can be controlled + * for access restrictions. + * Use this enum with the `allowedRoutes` configuration + * and `blockedRoutes` configuration to restrict navigation + * which routes users can access in the embedded view. + * + * @example + * + * ```js + * const embed = new AppEmbed('#tsEmbed', { + * allowedRoutes: [Path.Home, Path.Search, Path.Liveboard], + * accessDeniedMessage: 'You do not have access to this page' + * }); + * ``` + * + * ```js + * const embed = new AppEmbed('#tsEmbed', { + * blockedRoutes: [Path.Home, Path.Search, Path.Liveboard], + * accessDeniedMessage: 'You do not have access to this page' + * }); + * ``` + * @version SDK: 1.45.0 | ThoughtSpot: 26.2.0.cl + */ +export enum NavigationPath { + All = '/*', + RootPage = '/', + // Core navigation methods + DataModelPage = '/data/*', + AdminPage = '/admin', + Home = '/home', + Answers = '/insights/answers', + Copilot = '/copilot', + CopilotChat = '/copilot/chat', + ConvAssist = '/insights/conv-assist', + TryEverywhere = '/everywhere', + Documents = '/insights/doc-search', + + // Home sub-pages + HomePage = '/insights/home', + HomeAnswers = '/insights/home/answers', + HomeLiveboards = '/insights/home/liveboards', + HomeFavs = '/insights/home/favourites', + HomeLiveboardSchedules = '/insights/home/liveboard-schedules', + HomeCreatedByMe = '/insights/home/created-by-me', + HomeMonitorAlerts = '/insights/home/monitor-alerts', + HomeSpotIQAnalysis = '/insights/home/spotiq-analysis', + + // Answer/Search related + Answer = '/insights/answer', + SavedAnswer = '/insights/saved-answer/:answerId', + View = '/insights/view/:answerId', + EditACopy = '/insights/answer/edit/:editACopySessionKey', + + // Eureka/AI related + EurekaWithQueryParams = '/insights/eureka', + CreateAiAnswerWithQueryParams = '/insights/create-ai-answer', + TrainSageWithQueryParams = '/data/sage/train', + AiAnswer = '/insights/ai-answer/:eurekaAnswerSessionId', + + // Pinboard/Liveboard + Pinboard = '/insights/pinboard/:pinboardId', + VizBoard = '/insights/pinboard/:pinboardId/:vizId', + + // Monitor + MonitorV2 = '/monitor', + + // Authentication + Login = '/login', + ResetPassword = '/resetpassword', + ForgotPassword = '/requestresetpassword', + DeepLinkPage = '/deeplink', + + // Insights/SpotIQ + Insights = '/insights', + Insight = '/insights/insight/:analysisResultId', + + // Data related + Table = '/data/tables', + Dataset = '/data/dataset', + DestinationSync = '/data/destination-sync', + Utilities = '/data/utilities', + Dbt = '/data/dbt', + Destination = '/data/destination', + SqlView = '/data/sql-view', + DataGovernance = '/data/data-governance', + LiveBoardVerification = '/data/liveboard-verification', + FragmentFeedback = '/data/fragment-feedback', + QueryFeedback = '/data/query-feedback', + AppConnections = '/data/app-connections', + WorksheetCreate = '/worksheet/create', + AutoWorksheetWithConnectionId = '/data/worksheet/create/auto', + csvUpload = '/data/importcsv', + EmbraceConnections = '/data/embrace/connection', + Embrace = '/data/embrace', + EmbraceCsvUploadWithDatasourceID = '/data/importcsv/loaddata', + + // Teams/Admin related + TeamsMembers = '/teams/members', + TeamsPendingInvitations = '/teams/pending-invitations', + TeamsPlanOptions = '/teams/plan-options', + TeamsSpotIQOptions = '/teams/spotiq', + TeamsStyleCustomizationOptions = '/teams/style-customization', + TeamsUserManagementOptions = '/teams/user-management', + TeamsAuthenticationOptions = '/teams/authentication', + TeamsSystemActivitiesOptions = '/teams/system-activities', + TeamsDataUsage = '/teams/data-usage', + TeamsManageSubscription = '/teams/manage-subscription', + + // Organizations + OrgsHome = '/orgs', + OrgsUsers = '/orgs/users', + OrgsGroups = '/orgs/groups', + OrgsContent = '/orgs/content', + OrgsCreditConsumption = '/orgs/credit-consumption', + + // Schedules + ManageSchedules = '/schedules', + + // Setup and Configuration + Setup = '/setup', + Actions = '/develop/*/actionsCustomization', + OnBoarding = '/onboarding', + UserPreference = '/user-preference', + CustomCalendarWizard = '/custom-calendar', + CustomCalendarRedirection = '/custom-calendar-test', + + // Development + DevelopTab = '/develop', + GetStarted = '/get-started', + + // TML/TSL related + ImportTsl = '/import-tsl/:metadataType', + TmlUtility = '/data/utilities/tml', + TslEditor = '/tsl-editor', + TslEditorSingleFile = '/tsl-editor/:metadataType/:metadataGuid', + UpdateTslFromFile = '/import-tsl/:metadataType/:metadataGuid', + + // SpotApps + SpotAppsAdmin = '/data/spotapps-admin', + SpotAppDetailsAndAnalytics = '/spotapp', + DbtIntegration = '/data/dbt-integration', + SchemaViewer = '/schema-viewer/table', + + // Other + Purchase = '/purchase', + AutoAnswer = '/answer/create/auto/:dataSourceId', + RequestAccessForObject = '/requestaccess/:objectType/:objectId', + + EmbedAccessDeniedPage = '/embed-access-denied', +} +export interface RouteBlocking { + blockedRoutes?: (NavigationPath | string)[]; + allowedRoutes?: (NavigationPath | string)[]; + accessDeniedMessage?: string; +} + export type ApiInterceptFlags = { /** * Flag that allows using `EmbedEvent.OnBeforeGetVizDataIntercept`. diff --git a/src/utils/allowed-or-blocked-routes.spec.ts b/src/utils/allowed-or-blocked-routes.spec.ts new file mode 100644 index 00000000..f1cfd443 --- /dev/null +++ b/src/utils/allowed-or-blocked-routes.spec.ts @@ -0,0 +1,723 @@ +import { NavigationPath } from '../types'; +import { + validateAndProcessRoutes, + generateComponentRoutes, + EmbedRouteConfig, +} from './allowed-or-blocked-routes'; + +describe('generateComponentRoutes', () => { + describe('LiveboardEmbed', () => { + it('should generate routes for liveboard with vizId', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + vizId: '789-def-012', + }; + + const result = generateComponentRoutes(config); + + expect(result).toContain('/embed/viz/123-abc-456/789-def-012'); + expect(result).toContain('/embed/viz/123-abc-456'); + expect(result).toContain('/insights/pinboard/123-abc-456'); + }); + + it('should generate routes for liveboard with activeTabId', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + activeTabId: 'tab-1', + }; + + const result = generateComponentRoutes(config); + + expect(result).toContain('/embed/viz/123-abc-456/tab/tab-1'); + expect(result).toContain('/embed/viz/123-abc-456'); + }); + + it('should return empty array when no liveboardId is provided', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + }; + + const result = generateComponentRoutes(config); + + expect(result).toEqual([]); + }); + }); + + describe('AppEmbed', () => { + it('should generate routes for app with specific path', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + path: 'pinboard/123-abc-456', + }; + + const result = generateComponentRoutes(config); + + expect(result).toContain('/pinboard/123-abc-456'); + expect(result).toContain('/pinboard/123-abc-456/*'); + }); + + it('should handle path with leading slash', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + path: '/pinboard/123-abc-456', + }; + + const result = generateComponentRoutes(config); + + expect(result).toContain('/pinboard/123-abc-456'); + expect(result).toContain('/pinboard/123-abc-456/*'); + }); + + it('should generate routes for home page', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + pageId: 'home', + }; + + const result = generateComponentRoutes(config); + + expect(result).toContain('/home'); + }); + + it('should generate routes for search page', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + pageId: 'search', + }; + + const result = generateComponentRoutes(config); + + expect(result).toContain('/insights/answer'); + }); + }); + + describe('Other embed types', () => { + it('should return generic paths for SageEmbed', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'SageEmbed', + }; + + const result = generateComponentRoutes(config); + + expect(result).toContain('/embed/eureka'); + expect(result).toContain('/eureka'); + }); + + it('should return generic paths for SearchEmbed', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'SearchEmbed', + }; + + const result = generateComponentRoutes(config); + + expect(result).toContain('/embed/answer'); + expect(result).toContain('/insights/answer'); + }); + }); +}); + +describe('validateAndProcessRoutes', () => { + describe('when both blockedRoutes and allowedRoutes are provided', () => { + it('should return an error', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.AdminPage], + allowedRoutes: [NavigationPath.Home], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result).toEqual({ + allowedRoutes: [], + blockedRoutes: [], + hasError: true, + errorMessage: + 'You cannot have both blockedRoutes and allowedRoutes set at the same time', + }); + }); + }); + + describe('when only allowedRoutes is provided', () => { + it('should return auto-generated routes merged with user allowedRoutes', () => { + const routeBlocking = { + allowedRoutes: [NavigationPath.Home, NavigationPath.Answers], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.allowedRoutes).toContain(NavigationPath.Home); + expect(result.allowedRoutes).toContain(NavigationPath.Answers); + expect(result.allowedRoutes).toContain('/embed/viz/123-abc-456'); + expect(result.allowedRoutes).toContain('/insights/pinboard/123-abc-456'); + expect(result.allowedRoutes).toContain(NavigationPath.Login); + expect(result.blockedRoutes).toEqual([]); + expect(result.hasError).toBe(false); + expect(result.errorMessage).toBe(''); + }); + + it('should handle single allowedRoute with auto-generation', () => { + const routeBlocking = { + allowedRoutes: [NavigationPath.Copilot], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + pageId: 'home', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.allowedRoutes).toContain(NavigationPath.Copilot); + expect(result.allowedRoutes).toContain('/home'); + expect(result.allowedRoutes).toContain(NavigationPath.Login); + expect(result.blockedRoutes).toEqual([]); + expect(result.hasError).toBe(false); + }); + + it('should only use user allowedRoutes when no auto-routes are generated', () => { + const routeBlocking = { + allowedRoutes: [NavigationPath.Home, NavigationPath.Answers], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.allowedRoutes).toEqual([ + NavigationPath.Home, + NavigationPath.Answers, + NavigationPath.Login, + NavigationPath.EmbedAccessDeniedPage, + ]); + expect(result.blockedRoutes).toEqual([]); + expect(result.hasError).toBe(false); + }); + + it('should handle accessDeniedMessage with allowedRoutes', () => { + const routeBlocking = { + allowedRoutes: [NavigationPath.Home], + accessDeniedMessage: 'Custom access denied message', + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.errorMessage).toBe('Custom access denied message'); + expect(result.hasError).toBe(false); + }); + }); + + describe('when only blockedRoutes is provided', () => { + it('should return empty allowedRoutes and blockedRoutes as-is when no conflicts', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.AdminPage], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.allowedRoutes).toEqual([]); + expect(result.blockedRoutes).toEqual([NavigationPath.AdminPage]); + expect(result.hasError).toBe(false); + expect(result.errorMessage).toBe(''); + }); + + it('should handle single blockedRoute', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.AdminPage], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.allowedRoutes).toEqual([]); + expect(result.blockedRoutes).toEqual([NavigationPath.AdminPage]); + expect(result.hasError).toBe(false); + }); + + it('should handle multiple blockedRoutes', () => { + const routeBlocking = { + blockedRoutes: [ + NavigationPath.AdminPage, + NavigationPath.ResetPassword, + NavigationPath.DataModelPage, + ], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.allowedRoutes).toEqual([]); + expect(result.blockedRoutes).toEqual(routeBlocking.blockedRoutes); + expect(result.hasError).toBe(false); + }); + + it('should handle accessDeniedMessage with blockedRoutes', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.AdminPage], + accessDeniedMessage: 'You do not have access to this route', + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.errorMessage).toBe('You do not have access to this route'); + expect(result.hasError).toBe(false); + }); + + describe('conflict detection with AppEmbed path', () => { + it('should return error when blockedRoute exactly matches auto-generated route', () => { + const routeBlocking = { + blockedRoutes: ['/pinboard/123-abc-456'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + path: 'pinboard/123-abc-456', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + expect(result.errorMessage).toBe( + 'You cannot block a route that is being embedded. The path specified in AppEmbed configuration conflicts with blockedRoutes.', + ); + expect(result.allowedRoutes).toEqual([]); + expect(result.blockedRoutes).toEqual([]); + }); + + it('should return error when blockedRoute matches wildcard auto-generated route', () => { + const routeBlocking = { + blockedRoutes: ['/pinboard/123-abc-456/*'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + path: 'pinboard/123-abc-456', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + expect(result.errorMessage).toBe( + 'You cannot block a route that is being embedded. The path specified in AppEmbed configuration conflicts with blockedRoutes.', + ); + }); + + it('should NOT return error when blockedRoute does not conflict with path', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.AdminPage, '/different/route'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + path: 'pinboard/123-abc-456', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(false); + expect(result.blockedRoutes).toEqual(routeBlocking.blockedRoutes); + expect(result.allowedRoutes).toEqual([]); + }); + }); + + describe('conflict detection with AppEmbed pageId', () => { + it('should return error when blockedRoute conflicts with pageId home', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.Home], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + pageId: 'home', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + expect(result.errorMessage).toBe( + 'You cannot block a route that is being embedded. The path specified in AppEmbed configuration conflicts with blockedRoutes.', + ); + }); + + it('should return error when blockedRoute conflicts with pageId answers', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.Answers], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + pageId: 'answers', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + }); + + it('should return error when blockedRoute conflicts with any of multiple pageId routes', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.HomeAnswers], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + pageId: 'answers', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + }); + + it('should NOT return error when blockedRoute does not conflict with pageId', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.AdminPage, NavigationPath.DataModelPage], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + pageId: 'home', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(false); + expect(result.blockedRoutes).toEqual(routeBlocking.blockedRoutes); + }); + }); + + describe('conflict detection with AppEmbed without config', () => { + it('should NOT return error when AppEmbed has no path or pageId', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.AdminPage, NavigationPath.Home], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(false); + expect(result.blockedRoutes).toEqual(routeBlocking.blockedRoutes); + expect(result.allowedRoutes).toEqual([]); + }); + }); + + describe('conflict detection with LiveboardEmbed', () => { + it('should return error when blockedRoute matches liveboard route', () => { + const routeBlocking = { + blockedRoutes: ['/embed/viz/123-abc-456'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + expect(result.errorMessage).toBe( + 'You cannot block a route that is being embedded. The path specified in AppEmbed configuration conflicts with blockedRoutes.', + ); + }); + + it('should return error when blockedRoute matches pinboard route', () => { + const routeBlocking = { + blockedRoutes: ['/insights/pinboard/123-abc-456'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + }); + + it('should return error when blockedRoute matches viz with activeTabId', () => { + const routeBlocking = { + blockedRoutes: ['/embed/viz/123-abc-456/tab/tab-1'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + activeTabId: 'tab-1', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + }); + + it('should return error when blockedRoute matches specific vizId route', () => { + const routeBlocking = { + blockedRoutes: ['/embed/viz/123-abc-456/789-def-012'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + vizId: '789-def-012', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + }); + + it('should NOT return error when blockedRoute does not conflict with liveboard', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.AdminPage, '/embed/viz/different-id'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(false); + expect(result.blockedRoutes).toEqual(routeBlocking.blockedRoutes); + }); + + it('should NOT return error when LiveboardEmbed has no liveboardId', () => { + const routeBlocking = { + blockedRoutes: ['/embed/viz/123-abc-456'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(false); + expect(result.blockedRoutes).toEqual(routeBlocking.blockedRoutes); + }); + }); + + describe('conflict detection with other embed types', () => { + it('should NOT return error for SageEmbed with blockedRoutes', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.AdminPage], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'SageEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(false); + expect(result.blockedRoutes).toEqual(routeBlocking.blockedRoutes); + }); + + it('should NOT return error for SearchEmbed with blockedRoutes', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.AdminPage], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'SearchEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(false); + expect(result.blockedRoutes).toEqual(routeBlocking.blockedRoutes); + }); + }); + + describe('conflict detection edge cases', () => { + it('should handle multiple blockedRoutes where only one conflicts', () => { + const routeBlocking = { + blockedRoutes: [ + NavigationPath.AdminPage, + '/pinboard/123-abc-456', + NavigationPath.Login, + ], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + path: 'pinboard/123-abc-456', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + }); + + it('should handle empty blockedRoutes array', () => { + const routeBlocking = { + blockedRoutes: [] as string[], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + path: 'pinboard/123', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(false); + expect(result.blockedRoutes).toEqual([]); + }); + }); + }); + + describe('when neither blockedRoutes nor allowedRoutes is provided', () => { + it('should return empty arrays', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc-456', + }; + + const result = validateAndProcessRoutes(undefined, config); + + expect(result.allowedRoutes).toEqual([]); + expect(result.blockedRoutes).toEqual([]); + expect(result.hasError).toBe(false); + expect(result.errorMessage).toBe(''); + }); + + it('should return empty arrays when routeBlocking is empty object', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + pageId: 'home', + }; + + const result = validateAndProcessRoutes({}, config); + + expect(result.allowedRoutes).toEqual([]); + expect(result.blockedRoutes).toEqual([]); + expect(result.hasError).toBe(false); + expect(result.errorMessage).toBe(''); + }); + + it('should return empty arrays for AppEmbed without config', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + }; + + const result = validateAndProcessRoutes(undefined, config); + + expect(result.allowedRoutes).toEqual([]); + expect(result.blockedRoutes).toEqual([]); + expect(result.hasError).toBe(false); + }); + + it('should handle only accessDeniedMessage without routes', () => { + const routeBlocking = { + accessDeniedMessage: 'Access denied', + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.allowedRoutes).toEqual([]); + expect(result.blockedRoutes).toEqual([]); + expect(result.hasError).toBe(false); + expect(result.errorMessage).toBe('Access denied'); + }); + }); + + describe('real-world usage scenarios', () => { + it('should handle LiveboardEmbed with user-provided allowedRoutes', () => { + const routeBlocking = { + allowedRoutes: ['/custom/route'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '33248a57-cc70-4e39-9199-fb5092283381', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.allowedRoutes[0]).toBe('/embed/viz/33248a57-cc70-4e39-9199-fb5092283381'); + expect(result.allowedRoutes).toContain('/custom/route'); + expect(result.allowedRoutes).toContain(NavigationPath.Login); + expect(result.hasError).toBe(false); + }); + + it('should handle viz embed with specific vizId', () => { + const routeBlocking = { + allowedRoutes: ['/custom/page'], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '33248a57-cc70-4e39-9199-fb5092283381', + vizId: '730496d6-6903-4601-937e-2c691821af3c', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.allowedRoutes).toContain( + '/embed/viz/33248a57-cc70-4e39-9199-fb5092283381/730496d6-6903-4601-937e-2c691821af3c', + ); + expect(result.allowedRoutes).toContain('/custom/page'); + expect(result.allowedRoutes).toContain(NavigationPath.Login); + }); + + it('should handle AppEmbed with pageId', () => { + const routeBlocking = { + allowedRoutes: [NavigationPath.Copilot], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'AppEmbed', + pageId: 'home', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.allowedRoutes).toContain('/home'); + expect(result.allowedRoutes).toContain(NavigationPath.Copilot); + expect(result.allowedRoutes).toContain(NavigationPath.Login); + }); + + it('should test auto-route generation is working but not used without user routes', () => { + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + liveboardId: '123-abc', + }; + + const autoRoutes = generateComponentRoutes(config); + expect(autoRoutes.length).toBeGreaterThan(0); + + const result = validateAndProcessRoutes(undefined, config); + expect(result.allowedRoutes).toEqual([]); + }); + }); + + describe('error cases', () => { + it('should return error when blockedRoutes is having login or embed access denied page', () => { + const routeBlocking = { + blockedRoutes: [NavigationPath.Login, NavigationPath.EmbedAccessDeniedPage], + }; + const config: EmbedRouteConfig = { + embedComponentType: 'LiveboardEmbed', + }; + + const result = validateAndProcessRoutes(routeBlocking, config); + + expect(result.hasError).toBe(true); + expect(result.errorMessage).toBe( + 'You cannot block the login or embed access denied page', + ); + expect(result.allowedRoutes).toEqual([]); + expect(result.blockedRoutes).toEqual([]); + }); + }); +}); diff --git a/src/utils/allowed-or-blocked-routes.ts b/src/utils/allowed-or-blocked-routes.ts new file mode 100644 index 00000000..46d04e15 --- /dev/null +++ b/src/utils/allowed-or-blocked-routes.ts @@ -0,0 +1,254 @@ +import { ERROR_MESSAGE } from '../errors'; +import { NavigationPath, RouteBlocking } from '../types'; + +/** + * Configuration for generating component-specific routes + */ +export interface EmbedRouteConfig { + embedComponentType: string; + liveboardId?: string; + vizId?: string; + activeTabId?: string; + pageId?: string; + path?: string; +} + +/** + * Result of route validation and processing + */ +export interface RouteValidationResult { + allowedRoutes: (NavigationPath | string)[]; + blockedRoutes: (NavigationPath | string)[]; + hasError: boolean; + errorMessage: string; +} + +/** + * Default routes for each embed component type + * AppEmbed and LiveboardEmbed have dynamic routes, so they're empty here + */ +const COMPONENT_DEFAULT_ROUTES: Record = { + AppEmbed: [], // Matches all paths - routes generated dynamically + 'bodyless-conversation': ['/embed/conv-assist-answer', '/conv-assist-answer'], + conversation: ['/embed/insights/conv-assist', '/insights/conv-assist'], + LiveboardEmbed: [], // Routes generated dynamically based on liveboard configuration + SageEmbed: ['/embed/eureka', '/eureka'], + SearchBarEmbed: ['/embed/search-bar-embed', '/search-bar-embed'], + SearchEmbed: ['/embed/answer', '/insights/answer'], +}; + +/** + * Maps page IDs to their corresponding navigation paths + */ +const PAGE_ID_TO_ROUTES: Record = { + answers: [NavigationPath.Answers, NavigationPath.HomeAnswers], + data: [NavigationPath.DataModelPage], + home: [NavigationPath.Home, NavigationPath.RootPage, NavigationPath.HomePage], + liveboards: [NavigationPath.HomeLiveboards], + monitor: [NavigationPath.HomeMonitorAlerts], + pinboards: [NavigationPath.HomeLiveboards], + search: [NavigationPath.Answer], + spotiq: [NavigationPath.HomeSpotIQAnalysis, NavigationPath.Insights], +}; + +/** + * Routes that must never be blocked for embed functionality to work + */ +const PROTECTED_ROUTES = [NavigationPath.Login, NavigationPath.EmbedAccessDeniedPage]; + +/** + * Generates LiveboardEmbed-specific routes based on configuration + */ +const generateLiveboardRoutes = (config: EmbedRouteConfig): string[] => { + const { liveboardId, vizId, activeTabId } = config; + const routes: string[] = []; + + if (!liveboardId) { + return routes; + } + + // Base liveboard routes + routes.push(`/embed/viz/${liveboardId}`); + routes.push(`/insights/pinboard/${liveboardId}`); + routes.push(`/embed/insights/viz/${liveboardId}`); + + // Visualization-specific routes + if (vizId) { + const vizRoute = activeTabId + ? `/embed/viz/${liveboardId}/tab/${activeTabId}/${vizId}` + : `/embed/viz/${liveboardId}/${vizId}`; + routes.push(vizRoute); + } else if (activeTabId) { + // Tab route without specific visualization + routes.push(`/embed/viz/${liveboardId}/tab/${activeTabId}`); + } + + return routes; +}; + +/** + * Generates AppEmbed-specific routes based on configuration + */ +const generateAppEmbedRoutes = (config: EmbedRouteConfig): string[] => { + const { path, pageId } = config; + const routes: string[] = []; + + if (path) { + const normalizedPath = path.startsWith('/') ? path.substring(1) : path; + routes.push(`/${normalizedPath}`); + routes.push(`/${normalizedPath}/*`); + } else if (pageId) { + const pageRoutes = getRoutesByPageId(pageId); + routes.push(...pageRoutes); + } + + return routes; +}; + +/** + * Retrieves navigation routes for a specific page ID + */ +const getRoutesByPageId = (pageId: string): string[] => { + return PAGE_ID_TO_ROUTES[pageId] || []; +}; + +/** + * Normalizes a route by removing trailing wildcard + */ +const normalizeRoute = (route: string): string => { + return route.replace(/\/\*$/, ''); +}; + +/** + * Checks if two routes conflict with each other + * Routes conflict if one is a prefix of the other or they are identical + */ +const areRoutesConflicting = (route1: string, route2: string): boolean => { + const normalized1 = normalizeRoute(route1); + const normalized2 = normalizeRoute(route2); + + return ( + normalized1 === normalized2 || + normalized1.startsWith(normalized2 + '/') || + normalized2.startsWith(normalized1 + '/') + ); +}; + +/** + * Checks if any blocked route conflicts with protected or auto-allowed routes + */ +const hasRouteConflict = ( + blockedRoutes: (NavigationPath | string)[], + protectedRoutes: string[], +): boolean => { + return blockedRoutes.some((blockedRoute) => + protectedRoutes.some((protectedRoute) => + areRoutesConflicting(blockedRoute, protectedRoute), + ), + ); +}; + +/** + * Filters out null and undefined values from route arrays + */ +const sanitizeRoutes = (routes: (NavigationPath | string)[]): (NavigationPath | string)[] => { + return routes.filter((route) => route !== undefined && route !== null); +}; + +/** + * Generate component-specific allowed routes based on embed configuration + */ +export const generateComponentRoutes = (config: EmbedRouteConfig): string[] => { + const { embedComponentType } = config; + + switch (embedComponentType) { + case 'LiveboardEmbed': + return generateLiveboardRoutes(config); + + case 'AppEmbed': + return generateAppEmbedRoutes(config); + + default: + return COMPONENT_DEFAULT_ROUTES[embedComponentType] || []; + } +}; + +/** + * Validates and processes route blocking configuration + * Returns allowed and blocked routes along with any validation errors + */ +export const validateAndProcessRoutes = ( + routeBlocking?: RouteBlocking, + embedConfig?: EmbedRouteConfig, +): RouteValidationResult => { + const defaultResult: RouteValidationResult = { + allowedRoutes: [], + blockedRoutes: [], + hasError: false, + errorMessage: routeBlocking?.accessDeniedMessage || '', + }; + + // No route blocking configured + if (!routeBlocking) { + return defaultResult; + } + + const { blockedRoutes, allowedRoutes, accessDeniedMessage = '' } = routeBlocking; + + // Validation: Cannot have both blocked and allowed routes + if (blockedRoutes && allowedRoutes) { + return { + allowedRoutes: [], + blockedRoutes: [], + hasError: true, + errorMessage: ERROR_MESSAGE.CONFLICTING_ROUTES_CONFIG, + }; + } + + const componentRoutes = generateComponentRoutes(embedConfig); + + // Process allowed routes + if (allowedRoutes) { + const sanitizedAllowedRoutes = sanitizeRoutes(allowedRoutes); + return { + allowedRoutes: [...componentRoutes, ...sanitizedAllowedRoutes, ...PROTECTED_ROUTES], + blockedRoutes: [], + hasError: false, + errorMessage: accessDeniedMessage, + }; + } + + // Process blocked routes + if (blockedRoutes) { + const sanitizedBlockedRoutes = sanitizeRoutes(blockedRoutes); + + // Validation: Cannot block protected routes (login, access denied page) + if (hasRouteConflict(sanitizedBlockedRoutes, PROTECTED_ROUTES)) { + return { + allowedRoutes: [], + blockedRoutes: [], + hasError: true, + errorMessage: ERROR_MESSAGE.BLOCKING_PROTECTED_ROUTES, + }; + } + + // Validation: Cannot block routes required by the embed component + if (hasRouteConflict(sanitizedBlockedRoutes, componentRoutes)) { + return { + allowedRoutes: [], + blockedRoutes: [], + hasError: true, + errorMessage: ERROR_MESSAGE.BLOCKING_COMPONENT_ROUTES, + }; + } + + return { + allowedRoutes: [], + blockedRoutes: sanitizedBlockedRoutes, + hasError: false, + errorMessage: accessDeniedMessage, + }; + } + + return defaultResult; +};