Skip to content

Commit 5a7225e

Browse files
feat(ui,shared,localizations): Add test step for <ConfigureSSO /> (#8544)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6b27d54 commit 5a7225e

19 files changed

Lines changed: 1276 additions & 30 deletions

.changeset/three-ducks-hang.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': patch
3+
'@clerk/shared': patch
4+
'@clerk/ui': patch
5+
---
6+
7+
Add test step for `<__experimental_ConfigureSSO />`

packages/localizations/src/en-US.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,74 @@ export const enUS: LocalizationResource = {
274274
subtitle: "Contact the application's administrator to get access through the existing connection.",
275275
},
276276
},
277+
testConfigurationStep: {
278+
title: 'Test your SSO connection',
279+
subtitle: 'Authenticate using the test SSO URL to verify you configured the connection correctly.',
280+
error__noSuccessfulTestRun:
281+
'You need at least one successful test run before you can continue. Generate a test SSO URL and complete the sign-in flow.',
282+
testUrl: {
283+
title: 'Test your SSO URL',
284+
subtitle: 'Generate and copy a test SSO URL to authenticate with.',
285+
actionLabel__copy: 'Copy test URL',
286+
},
287+
testResults: {
288+
title: 'Test results',
289+
actionLabel__refresh: 'Refresh logs',
290+
polling: 'Waiting for the test run to complete…',
291+
status__success: 'Success',
292+
status__failed: 'Failed',
293+
status__pending: 'Pending',
294+
},
295+
testRunDetails: {
296+
title: 'Test run',
297+
runDetails: {
298+
sectionTitle: 'Run details',
299+
timestamp: 'Timestamp',
300+
status: 'Status',
301+
errorCode: 'Error code',
302+
fullMessage: 'Full message',
303+
actionLabel__copy: 'Copy message',
304+
actionLabel__copied: 'Copied',
305+
},
306+
parsedUserInfo: {
307+
sectionTitle: 'Parsed user info',
308+
email: 'Email',
309+
firstName: 'First name',
310+
},
311+
howToFix: {
312+
sectionTitle: 'How to fix',
313+
actionLabel__viewDocumentation: 'View documentation',
314+
saml_user_attribute_missing: {
315+
intro: 'To fix this error, follow these steps:',
316+
step1: "Access your identity provider's configuration dashboard.",
317+
step2: "Navigate to your application's SAML settings or attribute mapping configuration.",
318+
step3: "Ensure that the 'mail' attribute is properly mapped to the user's email address field.",
319+
},
320+
saml_response_relaystate_missing: {
321+
description:
322+
'Check that your identity provider is correctly returning the RelayState parameter that was sent in the original request.',
323+
},
324+
saml_email_address_domain_mismatch: {
325+
description:
326+
'Verify that the user is signing in with an email address that matches one of the allowed domains for this connection. If you need to add additional domains, update the allowed domains in your connection settings.',
327+
},
328+
oauth_access_denied: {
329+
description:
330+
"This error occurs when the user clicked Cancel or Deny on the OAuth provider's authorization screen, or the provider rejected the authorization request. Verify that the OAuth application credentials (Client ID and Client Secret) are correctly configured.",
331+
},
332+
oauth_token_exchange_error: {
333+
description:
334+
"Verify that your OAuth application's Client ID and Client Secret are correctly configured and match the credentials from your OAuth provider's dashboard.",
335+
},
336+
oauth_fetch_user_error: {
337+
intro: 'To fix this error, follow these steps:',
338+
step1:
339+
'Verify that the OAuth scopes configured in your connection settings include the necessary permissions to read user profile information.',
340+
step2: 'Ensure that the user info endpoint URL is correctly configured.',
341+
},
342+
},
343+
},
344+
},
277345
configureStep: {
278346
spFields: {
279347
acsUrl: {

packages/shared/src/react/hooks/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export type {
3838
UseUserEnterpriseConnectionsParams,
3939
UseUserEnterpriseConnectionsReturn,
4040
} from './useUserEnterpriseConnections';
41+
export { __internal_useEnterpriseConnectionTestRuns } from './useEnterpriseConnectionTestRuns';
42+
export type {
43+
UseEnterpriseConnectionTestRunsParams,
44+
UseEnterpriseConnectionTestRunsReturn,
45+
} from './useEnterpriseConnectionTestRuns';
4146

4247
export { useUserBase as __internal_useUserBase } from './base/useUserBase';
4348
export { useClientBase as __internal_useClientBase } from './base/useClientBase';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useMemo } from 'react';
2+
3+
import type { GetEnterpriseConnectionTestRunsParams } from '../../types/enterpriseConnectionTestRun';
4+
import { INTERNAL_STABLE_KEYS } from '../stable-keys';
5+
import { createCacheKeys } from './createCacheKeys';
6+
7+
/**
8+
* @internal
9+
*/
10+
export function useEnterpriseConnectionTestRunsCacheKeys(params: {
11+
userId: string | null;
12+
enterpriseConnectionId: string | null;
13+
args: GetEnterpriseConnectionTestRunsParams;
14+
}) {
15+
const { userId, enterpriseConnectionId, args } = params;
16+
return useMemo(() => {
17+
return createCacheKeys({
18+
stablePrefix: INTERNAL_STABLE_KEYS.ENTERPRISE_CONNECTION_TEST_RUNS_KEY,
19+
authenticated: Boolean(userId),
20+
tracked: {
21+
userId: userId ?? null,
22+
enterpriseConnectionId: enterpriseConnectionId ?? null,
23+
},
24+
untracked: {
25+
args,
26+
},
27+
});
28+
// The args object is intentionally serialized via the consumer to keep stability.
29+
// eslint-disable-next-line react-hooks/exhaustive-deps
30+
}, [userId, enterpriseConnectionId, JSON.stringify(args)]);
31+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
3+
import type {
4+
EnterpriseConnectionTestRunResource,
5+
GetEnterpriseConnectionTestRunsParams,
6+
} from '../../types/enterpriseConnectionTestRun';
7+
import { useClerkInstanceContext } from '../contexts';
8+
import { useClerkQueryClient } from '../query/use-clerk-query-client';
9+
import { useClerkQuery } from '../query/useQuery';
10+
import { useUserBase } from './base/useUserBase';
11+
import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut';
12+
import { useEnterpriseConnectionTestRunsCacheKeys } from './useEnterpriseConnectionTestRuns.shared';
13+
14+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
15+
16+
export type UseEnterpriseConnectionTestRunsParams = {
17+
enterpriseConnectionId: string | null;
18+
/**
19+
* Pass-through fetch parameters (pagination, status filter).
20+
* Defaults to `{ initialPage: 1, pageSize: 10 }`.
21+
*/
22+
params?: GetEnterpriseConnectionTestRunsParams;
23+
/**
24+
* Polling interval (ms) applied between `revalidate()` and the moment the
25+
* first record arrives in the response.
26+
*
27+
* @default 2000
28+
*/
29+
pollIntervalMs?: number;
30+
/**
31+
* If `false`, the hook is dormant — no fetch, no polling.
32+
*
33+
* @default true
34+
*/
35+
enabled?: boolean;
36+
};
37+
38+
export type UseEnterpriseConnectionTestRunsReturn = {
39+
data: EnterpriseConnectionTestRunResource[] | undefined;
40+
totalCount: number | undefined;
41+
error: Error | null;
42+
isLoading: boolean;
43+
isFetching: boolean;
44+
/**
45+
* `true` while the hook is actively polling for the first record to appear
46+
*/
47+
isPolling: boolean;
48+
/**
49+
* Force a refetch and (if the list is currently empty) arm polling
50+
*/
51+
revalidate: () => Promise<void>;
52+
};
53+
54+
/**
55+
* Subscribes to the list of enterprise-connection test runs for the signed-in user
56+
*
57+
* @internal
58+
*/
59+
function useEnterpriseConnectionTestRuns(
60+
params: UseEnterpriseConnectionTestRunsParams,
61+
): UseEnterpriseConnectionTestRunsReturn {
62+
const {
63+
enterpriseConnectionId,
64+
params: fetchParams = { initialPage: 1, pageSize: 10 },
65+
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
66+
enabled = true,
67+
} = params;
68+
69+
const clerk = useClerkInstanceContext();
70+
const user = useUserBase();
71+
const [queryClient] = useClerkQueryClient();
72+
73+
const { queryKey, invalidationKey, stableKey, authenticated } = useEnterpriseConnectionTestRunsCacheKeys({
74+
userId: user?.id ?? null,
75+
enterpriseConnectionId,
76+
args: fetchParams,
77+
});
78+
79+
useClearQueriesOnSignOut({
80+
isSignedOut: user === null,
81+
authenticated,
82+
stableKeys: stableKey,
83+
});
84+
85+
const queryEnabled = enabled && clerk.loaded && Boolean(user) && Boolean(enterpriseConnectionId);
86+
87+
const [shouldPoll, setShouldPoll] = useState(false);
88+
89+
const query = useClerkQuery({
90+
queryKey,
91+
queryFn: () => {
92+
if (!enterpriseConnectionId) {
93+
throw new Error('enterpriseConnectionId is required to fetch test runs');
94+
}
95+
return user?.getEnterpriseConnectionTestRuns(enterpriseConnectionId, fetchParams);
96+
},
97+
refetchInterval: q => {
98+
if (!shouldPoll) {
99+
return false;
100+
}
101+
102+
const hasRows = (q.state.data?.data?.length ?? 0) > 0;
103+
return hasRows ? false : pollIntervalMs;
104+
},
105+
enabled: queryEnabled,
106+
refetchIntervalInBackground: false,
107+
});
108+
109+
const hasRows = (query.data?.data?.length ?? 0) > 0;
110+
111+
useEffect(() => {
112+
if (shouldPoll && hasRows) {
113+
setShouldPoll(false);
114+
}
115+
}, [shouldPoll, hasRows]);
116+
117+
const revalidate = useCallback(async () => {
118+
// Only arm polling when there is nothing in the list yet — once any record
119+
// has been seen, this is a one-shot refetch.
120+
if (!hasRows) {
121+
setShouldPoll(true);
122+
}
123+
await queryClient.invalidateQueries({ queryKey: invalidationKey });
124+
}, [queryClient, invalidationKey, hasRows]);
125+
126+
const isPolling = queryEnabled && shouldPoll && !hasRows;
127+
128+
return {
129+
data: query.data?.data,
130+
totalCount: query.data?.total_count,
131+
error: query.error ?? null,
132+
isLoading: query.isLoading,
133+
isFetching: query.isFetching,
134+
isPolling,
135+
revalidate,
136+
};
137+
}
138+
139+
export { useEnterpriseConnectionTestRuns as __internal_useEnterpriseConnectionTestRuns };

packages/shared/src/react/stable-keys.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,14 @@ const PAYMENT_ATTEMPT_KEY = 'billing-payment-attempt';
7373
const BILLING_PLANS_KEY = 'billing-plan';
7474
const BILLING_STATEMENTS_KEY = 'billing-statement';
7575
const USER_ENTERPRISE_CONNECTIONS_KEY = 'userEnterpriseConnections';
76+
const ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'enterpriseConnectionTestRuns';
7677

7778
export const INTERNAL_STABLE_KEYS = {
7879
PAYMENT_ATTEMPT_KEY,
7980
BILLING_PLANS_KEY,
8081
BILLING_STATEMENTS_KEY,
8182
USER_ENTERPRISE_CONNECTIONS_KEY,
83+
ENTERPRISE_CONNECTION_TEST_RUNS_KEY,
8284
} as const;
8385

8486
export type __internal_ResourceCacheStableKey = (typeof INTERNAL_STABLE_KEYS)[keyof typeof INTERNAL_STABLE_KEYS];

packages/shared/src/types/elementIds.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ export type ProfileSectionId =
5757
| 'ssoDomain'
5858
| 'ssoConfiguration'
5959
| 'configureAgain'
60-
| 'resetSso';
60+
| 'resetSso'
61+
| 'testSsoUrl'
62+
| 'testResults';
6163
export type ProfilePageId = 'account' | 'security' | 'organizationGeneral' | 'organizationMembers' | 'billing';
6264

6365
export type UserPreviewId = 'userButton' | 'personalWorkspace';

packages/shared/src/types/localization.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,68 @@ export type __internal_LocalizationResource = {
13381338
subtitle: LocalizationValue;
13391339
};
13401340
};
1341+
testConfigurationStep: {
1342+
title: LocalizationValue;
1343+
subtitle: LocalizationValue;
1344+
error__noSuccessfulTestRun: LocalizationValue;
1345+
testUrl: {
1346+
title: LocalizationValue;
1347+
subtitle: LocalizationValue;
1348+
actionLabel__copy: LocalizationValue;
1349+
};
1350+
testResults: {
1351+
title: LocalizationValue;
1352+
actionLabel__refresh: LocalizationValue;
1353+
polling: LocalizationValue;
1354+
status__success: LocalizationValue;
1355+
status__failed: LocalizationValue;
1356+
status__pending: LocalizationValue;
1357+
};
1358+
testRunDetails: {
1359+
title: LocalizationValue;
1360+
runDetails: {
1361+
sectionTitle: LocalizationValue;
1362+
timestamp: LocalizationValue;
1363+
status: LocalizationValue;
1364+
errorCode: LocalizationValue;
1365+
fullMessage: LocalizationValue;
1366+
actionLabel__copy: LocalizationValue;
1367+
actionLabel__copied: LocalizationValue;
1368+
};
1369+
parsedUserInfo: {
1370+
sectionTitle: LocalizationValue;
1371+
email: LocalizationValue;
1372+
firstName: LocalizationValue;
1373+
};
1374+
howToFix: {
1375+
sectionTitle: LocalizationValue;
1376+
actionLabel__viewDocumentation: LocalizationValue;
1377+
saml_user_attribute_missing: {
1378+
intro: LocalizationValue;
1379+
step1: LocalizationValue;
1380+
step2: LocalizationValue;
1381+
step3: LocalizationValue;
1382+
};
1383+
saml_response_relaystate_missing: {
1384+
description: LocalizationValue;
1385+
};
1386+
saml_email_address_domain_mismatch: {
1387+
description: LocalizationValue;
1388+
};
1389+
oauth_access_denied: {
1390+
description: LocalizationValue;
1391+
};
1392+
oauth_token_exchange_error: {
1393+
description: LocalizationValue;
1394+
};
1395+
oauth_fetch_user_error: {
1396+
intro: LocalizationValue;
1397+
step1: LocalizationValue;
1398+
step2: LocalizationValue;
1399+
};
1400+
};
1401+
};
1402+
};
13411403
configureStep: {
13421404
spFields: {
13431405
acsUrl: {

packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,15 @@ const AuthenticatedContent = withCoreUserGuard(() => {
5555
})}
5656
>
5757
<ConfigureSSOCardProtect>
58-
<ConfigureSSOCardContent />
58+
<ConfigureSSOCardContent contentRef={contentRef} />
5959
</ConfigureSSOCardProtect>
6060
</Col>
6161
</ConfigureSSONavbar>
6262
</ProfileCard.Root>
6363
);
6464
});
6565

66-
const ConfigureSSOCardContent = () => {
66+
const ConfigureSSOCardContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
6767
const { data: enterpriseConnections, isLoading } = __internal_useUserEnterpriseConnections({ enabled: true });
6868

6969
// Currently FAPI only supports one enterprise connection per user
@@ -74,7 +74,10 @@ const ConfigureSSOCardContent = () => {
7474
}
7575

7676
return (
77-
<ConfigureSSOProvider enterpriseConnection={enterpriseConnection}>
77+
<ConfigureSSOProvider
78+
enterpriseConnection={enterpriseConnection}
79+
contentRef={contentRef}
80+
>
7881
<ConfigureSSOSteps />
7982
</ConfigureSSOProvider>
8083
);

0 commit comments

Comments
 (0)