From 996227fa6e7aca9db12a6a1cb4220d2ec8f5dbf8 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Wed, 29 Jul 2026 15:19:44 -0300 Subject: [PATCH] fix(ui): throttle self-serve SSO domain verification retries --- .changeset/bright-trees-retry.md | 5 + .../__tests__/ConfigureSSO.test.tsx | 66 +++++++++- .../steps/OrganizationDomainsStep.tsx | 113 +++++++++++++----- 3 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 .changeset/bright-trees-retry.md diff --git a/.changeset/bright-trees-retry.md b/.changeset/bright-trees-retry.md new file mode 100644 index 00000000000..590ae7a5f2f --- /dev/null +++ b/.changeset/bright-trees-retry.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Allow self-serve SSO domain verification TXT records to be regenerated while verification is pending, with a five-minute retry cooldown. diff --git a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx index b5309a864c5..88e41dd444c 100644 --- a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx @@ -1,7 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; -import { render, waitFor } from '@/test/utils'; +import { act, render, waitFor } from '@/test/utils'; import { ConfigureSSO } from '../ConfigureSSO'; @@ -23,10 +23,19 @@ const unverifiedDomain = { ownershipVerification: { status: 'unverified', strategy: 'txt' }, } as any; +const expiredDomain = { + ...unverifiedDomain, + ownershipVerification: { status: 'expired', strategy: 'txt', expiresAt: new Date('2026-01-01') }, +}; + const mockOrganizationDomains = (fixtures: any, domains: any[]) => fixtures.clerk.organization?.getDomains.mockResolvedValue({ data: domains, total_count: domains.length } as any); describe('ConfigureSSO', () => { + afterEach(() => { + vi.useRealTimers(); + }); + describe('within an organization', () => { it('shows a warning if the active organization membership lacks the manage enterprise connections permission', async () => { const { wrapper, fixtures } = await createFixtures(f => { @@ -158,6 +167,59 @@ describe('ConfigureSSO', () => { expect(queryByText(/select your identity provider/i)).not.toBeInTheDocument(); }); + it('shows the verification retry action while domain ownership verification is pending', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEnterpriseSso({ selfServeSSO: true }); + f.withEmailAddress(); + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }], + }); + }); + + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]); + mockOrganizationDomains(fixtures, [unverifiedDomain]); + + const { findByRole } = render(, { wrapper }); + + await findByRole('button', { name: /verify again/i }); + }); + + it('throttles domain verification retries for five minutes', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withEnterpriseSso({ selfServeSSO: true }); + f.withEmailAddress(); + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', permissions: ['org:sys_entconns:manage'] }], + }); + }); + + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]); + mockOrganizationDomains(fixtures, [expiredDomain]); + fixtures.clerk.organization?.prepareOwnershipVerification.mockResolvedValue({ data: [expiredDomain] } as any); + + const { findByRole } = render(, { wrapper }); + const verifyAgainButton = await findByRole('button', { name: /verify again/i }); + + vi.useFakeTimers(); + await act(() => { + verifyAgainButton.click(); + return Promise.resolve(); + }); + + expect(fixtures.clerk.organization?.prepareOwnershipVerification).toHaveBeenCalledWith([expiredDomain.id]); + expect(verifyAgainButton).toBeDisabled(); + + act(() => { + vi.advanceTimersByTime(5 * 60 * 1000); + }); + + expect(verifyAgainButton).not.toBeDisabled(); + }); + it('short-circuits to the activate step for an active connection', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withEnterpriseSso({ selfServeSSO: true }); diff --git a/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx index 8c449089080..3a01e18ee2c 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx @@ -36,6 +36,8 @@ import { Step } from '../elements/Step'; import { useWizard } from '../elements/Wizard/WizardContext'; import { RemoveDomainDialog } from '../RemoveDomainDialog'; +const OWNERSHIP_VERIFICATION_RETRY_THROTTLE_MS = 5 * 60 * 1000; + export const OrganizationDomainsStep = (): JSX.Element => { const { t } = useLocalizations(); const { @@ -375,6 +377,13 @@ const DomainCard = ({ isRemoveDisabled?: boolean; removeDisabledTooltip?: ReturnType; }): JSX.Element | null => { + const [isVerifying, setIsVerifying] = useState(false); + const [isVerificationRetryThrottled, setIsVerificationRetryThrottled] = useState(false); + const retryTimerRef = useRef(undefined); + const isVerificationRetryThrottledRef = useRef(false); + + useEffect(() => () => window.clearTimeout(retryTimerRef.current), []); + if (!domain.name) { return null; } @@ -384,6 +393,21 @@ const DomainCard = ({ const isExpired = ownershipVerification?.status === 'expired'; const cardId = ownershipVerification?.status ?? 'unverified'; + const handleVerificationRetry = () => { + if (isVerificationRetryThrottledRef.current) { + return; + } + + isVerificationRetryThrottledRef.current = true; + setIsVerificationRetryThrottled(true); + retryTimerRef.current = window.setTimeout(() => { + isVerificationRetryThrottledRef.current = false; + setIsVerificationRetryThrottled(false); + }, OWNERSHIP_VERIFICATION_RETRY_THROTTLE_MS); + setIsVerifying(true); + void onPrepareOwnershipVerification().finally(() => setIsVerifying(false)); + }; + const removeButton = ( + ); }; +const VerificationRetryButton = ({ + isVerifying, + isThrottled, + onClick, +}: { + isVerifying: boolean; + isThrottled: boolean; + onClick: () => void; +}): JSX.Element => { + return ( + + ); +}; + const TxtRecord = ({ ownershipVerification, + isVerifying, + isVerificationRetryThrottled, + onVerificationRetry, }: { ownershipVerification: OrganizationDomainResource['ownershipVerification']; + isVerifying: boolean; + isVerificationRetryThrottled: boolean; + onVerificationRetry: () => void; }): JSX.Element => { return ( + + ); };