From 996227fa6e7aca9db12a6a1cb4220d2ec8f5dbf8 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Wed, 29 Jul 2026 15:19:44 -0300 Subject: [PATCH 1/5] 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 ( + + ); }; From 791ee57b0ed82e7b01ab63ab054dc13405d2b247 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Fri, 7 Aug 2026 10:35:24 -0300 Subject: [PATCH 2/5] fix(ui): start SSO domain retry cooldown on success and surface it Drop the ref that mirrored the throttle flag: the click handler closes over current state, and the button is already disabled while throttled. Track the cooldown as a deadline instead of a boolean, start it only after prepareOwnershipVerification resolves, and show the remaining time on the disabled retry button. Move the retry action inline with the TXT record value. --- .changeset/bright-trees-retry.md | 2 +- packages/localizations/src/en-US.ts | 2 + packages/shared/src/types/localization.ts | 1 + .../__tests__/ConfigureSSO.test.tsx | 64 +++++++++- .../steps/OrganizationDomainsStep.tsx | 117 +++++++++++++----- 5 files changed, 147 insertions(+), 39 deletions(-) diff --git a/.changeset/bright-trees-retry.md b/.changeset/bright-trees-retry.md index 590ae7a5f2f..27b10493191 100644 --- a/.changeset/bright-trees-retry.md +++ b/.changeset/bright-trees-retry.md @@ -2,4 +2,4 @@ '@clerk/ui': patch --- -Allow self-serve SSO domain verification TXT records to be regenerated while verification is pending, with a five-minute retry cooldown. +Allow self-serve SSO domain verification TXT records to be regenerated while verification is pending. Each successful retry starts a five-minute cooldown, and the remaining time is shown on the disabled retry button. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 7e8450b354b..16279151237 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -746,6 +746,8 @@ export const enUS: LocalizationResource = { }, verifiedAtLabel: "Verified on {{ date | shortDate('en-US') }}", verifyAgainButton: 'Verify again', + verifyAgainButtonTooltip__throttled: + 'DNS changes take a few minutes to propagate. You can check again in {{countdown}}.', }, domainSuggestion: { formButtonPrimary__add: 'Add {{domain}}', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index d73ed179a04..4a1b0b9af0e 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1434,6 +1434,7 @@ export type __internal_LocalizationResource = { expiredAtLabel: LocalizationValue<'date'>; expiredLabel: LocalizationValue; verifyAgainButton: LocalizationValue; + verifyAgainButtonTooltip__throttled: LocalizationValue<'countdown'>; removeButtonTooltip__lastVerifiedDomain: LocalizationValue; removeButtonTooltip__lastVerifiedDomainActive: LocalizationValue; txtRecord: { diff --git a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx index 88e41dd444c..e75838493af 100644 --- a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx @@ -201,23 +201,77 @@ describe('ConfigureSSO', () => { 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 }); + const { findByRole, getByRole } = render(, { wrapper }); + await findByRole('button', { name: /verify again/i }); vi.useFakeTimers(); await act(() => { - verifyAgainButton.click(); + getByRole('button', { name: /verify again/i }).click(); return Promise.resolve(); }); expect(fixtures.clerk.organization?.prepareOwnershipVerification).toHaveBeenCalledWith([expiredDomain.id]); - expect(verifyAgainButton).toBeDisabled(); + expect(getByRole('button', { name: /verify again/i })).toBeDisabled(); act(() => { vi.advanceTimersByTime(5 * 60 * 1000); }); - expect(verifyAgainButton).not.toBeDisabled(); + expect(getByRole('button', { name: /verify again/i })).not.toBeDisabled(); + }); + + it('surfaces the remaining cooldown while a domain verification retry is throttled', 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, getByRole, findByText, userEvent } = render(, { wrapper }); + await userEvent.click(await findByRole('button', { name: /verify again/i })); + + const throttledButton = await waitFor(() => { + const button = getByRole('button', { name: /verify again/i }); + expect(button).toBeDisabled(); + return button; + }); + + await userEvent.hover(throttledButton.parentElement as HTMLElement); + await findByText(/you can check again in \d:\d{2}/i); + }); + + it('does not throttle domain verification retries when the request fails', 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.mockRejectedValue(new Error('nope')); + + const { findByRole, getByRole } = render(, { wrapper }); + await findByRole('button', { name: /verify again/i }); + + await act(() => { + getByRole('button', { name: /verify again/i }).click(); + return Promise.resolve(); + }); + + expect(getByRole('button', { name: /verify again/i })).not.toBeDisabled(); }); it('short-circuits to the activate step for an active connection', async () => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx index 3a01e18ee2c..111878586ec 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx @@ -26,7 +26,7 @@ import { Field } from '@/elements/FieldControl'; import { Form } from '@/elements/Form'; import { Tooltip } from '@/elements/Tooltip'; import { Checkmark, Clipboard, Close, RotateLeftRight } from '@/icons'; -import { common } from '@/styledSystem'; +import { common, type ThemableCssProp } from '@/styledSystem'; import { useFormControl } from '@/ui/utils/useFormControl'; import { getFieldError, getGlobalError } from '@/utils/errorHandler'; @@ -83,14 +83,16 @@ export const OrganizationDomainsStep = (): JSX.Element => { } }; - const handlePrepareOwnershipVerification = async (domain: OrganizationDomainResource) => { + const handlePrepareOwnershipVerification = async (domain: OrganizationDomainResource): Promise => { card.setError(undefined); try { await prepareOwnershipVerification([domain]); + return true; } catch (err: any) { const apiError = getFieldError(err) ?? getGlobalError(err); card.setError(apiError); + return false; } }; @@ -364,6 +366,38 @@ const DomainSuggestion = ({ onSubmit }: { onSubmit: (domain: string) => Promise< ); }; +const getRetryCooldownMs = (retryThrottledUntil: number | null): number => + retryThrottledUntil ? Math.max(0, retryThrottledUntil - Date.now()) : 0; + +const formatRetryCooldown = (remainingMs: number): string => { + const totalSeconds = Math.ceil(remainingMs / 1000); + return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, '0')}`; +}; + +const useRetryCooldown = (retryThrottledUntil: number | null): number => { + const [remainingMs, setRemainingMs] = useState(() => getRetryCooldownMs(retryThrottledUntil)); + + useEffect(() => { + setRemainingMs(getRetryCooldownMs(retryThrottledUntil)); + + if (!retryThrottledUntil) { + return; + } + + const intervalId = window.setInterval(() => { + const remaining = getRetryCooldownMs(retryThrottledUntil); + setRemainingMs(remaining); + if (remaining === 0) { + window.clearInterval(intervalId); + } + }, 1000); + + return () => window.clearInterval(intervalId); + }, [retryThrottledUntil]); + + return remainingMs; +}; + const DomainCard = ({ domain, onRemove, @@ -373,16 +407,12 @@ const DomainCard = ({ }: { domain: OrganizationDomainResource; onRemove: () => void; - onPrepareOwnershipVerification: () => Promise; + onPrepareOwnershipVerification: () => Promise; 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), []); + const [retryThrottledUntil, setRetryThrottledUntil] = useState(null); if (!domain.name) { return null; @@ -394,18 +424,18 @@ const DomainCard = ({ const cardId = ownershipVerification?.status ?? 'unverified'; const handleVerificationRetry = () => { - if (isVerificationRetryThrottledRef.current) { + if (isVerifying || getRetryCooldownMs(retryThrottledUntil) > 0) { 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)); + void onPrepareOwnershipVerification() + .then(didPrepare => { + if (didPrepare) { + setRetryThrottledUntil(Date.now() + OWNERSHIP_VERIFICATION_RETRY_THROTTLE_MS); + } + }) + .finally(() => setIsVerifying(false)); }; const removeButton = ( @@ -497,7 +527,7 @@ const DomainCard = ({ key='expired' expiresAt={ownershipVerification?.expiresAt ?? null} isVerifying={isVerifying} - isVerificationRetryThrottled={isVerificationRetryThrottled} + retryThrottledUntil={retryThrottledUntil} onVerificationRetry={handleVerificationRetry} /> ) : ownershipVerification?.verifiedAt ? ( @@ -515,7 +545,7 @@ const DomainCard = ({ key='unverified' ownershipVerification={ownershipVerification} isVerifying={isVerifying} - isVerificationRetryThrottled={isVerificationRetryThrottled} + retryThrottledUntil={retryThrottledUntil} onVerificationRetry={handleVerificationRetry} /> )} @@ -528,12 +558,12 @@ const DomainCard = ({ const ExpiredNotice = ({ expiresAt, isVerifying, - isVerificationRetryThrottled, + retryThrottledUntil, onVerificationRetry, }: { expiresAt: Date | null; isVerifying: boolean; - isVerificationRetryThrottled: boolean; + retryThrottledUntil: number | null; onVerificationRetry: () => void; }): JSX.Element => { return ( @@ -553,8 +583,9 @@ const ExpiredNotice = ({ ); @@ -562,14 +593,19 @@ const ExpiredNotice = ({ const VerificationRetryButton = ({ isVerifying, - isThrottled, + retryThrottledUntil, onClick, + sx, }: { isVerifying: boolean; - isThrottled: boolean; + retryThrottledUntil: number | null; onClick: () => void; + sx?: ThemableCssProp; }): JSX.Element => { - return ( + const remainingMs = useRetryCooldown(retryThrottledUntil); + const isThrottled = remainingMs > 0; + + const button = ( ); + + if (!isThrottled) { + return button; + } + + return ( + + {button} + + + ); }; const TxtRecord = ({ ownershipVerification, isVerifying, - isVerificationRetryThrottled, + retryThrottledUntil, onVerificationRetry, }: { ownershipVerification: OrganizationDomainResource['ownershipVerification']; isVerifying: boolean; - isVerificationRetryThrottled: boolean; + retryThrottledUntil: number | null; onVerificationRetry: () => void; }): JSX.Element => { return ( @@ -655,13 +706,13 @@ const TxtRecord = ({ copiedIcon={Checkmark} sx={{ flex: 1, minWidth: 0 }} /> - - + + ); }; From 55e8d42e0606c4ba7aafc3bb68f1beacee9784f3 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Fri, 7 Aug 2026 11:03:44 -0300 Subject: [PATCH 3/5] refactor(ui): simplify SSO domain verification retry throttle Build the retry button once in DomainCard and pass it down as a node, mirroring the existing removeButton pattern, instead of drilling three props through ExpiredNotice and TxtRecord. Drop the live countdown so the cooldown needs a single setTimeout rather than a per-second interval plus its derived-state mirror and formatter. --- .changeset/bright-trees-retry.md | 4 +- packages/localizations/src/en-US.ts | 2 +- packages/shared/src/types/localization.ts | 2 +- .../__tests__/ConfigureSSO.test.tsx | 4 +- .../steps/OrganizationDomainsStep.tsx | 116 +++++------------- 5 files changed, 40 insertions(+), 88 deletions(-) diff --git a/.changeset/bright-trees-retry.md b/.changeset/bright-trees-retry.md index 27b10493191..a372fe70f0c 100644 --- a/.changeset/bright-trees-retry.md +++ b/.changeset/bright-trees-retry.md @@ -1,5 +1,7 @@ --- '@clerk/ui': patch +'@clerk/localizations': patch +'@clerk/shared': patch --- -Allow self-serve SSO domain verification TXT records to be regenerated while verification is pending. Each successful retry starts a five-minute cooldown, and the remaining time is shown on the disabled retry button. +Allow self-serve SSO domain verification TXT records to be regenerated while verification is pending. Each successful retry starts a five-minute cooldown, explained on the disabled retry button. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 16279151237..58f91b51cea 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -747,7 +747,7 @@ export const enUS: LocalizationResource = { verifiedAtLabel: "Verified on {{ date | shortDate('en-US') }}", verifyAgainButton: 'Verify again', verifyAgainButtonTooltip__throttled: - 'DNS changes take a few minutes to propagate. You can check again in {{countdown}}.', + 'DNS changes take a few minutes to propagate. You can check again shortly.', }, domainSuggestion: { formButtonPrimary__add: 'Add {{domain}}', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 4a1b0b9af0e..7aaabfdf710 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1434,7 +1434,7 @@ export type __internal_LocalizationResource = { expiredAtLabel: LocalizationValue<'date'>; expiredLabel: LocalizationValue; verifyAgainButton: LocalizationValue; - verifyAgainButtonTooltip__throttled: LocalizationValue<'countdown'>; + verifyAgainButtonTooltip__throttled: LocalizationValue; removeButtonTooltip__lastVerifiedDomain: LocalizationValue; removeButtonTooltip__lastVerifiedDomainActive: LocalizationValue; txtRecord: { diff --git a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx index e75838493af..0b8104419e8 100644 --- a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx @@ -220,7 +220,7 @@ describe('ConfigureSSO', () => { expect(getByRole('button', { name: /verify again/i })).not.toBeDisabled(); }); - it('surfaces the remaining cooldown while a domain verification retry is throttled', async () => { + it('explains the cooldown while a domain verification retry is throttled', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withEnterpriseSso({ selfServeSSO: true }); f.withEmailAddress(); @@ -245,7 +245,7 @@ describe('ConfigureSSO', () => { }); await userEvent.hover(throttledButton.parentElement as HTMLElement); - await findByText(/you can check again in \d:\d{2}/i); + await findByText(/you can check again shortly/i); }); it('does not throttle domain verification retries when the request fails', async () => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx index 111878586ec..1d490563f31 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx @@ -26,7 +26,7 @@ import { Field } from '@/elements/FieldControl'; import { Form } from '@/elements/Form'; import { Tooltip } from '@/elements/Tooltip'; import { Checkmark, Clipboard, Close, RotateLeftRight } from '@/icons'; -import { common, type ThemableCssProp } from '@/styledSystem'; +import { common } from '@/styledSystem'; import { useFormControl } from '@/ui/utils/useFormControl'; import { getFieldError, getGlobalError } from '@/utils/errorHandler'; @@ -366,38 +366,6 @@ const DomainSuggestion = ({ onSubmit }: { onSubmit: (domain: string) => Promise< ); }; -const getRetryCooldownMs = (retryThrottledUntil: number | null): number => - retryThrottledUntil ? Math.max(0, retryThrottledUntil - Date.now()) : 0; - -const formatRetryCooldown = (remainingMs: number): string => { - const totalSeconds = Math.ceil(remainingMs / 1000); - return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, '0')}`; -}; - -const useRetryCooldown = (retryThrottledUntil: number | null): number => { - const [remainingMs, setRemainingMs] = useState(() => getRetryCooldownMs(retryThrottledUntil)); - - useEffect(() => { - setRemainingMs(getRetryCooldownMs(retryThrottledUntil)); - - if (!retryThrottledUntil) { - return; - } - - const intervalId = window.setInterval(() => { - const remaining = getRetryCooldownMs(retryThrottledUntil); - setRemainingMs(remaining); - if (remaining === 0) { - window.clearInterval(intervalId); - } - }, 1000); - - return () => window.clearInterval(intervalId); - }, [retryThrottledUntil]); - - return remainingMs; -}; - const DomainCard = ({ domain, onRemove, @@ -412,7 +380,17 @@ const DomainCard = ({ removeDisabledTooltip?: ReturnType; }): JSX.Element | null => { const [isVerifying, setIsVerifying] = useState(false); - const [retryThrottledUntil, setRetryThrottledUntil] = useState(null); + const [isRetryThrottled, setIsRetryThrottled] = useState(false); + + useEffect(() => { + if (!isRetryThrottled) { + return; + } + + const timeoutId = window.setTimeout(() => setIsRetryThrottled(false), OWNERSHIP_VERIFICATION_RETRY_THROTTLE_MS); + + return () => window.clearTimeout(timeoutId); + }, [isRetryThrottled]); if (!domain.name) { return null; @@ -424,20 +402,20 @@ const DomainCard = ({ const cardId = ownershipVerification?.status ?? 'unverified'; const handleVerificationRetry = () => { - if (isVerifying || getRetryCooldownMs(retryThrottledUntil) > 0) { - return; - } - setIsVerifying(true); void onPrepareOwnershipVerification() - .then(didPrepare => { - if (didPrepare) { - setRetryThrottledUntil(Date.now() + OWNERSHIP_VERIFICATION_RETRY_THROTTLE_MS); - } - }) + .then(setIsRetryThrottled) .finally(() => setIsVerifying(false)); }; + const retryButton = ( + + ); + const removeButton = (