From 4785e6365c0538562c0713cd245a3fc09f959aa1 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Wed, 13 May 2026 14:58:08 -0300 Subject: [PATCH 01/14] Rename `Flow.Part` from `test-sso` to `testSso` --- .../src/components/ConfigureSSO/steps/TestConfigurationStep.tsx | 2 +- packages/ui/src/elements/contexts/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 91b7d7a214c..4a6ec9acb40 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -7,7 +7,7 @@ export const TestConfigurationStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); return ( - + Date: Wed, 13 May 2026 15:43:57 -0300 Subject: [PATCH 02/14] Introduce basic UI --- packages/shared/src/types/elementIds.ts | 4 +- .../steps/TestConfigurationStep.tsx | 132 +++++++++++++++++- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 6c2458a371a..0500034745e 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -57,7 +57,9 @@ export type ProfileSectionId = | 'ssoDomain' | 'ssoConfiguration' | 'configureAgain' - | 'resetSso'; + | 'resetSso' + | 'testSsoUrl' + | 'testResults'; export type ProfilePageId = 'account' | 'security' | 'organizationGeneral' | 'organizationMembers' | 'billing'; export type UserPreviewId = 'userButton' | 'personalWorkspace'; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 4a6ec9acb40..d5b3df7e697 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -1,4 +1,8 @@ -import { descriptors, Flow, Text } from '@/customizables'; +import { descriptors, Flex, Flow, Icon, Spinner, Table, Tbody, Td, Text, Tr } from '@/customizables'; +import { ProfileSection } from '@/elements/Section'; +import { useClipboard } from '@/hooks'; +import { Check, Copy } from '@/icons'; +import { mqu } from '@/styledSystem'; import { Step } from '../elements/Step'; import { useWizard } from '../elements/Wizard'; @@ -25,11 +29,51 @@ export const TestConfigurationStep = (): JSX.Element => { borderBottomColor: theme.colors.$borderAlpha100, })} > - Test your SSO URL + ({ + borderTopWidth: 0, + paddingTop: 0, + paddingBottom: 0, + flexDirection: 'column-reverse', + gap: t.space.$2, + })} + > + + Authenticate using the test SSO URL to verify you configured the connection correctly. + + + + + - Your test results + ({ + borderTopWidth: 0, + paddingTop: 0, + paddingBottom: 0, + flexDirection: 'column-reverse', + gap: t.space.$2, + })} + > + + @@ -47,3 +91,85 @@ export const TestConfigurationStep = (): JSX.Element => { ); }; + +type TestResultRow = { + id: string; +}; + +const TestResultsTable = ({ rows, isLoading }: { rows: TestResultRow[]; isLoading: boolean }): JSX.Element => { + return ( + ({ width: '100%', [mqu.sm]: { overflowX: 'auto', padding: t.space.$0x25 } })}> + ({ background: t.colors.$colorBackground })}> + + {isLoading ? ( + + + + ) : !rows.length ? ( + + ) : ( + rows.map(row => ( + + + + )) + )} + +
+ ({ padding: `${t.space.$10} 0` })} + > + + loading logs + +
{row.id}
+
+ ); +}; + +const EmptyTestResultsRow = (): JSX.Element => { + return ( + + + ({ display: 'block', textAlign: 'center', padding: `${t.space.$10} 0` })} + > + No test results yet + + + + ); +}; + +const CopyTestUrlButton = ({ url, isLoading }: { url: string; isLoading: boolean }): JSX.Element => { + const { onCopy, hasCopied } = useClipboard(url); + + return ( + ({ + gap: t.space.$1x5, + alignSelf: 'flex-start', + })} + > + + {hasCopied ? 'Copied' : 'Copy test URL'} + + ); +}; From 97b0daa081d55d34e65ba006e0bad1bff9fe790d Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Wed, 13 May 2026 16:22:14 -0300 Subject: [PATCH 03/14] Add localization keys Co-authored-by: Cursor --- packages/localizations/src/en-US.ts | 17 ++++++ packages/shared/src/types/localization.ts | 17 ++++++ .../steps/TestConfigurationStep.tsx | 54 +++++++++++++------ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 39999361a42..e4fb25ffc1f 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -274,6 +274,23 @@ export const enUS: LocalizationResource = { subtitle: "Contact the application's administrator to get access through the existing connection.", }, }, + testConfigurationStep: { + title: 'Test your SSO connection', + subtitle: 'Authenticate using the test SSO URL to verify you configured the connection correctly.', + testUrl: { + title: 'Test your SSO URL', + subtitle: 'Generate and copy a test SSO URL to authenticate with.', + actionLabel__copy: 'Copy test URL', + }, + testResults: { + title: 'Test results', + actionLabel__refresh: 'Refresh', + polling: 'Waiting for the test run to complete…', + status__success: 'Success', + status__failed: 'Failed', + status__pending: 'Pending', + }, + }, configureStep: { spFields: { acsUrl: { diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index b0ed1f17631..a9c80050ff7 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1338,6 +1338,23 @@ export type __internal_LocalizationResource = { subtitle: LocalizationValue; }; }; + testConfigurationStep: { + title: LocalizationValue; + subtitle: LocalizationValue; + testUrl: { + title: LocalizationValue; + subtitle: LocalizationValue; + actionLabel__copy: LocalizationValue; + }; + testResults: { + title: LocalizationValue; + actionLabel__refresh: LocalizationValue; + polling: LocalizationValue; + status__success: LocalizationValue; + status__failed: LocalizationValue; + status__pending: LocalizationValue; + }; + }; configureStep: { spFields: { acsUrl: { diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index d5b3df7e697..4c86c0af11d 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -1,4 +1,17 @@ -import { descriptors, Flex, Flow, Icon, Spinner, Table, Tbody, Td, Text, Tr } from '@/customizables'; +import { + descriptors, + Flex, + Flow, + Icon, + localizationKeys, + Spinner, + Table, + Tbody, + Td, + Text, + Tr, + useLocalizations, +} from '@/customizables'; import { ProfileSection } from '@/elements/Section'; import { useClipboard } from '@/hooks'; import { Check, Copy } from '@/icons'; @@ -17,8 +30,8 @@ export const TestConfigurationStep = (): JSX.Element => { elementId={descriptors.configureSSOStep.setId('test')} > @@ -30,7 +43,7 @@ export const TestConfigurationStep = (): JSX.Element => { })} > ({ @@ -38,12 +51,13 @@ export const TestConfigurationStep = (): JSX.Element => { paddingTop: 0, paddingBottom: 0, flexDirection: 'column-reverse', - gap: t.space.$2, + gap: t.space.$1, })} > - - Authenticate using the test SSO URL to verify you configured the connection correctly. - + { ({ @@ -114,7 +128,10 @@ const TestResultsTable = ({ rows, isLoading }: { rows: TestResultRow[]; isLoadin colorScheme='primary' elementDescriptor={descriptors.spinner} /> - loading logs + @@ -139,10 +156,9 @@ const EmptyTestResultsRow = (): JSX.Element => { ({ display: 'block', textAlign: 'center', padding: `${t.space.$10} 0` })} - > - No test results yet - + /> ); @@ -150,6 +166,7 @@ const EmptyTestResultsRow = (): JSX.Element => { const CopyTestUrlButton = ({ url, isLoading }: { url: string; isLoading: boolean }): JSX.Element => { const { onCopy, hasCopied } = useClipboard(url); + const { t } = useLocalizations(); return ( ({ gap: t.space.$1x5, @@ -169,7 +186,14 @@ const CopyTestUrlButton = ({ url, isLoading }: { url: string; isLoading: boolean icon={hasCopied ? Check : Copy} size='sm' /> - {hasCopied ? 'Copied' : 'Copy test URL'} + ); }; From 64f12804f6f93865ee4d54dbfc346ca3b0e6dda4 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Wed, 13 May 2026 16:57:16 -0300 Subject: [PATCH 04/14] Create test run URL --- .../steps/TestConfigurationStep.tsx | 120 +++++++++++------- 1 file changed, 77 insertions(+), 43 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 4c86c0af11d..b64ae5f9ba7 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -1,4 +1,8 @@ +import { useUser } from '@clerk/shared/react/index'; +import { useState } from 'react'; + import { + Button, descriptors, Flex, Flow, @@ -12,11 +16,14 @@ import { Tr, useLocalizations, } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; import { ProfileSection } from '@/elements/Section'; import { useClipboard } from '@/hooks'; import { Check, Copy } from '@/icons'; import { mqu } from '@/styledSystem'; +import { handleError } from '@/utils/errorHandler'; +import { useConfigureSSOFlow } from '../ConfigureSSOContext'; import { Step } from '../elements/Step'; import { useWizard } from '../elements/Wizard'; @@ -62,15 +69,12 @@ export const TestConfigurationStep = (): JSX.Element => { id='testSsoUrl' sx={{ paddingInlineStart: 0 }} > - + - + { paddingBottom: 0, flexDirection: 'column-reverse', gap: t.space.$2, + flex: 1, + minHeight: 0, + '& > *:first-of-type': { + flex: 1, + minHeight: 0, + }, })} > - goPrev()} - isDisabled={isFirstStep} - /> + goPrev()} /> + {/* TODO - Only allow to continue if the test run has been created and there's at least one successful result */} goNext()} isDisabled={isLastStep} @@ -112,8 +121,18 @@ type TestResultRow = { const TestResultsTable = ({ rows, isLoading }: { rows: TestResultRow[]; isLoading: boolean }): JSX.Element => { return ( - ({ width: '100%', [mqu.sm]: { overflowX: 'auto', padding: t.space.$0x25 } })}> - ({ background: t.colors.$colorBackground })}> + ({ + width: '100%', + flex: 1, + minHeight: 0, + [mqu.sm]: { overflowX: 'auto', padding: t.space.$0x25 }, + })} + > +
({ background: t.colors.$colorBackground, height: '100%' })} + > {isLoading ? ( @@ -136,7 +155,17 @@ const TestResultsTable = ({ rows, isLoading }: { rows: TestResultRow[]; isLoadin ) : !rows.length ? ( - + + + ) : ( rows.map(row => ( @@ -150,36 +179,45 @@ const TestResultsTable = ({ rows, isLoading }: { rows: TestResultRow[]; isLoadin ); }; -const EmptyTestResultsRow = (): JSX.Element => { - return ( - - - - ); -}; - -const CopyTestUrlButton = ({ url, isLoading }: { url: string; isLoading: boolean }): JSX.Element => { - const { onCopy, hasCopied } = useClipboard(url); +const CopyTestUrlButton = (): JSX.Element => { const { t } = useLocalizations(); + const { user } = useUser(); + const card = useCardState(); + const { enterpriseConnection } = useConfigureSSOFlow(); + + const [testUrl, setTestUrl] = useState(''); + const [isCreatingTestRun, setIsCreatingTestRun] = useState(false); + const { onCopy, hasCopied } = useClipboard(testUrl); + + const createTestRun = () => { + if (!user || !enterpriseConnection) { + return; + } + + setIsCreatingTestRun(true); + + user + .createEnterpriseConnectionTestRun(enterpriseConnection.id) + .then(({ url }) => { + setTestUrl(url); + onCopy(); + }) + .catch(err => handleError(err as Error, [], card.setError)) + .finally(() => setIsCreatingTestRun(false)); + }; return ( - ({ gap: t.space.$1x5, - alignSelf: 'flex-start', })} > - + ); }; From 34d2f15ec5cb2d7e73e10fff712adad29699b922 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Wed, 13 May 2026 17:09:32 -0300 Subject: [PATCH 05/14] Introduce internal hook to fetch test runs --- packages/localizations/src/en-US.ts | 2 +- packages/shared/src/react/hooks/index.ts | 5 + .../useEnterpriseConnectionTestRuns.shared.ts | 31 +++ .../hooks/useEnterpriseConnectionTestRuns.tsx | 147 ++++++++++++++ packages/shared/src/react/stable-keys.ts | 2 + .../ui/src/components/APIKeys/APIKeys.tsx | 5 +- .../steps/TestConfigurationStep.tsx | 184 ++++++++++++++++-- 7 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.shared.ts create mode 100644 packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index e4fb25ffc1f..dbaf94a9022 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -284,7 +284,7 @@ export const enUS: LocalizationResource = { }, testResults: { title: 'Test results', - actionLabel__refresh: 'Refresh', + actionLabel__refresh: 'Refresh logs', polling: 'Waiting for the test run to complete…', status__success: 'Success', status__failed: 'Failed', diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 4029e9087c6..fd2c7e5cb0d 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -38,6 +38,11 @@ export type { UseUserEnterpriseConnectionsParams, UseUserEnterpriseConnectionsReturn, } from './useUserEnterpriseConnections'; +export { __internal_useEnterpriseConnectionTestRuns } from './useEnterpriseConnectionTestRuns'; +export type { + UseEnterpriseConnectionTestRunsParams, + UseEnterpriseConnectionTestRunsReturn, +} from './useEnterpriseConnectionTestRuns'; export { useUserBase as __internal_useUserBase } from './base/useUserBase'; export { useClientBase as __internal_useClientBase } from './base/useClientBase'; diff --git a/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.shared.ts b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.shared.ts new file mode 100644 index 00000000000..8cd18085106 --- /dev/null +++ b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.shared.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; + +import type { GetEnterpriseConnectionTestRunsParams } from '../../types/enterpriseConnectionTestRun'; +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +/** + * @internal + */ +export function useEnterpriseConnectionTestRunsCacheKeys(params: { + userId: string | null; + enterpriseConnectionId: string | null; + args: GetEnterpriseConnectionTestRunsParams; +}) { + const { userId, enterpriseConnectionId, args } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ENTERPRISE_CONNECTION_TEST_RUNS_KEY, + authenticated: Boolean(userId), + tracked: { + userId: userId ?? null, + enterpriseConnectionId: enterpriseConnectionId ?? null, + }, + untracked: { + args, + }, + }); + // The args object is intentionally serialized via the consumer to keep stability. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, enterpriseConnectionId, JSON.stringify(args)]); +} diff --git a/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx new file mode 100644 index 00000000000..26574a4d375 --- /dev/null +++ b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { + EnterpriseConnectionTestRunResource, + GetEnterpriseConnectionTestRunsParams, +} from '../../types/enterpriseConnectionTestRun'; +import { useClerkInstanceContext } from '../contexts'; +import { useClerkQueryClient } from '../query/use-clerk-query-client'; +import { useClerkQuery } from '../query/useQuery'; +import { useUserBase } from './base/useUserBase'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useEnterpriseConnectionTestRunsCacheKeys } from './useEnterpriseConnectionTestRuns.shared'; + +const DEFAULT_POLL_INTERVAL_MS = 2_000; + +export type UseEnterpriseConnectionTestRunsParams = { + enterpriseConnectionId: string | null; + /** + * Pass-through fetch parameters (pagination, status filter). + * Defaults to `{ initialPage: 1, pageSize: 10 }`. + */ + params?: GetEnterpriseConnectionTestRunsParams; + /** + * Polling interval (ms) applied between `revalidate()` and the moment the + * first record arrives in the response. + * + * @default 2000 + */ + pollIntervalMs?: number; + /** + * If `false`, the hook is dormant — no fetch, no polling. + * + * @default true + */ + enabled?: boolean; +}; + +export type UseEnterpriseConnectionTestRunsReturn = { + data: EnterpriseConnectionTestRunResource[] | undefined; + /** Convenience accessor for the most recent run (i.e. `data[0]`). */ + latest: EnterpriseConnectionTestRunResource | undefined; + totalCount: number | undefined; + error: Error | null; + isLoading: boolean; + isFetching: boolean; + /** + * `true` while the hook is actively polling for the first record to appear. + * Becomes `true` once `revalidate()` is called against an empty list and + * flips back to `false` permanently as soon as the response contains at + * least one record. + */ + isPolling: boolean; + /** + * Force a refetch and (if the list is currently empty) arm polling. Once any + * record has been observed in the response, polling is disabled for the rest + * of this hook instance's lifetime — subsequent `revalidate()` calls just + * trigger a single refetch. + */ + revalidate: () => Promise; +}; + +/** + * Subscribes to the list of enterprise-connection test runs for the signed-in user + * + * @internal + */ +function useEnterpriseConnectionTestRuns( + params: UseEnterpriseConnectionTestRunsParams, +): UseEnterpriseConnectionTestRunsReturn { + const { + enterpriseConnectionId, + params: fetchParams = { initialPage: 1, pageSize: 10 }, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + enabled = true, + } = params; + + const clerk = useClerkInstanceContext(); + const user = useUserBase(); + const [queryClient] = useClerkQueryClient(); + + const { queryKey, stableKey, authenticated } = useEnterpriseConnectionTestRunsCacheKeys({ + userId: user?.id ?? null, + enterpriseConnectionId, + args: fetchParams, + }); + + useClearQueriesOnSignOut({ + isSignedOut: user === null, + authenticated, + stableKeys: stableKey, + }); + + const queryEnabled = enabled && clerk.loaded && Boolean(user) && Boolean(enterpriseConnectionId); + + const [shouldPoll, setShouldPoll] = useState(false); + + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!enterpriseConnectionId) { + throw new Error('enterpriseConnectionId is required to fetch test runs'); + } + return user?.getEnterpriseConnectionTestRuns(enterpriseConnectionId, fetchParams); + }, + enabled: queryEnabled, + refetchInterval: q => { + if (!shouldPoll) { + return false; + } + + const hasRows = (q.state.data?.data?.length ?? 0) > 0; + return hasRows ? false : pollIntervalMs; + }, + }); + + const hasRows = (query.data?.data?.length ?? 0) > 0; + + useEffect(() => { + if (shouldPoll && hasRows) { + setShouldPoll(false); + } + }, [shouldPoll, hasRows]); + + const revalidate = useCallback(async () => { + // Only arm polling when there is nothing in the list yet — once any record + // has been seen, this is a one-shot refetch. + if (!hasRows) { + setShouldPoll(true); + } + await queryClient.invalidateQueries({ queryKey: [stableKey] }); + }, [queryClient, stableKey, hasRows]); + + const isPolling = queryEnabled && shouldPoll && !hasRows; + + return { + data: query.data?.data, + latest: query.data?.data?.[0], + totalCount: query.data?.total_count, + error: query.error ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + isPolling, + revalidate, + }; +} + +export { useEnterpriseConnectionTestRuns as __internal_useEnterpriseConnectionTestRuns }; diff --git a/packages/shared/src/react/stable-keys.ts b/packages/shared/src/react/stable-keys.ts index 415d1daccfd..91940a47b57 100644 --- a/packages/shared/src/react/stable-keys.ts +++ b/packages/shared/src/react/stable-keys.ts @@ -73,12 +73,14 @@ const PAYMENT_ATTEMPT_KEY = 'billing-payment-attempt'; const BILLING_PLANS_KEY = 'billing-plan'; const BILLING_STATEMENTS_KEY = 'billing-statement'; const USER_ENTERPRISE_CONNECTIONS_KEY = 'userEnterpriseConnections'; +const ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'enterpriseConnectionTestRuns'; export const INTERNAL_STABLE_KEYS = { PAYMENT_ATTEMPT_KEY, BILLING_PLANS_KEY, BILLING_STATEMENTS_KEY, USER_ENTERPRISE_CONNECTIONS_KEY, + ENTERPRISE_CONNECTION_TEST_RUNS_KEY, } as const; export type __internal_ResourceCacheStableKey = (typeof INTERNAL_STABLE_KEYS)[keyof typeof INTERNAL_STABLE_KEYS]; diff --git a/packages/ui/src/components/APIKeys/APIKeys.tsx b/packages/ui/src/components/APIKeys/APIKeys.tsx index d5a5d2b2a09..e4e6dbddb4b 100644 --- a/packages/ui/src/components/APIKeys/APIKeys.tsx +++ b/packages/ui/src/components/APIKeys/APIKeys.tsx @@ -1,6 +1,6 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import { isOrganizationId } from '@clerk/shared/internal/clerk-js/organization'; -import { useAPIKeys, __internal_useOrganizationBase, useClerk, useUser } from '@clerk/shared/react'; +import { __internal_useOrganizationBase, useAPIKeys, useClerk, useUser } from '@clerk/shared/react'; import type { APIKeyResource } from '@clerk/shared/types'; import { lazy, useState } from 'react'; @@ -22,9 +22,9 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { InputWithIcon } from '@/ui/elements/InputWithIcon'; import { Pagination } from '@/ui/elements/Pagination'; import { useDebounce } from '@/ui/hooks'; -import { handleError } from '@/ui/utils/errorHandler'; import { MagnifyingGlass } from '@/ui/icons'; import { mqu } from '@/ui/styledSystem'; +import { handleError } from '@/ui/utils/errorHandler'; import { APIKeysTable } from './ApiKeysTable'; import type { OnCreateParams } from './CreateAPIKeyForm'; @@ -208,6 +208,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr /> + {/* here reference to paginated table for test runs */} { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { goNext, goPrev, isLastStep } = useWizard(); + const { enterpriseConnection } = useConfigureSSO(); + + const { + data: testRuns, + latest, + isLoading: areTestRunsLoading, + isPolling, + revalidate: revalidateTestRuns, + } = __internal_useEnterpriseConnectionTestRuns({ + enterpriseConnectionId: enterpriseConnection?.id ?? null, + params: { initialPage: 1, pageSize: 10 }, + }); + + const hasSuccessfulTestRun = latest?.status === 'success'; + + const handleTestRunCreated = () => { + revalidateTestRuns(); + }; return ( @@ -69,7 +91,30 @@ export const TestConfigurationStep = (): JSX.Element => { id='testSsoUrl' sx={{ paddingInlineStart: 0 }} > - + + + + + @@ -94,9 +139,10 @@ export const TestConfigurationStep = (): JSX.Element => { })} > @@ -104,10 +150,9 @@ export const TestConfigurationStep = (): JSX.Element => { goPrev()} /> - {/* TODO - Only allow to continue if the test run has been created and there's at least one successful result */} goNext()} - isDisabled={isLastStep} + isDisabled={isLastStep || !hasSuccessfulTestRun} /> @@ -115,26 +160,35 @@ export const TestConfigurationStep = (): JSX.Element => { ); }; -type TestResultRow = { - id: string; +type TestResultsTableProps = { + rows: EnterpriseConnectionTestRunResource[]; + isLoading: boolean; + isPolling: boolean; + onTestRunCreated?: (testUrl: string) => void; }; -const TestResultsTable = ({ rows, isLoading }: { rows: TestResultRow[]; isLoading: boolean }): JSX.Element => { +const TestResultsTable = ({ rows, isLoading, isPolling, onTestRunCreated }: TestResultsTableProps): JSX.Element => { return ( ({ width: '100%', - flex: 1, minHeight: 0, [mqu.sm]: { overflowX: 'auto', padding: t.space.$0x25 }, })} >
+ ({ padding: `${t.space.$10} 0`, flex: 1 })} + > + + +
- ({ display: 'block', textAlign: 'center', padding: `${t.space.$10} 0` })} - /> -
({ background: t.colors.$colorBackground, height: '100%' })} > + + + + + + + - {isLoading ? ( + {isLoading || isPolling ? ( @@ -162,14 +218,22 @@ const TestResultsTable = ({ rows, isLoading }: { rows: TestResultRow[]; isLoadin justify='center' sx={t => ({ padding: `${t.space.$10} 0`, flex: 1 })} > - + ) : ( rows.map(row => ( - + + + )) )} @@ -179,11 +243,87 @@ const TestResultsTable = ({ rows, isLoading }: { rows: TestResultRow[]; isLoadin ); }; -const CopyTestUrlButton = (): JSX.Element => { +const TestRunTimestampCell = ({ testRun }: { testRun: EnterpriseConnectionTestRunResource }): JSX.Element | null => { + const { locale } = useLocalizations(); + + if (!testRun.createdAt) { + return null; + } + + const time = new Intl.DateTimeFormat(locale, { timeStyle: 'medium' }).format(testRun.createdAt); + const day = new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' }).format(testRun.createdAt); + + return ( + ({ whiteSpace: 'nowrap' })} + > + {time} + {day} + + ); +}; + +const TestRunDetailsCell = ({ testRun }: { testRun: EnterpriseConnectionTestRunResource }): JSX.Element | null => { + if (testRun.status === 'pending') { + return ( + ({ fontFamily: t.fonts.$mono })}> + - + + ); + } + + if (testRun.status === 'success') { + return ( + ({ fontFamily: t.fonts.$mono })}> + {testRun.parsedUserInfo?.emailAddress} + + ); + } + + return ( + ({ fontFamily: t.fonts.$mono })}> + {testRun.logs?.[0]?.shortMessage} + + ); +}; + +const TestRunStatusCell = ({ testRun }: { testRun: EnterpriseConnectionTestRunResource }): JSX.Element => { + if (testRun.status === 'success') { + return ( + + ); + } + if (testRun.status === 'failed') { + return ( + + ); + } + return ( + + ); +}; + +type CopyTestUrlButtonProps = { + /** Called once a new test run has been created and copied to the clipboard, with the generated test URL. */ + onTestRunCreated?: (testUrl: string) => void; +}; + +const CopyTestUrlButton = ({ onTestRunCreated }: CopyTestUrlButtonProps): JSX.Element => { const { t } = useLocalizations(); const { user } = useUser(); const card = useCardState(); - const { enterpriseConnection } = useConfigureSSOFlow(); + const { enterpriseConnection } = useConfigureSSO(); const [testUrl, setTestUrl] = useState(''); const [isCreatingTestRun, setIsCreatingTestRun] = useState(false); @@ -201,6 +341,7 @@ const CopyTestUrlButton = (): JSX.Element => { .then(({ url }) => { setTestUrl(url); onCopy(); + onTestRunCreated?.(url); }) .catch(err => handleError(err as Error, [], card.setError)) .finally(() => setIsCreatingTestRun(false)); @@ -223,6 +364,7 @@ const CopyTestUrlButton = (): JSX.Element => { Date: Thu, 14 May 2026 15:42:02 -0300 Subject: [PATCH 06/14] Open drawer on test click --- packages/localizations/src/en-US.ts | 3 + packages/shared/src/types/localization.ts | 3 + .../steps/TestConfigurationStep.tsx | 170 +++++++++++------- 3 files changed, 110 insertions(+), 66 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index dbaf94a9022..6f3ad6877ed 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -290,6 +290,9 @@ export const enUS: LocalizationResource = { status__failed: 'Failed', status__pending: 'Pending', }, + testRunDetails: { + title: 'Test run', + }, }, configureStep: { spFields: { diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index a9c80050ff7..c5953a0293d 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1354,6 +1354,9 @@ export type __internal_LocalizationResource = { status__failed: LocalizationValue; status__pending: LocalizationValue; }; + testRunDetails: { + title: LocalizationValue; + }; }; configureStep: { spFields: { diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 14fa00f31a1..85aa039a6ca 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -21,6 +21,7 @@ import { useLocalizations, } from '@/customizables'; import { useCardState } from '@/elements/contexts'; +import { Drawer } from '@/elements/Drawer'; import { ProfileSection } from '@/elements/Section'; import { useClipboard } from '@/hooks'; import { Check, Copy, RotateLeftRight } from '@/icons'; @@ -49,7 +50,7 @@ export const TestConfigurationStep = (): JSX.Element => { const hasSuccessfulTestRun = latest?.status === 'success'; const handleTestRunCreated = () => { - revalidateTestRuns(); + void revalidateTestRuns(); }; return ( @@ -152,7 +153,7 @@ export const TestConfigurationStep = (): JSX.Element => { goPrev()} /> goNext()} - isDisabled={isLastStep || !hasSuccessfulTestRun} + isDisabled={!hasSuccessfulTestRun || enterpriseConnection?.active} /> @@ -168,78 +169,115 @@ type TestResultsTableProps = { }; const TestResultsTable = ({ rows, isLoading, isPolling, onTestRunCreated }: TestResultsTableProps): JSX.Element => { + const { t } = useLocalizations(); + const [selectedTestRun, setSelectedTestRun] = useState(null); + + const drawerTitle = + selectedTestRun?.status === 'failed' + ? selectedTestRun.logs?.[0]?.shortMessage || + t(localizationKeys('configureSSO.testConfigurationStep.testRunDetails.title')) + : t(localizationKeys('configureSSO.testConfigurationStep.testRunDetails.title')); + return ( - ({ - width: '100%', - minHeight: 0, - [mqu.sm]: { overflowX: 'auto', padding: t.space.$0x25 }, - })} - > -
TimestampDetailsStatus
{row.id} + + + + + +
({ background: t.colors.$colorBackground, height: '100%' })} + <> + ({ + width: '100%', + minHeight: 0, + [mqu.sm]: { overflowX: 'auto', padding: t.space.$0x25 }, + })} > - - - - - - - - - {isLoading || isPolling ? ( +
TimestampDetailsStatus
({ background: t.colors.$colorBackground, height: '100%' })} + > + - - - ) : !rows.length ? ( - - + + + - ) : ( - rows.map(row => ( - + + + {isLoading || isPolling ? ( + - + + ) : !rows.length ? ( + - )) - )} - -
- ({ padding: `${t.space.$10} 0` })} - > - - - -
- ({ padding: `${t.space.$10} 0`, flex: 1 })} - > - - - TimestampDetailsStatus
- - - + ({ padding: `${t.space.$10} 0` })} + > + + +
- + ({ padding: `${t.space.$10} 0`, flex: 1 })} + > + +
-
+ ) : ( + rows.map(row => ( + setSelectedTestRun(row)} + sx={t => ({ + cursor: 'pointer', + '&:hover > td': { + backgroundColor: t.colors.$neutralAlpha50, + }, + })} + > + + + + + + + + + + + )) + )} + + + + + { + if (!open) { + setSelectedTestRun(null); + } + }} + > + + + + {null} + + + ); }; @@ -257,7 +295,7 @@ const TestRunTimestampCell = ({ testRun }: { testRun: EnterpriseConnectionTestRu ({ whiteSpace: 'nowrap' })} + sx={{ whiteSpace: 'nowrap' }} > {time} {day} From 5497caccc8f47de3fc61447c4e38f88fbc5b8501 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 14 May 2026 16:53:31 -0300 Subject: [PATCH 07/14] Add how to fix section for test runs --- packages/localizations/src/en-US.ts | 43 ++++ .../hooks/useEnterpriseConnectionTestRuns.tsx | 1 + packages/shared/src/types/localization.ts | 37 ++++ .../steps/TestConfigurationStep.tsx | 154 +++++++++++++- .../steps/TestRunHowToFixSection.tsx | 193 ++++++++++++++++++ packages/ui/src/elements/LineItems.tsx | 79 +++++-- 6 files changed, 478 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 6f3ad6877ed..85b310420ce 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -292,6 +292,49 @@ export const enUS: LocalizationResource = { }, testRunDetails: { title: 'Test run', + runDetails: { + sectionTitle: 'Run details', + timestamp: 'Timestamp', + status: 'Status', + errorCode: 'Error code', + fullMessage: 'Full message', + actionLabel__copy: 'Copy message', + actionLabel__copied: 'Copied', + }, + howToFix: { + sectionTitle: 'How to fix', + actionLabel__viewDocumentation: 'View documentation', + generic: + 'There is no specific guidance for this error. Refer to the documentation for general troubleshooting tips.', + saml_user_attribute_missing: { + intro: 'To fix this error, follow these steps:', + step1: "Access your identity provider's configuration dashboard.", + step2: "Navigate to your application's SAML settings or attribute mapping configuration.", + step3: "Ensure that the 'mail' attribute is properly mapped to the user's email address field.", + }, + saml_response_relaystate_missing: { + description: + 'Check that your identity provider is correctly returning the RelayState parameter that was sent in the original request.', + }, + saml_email_address_domain_mismatch: { + description: + '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.', + }, + oauth_access_denied: { + description: + "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.", + }, + oauth_token_exchange_error: { + description: + "Verify that your OAuth application's Client ID and Client Secret are correctly configured and match the credentials from your OAuth provider's dashboard.", + }, + oauth_fetch_user_error: { + intro: 'To fix this error, follow these steps:', + step1: + 'Verify that the OAuth scopes configured in your connection settings include the necessary permissions to read user profile information.', + step2: 'Ensure that the user info endpoint URL is correctly configured.', + }, + }, }, }, configureStep: { diff --git a/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx index 26574a4d375..1500d832b05 100644 --- a/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx +++ b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx @@ -103,6 +103,7 @@ function useEnterpriseConnectionTestRuns( return user?.getEnterpriseConnectionTestRuns(enterpriseConnectionId, fetchParams); }, enabled: queryEnabled, + refetchIntervalInBackground: false, refetchInterval: q => { if (!shouldPoll) { return false; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index c5953a0293d..54d2e053df3 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1356,6 +1356,43 @@ export type __internal_LocalizationResource = { }; testRunDetails: { title: LocalizationValue; + runDetails: { + sectionTitle: LocalizationValue; + timestamp: LocalizationValue; + status: LocalizationValue; + errorCode: LocalizationValue; + fullMessage: LocalizationValue; + actionLabel__copy: LocalizationValue; + actionLabel__copied: LocalizationValue; + }; + howToFix: { + sectionTitle: LocalizationValue; + actionLabel__viewDocumentation: LocalizationValue; + generic: LocalizationValue; + saml_user_attribute_missing: { + intro: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; + step3: LocalizationValue; + }; + saml_response_relaystate_missing: { + description: LocalizationValue; + }; + saml_email_address_domain_mismatch: { + description: LocalizationValue; + }; + oauth_access_denied: { + description: LocalizationValue; + }; + oauth_token_exchange_error: { + description: LocalizationValue; + }; + oauth_fetch_user_error: { + intro: LocalizationValue; + step1: LocalizationValue; + step2: LocalizationValue; + }; + }; }; }; configureStep: { diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 85aa039a6ca..0472dffde76 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -4,10 +4,12 @@ import { useState } from 'react'; import { Badge, + Box, Button, descriptors, Flex, Flow, + Heading, Icon, localizationKeys, Spinner, @@ -22,6 +24,8 @@ import { } from '@/customizables'; import { useCardState } from '@/elements/contexts'; import { Drawer } from '@/elements/Drawer'; +import { IconButton } from '@/elements/IconButton'; +import { LineItems } from '@/elements/LineItems'; import { ProfileSection } from '@/elements/Section'; import { useClipboard } from '@/hooks'; import { Check, Copy, RotateLeftRight } from '@/icons'; @@ -31,9 +35,10 @@ import { handleError } from '@/utils/errorHandler'; import { useConfigureSSO } from '../ConfigureSSOContext'; import { Step } from '../elements/Step'; import { useWizard } from '../elements/Wizard'; +import { TestRunHowToFixSection } from './TestRunHowToFixSection'; export const TestConfigurationStep = (): JSX.Element => { - const { goNext, goPrev, isLastStep } = useWizard(); + const { goNext, goPrev } = useWizard(); const { enterpriseConnection } = useConfigureSSO(); const { @@ -274,31 +279,166 @@ const TestResultsTable = ({ rows, isLoading, isPolling, onTestRunCreated }: Test - {null} + {selectedTestRun ? : null} ); }; -const TestRunTimestampCell = ({ testRun }: { testRun: EnterpriseConnectionTestRunResource }): JSX.Element | null => { +const useTestRunFormattedTimestamp = (testRun: EnterpriseConnectionTestRunResource) => { const { locale } = useLocalizations(); - if (!testRun.createdAt) { return null; } - const time = new Intl.DateTimeFormat(locale, { timeStyle: 'medium' }).format(testRun.createdAt); const day = new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' }).format(testRun.createdAt); + return { time, day }; +}; + +const TestRunTimestampCell = ({ testRun }: { testRun: EnterpriseConnectionTestRunResource }): JSX.Element | null => { + const formatted = useTestRunFormattedTimestamp(testRun); + if (!formatted) { + return null; + } + return ( - {time} - {day} + {formatted.time} + {formatted.day} + + ); +}; + +const TestRunDetailsBody = ({ testRun }: { testRun: EnterpriseConnectionTestRunResource }): JSX.Element => { + const formatted = useTestRunFormattedTimestamp(testRun); + const failedLog = testRun.status === 'failed' ? testRun.logs?.[0] : null; + + return ( + ({ + display: 'flex', + flexDirection: 'column', + flex: 1, + overflowY: 'auto', + padding: t.space.$4, + gap: t.space.$4, + })} + > + + + + {formatted ? ( + + + + + {formatted.time} + {formatted.day} + + + + ) : null} + + {testRun.status === 'failed' ? ( + failedLog?.code ? ( + + + + ({ fontFamily: t.fonts.$mono })}>{failedLog.code} + + + ) : null + ) : ( + + + + + + + )} + + + {testRun.status === 'failed' && failedLog?.message ? : null} + + {testRun.status === 'failed' ? : null} + + ); +}; + +const FullMessageBlock = ({ message }: { message: string }): JSX.Element => { + const { t } = useLocalizations(); + const { onCopy, hasCopied } = useClipboard(message); + const copyLabel = t( + localizationKeys( + hasCopied + ? 'configureSSO.testConfigurationStep.testRunDetails.runDetails.actionLabel__copied' + : 'configureSSO.testConfigurationStep.testRunDetails.runDetails.actionLabel__copy', + ), + ); + + return ( + + + + onCopy()} + /> + + ({ + margin: 0, + padding: t.space.$3, + backgroundColor: t.colors.$colorBackground, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + borderRadius: t.radii.$md, + boxShadow: t.shadows.$cardContentShadow, + fontFamily: t.fonts.$mono, + fontSize: t.fontSizes.$sm, + color: t.colors.$colorForeground, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + })} + > + {message} + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx new file mode 100644 index 00000000000..6fde9228dd3 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx @@ -0,0 +1,193 @@ +import { Box, Flex, Heading, Icon, Link, localizationKeys, Span, Text } from '@/customizables'; +import { ArrowRightIcon } from '@/icons'; + +import type { LocalizationKey } from '../../../localization'; + +const DOCS_BASE_URL = 'https://clerk.com/docs/guides/organizations/add-members/sso'; + +type HowToFixContent = + | { kind: 'description'; descriptionKey: LocalizationKey } + | { kind: 'steps'; introKey?: LocalizationKey; stepKeys: LocalizationKey[] }; + +const HOW_TO_FIX_BY_ERROR_CODE: Record = { + saml_user_attribute_missing: { + kind: 'steps', + introKey: localizationKeys( + 'configureSSO.testConfigurationStep.testRunDetails.howToFix.saml_user_attribute_missing.intro', + ), + stepKeys: [ + localizationKeys('configureSSO.testConfigurationStep.testRunDetails.howToFix.saml_user_attribute_missing.step1'), + localizationKeys('configureSSO.testConfigurationStep.testRunDetails.howToFix.saml_user_attribute_missing.step2'), + localizationKeys('configureSSO.testConfigurationStep.testRunDetails.howToFix.saml_user_attribute_missing.step3'), + ], + }, + saml_response_relaystate_missing: { + kind: 'description', + descriptionKey: localizationKeys( + 'configureSSO.testConfigurationStep.testRunDetails.howToFix.saml_response_relaystate_missing.description', + ), + }, + saml_email_address_domain_mismatch: { + kind: 'description', + descriptionKey: localizationKeys( + 'configureSSO.testConfigurationStep.testRunDetails.howToFix.saml_email_address_domain_mismatch.description', + ), + }, + oauth_access_denied: { + kind: 'description', + descriptionKey: localizationKeys( + 'configureSSO.testConfigurationStep.testRunDetails.howToFix.oauth_access_denied.description', + ), + }, + oauth_token_exchange_error: { + kind: 'description', + descriptionKey: localizationKeys( + 'configureSSO.testConfigurationStep.testRunDetails.howToFix.oauth_token_exchange_error.description', + ), + }, + oauth_fetch_user_error: { + kind: 'steps', + introKey: localizationKeys( + 'configureSSO.testConfigurationStep.testRunDetails.howToFix.oauth_fetch_user_error.intro', + ), + stepKeys: [ + localizationKeys('configureSSO.testConfigurationStep.testRunDetails.howToFix.oauth_fetch_user_error.step1'), + localizationKeys('configureSSO.testConfigurationStep.testRunDetails.howToFix.oauth_fetch_user_error.step2'), + ], + }, +}; + +type TestRunHowToFixSectionProps = { + errorCode: string | undefined; +}; + +export const TestRunHowToFixSection = ({ errorCode }: TestRunHowToFixSectionProps): JSX.Element | null => { + if (!errorCode) { + return null; + } + + const content = HOW_TO_FIX_BY_ERROR_CODE[errorCode]; + const docsHref = `${DOCS_BASE_URL}#${errorCode.replaceAll('_', '-')}`; + + return ( + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + paddingTop: t.space.$4, + })} + > + + + ({ + padding: t.space.$3, + backgroundColor: t.colors.$colorBackground, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + borderRadius: t.radii.$md, + boxShadow: t.shadows.$cardContentShadow, + })} + > + {content ? ( + + ) : ( + + )} + + + ({ + alignSelf: 'flex-start', + display: 'inline-flex', + alignItems: 'center', + gap: t.space.$1x5, + paddingBlock: t.space.$1, + paddingInline: t.space.$3, + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + color: t.colors.$colorForeground, + fontSize: t.fontSizes.$sm, + fontWeight: t.fontWeights.$medium, + textDecoration: 'none', + '&:hover': { backgroundColor: t.colors.$neutralAlpha50, textDecoration: 'none' }, + })} + > + + + + + ); +}; + +const HowToFixContent = ({ content }: { content: HowToFixContent }): JSX.Element => { + if (content.kind === 'description') { + return ( + + ); + } + + return ( + + {content.introKey ? ( + + ) : null} + ({ + margin: 0, + paddingInlineStart: t.space.$5, + display: 'flex', + flexDirection: 'column', + gap: t.space.$1, + })} + > + {content.stepKeys.map((stepKey, idx) => ( + ({ color: t.colors.$colorMutedForeground })} + > + + + ))} + + + ); +}; diff --git a/packages/ui/src/elements/LineItems.tsx b/packages/ui/src/elements/LineItems.tsx index ab5173392ba..9ccaa6cb903 100644 --- a/packages/ui/src/elements/LineItems.tsx +++ b/packages/ui/src/elements/LineItems.tsx @@ -150,33 +150,49 @@ const Title = React.forwardRef(({ title, descr * LineItems.Description * -----------------------------------------------------------------------------------------------*/ -interface DescriptionProps { - text: string | LocalizationKey; - /** - * When true, the text will be truncated with an ellipsis in the middle and the last 5 characters will be visible. - * @default `false` - */ - truncateText?: boolean; - /** - * When true, there will be a button to copy the providedtext. - * @default `false` - */ - copyText?: boolean; - /** - * The visually hidden label for the copy button. - * @default `Copy` - */ - copyLabel?: string; - prefix?: string | LocalizationKey; - suffix?: string | LocalizationKey; -} +type DescriptionProps = + | { + /** + * Custom value content. When provided, `text`, `prefix`, `suffix`, `truncateText`, + * `copyText`, and `copyLabel` are ignored. + */ + children: React.ReactNode; + text?: never; + truncateText?: never; + copyText?: never; + copyLabel?: never; + prefix?: never; + suffix?: never; + } + | { + children?: never; + text: string | LocalizationKey; + /** + * When true, the text will be truncated with an ellipsis in the middle and the last 5 characters will be visible. + * @default `false` + */ + truncateText?: boolean; + /** + * When true, there will be a button to copy the providedtext. + * @default `false` + */ + copyText?: boolean; + /** + * The visually hidden label for the copy button. + * @default `Copy` + */ + copyLabel?: string; + prefix?: string | LocalizationKey; + suffix?: string | LocalizationKey; + }; -function Description({ text, prefix, suffix, truncateText = false, copyText = false, copyLabel }: DescriptionProps) { +function Description(props: DescriptionProps) { const context = React.useContext(GroupContext); if (!context) { throw new Error('LineItems.Description must be used within LineItems.Group'); } const { variant } = context; + return (
+ {'children' in props && props.children !== undefined ? ( + props.children + ) : ( + )} /> + )} +
+ ); +} + +function DescriptionContent({ + text, + prefix, + suffix, + truncateText = false, + copyText = false, + copyLabel, +}: Exclude) { + return ( + <> ({ @@ -240,7 +275,7 @@ function Description({ text, prefix, suffix, truncateText = false, copyText = fa })} /> ) : null} - + ); } From 731a332587c6f41e58e67946b2e862e130bf6c0e Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 14 May 2026 17:16:41 -0300 Subject: [PATCH 08/14] Display parsed user info for success --- packages/localizations/src/en-US.ts | 7 ++- packages/shared/src/types/localization.ts | 6 +- .../steps/TestConfigurationStep.tsx | 57 +++++++++++++++++++ .../steps/TestRunHowToFixSection.tsx | 17 +++--- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 85b310420ce..357de38fe05 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -301,11 +301,14 @@ export const enUS: LocalizationResource = { actionLabel__copy: 'Copy message', actionLabel__copied: 'Copied', }, + parsedUserInfo: { + sectionTitle: 'Parsed user info', + email: 'Email', + firstName: 'First name', + }, howToFix: { sectionTitle: 'How to fix', actionLabel__viewDocumentation: 'View documentation', - generic: - 'There is no specific guidance for this error. Refer to the documentation for general troubleshooting tips.', saml_user_attribute_missing: { intro: 'To fix this error, follow these steps:', step1: "Access your identity provider's configuration dashboard.", diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 54d2e053df3..41a8832194e 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1365,10 +1365,14 @@ export type __internal_LocalizationResource = { actionLabel__copy: LocalizationValue; actionLabel__copied: LocalizationValue; }; + parsedUserInfo: { + sectionTitle: LocalizationValue; + email: LocalizationValue; + firstName: LocalizationValue; + }; howToFix: { sectionTitle: LocalizationValue; actionLabel__viewDocumentation: LocalizationValue; - generic: LocalizationValue; saml_user_attribute_missing: { intro: LocalizationValue; step1: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 0472dffde76..213fcf170d3 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -381,10 +381,67 @@ const TestRunDetailsBody = ({ testRun }: { testRun: EnterpriseConnectionTestRunR {testRun.status === 'failed' && failedLog?.message ? : null} {testRun.status === 'failed' ? : null} + + {testRun.status === 'success' ? : null} ); }; +const ParsedUserInfoSection = ({ + parsedUserInfo, +}: { + parsedUserInfo: EnterpriseConnectionTestRunResource['parsedUserInfo']; +}): JSX.Element | null => { + if (!parsedUserInfo?.emailAddress && !parsedUserInfo?.firstName) { + return null; + } + + return ( + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + paddingTop: t.space.$4, + })} + > + + + + {parsedUserInfo.emailAddress ? ( + + + + ({ fontFamily: t.fonts.$mono })}>{parsedUserInfo.emailAddress} + + + ) : null} + + {parsedUserInfo.firstName ? ( + + + + ({ fontFamily: t.fonts.$mono })}>{parsedUserInfo.firstName} + + + ) : null} + + + ); +}; + const FullMessageBlock = ({ message }: { message: string }): JSX.Element => { const { t } = useLocalizations(); const { onCopy, hasCopied } = useClipboard(message); diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx index 6fde9228dd3..027f84c2b9f 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx @@ -67,6 +67,10 @@ export const TestRunHowToFixSection = ({ errorCode }: TestRunHowToFixSectionProp } const content = HOW_TO_FIX_BY_ERROR_CODE[errorCode]; + if (!content) { + return null; + } + const docsHref = `${DOCS_BASE_URL}#${errorCode.replaceAll('_', '-')}`; return ( @@ -97,14 +101,7 @@ export const TestRunHowToFixSection = ({ errorCode }: TestRunHowToFixSectionProp boxShadow: t.shadows.$cardContentShadow, })} > - {content ? ( - - ) : ( - - )} + - {content.stepKeys.map((stepKey, idx) => ( + {content.stepKeys.map(stepKey => ( ({ color: t.colors.$colorMutedForeground })} > From 0c30f012ef77d3e3cd71c92671f305771a82c55a Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 14 May 2026 17:25:14 -0300 Subject: [PATCH 09/14] Add pagination --- .../steps/TestConfigurationStep.tsx | 46 ++++++++++- .../steps/TestRunHowToFixSection.tsx | 79 +++++++++++-------- 2 files changed, 89 insertions(+), 36 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 213fcf170d3..ca03ab69250 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -26,6 +26,7 @@ import { useCardState } from '@/elements/contexts'; import { Drawer } from '@/elements/Drawer'; import { IconButton } from '@/elements/IconButton'; import { LineItems } from '@/elements/LineItems'; +import { Pagination } from '@/elements/Pagination'; import { ProfileSection } from '@/elements/Section'; import { useClipboard } from '@/hooks'; import { Check, Copy, RotateLeftRight } from '@/icons'; @@ -37,24 +38,31 @@ import { Step } from '../elements/Step'; import { useWizard } from '../elements/Wizard'; import { TestRunHowToFixSection } from './TestRunHowToFixSection'; +const TEST_RUNS_PAGE_SIZE = 5; + export const TestConfigurationStep = (): JSX.Element => { const { goNext, goPrev } = useWizard(); const { enterpriseConnection } = useConfigureSSO(); + const [currentPage, setCurrentPage] = useState(1); + const { data: testRuns, latest, + totalCount, isLoading: areTestRunsLoading, isPolling, revalidate: revalidateTestRuns, } = __internal_useEnterpriseConnectionTestRuns({ enterpriseConnectionId: enterpriseConnection?.id ?? null, - params: { initialPage: 1, pageSize: 10 }, + params: { initialPage: currentPage, pageSize: TEST_RUNS_PAGE_SIZE }, }); const hasSuccessfulTestRun = latest?.status === 'success'; + const pageCount = totalCount ? Math.ceil(totalCount / TEST_RUNS_PAGE_SIZE) : 0; const handleTestRunCreated = () => { + setCurrentPage(1); void revalidateTestRuns(); }; @@ -149,6 +157,11 @@ export const TestConfigurationStep = (): JSX.Element => { isLoading={areTestRunsLoading} onTestRunCreated={handleTestRunCreated} isPolling={isPolling} + page={currentPage} + pageCount={pageCount} + pageSize={TEST_RUNS_PAGE_SIZE} + totalCount={totalCount ?? 0} + onPageChange={setCurrentPage} /> @@ -171,9 +184,24 @@ type TestResultsTableProps = { isLoading: boolean; isPolling: boolean; onTestRunCreated?: (testUrl: string) => void; + page: number; + pageCount: number; + pageSize: number; + totalCount: number; + onPageChange: (page: number) => void; }; -const TestResultsTable = ({ rows, isLoading, isPolling, onTestRunCreated }: TestResultsTableProps): JSX.Element => { +const TestResultsTable = ({ + rows, + isLoading, + isPolling, + onTestRunCreated, + page, + pageCount, + pageSize, + totalCount, + onPageChange, +}: TestResultsTableProps): JSX.Element => { const { t } = useLocalizations(); const [selectedTestRun, setSelectedTestRun] = useState(null); @@ -268,6 +296,20 @@ const TestResultsTable = ({ rows, isLoading, isPolling, onTestRunCreated }: Test
+ {pageCount > 1 ? ( + 0 ? Math.max(0, (page - 1) * pageSize) + 1 : 0, + endingRow: Math.min(page * pageSize, totalCount), + }} + /> + ) : null} + { diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx index 027f84c2b9f..ed8f50d6b75 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx @@ -66,6 +66,8 @@ export const TestRunHowToFixSection = ({ errorCode }: TestRunHowToFixSectionProp return null; } + errorCode = 'saml_user_attribute_missing'; + const content = HOW_TO_FIX_BY_ERROR_CODE[errorCode]; if (!content) { return null; @@ -102,40 +104,41 @@ export const TestRunHowToFixSection = ({ errorCode }: TestRunHowToFixSectionProp })} > - - ({ - alignSelf: 'flex-start', - display: 'inline-flex', - alignItems: 'center', - gap: t.space.$1x5, - paddingBlock: t.space.$1, - paddingInline: t.space.$3, - borderRadius: t.radii.$md, - borderWidth: t.borderWidths.$normal, - borderStyle: t.borderStyles.$solid, - borderColor: t.colors.$borderAlpha150, - color: t.colors.$colorForeground, - fontSize: t.fontSizes.$sm, - fontWeight: t.fontWeights.$medium, - textDecoration: 'none', - '&:hover': { backgroundColor: t.colors.$neutralAlpha50, textDecoration: 'none' }, - })} - > - - - + ({ + alignSelf: 'flex-start', + display: 'inline-flex', + alignItems: 'center', + gap: t.space.$1x5, + paddingBlock: t.space.$1, + paddingInline: t.space.$3, + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + color: t.colors.$colorForeground, + fontSize: t.fontSizes.$sm, + fontWeight: t.fontWeights.$medium, + textDecoration: 'none', + '&:hover': { backgroundColor: t.colors.$neutralAlpha50, textDecoration: 'none' }, + marginTop: t.space.$2, + })} + > + + + + ); }; @@ -166,6 +169,7 @@ const HowToFixContent = ({ content }: { content: HowToFixContent }): JSX.Element sx={t => ({ margin: 0, paddingInlineStart: t.space.$5, + listStyleType: 'decimal', display: 'flex', flexDirection: 'column', gap: t.space.$1, @@ -175,7 +179,14 @@ const HowToFixContent = ({ content }: { content: HowToFixContent }): JSX.Element ({ color: t.colors.$colorMutedForeground })} + sx={t => ({ + color: t.colors.$colorMutedForeground, + fontSize: t.fontSizes.$sm, + '&::marker': { + color: t.colors.$colorMutedForeground, + fontSize: t.fontSizes.$sm, + }, + })} > Date: Thu, 14 May 2026 18:00:42 -0300 Subject: [PATCH 10/14] Query for successful test on continue click --- packages/localizations/src/en-US.ts | 2 + packages/shared/src/types/localization.ts | 1 + .../ui/src/components/APIKeys/APIKeys.tsx | 1 - .../steps/TestConfigurationStep.tsx | 150 +++++++++++++++--- .../steps/TestRunHowToFixSection.tsx | 2 - 5 files changed, 129 insertions(+), 27 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 357de38fe05..2c53f0070d1 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -277,6 +277,8 @@ export const enUS: LocalizationResource = { testConfigurationStep: { title: 'Test your SSO connection', subtitle: 'Authenticate using the test SSO URL to verify you configured the connection correctly.', + error__noSuccessfulTestRun: + 'You need at least one successful test run before you can continue. Generate a test SSO URL and complete the sign-in flow.', testUrl: { title: 'Test your SSO URL', subtitle: 'Generate and copy a test SSO URL to authenticate with.', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 41a8832194e..05d9a743102 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1341,6 +1341,7 @@ export type __internal_LocalizationResource = { testConfigurationStep: { title: LocalizationValue; subtitle: LocalizationValue; + error__noSuccessfulTestRun: LocalizationValue; testUrl: { title: LocalizationValue; subtitle: LocalizationValue; diff --git a/packages/ui/src/components/APIKeys/APIKeys.tsx b/packages/ui/src/components/APIKeys/APIKeys.tsx index e4e6dbddb4b..6dfe727ce14 100644 --- a/packages/ui/src/components/APIKeys/APIKeys.tsx +++ b/packages/ui/src/components/APIKeys/APIKeys.tsx @@ -208,7 +208,6 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr /> - {/* here reference to paginated table for test runs */} { const { goNext, goPrev } = useWizard(); const { enterpriseConnection } = useConfigureSSO(); + const card = useCardState(); const [currentPage, setCurrentPage] = useState(1); const { data: testRuns, - latest, totalCount, isLoading: areTestRunsLoading, isPolling, @@ -58,7 +59,6 @@ export const TestConfigurationStep = (): JSX.Element => { params: { initialPage: currentPage, pageSize: TEST_RUNS_PAGE_SIZE }, }); - const hasSuccessfulTestRun = latest?.status === 'success'; const pageCount = totalCount ? Math.ceil(totalCount / TEST_RUNS_PAGE_SIZE) : 0; const handleTestRunCreated = () => { @@ -167,11 +167,33 @@ export const TestConfigurationStep = (): JSX.Element => { + {card.error ? ( + ({ + flexShrink: 0, + paddingInline: t.space.$5, + paddingBlock: t.space.$3, + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + })} + > + ({ color: t.colors.$danger500, fontSize: t.fontSizes.$sm })} + > + {card.error} + + + ) : null} + goPrev()} /> - goNext()} - isDisabled={!hasSuccessfulTestRun || enterpriseConnection?.active} + void goNext()} /> @@ -179,6 +201,58 @@ export const TestConfigurationStep = (): JSX.Element => { ); }; +type ContinueTestSsoStepButtonProps = { + enterpriseConnectionId: string | undefined; + isConnectionActive: boolean | undefined; + onContinue: () => void; +}; + +const ContinueTestSsoStepButton = ({ + enterpriseConnectionId, + isConnectionActive, + onContinue, +}: ContinueTestSsoStepButtonProps): JSX.Element => { + const { user } = useUser(); + const { t } = useLocalizations(); + const card = useCardState(); + const [isValidating, setIsValidating] = useState(false); + + const handleContinue = async () => { + if (!user || !enterpriseConnectionId) { + return; + } + + setIsValidating(true); + card.setError(undefined); + + try { + const result = await user.getEnterpriseConnectionTestRuns(enterpriseConnectionId, { + initialPage: 1, + pageSize: 1, + status: ['success'], + }); + + if (result.data.length > 0) { + onContinue(); + } else { + card.setError(t(localizationKeys('configureSSO.testConfigurationStep.error__noSuccessfulTestRun'))); + } + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + setIsValidating(false); + } + }; + + return ( + void handleContinue()} + isLoading={isValidating} + isDisabled={!enterpriseConnectionId || isConnectionActive} + /> + ); +}; + type TestResultsTableProps = { rows: EnterpriseConnectionTestRunResource[]; isLoading: boolean; @@ -214,27 +288,53 @@ const TestResultsTable = ({ return ( <> ({ width: '100%', + flex: '0 1 auto', minHeight: 0, + overflowY: 'auto', + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha150, + borderRadius: t.radii.$lg, + ...common.unstyledScrollbar(t), [mqu.sm]: { overflowX: 'auto', padding: t.space.$0x25 }, })} > ({ background: t.colors.$colorBackground, height: '100%' })} + sx={t => ({ + background: t.colors.$colorBackground, + '&&': { + border: 'none', + borderRadius: 0, + }, + })} > - - - + {isLoading || isPolling ? ( - -
TimestampDetailsStatus + +
+ ) : !rows.length ? (
+ {pageCount > 1 ? ( - 0 ? Math.max(0, (page - 1) * pageSize) + 1 : 0, - endingRow: Math.min(page * pageSize, totalCount), - }} - /> + + 0 ? Math.max(0, (page - 1) * pageSize) + 1 : 0, + endingRow: Math.min(page * pageSize, totalCount), + }} + /> + ) : null} { setTestUrl(url); - onCopy(); + onCopy(url); onTestRunCreated?.(url); }) .catch(err => handleError(err as Error, [], card.setError)) diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx index ed8f50d6b75..deab36035e5 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx @@ -66,8 +66,6 @@ export const TestRunHowToFixSection = ({ errorCode }: TestRunHowToFixSectionProp return null; } - errorCode = 'saml_user_attribute_missing'; - const content = HOW_TO_FIX_BY_ERROR_CODE[errorCode]; if (!content) { return null; From 691c6312792526cb9d7c123d425faea0e126fabd Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Thu, 14 May 2026 18:50:48 -0300 Subject: [PATCH 11/14] Add changeset --- .changeset/three-ducks-hang.md | 7 + .../hooks/useEnterpriseConnectionTestRuns.tsx | 17 +-- .../ui/src/components/APIKeys/APIKeys.tsx | 4 +- .../steps/TestConfigurationStep.tsx | 122 ++++++++++-------- .../steps/TestRunHowToFixSection.tsx | 2 +- packages/ui/src/elements/LineItems.tsx | 79 ++++-------- 6 files changed, 102 insertions(+), 129 deletions(-) create mode 100644 .changeset/three-ducks-hang.md diff --git a/.changeset/three-ducks-hang.md b/.changeset/three-ducks-hang.md new file mode 100644 index 00000000000..c885aa0dd7d --- /dev/null +++ b/.changeset/three-ducks-hang.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Add test step for `<__experimental_ConfigureSSO />` diff --git a/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx index 1500d832b05..4b78a85a9b3 100644 --- a/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx +++ b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx @@ -37,24 +37,16 @@ export type UseEnterpriseConnectionTestRunsParams = { export type UseEnterpriseConnectionTestRunsReturn = { data: EnterpriseConnectionTestRunResource[] | undefined; - /** Convenience accessor for the most recent run (i.e. `data[0]`). */ - latest: EnterpriseConnectionTestRunResource | undefined; totalCount: number | undefined; error: Error | null; isLoading: boolean; isFetching: boolean; /** - * `true` while the hook is actively polling for the first record to appear. - * Becomes `true` once `revalidate()` is called against an empty list and - * flips back to `false` permanently as soon as the response contains at - * least one record. + * `true` while the hook is actively polling for the first record to appear */ isPolling: boolean; /** - * Force a refetch and (if the list is currently empty) arm polling. Once any - * record has been observed in the response, polling is disabled for the rest - * of this hook instance's lifetime — subsequent `revalidate()` calls just - * trigger a single refetch. + * Force a refetch and (if the list is currently empty) arm polling */ revalidate: () => Promise; }; @@ -102,8 +94,6 @@ function useEnterpriseConnectionTestRuns( } return user?.getEnterpriseConnectionTestRuns(enterpriseConnectionId, fetchParams); }, - enabled: queryEnabled, - refetchIntervalInBackground: false, refetchInterval: q => { if (!shouldPoll) { return false; @@ -112,6 +102,8 @@ function useEnterpriseConnectionTestRuns( const hasRows = (q.state.data?.data?.length ?? 0) > 0; return hasRows ? false : pollIntervalMs; }, + enabled: queryEnabled, + refetchIntervalInBackground: false, }); const hasRows = (query.data?.data?.length ?? 0) > 0; @@ -135,7 +127,6 @@ function useEnterpriseConnectionTestRuns( return { data: query.data?.data, - latest: query.data?.data?.[0], totalCount: query.data?.total_count, error: query.error ?? null, isLoading: query.isLoading, diff --git a/packages/ui/src/components/APIKeys/APIKeys.tsx b/packages/ui/src/components/APIKeys/APIKeys.tsx index 6dfe727ce14..d5a5d2b2a09 100644 --- a/packages/ui/src/components/APIKeys/APIKeys.tsx +++ b/packages/ui/src/components/APIKeys/APIKeys.tsx @@ -1,6 +1,6 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import { isOrganizationId } from '@clerk/shared/internal/clerk-js/organization'; -import { __internal_useOrganizationBase, useAPIKeys, useClerk, useUser } from '@clerk/shared/react'; +import { useAPIKeys, __internal_useOrganizationBase, useClerk, useUser } from '@clerk/shared/react'; import type { APIKeyResource } from '@clerk/shared/types'; import { lazy, useState } from 'react'; @@ -22,9 +22,9 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { InputWithIcon } from '@/ui/elements/InputWithIcon'; import { Pagination } from '@/ui/elements/Pagination'; import { useDebounce } from '@/ui/hooks'; +import { handleError } from '@/ui/utils/errorHandler'; import { MagnifyingGlass } from '@/ui/icons'; import { mqu } from '@/ui/styledSystem'; -import { handleError } from '@/ui/utils/errorHandler'; import { APIKeysTable } from './ApiKeysTable'; import type { OnCreateParams } from './CreateAPIKeyForm'; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 916b8757ec7..582e7b7f3a4 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -1,12 +1,17 @@ import { __internal_useEnterpriseConnectionTestRuns, useUser } from '@clerk/shared/react/index'; import type { EnterpriseConnectionTestRunResource } from '@clerk/shared/types'; +import type { ReactNode } from 'react'; import { useState } from 'react'; +import type { LocalizationKey } from '@/customizables'; import { Badge, Box, Button, + Dd, descriptors, + Dl, + Dt, Flex, Flow, Heading, @@ -25,7 +30,6 @@ import { import { useCardState } from '@/elements/contexts'; import { Drawer } from '@/elements/Drawer'; import { IconButton } from '@/elements/IconButton'; -import { LineItems } from '@/elements/LineItems'; import { Pagination } from '@/elements/Pagination'; import { ProfileSection } from '@/elements/Section'; import { useClipboard } from '@/hooks'; @@ -154,14 +158,14 @@ export const TestConfigurationStep = (): JSX.Element => { > @@ -459,6 +463,34 @@ const TestRunTimestampCell = ({ testRun }: { testRun: EnterpriseConnectionTestRu ); }; +const DetailRow = ({ title, children }: { title: LocalizationKey; children: ReactNode }): JSX.Element => ( + ({ + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + gap: t.space.$2, + })} + > +
({ + color: t.colors.$colorForeground, + ...common.textVariants(t).subtitle, + })} + /> +
({ + display: 'grid', + justifyContent: 'end', + color: t.colors.$colorForeground, + })} + > + {children} +
+
+); + const TestRunDetailsBody = ({ testRun }: { testRun: EnterpriseConnectionTestRunResource }): JSX.Element => { const formatted = useTestRunFormattedTimestamp(testRun); const failedLog = testRun.status === 'failed' ? testRun.logs?.[0] : null; @@ -480,47 +512,34 @@ const TestRunDetailsBody = ({ testRun }: { testRun: EnterpriseConnectionTestRunR localizationKey={localizationKeys('configureSSO.testConfigurationStep.testRunDetails.runDetails.sectionTitle')} /> - +
({ display: 'grid', gridRowGap: t.space.$2 })}> {formatted ? ( - - - - - {formatted.time} - {formatted.day} - - - + + + {formatted.time} + {formatted.day} + + ) : null} {testRun.status === 'failed' ? ( failedLog?.code ? ( - - - - ({ fontFamily: t.fonts.$mono })}>{failedLog.code} - - + + ({ fontFamily: t.fonts.$mono })}>{failedLog.code} + ) : null ) : ( - - - - - - + + + )} - +
{testRun.status === 'failed' && failedLog?.message ? : null} @@ -559,29 +578,21 @@ const ParsedUserInfoSection = ({ )} /> - +
({ display: 'grid', gridRowGap: t.space.$2 })}> {parsedUserInfo.emailAddress ? ( - - - - ({ fontFamily: t.fonts.$mono })}>{parsedUserInfo.emailAddress} - - + + ({ fontFamily: t.fonts.$mono })}>{parsedUserInfo.emailAddress} + ) : null} {parsedUserInfo.firstName ? ( - - - - ({ fontFamily: t.fonts.$mono })}>{parsedUserInfo.firstName} - - + + ({ fontFamily: t.fonts.$mono })}>{parsedUserInfo.firstName} + ) : null} - +
); }; @@ -694,7 +705,6 @@ const TestRunStatusCell = ({ testRun }: { testRun: EnterpriseConnectionTestRunRe }; type CopyTestUrlButtonProps = { - /** Called once a new test run has been created and copied to the clipboard, with the generated test URL. */ onTestRunCreated?: (testUrl: string) => void; }; @@ -719,7 +729,7 @@ const CopyTestUrlButton = ({ onTestRunCreated }: CopyTestUrlButtonProps): JSX.El .createEnterpriseConnectionTestRun(enterpriseConnection.id) .then(({ url }) => { setTestUrl(url); - onCopy(url); + onCopy(); onTestRunCreated?.(url); }) .catch(err => handleError(err as Error, [], card.setError)) diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx index deab36035e5..1b08d8d12e0 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx @@ -122,8 +122,8 @@ export const TestRunHowToFixSection = ({ errorCode }: TestRunHowToFixSectionProp fontSize: t.fontSizes.$sm, fontWeight: t.fontWeights.$medium, textDecoration: 'none', - '&:hover': { backgroundColor: t.colors.$neutralAlpha50, textDecoration: 'none' }, marginTop: t.space.$2, + '&:hover': { backgroundColor: t.colors.$neutralAlpha50, textDecoration: 'none' }, })} > (({ title, descr * LineItems.Description * -----------------------------------------------------------------------------------------------*/ -type DescriptionProps = - | { - /** - * Custom value content. When provided, `text`, `prefix`, `suffix`, `truncateText`, - * `copyText`, and `copyLabel` are ignored. - */ - children: React.ReactNode; - text?: never; - truncateText?: never; - copyText?: never; - copyLabel?: never; - prefix?: never; - suffix?: never; - } - | { - children?: never; - text: string | LocalizationKey; - /** - * When true, the text will be truncated with an ellipsis in the middle and the last 5 characters will be visible. - * @default `false` - */ - truncateText?: boolean; - /** - * When true, there will be a button to copy the providedtext. - * @default `false` - */ - copyText?: boolean; - /** - * The visually hidden label for the copy button. - * @default `Copy` - */ - copyLabel?: string; - prefix?: string | LocalizationKey; - suffix?: string | LocalizationKey; - }; +interface DescriptionProps { + text: string | LocalizationKey; + /** + * When true, the text will be truncated with an ellipsis in the middle and the last 5 characters will be visible. + * @default `false` + */ + truncateText?: boolean; + /** + * When true, there will be a button to copy the providedtext. + * @default `false` + */ + copyText?: boolean; + /** + * The visually hidden label for the copy button. + * @default `Copy` + */ + copyLabel?: string; + prefix?: string | LocalizationKey; + suffix?: string | LocalizationKey; +} -function Description(props: DescriptionProps) { +function Description({ text, prefix, suffix, truncateText = false, copyText = false, copyLabel }: DescriptionProps) { const context = React.useContext(GroupContext); if (!context) { throw new Error('LineItems.Description must be used within LineItems.Group'); } const { variant } = context; - return (
- {'children' in props && props.children !== undefined ? ( - props.children - ) : ( - )} /> - )} -
- ); -} - -function DescriptionContent({ - text, - prefix, - suffix, - truncateText = false, - copyText = false, - copyLabel, -}: Exclude) { - return ( - <> ({ @@ -275,7 +240,7 @@ function DescriptionContent({ })} /> ) : null} - + ); } From 37a23161d78dd75c851269f9153461039cca00dd Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Fri, 15 May 2026 13:30:05 -0300 Subject: [PATCH 12/14] Open drawer as relative to component content --- packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx | 9 ++++++--- .../src/components/ConfigureSSO/ConfigureSSOContext.tsx | 9 ++++++++- .../ConfigureSSO/steps/TestConfigurationStep.tsx | 3 +++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index edb4fed4127..e366df1078f 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -55,7 +55,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { })} > - + @@ -63,7 +63,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { ); }); -const ConfigureSSOCardContent = () => { +const ConfigureSSOCardContent = ({ contentRef }: { contentRef: React.RefObject }) => { const { data: enterpriseConnections, isLoading } = __internal_useUserEnterpriseConnections({ enabled: true }); // Currently FAPI only supports one enterprise connection per user @@ -74,7 +74,10 @@ const ConfigureSSOCardContent = () => { } return ( - + ); diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index 1df2c315e6a..db6ac7dd9e9 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -24,10 +24,15 @@ export interface ConfigureSSOData { * connection has been created. */ setProvider: (provider: ProviderType) => void; + /** + * Ref to the scrollable content container of the wizard. + */ + contentRef: React.RefObject; } interface ConfigureSSOProviderProps { enterpriseConnection: EnterpriseConnectionResource | undefined; + contentRef: React.RefObject; } const ConfigureSSOContext = React.createContext(null); @@ -35,6 +40,7 @@ ConfigureSSOContext.displayName = 'ConfigureSSOContext'; export const ConfigureSSOProvider = ({ enterpriseConnection, + contentRef, children, }: PropsWithChildren): JSX.Element => { const [provider, setProvider] = React.useState( @@ -49,8 +55,9 @@ export const ConfigureSSOProvider = ({ enterpriseConnection, provider, setProvider, + contentRef, }), - [initialStepId, enterpriseConnection, provider], + [initialStepId, enterpriseConnection, provider, contentRef], ); return {children}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 582e7b7f3a4..e3eff1bfa81 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -281,6 +281,7 @@ const TestResultsTable = ({ onPageChange, }: TestResultsTableProps): JSX.Element => { const { t } = useLocalizations(); + const { contentRef } = useConfigureSSO(); const [selectedTestRun, setSelectedTestRun] = useState(null); const drawerTitle = @@ -423,6 +424,8 @@ const TestResultsTable = ({ setSelectedTestRun(null); } }} + strategy='absolute' + portalProps={{ root: contentRef }} > From 15944be9b06a2fcc90609ece40e7c1a291a9ec0a Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Fri, 15 May 2026 15:28:50 -0300 Subject: [PATCH 13/14] Use invalidation key --- .../src/react/hooks/useEnterpriseConnectionTestRuns.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx index 4b78a85a9b3..7f7338f36d2 100644 --- a/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx +++ b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx @@ -70,7 +70,7 @@ function useEnterpriseConnectionTestRuns( const user = useUserBase(); const [queryClient] = useClerkQueryClient(); - const { queryKey, stableKey, authenticated } = useEnterpriseConnectionTestRunsCacheKeys({ + const { queryKey, invalidationKey, stableKey, authenticated } = useEnterpriseConnectionTestRunsCacheKeys({ userId: user?.id ?? null, enterpriseConnectionId, args: fetchParams, @@ -120,8 +120,8 @@ function useEnterpriseConnectionTestRuns( if (!hasRows) { setShouldPoll(true); } - await queryClient.invalidateQueries({ queryKey: [stableKey] }); - }, [queryClient, stableKey, hasRows]); + await queryClient.invalidateQueries({ queryKey: invalidationKey }); + }, [queryClient, invalidationKey, hasRows]); const isPolling = queryEnabled && shouldPoll && !hasRows; From 807338e73ed1f487ce8d2c2a76fa75db351ecf37 Mon Sep 17 00:00:00 2001 From: Laura Beatris Date: Fri, 15 May 2026 15:32:14 -0300 Subject: [PATCH 14/14] Fix stale closure with `onCopy` --- .../ConfigureSSO/steps/TestConfigurationStep.tsx | 2 +- .../components/PaymentAttempts/PaymentAttemptPage.tsx | 2 +- packages/ui/src/components/Statements/Statement.tsx | 2 +- .../src/components/UserProfile/MfaBackupCodeList.tsx | 2 +- packages/ui/src/elements/ClipboardInput.tsx | 2 +- packages/ui/src/elements/LineItems.tsx | 2 +- packages/ui/src/hooks/useClipboard.ts | 11 +++++++---- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index e3eff1bfa81..7b9f115355d 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -732,7 +732,7 @@ const CopyTestUrlButton = ({ onTestRunCreated }: CopyTestUrlButtonProps): JSX.El .createEnterpriseConnectionTestRun(enterpriseConnection.id) .then(({ url }) => { setTestUrl(url); - onCopy(); + onCopy(url); onTestRunCreated?.(url); }) .catch(err => handleError(err as Error, [], card.setError)) diff --git a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx index 2ebd2973aa2..74220d9bd88 100644 --- a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -266,7 +266,7 @@ function CopyButton({ text, copyLabel = 'Copy' }: { text: string; copyLabel?: st