Skip to content
Open
15 changes: 15 additions & 0 deletions .changeset/blocked-request-screen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@clerk/localizations': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Show a dedicated screen when a sign-in or sign-up request is blocked, instead of a generic inline error.

A blocked request is terminal — there is no field to correct and no retry that helps — so it now replaces the card rather than appearing as a small error beside a form the user cannot resubmit.

The screen shows a short reference for the request, which the end user can quote when contacting support. When the application supplies its own wording, the screen renders that instead of the default: `title`, `description`, and an optional `https` link with a label are read from the error's `meta`.

Also adds the `actionBlocked` localization keys (`title`, `subtitle`, `traceIdLabel`) and appearance descriptors for the new elements, so both the copy and the styling are customizable.

This is additive and degrades safely: the error's `code`, `message` and `long_message` are unchanged, and a response without the new `meta` renders exactly as before.
5 changes: 5 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import type { LocalizationResource } from '@clerk/shared/types';

export const enUS: LocalizationResource = {
locale: 'en-US',
actionBlocked: {
subtitle: 'For your security, this request could not be completed.',
title: "We couldn't complete this request",
traceIdLabel: 'Reference',
},
apiKeys: {
action__add: 'Add new key',
action__search: 'Search keys',
Expand Down
54 changes: 54 additions & 0 deletions packages/shared/src/__tests__/blockedRequestMeta.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';

import { ClerkAPIError } from '../errors/clerkApiError';
import { errorToJSON } from '../errors/parseError';

// The details shown on the blocked-request screen ride on the error's meta.
// Both directions of the mapping have an exhaustive field list, so a field
// added to one and not the other is dropped silently — which reads as "the
// application configured no message" rather than as a bug.
describe('blocked request error meta', () => {
const json = {
code: 'action_blocked',
message: 'Action blocked',
long_message: 'This action was detected as suspicious and has been blocked.',
meta: {
trace_id: '7Q8ikxgt',
title: 'We could not verify this sign-in',
description: 'Try again from a different network.',
link_url: 'https://help.example.com/blocked?ref=7Q8ikxgt',
link_text: 'Contact support',
},
};

it('parses every field off the wire', () => {
const error = new ClerkAPIError(json as any);
expect(error.meta).toMatchObject({
traceId: '7Q8ikxgt',
title: 'We could not verify this sign-in',
description: 'Try again from a different network.',
linkUrl: 'https://help.example.com/blocked?ref=7Q8ikxgt',
linkText: 'Contact support',
});
});

// errorToJSON backs __internal_toSnapshot, so this is the SSR/hydration path:
// without it the screen loses its message and reference after rehydration and
// silently degrades to the generic wording.
it('survives a snapshot round trip', () => {
const roundTripped = new ClerkAPIError(errorToJSON(new ClerkAPIError(json as any)) as any);
expect(roundTripped.meta).toMatchObject({
traceId: '7Q8ikxgt',
title: 'We could not verify this sign-in',
description: 'Try again from a different network.',
linkUrl: 'https://help.example.com/blocked?ref=7Q8ikxgt',
linkText: 'Contact support',
});
});

it('leaves an error without these fields alone', () => {
const error = new ClerkAPIError({ code: 'form_param_nil', message: 'x', meta: { param_name: 'email' } } as any);
expect(error.meta.traceId).toBeUndefined();
expect(errorToJSON(error).meta?.trace_id).toBeUndefined();
});
});
5 changes: 5 additions & 0 deletions packages/shared/src/errors/clerkApiError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export class ClerkAPIError<Meta extends ClerkAPIErrorMeta = any> implements Cler
isPlanUpgradePossible: json.meta?.is_plan_upgrade_possible,
seatsQuantityToAdd: json.meta?.seats_quantity_to_add,
seatsQuantity: json.meta?.seats_quantity,
traceId: json.meta?.trace_id,
title: json.meta?.title,
description: json.meta?.description,
linkUrl: json.meta?.link_url,
linkText: json.meta?.link_text,
} as unknown as Meta,
};
this.code = parsedError.code;
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/errors/parseError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON {
is_plan_upgrade_possible: error?.meta?.isPlanUpgradePossible,
seats_quantity_to_add: error?.meta?.seatsQuantityToAdd,
seats_quantity: error?.meta?.seatsQuantity,
trace_id: error?.meta?.traceId,
title: error?.meta?.title,
description: error?.meta?.description,
link_url: error?.meta?.linkUrl,
link_text: error?.meta?.linkText,
},
};
}
34 changes: 34 additions & 0 deletions packages/shared/src/types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export interface ClerkAPIErrorJSON {
is_plan_upgrade_possible?: boolean;
seats_quantity_to_add?: number;
seats_quantity?: number;
trace_id?: string;
title?: string;
description?: string;
link_url?: string;
link_text?: string;
};
}

