Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ DESCOPE_TENANT_ID= # Your Descope Tenant ID
DESCOPE_FLOW_ID="sign-up-or-in" # Your Descope flow ID
DESCOPE_STYLE_ID= # Your Descope Style ID
DESCOPE_FLOW_DEBUG= # Set to true in case you want to debug your flow
DESCOPE_BG= # Optional page background color or https:// image URL
DESCOPE_FLOW_LOADING= # Set to true to show a loading spinner while the flow initializes
DESCOPE_LOADING_COLOR= # Optional loading spinner color (defaults to bg color, then #0082b5)
REACT_APP_DESCOPE_BASE_URL= # Descope API base URL
REACT_APP_USE_ORIGIN_BASE_URL= # Set in case you want to use the origin as
REACT_APP_FAVICON_URL= # Set in case you want to use a custom favicon
Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,25 @@ These are the different query parameters you can use:
5. `bg` query parameter is optional. If you wish to use a different background color or URL, you can use this parameter.
- **Color name**: You can use a [web color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value), e.g. `bg=red`, `bg=%23ff0000`. Note that some symbols such as `#` will have to be URL encoded.
- **Image URL**: You can specify a URL to an image such as `https://example.com/background.png`. This image will be sized to cover the screen.
- When `bg` is a color, the flow loading spinner uses the same color by default.

6. `wide` query parameter is optional. If wide mode is nedded use `wide=true`. This will widen the flow component that is rendered, which is used for large forms made with Flow screens.
6. `loading` query parameter is optional. By default, no loading spinner is shown. Set `loading=true` to show a spinner while the flow initializes and during redirect steps (for example, when the first step is SSO).

7. `theme` query parameter is optional. The default value is `light`, but otherwise it will override the theme for your flows rendered with the SDK.
7. `loading_color` query parameter is optional. Sets the loading spinner color using the same formats as `bg` (including bare hex, e.g. `loading_color=ffffff`). Only applies when `loading=true`. If omitted, the spinner uses `bg` when it is a color; otherwise it defaults to `#0082b5`.

8. `style` query parameter is optional. The default style in your project will be used if not defined, but this allows you to override the `style` for the flows rendered with the SDK.
8. `wide` query parameter is optional. If wide mode is nedded use `wide=true`. This will widen the flow component that is rendered, which is used for large forms made with Flow screens.

9. `store_last_auth_user` query parameter is optional. Pass this parameter to ensure the last authenticated user is not saved when the flow ends. For example, append `store_last_auth_user=false` to the URL to disable saving the last user.
9. `theme` query parameter is optional. The default value is `light`, but otherwise it will override the theme for your flows rendered with the SDK.

10. Additional query parameters prefixed with `client.` are passed to the `Descope` component as its `client` prop. For example: `client.k1=v1&client.k2=v2` becomes `{ k1: 'v1', k2: 'v2' }`.
10. `style` query parameter is optional. The default style in your project will be used if not defined, but this allows you to override the `style` for the flows rendered with the SDK.

11. `width` & `height` are optional query parameters, controlling the sizing of the flow screen in either pixels or a percentage of the viewport (e.g. `50%`, `1200px`). Any value larger than the screen is clamped down.
11. `store_last_auth_user` query parameter is optional. Pass this parameter to ensure the last authenticated user is not saved when the flow ends. For example, append `store_last_auth_user=false` to the URL to disable saving the last user.

12. `title` query parameter is optional. If provided, it sets the browser tab/document title (e.g. `title=Sign%20in`).
12. Additional query parameters prefixed with `client.` are passed to the `Descope` component as its `client` prop. For example: `client.k1=v1&client.k2=v2` becomes `{ k1: 'v1', k2: 'v2' }`.

13. `width` & `height` are optional query parameters, controlling the sizing of the flow screen in either pixels or a percentage of the viewport (e.g. `50%`, `1200px`). Any value larger than the screen is clamped down.

14. `title` query parameter is optional. If provided, it sets the browser tab/document title (e.g. `title=Sign%20in`).

**Using .env**

Expand Down
27 changes: 27 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,33 @@ body,
display: inline-block;
}

.flow-loading-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(2px);
}

