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 = (
) : ownershipVerification?.verifiedAt ? (
)}
@@ -498,18 +527,15 @@ const DomainCard = ({
const ExpiredNotice = ({
expiresAt,
- onPrepareOwnershipVerification,
+ isVerifying,
+ isVerificationRetryThrottled,
+ onVerificationRetry,
}: {
expiresAt: Date | null;
- onPrepareOwnershipVerification: () => Promise;
+ isVerifying: boolean;
+ isVerificationRetryThrottled: boolean;
+ onVerificationRetry: () => void;
}): JSX.Element => {
- const [isVerifying, setIsVerifying] = useState(false);
-
- const handleVerifyAgain = () => {
- setIsVerifying(true);
- void onPrepareOwnershipVerification().finally(() => setIsVerifying(false));
- };
-
return (
-
+
);
};
+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 (
+
+
);
};