From a49be2b67b7eb8e3535647a94960f59396c70a0b Mon Sep 17 00:00:00 2001 From: GuyP <105154237+guyp-descope@users.noreply.github.com> Date: Mon, 2 Dec 2024 18:02:29 +0200 Subject: [PATCH] feat: Css vars (#853) Related to https://github.com/descope/etc/issues/7890 --------- Co-authored-by: Nir Gur Arie Co-authored-by: nirgur --- packages/sdks/core-js-sdk/src/sdk/types.ts | 1 + .../web-component/src/lib/constants/index.ts | 1 + .../src/lib/descope-wc/BaseDescopeWc.ts | 2 + .../src/lib/descope-wc/DescopeWc.ts | 7 +++ .../web-component/src/lib/helpers/helpers.ts | 15 ++++++ .../src/lib/helpers/templates.ts | 54 ++++++++++++++++++- packages/sdks/web-component/src/lib/types.ts | 3 ++ .../web-component/test/descope-wc.test.ts | 37 +++++++++++++ packages/sdks/web-js-sdk/src/sdk/flow.ts | 1 + 9 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/sdks/core-js-sdk/src/sdk/types.ts b/packages/sdks/core-js-sdk/src/sdk/types.ts index ad2bf748e..9fcca599f 100644 --- a/packages/sdks/core-js-sdk/src/sdk/types.ts +++ b/packages/sdks/core-js-sdk/src/sdk/types.ts @@ -326,6 +326,7 @@ export type Options = { samlIdpStateId?: string; samlIdpUsername?: string; ssoAppId?: string; + thirdPartyAppId?: string; oidcLoginHint?: string; abTestingKey?: number; startOptionsVersion?: number; diff --git a/packages/sdks/web-component/src/lib/constants/index.ts b/packages/sdks/web-component/src/lib/constants/index.ts index ad75d361c..8c9d12e10 100644 --- a/packages/sdks/web-component/src/lib/constants/index.ts +++ b/packages/sdks/web-component/src/lib/constants/index.ts @@ -22,6 +22,7 @@ export const SAML_IDP_STATE_ID_PARAM_NAME = 'saml_idp_state_id'; export const SAML_IDP_USERNAME_PARAM_NAME = 'saml_idp_username'; export const DESCOPE_IDP_INITIATED_PARAM_NAME = 'descope_idp_initiated'; export const SSO_APP_ID_PARAM_NAME = 'sso_app_id'; +export const THIRD_PARTY_APP_ID_PARAM_NAME = 'third_party_app_id'; export const OIDC_LOGIN_HINT_PARAM_NAME = 'oidc_login_hint'; export const OIDC_PROMPT_PARAM_NAME = 'oidc_prompt'; export const OIDC_ERROR_REDIRECT_URI_PARAM_NAME = 'oidc_error_redirect_uri'; diff --git a/packages/sdks/web-component/src/lib/descope-wc/BaseDescopeWc.ts b/packages/sdks/web-component/src/lib/descope-wc/BaseDescopeWc.ts index 2906da497..2f25b61cc 100644 --- a/packages/sdks/web-component/src/lib/descope-wc/BaseDescopeWc.ts +++ b/packages/sdks/web-component/src/lib/descope-wc/BaseDescopeWc.ts @@ -506,6 +506,7 @@ class BaseDescopeWc extends BaseClass { redirectAuthBackupCallbackUri, redirectAuthCodeChallenge, redirectAuthInitiator, + thirdPartyAppId, ssoQueryParams, } = handleUrlParams(); @@ -537,6 +538,7 @@ class BaseDescopeWc extends BaseClass { redirectAuthBackupCallbackUri, redirectAuthCodeChallenge, redirectAuthInitiator, + thirdPartyAppId, ...ssoQueryParams, }); diff --git a/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts b/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts index 8aba609f9..23d408689 100644 --- a/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts +++ b/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts @@ -42,6 +42,7 @@ import { getABTestingKey } from '../helpers/abTestingKey'; import { IsChanged } from '../helpers/state'; import { disableWebauthnButtons, + setCssVars, setNOTPVariable, setPhoneAutoDetectDefaultCode, } from '../helpers/templates'; @@ -298,6 +299,7 @@ class DescopeWc extends BaseDescopeWc { samlIdpResponseRelayState, nativeResponseType, nativePayload, + thirdPartyAppId, ...ssoQueryParams } = currentState; @@ -368,6 +370,7 @@ class DescopeWc extends BaseDescopeWc { { tenant, redirectAuth, + thirdPartyAppId, ...ssoQueryParams, client: this.client, ...(redirectUrl && { redirectUrl }), @@ -616,6 +619,7 @@ class DescopeWc extends BaseDescopeWc { flowId, { tenant, + thirdPartyAppId, redirectAuth, ...ssoQueryParams, lastAuth, @@ -1014,6 +1018,9 @@ class DescopeWc extends BaseDescopeWc { setNOTPVariable(rootElement, screenState?.notp?.image); + // set dynamic css variables that should be set at runtime + setCssVars(rootElement, clone, screenState.cssVars, this.loggerWrapper); + this.rootElement.replaceChildren(clone); // If before html url was empty, we deduce its the first time a screen is shown diff --git a/packages/sdks/web-component/src/lib/helpers/helpers.ts b/packages/sdks/web-component/src/lib/helpers/helpers.ts index cc1b8b64e..989ca2947 100644 --- a/packages/sdks/web-component/src/lib/helpers/helpers.ts +++ b/packages/sdks/web-component/src/lib/helpers/helpers.ts @@ -19,6 +19,7 @@ import { OVERRIDE_CONTENT_URL, OIDC_PROMPT_PARAM_NAME, OIDC_ERROR_REDIRECT_URI_PARAM_NAME, + THIRD_PARTY_APP_ID_PARAM_NAME, } from '../constants'; import { AutoFocusOptions, Direction, Locale, SSOQueryParams } from '../types'; @@ -207,10 +208,18 @@ export function getSSOAppIdParamFromUrl() { return getUrlParam(SSO_APP_ID_PARAM_NAME); } +export function getThirdPartyAppIdParamFromUrl() { + return getUrlParam(THIRD_PARTY_APP_ID_PARAM_NAME); +} + export function clearSSOAppIdParamFromUrl() { resetUrlParam(SSO_APP_ID_PARAM_NAME); } +export function clearThirdPartyAppIdParamFromUrl() { + resetUrlParam(THIRD_PARTY_APP_ID_PARAM_NAME); +} + export function getOIDCLoginHintParamFromUrl() { return getUrlParam(OIDC_LOGIN_HINT_PARAM_NAME); } @@ -312,6 +321,11 @@ export const handleUrlParams = () => { clearSSOAppIdParamFromUrl(); } + const thirdPartyAppId = getThirdPartyAppIdParamFromUrl(); + if (thirdPartyAppId) { + clearThirdPartyAppIdParamFromUrl(); + } + const oidcLoginHint = getOIDCLoginHintParamFromUrl(); if (oidcLoginHint) { clearOIDCLoginHintParamFromUrl(); @@ -339,6 +353,7 @@ export const handleUrlParams = () => { redirectAuthCallbackUrl, redirectAuthBackupCallbackUri, redirectAuthInitiator, + thirdPartyAppId, ssoQueryParams: { oidcIdpStateId, samlIdpStateId, diff --git a/packages/sdks/web-component/src/lib/helpers/templates.ts b/packages/sdks/web-component/src/lib/helpers/templates.ts index 5f60720d7..a64b78c4c 100644 --- a/packages/sdks/web-component/src/lib/helpers/templates.ts +++ b/packages/sdks/web-component/src/lib/helpers/templates.ts @@ -4,7 +4,7 @@ import { DESCOPE_ATTRIBUTE_EXCLUDE_FIELD, HAS_DYNAMIC_VALUES_ATTR_NAME, } from '../constants'; -import { ComponentsConfig, ScreenState } from '../types'; +import { ComponentsConfig, CssVars, ScreenState } from '../types'; import { shouldHandleMarkdown } from './helpers'; const ALLOWED_INPUT_CONFIG_ATTRS = ['disabled']; @@ -141,6 +141,58 @@ const setFormConfigValues = ( }); }; +export const setCssVars = ( + rootEle: HTMLElement, + nextPageTemplate: DocumentFragment, + cssVars: CssVars, + logger: { + error: (message: string, description: string) => void; + info: (message: string, description: string) => void; + debug: (message: string, description: string) => void; + }, +) => { + if (!cssVars) { + return; + } + + Object.keys(cssVars).forEach((componentName) => { + if (!nextPageTemplate.querySelector(componentName)) { + logger.debug( + `Skipping css vars for component "${componentName}}"`, + `Got css vars for component ${componentName} but Could not find it on next page`, + ); + } + const componentClass: + | (CustomElementConstructor & { cssVarList: CssVars }) + | undefined = customElements.get(componentName) as any; + + if (!componentClass) { + logger.info( + `Could not find component class for ${componentName}`, + 'Check if the component is registered', + ); + return; + } + + Object.keys(cssVars[componentName]).forEach((cssVarKey) => { + const componentCssVars = cssVars[componentName]; + const varName = componentClass?.cssVarList?.[cssVarKey]; + + if (!varName) { + logger.info( + `Could not find css variable name for ${cssVarKey} in ${componentName}`, + 'Check if the css variable is defined in the component', + ); + return; + } + + const value = componentCssVars[cssVarKey]; + + rootEle.style.setProperty(varName, value); + }); + }); +}; + const setElementConfig = ( baseEle: DocumentFragment, componentsConfig: ComponentsConfig, diff --git a/packages/sdks/web-component/src/lib/types.ts b/packages/sdks/web-component/src/lib/types.ts index 31ab8d1c3..c9a8379d9 100644 --- a/packages/sdks/web-component/src/lib/types.ts +++ b/packages/sdks/web-component/src/lib/types.ts @@ -8,6 +8,7 @@ export type Sdk = ReturnType; export type SdkFlowNext = Sdk['flow']['next']; export type ComponentsConfig = Record; +export type CssVars = Record; type OmitFirstArg = F extends (x: any, ...args: infer P) => infer R ? (...args: P) => R @@ -27,6 +28,7 @@ export interface ScreenState { errorText?: string; errorType?: string; componentsConfig?: ComponentsConfig; + cssVars?: CssVars; form?: Record; inputs?: Record; // Backward compatibility lastAuth?: LastAuthState; @@ -81,6 +83,7 @@ export type FlowState = { samlIdpResponseSamlResponse: string; samlIdpResponseRelayState: string; nativeResponseType: string; + thirdPartyAppId: string; nativePayload: Record; } & SSOQueryParams; diff --git a/packages/sdks/web-component/test/descope-wc.test.ts b/packages/sdks/web-component/test/descope-wc.test.ts index 812aadc9a..4b4566fac 100644 --- a/packages/sdks/web-component/test/descope-wc.test.ts +++ b/packages/sdks/web-component/test/descope-wc.test.ts @@ -72,6 +72,8 @@ const defaultOptionsValues = { redirectAuth: undefined, tenant: undefined, locale: 'en-us', + nativeOptions: undefined, + thirdPartyAppId: null, }; class MockFileReader { @@ -4528,6 +4530,41 @@ describe('web-component', () => { }); }); + describe('cssVars', () => { + it('should set css vars on root element', async () => { + const spyGet = jest.spyOn(customElements, 'get'); + spyGet.mockReturnValue({ cssVarList: { varName: '--var-name' } } as any); + + startMock.mockReturnValueOnce( + generateSdkResponse({ + screenState: { + cssVars: { 'descope-button': { varName: 'value' } }, + }, + }), + ); + + pageContent = `click
Loaded
`; + + document.body.innerHTML = `

Custom element test

`; + + await waitFor(() => screen.getByShadowText('Loaded'), { + timeout: WAIT_TIMEOUT, + }); + + const shadowEle = + document.getElementsByTagName('descope-wc')[0].shadowRoot; + const rootEle = shadowEle.querySelector('#root'); + + await waitFor( + () => + expect(rootEle).toHaveStyle({ + '--var-name': 'value', + }), + { timeout: WAIT_TIMEOUT }, + ); + }); + }); + describe('Input Flows', () => { it('should pre-populate input with flat structure config structure', async () => { startMock.mockReturnValueOnce(generateSdkResponse()); diff --git a/packages/sdks/web-js-sdk/src/sdk/flow.ts b/packages/sdks/web-js-sdk/src/sdk/flow.ts index 1a10f1e76..995d0a157 100644 --- a/packages/sdks/web-js-sdk/src/sdk/flow.ts +++ b/packages/sdks/web-js-sdk/src/sdk/flow.ts @@ -11,6 +11,7 @@ type Options = Pick< | 'samlIdpStateId' | 'samlIdpUsername' | 'ssoAppId' + | 'thirdPartyAppId' | 'oidcLoginHint' | 'preview' | 'abTestingKey'