.flow-loading-spinner {
width: 44px;
height: 44px;
border-radius: 50%;
border: 3px solid
color-mix(in srgb, var(--flow-loading-color, #0082b5) 20%, transparent);
border-top-color: var(--flow-loading-color, #0082b5);
Comment on lines +106 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick fix

Keep the spinner visible when the color value is unusable

Putting color-mix() with a var() reference in the border shorthand makes width and style depend on the color resolving: if --flow-loading-color holds any non-color string (an arbitrary bg/loading_color value reaches it unvalidated) or the browser lacks color-mix() — still possible under the repo's >0.2%, not dead browserslist — the whole shorthand is dropped to border-style: none and the spinner renders invisible while the opaque overlay keeps blocking the flow. A static track color isolates the failure to the accent arc.

Suggested change
color-mix(in srgb, var(--flow-loading-color, #0082b5) 20%, transparent);
border-top-color: var(--flow-loading-color, #0082b5);
border: 3px solid rgba(0, 0, 0, 0.12);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open on the current head (2da0004): src/App.css was not touched by the follow-up commits, so the border shorthand still collapses to border-style: none when the color value is invalid or color-mix() is unsupported.

animation: flow-loading-spin 0.8s linear infinite;
}

@keyframes flow-loading-spin {
to {
transform: rotate(360deg);
}
}

h1 {
margin: 0px;
font-weight: 800;
Expand Down
52 changes: 51 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@ const mockAuthProvider = jest.fn();

jest.mock('@descope/react-sdk', () => ({
...jest.requireActual('@descope/react-sdk'),
Descope: ({ onSuccess, ...props }: { onSuccess: () => void }) => {
Descope: ({
onSuccess,
onReady = () => {},
...props
}: {
onSuccess: () => void;
onReady: () => void;
}) => {
mockDescope(props);
setTimeout(onReady, 0);
return (
<button data-testid="descope-button" type="button" onClick={onSuccess}>
Descope
Expand Down Expand Up @@ -173,6 +181,48 @@ describe('App component', () => {
);
});

test('shows a loading overlay until the flow is ready when loading=true', async () => {
window.location.pathname = `/${packageJson.homepage}/${validProjectId}`;
window.location.search = `?flow=${flowId}&loading=true`;
render(<App />);
expect(screen.getByTestId('flow-loading-overlay')).toBeInTheDocument();
await waitFor(() =>
expect(
screen.queryByTestId('flow-loading-overlay')
).not.toBeInTheDocument()
);
});

test('hides the loading overlay by default', async () => {
window.location.pathname = `/${packageJson.homepage}/${validProjectId}`;
window.location.search = `?flow=${flowId}`;
render(<App />);
await waitFor(() =>
expect(mockDescope).toHaveBeenCalledWith(
expect.objectContaining({ flowId })
)
);
expect(
screen.queryByTestId('flow-loading-overlay')
).not.toBeInTheDocument();
});

test('uses loading_color for the spinner when provided', async () => {
window.location.pathname = `/${packageJson.homepage}/${validProjectId}`;
window.location.search = `?flow=${flowId}&loading=true&loading_color=ff0000`;
render(<App />);
const spinner = await screen.findByTestId('flow-loading-spinner');
expect(spinner).toHaveStyle({ '--flow-loading-color': '#ff0000' });
});

test('uses bg color for the spinner when loading_color is not provided', async () => {
window.location.pathname = `/${packageJson.homepage}/${validProjectId}`;
window.location.search = `?flow=${flowId}&loading=true&bg=00ff00`;
render(<App />);
const spinner = await screen.findByTestId('flow-loading-spinner');
expect(spinner).toHaveStyle({ '--flow-loading-color': '#00ff00' });
});

test('that send_session_token search param enables sendSessionToken', async () => {
window.location.pathname = `/${packageJson.homepage}/${validProjectId}`;
window.location.search = `?flow=${flowId}&send_session_token=true`;
Expand Down
97 changes: 83 additions & 14 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { AuthProvider, Descope } from '@descope/react-sdk';
import { FlowJWTResponse } from '@descope/web-component';
import clsx from 'clsx';
import React, { useEffect, useMemo, useCallback, CSSProperties } from 'react';
import React, {
useEffect,
useMemo,
useCallback,
useState,
CSSProperties
} from 'react';
import './App.css';
import Done from './components/Done';
import Welcome from './components/Welcome';
import FlowGate from './components/FlowGate';
import FlowLoadingOverlay from './components/FlowLoadingOverlay';
import useOidcMfa from './hooks/useOidcMfa';
import { env } from './env';
import { logger } from './utils/logger';
Expand All @@ -25,6 +32,29 @@ const normalizeBackgroundParam = (
return value;
};

const DEFAULT_LOADING_COLOR = '#0082b5';

const isBackgroundImageUrl = (value: string | undefined) =>
Boolean(value?.startsWith('https://'));
Comment on lines +37 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick fix

Exclude http:// background URLs from the spinner color

isBackgroundImageUrl only matches https://, so an http:// image URL in bg/DESCOPE_BG is treated as a color and passed through to --flow-loading-color. Because the custom property is set (just not a color), the var(..., #0082b5) fallbacks in .flow-loading-spinner never apply and the declarations become invalid at computed-value time instead of falling back to the default color.

Suggested change
const isBackgroundImageUrl = (value: string | undefined) =>
Boolean(value?.startsWith('https://'));
const isBackgroundImageUrl = (value: string | undefined) =>
/^https?:\/\//i.test(value ?? '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open on the current head (2da0004): isBackgroundImageUrl is unchanged, so an http:// value in bg/DESCOPE_BG continues to reach --flow-loading-color whenever the spinner is enabled.


const getLoadingSpinnerColor = ({
loadingColor,
background
}: {
loadingColor: string | undefined;
background: string | undefined;
}) => {
if (loadingColor && !isBackgroundImageUrl(loadingColor)) {
return loadingColor;
}

if (background && !isBackgroundImageUrl(background)) {
return background;
}

return DEFAULT_LOADING_COLOR;
};

const isFaviconUrlSecure = (url: string) => {
try {
const parsedUrl = new URL(url);
Expand Down Expand Up @@ -294,18 +324,37 @@ const App = () => {

const client = useMemo(() => getClientParams(urlParams), [urlParams]);

const flowProps = {
flowId,
debug,
sendSessionToken,
locale,
tenant: tenantId,
theme,
styleId,
form,
client,
onSuccess: (e: CustomEvent<FlowJWTResponse>) => {
const showFlowLoading =
urlParams.get('loading') === 'true' || env.DESCOPE_FLOW_LOADING === 'true';

const loadingSpinnerColor = useMemo(
() =>
getLoadingSpinnerColor({
loadingColor: normalizeBackgroundParam(
urlParams.get('loading_color') || env.DESCOPE_LOADING_COLOR
),
background
}),
[urlParams, background]
);

const showFlow = !done && Boolean(projectId && flowId);
const flowSessionKey = `${projectId}:${flowId}`;
const [readyFlowKey, setReadyFlowKey] = useState<string | null>(null);
const isFlowReady = readyFlowKey === flowSessionKey;

const handleFlowReady = useCallback(() => {
setReadyFlowKey(flowSessionKey);
}, [flowSessionKey]);

const handleFlowError = useCallback(() => {
setReadyFlowKey(flowSessionKey);
}, [flowSessionKey]);

const handleFlowSuccess = useCallback(
(e: CustomEvent<FlowJWTResponse>) => {
if (flowId === 'saml-config' || flowId === 'sso-config') {
setReadyFlowKey(null);
let search = window?.location.search;
if (search) {
search = `${search}&done=true`;
Expand All @@ -320,10 +369,27 @@ const App = () => {
return;
}
if (e?.detail?.flowOutput?.onSuccessRedirectUrl) {
setReadyFlowKey(null);
// make sure to validate the URL in the flow against approved domains
window?.location.assign(e?.detail?.flowOutput?.onSuccessRedirectUrl);
}
},
[flowId]
);

const flowProps = {
flowId,
debug,
sendSessionToken,
locale,
tenant: tenantId,
theme,
styleId,
form,
client,
onReady: handleFlowReady,
onError: handleFlowError,
onSuccess: handleFlowSuccess,
...((flowId === 'saml-config' || flowId === 'sso-config') && {
autoFocus: false
})
Expand All @@ -337,14 +403,17 @@ const App = () => {
persistTokens={persistTokens}
>
<div className="app" style={bodyCss} data-testid="app">
{!done && projectId && flowId && (
{showFlow && showFlowLoading && !isFlowReady && (
<FlowLoadingOverlay color={loadingSpinnerColor} />
)}
Comment on lines +405 to +407

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🛠️ Moderate

Dismiss the overlay when the flow never mounts

The overlay is gated only on showFlow and on readyFlowKey, which is set exclusively by <Descope>'s onReady/onError. When FlowGate gets success !== true from /v1/flow/validate-domain it renders <ErrorScreen /> instead of its children, so <Descope> never mounts and neither callback ever fires — the position: fixed; z-index: 1000 overlay then covers the error message forever with a spinning indicator and no way out. The same dead end occurs whenever the web component fails to initialize without emitting error.

Proposed fix

Either render the overlay inside FlowGate (so the blocked branch replaces it too) or have FlowGate report its blocked state up to App and include it in the overlay condition. Also add a max-wait timeout that clears the overlay so an SDK that emits neither event can't strand the page.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open on the current head (2da0004). The overlay condition and FlowGate are unchanged; making the spinner opt-in (loading=true / DESCOPE_FLOW_LOADING=true) narrows the blast radius to deployments that enable it, but for those the blocked-domain path still renders ErrorScreen behind an undismissable overlay, and there is still no max-wait fallback.

{showFlow && (
<div
className={containerClasses}
style={containerCss}
data-testid="descope-component"
>
<FlowGate baseUrl={baseUrl} projectId={projectId}>
<Descope {...flowProps} />
<Descope key={flowSessionKey} {...flowProps} />
</FlowGate>
</div>
)}
Expand Down
32 changes: 32 additions & 0 deletions src/components/FlowLoadingOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { CSSProperties } from 'react';
import '../App.css';

type FlowLoadingOverlayProps = {
color: string;
};

const FlowLoadingOverlay = ({ color }: FlowLoadingOverlayProps) => {
const spinnerStyle = {
'--flow-loading-color': color
} as CSSProperties;

return (
<div
className="flow-loading-overlay"
data-testid="flow-loading-overlay"
role="status"
aria-live="polite"
aria-busy="true"
aria-label="Loading"
>
<div
className="flow-loading-spinner"
style={spinnerStyle}
data-testid="flow-loading-spinner"
aria-hidden="true"
/>
</div>
);
};

export default FlowLoadingOverlay;