Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/three-ducks-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': patch
'@clerk/shared': patch
'@clerk/ui': patch
---

Add test step for `<__experimental_ConfigureSSO />`
68 changes: 68 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,74 @@ export const enUS: LocalizationResource = {
subtitle: "Contact the application's administrator to get access through the existing connection.",
},
},
testConfigurationStep: {
Comment thread
LauraBeatris marked this conversation as resolved.
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: {
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/react/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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)]);
}
139 changes: 139 additions & 0 deletions packages/shared/src/react/hooks/useEnterpriseConnectionTestRuns.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
};

/**
* 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,
});
Comment thread
LauraBeatris marked this conversation as resolved.

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 };
2 changes: 2 additions & 0 deletions packages/shared/src/react/stable-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
4 changes: 3 additions & 1 deletion packages/shared/src/types/elementIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
62 changes: 62 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
9 changes: 6 additions & 3 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,15 @@ const AuthenticatedContent = withCoreUserGuard(() => {
})}
>
<ConfigureSSOCardProtect>
<ConfigureSSOCardContent />
<ConfigureSSOCardContent contentRef={contentRef} />
</ConfigureSSOCardProtect>
</Col>
</ConfigureSSONavbar>
</ProfileCard.Root>
);
});

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

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

return (
<ConfigureSSOProvider enterpriseConnection={enterpriseConnection}>
<ConfigureSSOProvider
enterpriseConnection={enterpriseConnection}
contentRef={contentRef}
>
<ConfigureSSOSteps />
</ConfigureSSOProvider>
);
Expand Down
Loading
Loading