Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
985409a
SCAL-239528 Implement restricting navigation for blocked embed routes
shivam-kumar-ts Nov 7, 2025
1866d6c
SCAL-239528 added exported types from sdk
shivam-kumar-ts Nov 7, 2025
b17b828
SCAL-239528 added blockedRoutes also
shivam-kumar-ts Nov 8, 2025
6e82f26
SCAL-239528 Refactor getBlockedAndAllowedRoutes to remove error suffi…
shivam-kumar-ts Nov 8, 2025
db02871
SCAL-239528 Refactor Path enum to NavigationPath and app paths
shivam-kumar-ts Nov 8, 2025
54f0340
SCAL-239528 added test for routes
shivam-kumar-ts Nov 8, 2025
a5c0c53
SCAL-239528 added embedComponentType in app
shivam-kumar-ts Nov 8, 2025
2ae76f3
SCAL-239528 implement auto-generation of allowed routes and conflict …
shivam-kumar-ts Nov 10, 2025
8d72b9f
SCAL-239528 add test and refactor hasConflictingBlockedRoute to impro…
shivam-kumar-ts Nov 10, 2025
152cb18
SCAL-239528 updated the embed types to routeBlocking object
shivam-kumar-ts Nov 10, 2025
a59bd31
SCAL-239528 Added EmbedAccessDeniedPage
shivam-kumar-ts Nov 11, 2025
ae1a09e
SCAL-239528 refactor getBlockedAndAllowedRoutes to filter out empty r…
shivam-kumar-ts Nov 11, 2025
ae099c4
SCAL-239528 Refactor getPageRoutes to defaultWhiteListedPaths and upd…
shivam-kumar-ts Nov 11, 2025
ddc5d47
SCAL-239528 Refactor route blocking and generation logic to improve e…
shivam-kumar-ts Nov 11, 2025
60321f1
SCAL-239528 Update size limit for tsembed.es.js to 40 kB
shivam-kumar-ts Nov 12, 2025
4509300
SCAL-239528 rebase the code
shivam-kumar-ts Dec 9, 2025
035769b
SCAL-239528 recerted docgen
shivam-kumar-ts Dec 9, 2025
dc83a6c
SCAL-239528 Enhance embed configuration validation with error codes
shivam-kumar-ts Dec 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"size-limit": [
{
"path": "dist/tsembed.es.js",
"limit": "32 kB"
"limit": "40 kB"
}
],
"scripts": {
Expand Down
270 changes: 270 additions & 0 deletions src/embed/ts-embed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
DefaultAppInitData,
ErrorDetailsTypes,
EmbedErrorCodes,
NavigationPath,
} from '../types';
import {
executeAfterWait,
Expand Down Expand Up @@ -130,6 +131,9 @@ const getMockAppInitPayload = (data: any) => {
customVariablesForThirdPartyTools,
interceptTimeout: undefined,
interceptUrls: [],
allowedRoutes: [],
blockedRoutes: [],
accessDeniedMessage: '',
};
return {
type: EmbedEvent.APP_INIT,
Expand Down Expand Up @@ -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('');
});
});
});
});


Expand Down
24 changes: 24 additions & 0 deletions src/embed/ts-embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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),
};

Expand Down
3 changes: 3 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ import {
CustomActionsPosition,
CustomActionTarget,
InterceptedApiType,
NavigationPath,
RouteBlocking,
} from './types';
import { CustomCssVariables } from './css-variables';
import { SageEmbed, SageViewConfig } from './embed/sage';
Expand Down Expand Up @@ -156,6 +158,8 @@ export {
CustomActionsPosition,
CustomActionTarget,
InterceptedApiType,
NavigationPath,
RouteBlocking,
};

export { resetCachedAuthToken } from './authToken';
Loading
Loading