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/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 39999361a42..2c53f0070d1 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -274,6 +274,74 @@ 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.', + 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.', + actionLabel__copy: 'Copy test URL', + }, + testResults: { + title: 'Test results', + actionLabel__refresh: 'Refresh logs', + polling: 'Waiting for the test run to complete…', + status__success: 'Success', + status__failed: 'Failed', + status__pending: 'Pending', + }, + 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', + }, + parsedUserInfo: { + sectionTitle: 'Parsed user info', + email: 'Email', + firstName: 'First name', + }, + howToFix: { + sectionTitle: 'How to fix', + actionLabel__viewDocumentation: 'View documentation', + 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: { spFields: { acsUrl: { 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..7f7338f36d2 --- /dev/null +++ b/packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx @@ -0,0 +1,139 @@ +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; + totalCount: number | undefined; + error: Error | null; + isLoading: boolean; + isFetching: boolean; + /** + * `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 + */ + 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, invalidationKey, 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); + }, + refetchInterval: q => { + if (!shouldPoll) { + return false; + } + + 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; + + 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: invalidationKey }); + }, [queryClient, invalidationKey, hasRows]); + + const isPolling = queryEnabled && shouldPoll && !hasRows; + + return { + data: query.data?.data, + 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/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/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index b0ed1f17631..05d9a743102 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1338,6 +1338,68 @@ export type __internal_LocalizationResource = { subtitle: LocalizationValue; }; }; + testConfigurationStep: { + title: LocalizationValue; + subtitle: LocalizationValue; + error__noSuccessfulTestRun: 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; + }; + testRunDetails: { + title: LocalizationValue; + runDetails: { + sectionTitle: LocalizationValue; + timestamp: LocalizationValue; + status: LocalizationValue; + errorCode: LocalizationValue; + fullMessage: LocalizationValue; + actionLabel__copy: LocalizationValue; + actionLabel__copied: LocalizationValue; + }; + parsedUserInfo: { + sectionTitle: LocalizationValue; + email: LocalizationValue; + firstName: LocalizationValue; + }; + howToFix: { + sectionTitle: LocalizationValue; + actionLabel__viewDocumentation: 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: { spFields: { acsUrl: { 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 91b7d7a214c..7b9f115355d 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -1,20 +1,84 @@ -import { descriptors, Flow, Text } from '@/customizables'; +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, + Icon, + localizationKeys, + Spinner, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + useLocalizations, +} from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { Drawer } from '@/elements/Drawer'; +import { IconButton } from '@/elements/IconButton'; +import { Pagination } from '@/elements/Pagination'; +import { ProfileSection } from '@/elements/Section'; +import { useClipboard } from '@/hooks'; +import { Check, Copy, RotateLeftRight } from '@/icons'; +import { common, mqu } from '@/styledSystem'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSO } from '../ConfigureSSOContext'; import { Step } from '../elements/Step'; import { useWizard } from '../elements/Wizard'; +import { TestRunHowToFixSection } from './TestRunHowToFixSection'; + +const TEST_RUNS_PAGE_SIZE = 5; +const TEST_RESULTS_TABLE_COLUMN_COUNT = 3; export const TestConfigurationStep = (): JSX.Element => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { goNext, goPrev } = useWizard(); + const { enterpriseConnection } = useConfigureSSO(); + const card = useCardState(); + + const [currentPage, setCurrentPage] = useState(1); + + const { + data: testRuns, + totalCount, + isLoading: areTestRunsLoading, + isPolling, + revalidate: revalidateTestRuns, + } = __internal_useEnterpriseConnectionTestRuns({ + enterpriseConnectionId: enterpriseConnection?.id ?? null, + params: { initialPage: currentPage, pageSize: TEST_RUNS_PAGE_SIZE }, + }); + + const pageCount = totalCount ? Math.ceil(totalCount / TEST_RUNS_PAGE_SIZE) : 0; + + const handleTestRunCreated = () => { + setCurrentPage(1); + void revalidateTestRuns(); + }; return ( - + @@ -25,25 +89,679 @@ 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.$1, + })} + > + + + + + + + + + - - Your test results + + ({ + borderTopWidth: 0, + paddingTop: 0, + paddingBottom: 0, + flexDirection: 'column-reverse', + gap: t.space.$2, + flex: 1, + minHeight: 0, + '& > *:first-of-type': { + flex: 1, + minHeight: 0, + }, + })} + > + + + {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()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} + goPrev()} /> + void goNext()} /> ); }; + +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; + isPolling: boolean; + onTestRunCreated?: (testUrl: string) => void; + page: number; + pageCount: number; + pageSize: number; + totalCount: number; + onPageChange: (page: number) => void; +}; + +const TestResultsTable = ({ + rows, + isLoading, + isPolling, + onTestRunCreated, + page, + pageCount, + pageSize, + totalCount, + onPageChange, +}: TestResultsTableProps): JSX.Element => { + const { t } = useLocalizations(); + const { contentRef } = useConfigureSSO(); + 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%', + 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, + '&&': { + border: 'none', + borderRadius: 0, + }, + })} + > + + + + + + {isLoading || isPolling ? ( + + + + ) : !rows.length ? ( + + + + ) : ( + rows.map(row => ( + setSelectedTestRun(row)} + sx={t => ({ + cursor: 'pointer', + '&:hover > td': { + backgroundColor: t.colors.$neutralAlpha50, + }, + })} + > + + + + + )) + )} + +
+ + +
+ ({ padding: `${t.space.$10} 0` })} + > + + + +
+ ({ padding: `${t.space.$10} 0`, flex: 1 })} + > + + +
+ + + + + +
+
+ + {pageCount > 1 ? ( + + 0 ? Math.max(0, (page - 1) * pageSize) + 1 : 0, + endingRow: Math.min(page * pageSize, totalCount), + }} + /> + + ) : null} + + { + if (!open) { + setSelectedTestRun(null); + } + }} + strategy='absolute' + portalProps={{ root: contentRef }} + > + + + + {selectedTestRun ? : 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 ( + + {formatted.time} + {formatted.day} + + ); +}; + +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; + + return ( + ({ + display: 'flex', + flexDirection: 'column', + flex: 1, + overflowY: 'auto', + padding: t.space.$4, + gap: t.space.$4, + })} + > + + +
({ display: 'grid', gridRowGap: t.space.$2 })}> + {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} + + {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, + })} + > + + +
({ display: 'grid', gridRowGap: t.space.$2 })}> + {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); + 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} + + + ); +}; + +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 = { + onTestRunCreated?: (testUrl: string) => void; +}; + +const CopyTestUrlButton = ({ onTestRunCreated }: CopyTestUrlButtonProps): JSX.Element => { + const { t } = useLocalizations(); + const { user } = useUser(); + const card = useCardState(); + const { enterpriseConnection } = useConfigureSSO(); + + 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(url); + onTestRunCreated?.(url); + }) + .catch(err => handleError(err as Error, [], card.setError)) + .finally(() => setIsCreatingTestRun(false)); + }; + + return ( + + ); +}; 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..1b08d8d12e0 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/TestRunHowToFixSection.tsx @@ -0,0 +1,199 @@ +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]; + if (!content) { + return null; + } + + 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, + })} + > + + + ({ + 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', + marginTop: t.space.$2, + '&: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, + listStyleType: 'decimal', + display: 'flex', + flexDirection: 'column', + gap: t.space.$1, + })} + > + {content.stepKeys.map(stepKey => ( + ({ + color: t.colors.$colorMutedForeground, + fontSize: t.fontSizes.$sm, + '&::marker': { + color: t.colors.$colorMutedForeground, + fontSize: t.fontSizes.$sm, + }, + })} + > + + + ))} + + + ); +}; 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