Expand Down Expand Up @@ -67,6 +72,35 @@ export interface ClerkAPIError {
isPlanUpgradePossible?: boolean;
seatsQuantityToAdd?: number;
seatsQuantity?: number;
/**
* A short reference for the request that produced this error. It is shown to
* the end user so they can quote it when contacting support.
*
* Treat it as an opaque string: do not parse it, reformat it, or assume a
* length.
*/
traceId?: string;
/**
* A heading for the error, configured by the application's owner.
*
* Plain text. Render it as text, never as HTML or markdown.
*/
title?: string;
/**
* A description of the error, configured by the application's owner.
*
* Plain text. Render it as text, never as HTML or markdown.
*/
description?: string;
/**
* An `https` URL the end user can follow for help, configured by the
* application's owner. Verify the scheme before using it as an `href`.
*/
linkUrl?: string;
/**
* The label for `linkUrl`. Only ever set when `linkUrl` is set.
*/
linkText?: string;
};
}

Expand Down
14 changes: 14 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1971,6 +1971,20 @@ export type __internal_LocalizationResource = {
doneButton: LocalizationValue;
};
};
/**
* The screen shown when a request is blocked and there is no way for the end
* user to retry. These are the fallbacks: an application can supply its own
* title and description, and when it does they are used instead.
*/
actionBlocked: {
title: LocalizationValue;
subtitle: LocalizationValue;
/**
* Labels the short reference the end user can quote when contacting
* support.
*/
traceIdLabel: LocalizationValue;
};
apiKeys: {
formTitle: LocalizationValue;
formHint: LocalizationValue;
Expand Down
206 changes: 206 additions & 0 deletions packages/ui/src/common/ActionBlockedCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants';
import type { ClerkAPIError } from '@clerk/shared/types';
import React from 'react';

import { Col, descriptors, Flex, Flow, Icon, localizationKeys, Text } from '../customizables';
import { Card } from '../elements/Card';
import { Header } from '../elements/Header';
import { ExclamationTriangle } from '../icons';

/**
* The details an application can attach to a blocked request. Every field is
* optional; when none are present the card falls back to its own wording and
* shows only the reference.
*
* The text fields are plain text and are rendered as text nodes. They are
* written by the application's owner, so they are treated as content, never as
* markup.
*/
export type ActionBlockedDetails = {
traceId?: string;
title?: string;
description?: string;
linkUrl?: string;
linkText?: string;
};

/**
* Reads the details off an API error, or returns null when the error carries
* none — which is also what happens against an older backend that does not send
* them. Callers use the null to fall back to the previous inline error, so a
* missing field degrades rather than rendering a blank screen.
*/
export const getActionBlockedDetails = (error: ClerkAPIError | undefined): ActionBlockedDetails | null => {
const meta = error?.meta as ActionBlockedDetails | undefined;
if (!meta) {
return null;
}
const { traceId, title, description, linkUrl, linkText } = meta;
if (!traceId && !title && !description && !linkUrl) {
return null;
}
return { traceId, title, description, linkUrl, linkText };
};

/**
* Only `https` links are rendered.
*
* The URL is already checked before it is sent, so this is a second, local
* check rather than the only one: it is what stands between a value that
* reached the browser anyway and a `javascript:` or `data:` URI becoming an
* `href`. A link that fails is dropped and the rest of the card still renders.
*/
export const safeHref = (url: string | undefined): string | null => {
if (!url) {
return null;
}
try {
return new URL(url).protocol === 'https:' ? url : null;
} catch {
return null;
}
};

type ActionBlockedCardProps = {
details: ActionBlockedDetails;
};

/**
* The screen shown when a request was blocked and there is nothing the end user
* can do to retry it.
*
* A block is terminal — there is no field to correct and no second attempt that
* helps — so it replaces the form rather than appearing as an inline error
* beside it. The one thing the user can act on is the reference, which is why it
* is always rendered and is selectable.
*/
export const ActionBlockedCard = (props: ActionBlockedCardProps) => {
const { traceId, title, description, linkUrl, linkText } = props.details;
const href = safeHref(linkUrl);

return (
<Flow.Part part='actionBlocked'>
<Card.Root>
<Card.Content>
<Header.Root>
{/* An application-supplied title is plain text, so it is passed as a
child rather than through localizationKey. Without one we fall
back to our own wording. */}
{title ? (
<Header.Title>{title}</Header.Title>
) : (
<Header.Title localizationKey={localizationKeys('actionBlocked.title')} />
)}
{description ? (
<Header.Subtitle>{description}</Header.Subtitle>
) : (
<Header.Subtitle localizationKey={localizationKeys('actionBlocked.subtitle')} />
)}
</Header.Root>

<Col
elementDescriptor={descriptors.main}
gap={6}
>
<Flex
elementDescriptor={descriptors.actionBlockedIconBox}
center
sx={theme => ({
alignSelf: 'center',
width: theme.sizes.$16,
height: theme.sizes.$16,
borderRadius: theme.radii.$circle,
backgroundColor: theme.colors.$neutralAlpha100,
color: theme.colors.$danger500,
})}
>
<Icon
elementDescriptor={descriptors.actionBlockedIcon}
icon={ExclamationTriangle}
sx={theme => ({ height: theme.sizes.$5, width: theme.sizes.$5 })}
/>
</Flex>

{href ? (
<Text
elementDescriptor={descriptors.actionBlockedLink}
as='a'
variant='buttonLarge'
colorScheme='inherit'
sx={{ textAlign: 'center', textDecoration: 'underline' }}
// rel is set because the destination is chosen by the
// application's owner and is not necessarily under their
// control once followed.
{...{ href, target: '_blank', rel: 'noopener noreferrer' }}
>
{linkText || href}
</Text>
) : null}

{traceId ? (
<Col
elementDescriptor={descriptors.actionBlockedTraceIdBox}
gap={1}
sx={{ alignItems: 'center' }}
>
<Text
elementDescriptor={descriptors.actionBlockedTraceIdLabel}
variant='caption'
colorScheme='secondary'
localizationKey={localizationKeys('actionBlocked.traceIdLabel')}
/>
{/* Selectable and monospaced: this is the one thing on the
screen the user is expected to copy or retype. */}
<Text
elementDescriptor={descriptors.actionBlockedTraceId}
variant='body'
colorScheme='secondary'
sx={theme => ({
fontFamily: theme.fonts.$buttons,
userSelect: 'all',
letterSpacing: theme.space.$xxs,
})}
>
{traceId}
</Text>
</Col>
) : null}
</Col>
</Card.Content>
<Card.Footer />
</Card.Root>
</Flow.Part>
);
};

/**
* Intercepts a blocked-request error on its way to the card's inline error slot
* and turns it into the terminal screen instead.
*
* Every error in these flows funnels through `card.setError`, so wrapping that
* one function catches both the form-submit path and the OAuth-callback path
* without either having to know about this.
*
* Anything that is not a blocked request — or that is, but carries no details,
* which is what an older backend sends — passes straight through and still
* renders as the inline error it always did.
*/
export const useActionBlocked = (setError: (e: any) => void) => {
const [blockedDetails, setBlockedDetails] = React.useState<ActionBlockedDetails | null>(null);

const setErrorOrBlock = React.useCallback(
(e: any) => {
if (e && typeof e === 'object' && e.code === ERROR_CODES.FRAUD_ACTION_BLOCKED) {
const details = getActionBlockedDetails(e as ClerkAPIError);
if (details) {
setBlockedDetails(details);
return;
}
}
setError(e);
},
[setError],
);

return { blockedDetails, setErrorOrBlock };
};
Loading
Loading