Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/bright-trees-retry.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 => {
Expand Down Expand Up @@ -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(<ConfigureSSO />, { 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(<ConfigureSSO />, { 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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -375,6 +377,13 @@ const DomainCard = ({
isRemoveDisabled?: boolean;
removeDisabledTooltip?: ReturnType<typeof localizationKeys>;
}): JSX.Element | null => {
const [isVerifying, setIsVerifying] = useState(false);
const [isVerificationRetryThrottled, setIsVerificationRetryThrottled] = useState(false);
const retryTimerRef = useRef<number | undefined>(undefined);
const isVerificationRetryThrottledRef = useRef(false);

Comment on lines +382 to +384

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the thinking behind the use of useRef to store timeouts? I feel like useState might be more appropriate but I'd like to understand more

useEffect(() => () => window.clearTimeout(retryTimerRef.current), []);

if (!domain.name) {
return null;
}
Expand All @@ -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 = (
<Button
elementDescriptor={descriptors.configureSSOVerifyDomainCardRemoveButton}
Expand Down Expand Up @@ -472,7 +496,9 @@ const DomainCard = ({
<ExpiredNotice
key='expired'
expiresAt={ownershipVerification?.expiresAt ?? null}
onPrepareOwnershipVerification={onPrepareOwnershipVerification}
isVerifying={isVerifying}
isVerificationRetryThrottled={isVerificationRetryThrottled}
onVerificationRetry={handleVerificationRetry}
/>
) : ownershipVerification?.verifiedAt ? (
<Text
Expand All @@ -488,6 +514,9 @@ const DomainCard = ({
<TxtRecord
key='unverified'
ownershipVerification={ownershipVerification}
isVerifying={isVerifying}
isVerificationRetryThrottled={isVerificationRetryThrottled}
onVerificationRetry={handleVerificationRetry}
/>
)}
</Animated>
Expand All @@ -498,18 +527,15 @@ const DomainCard = ({

const ExpiredNotice = ({
expiresAt,
onPrepareOwnershipVerification,
isVerifying,
isVerificationRetryThrottled,
onVerificationRetry,
}: {
expiresAt: Date | null;
onPrepareOwnershipVerification: () => Promise<void>;
isVerifying: boolean;
isVerificationRetryThrottled: boolean;
onVerificationRetry: () => void;
}): JSX.Element => {
const [isVerifying, setIsVerifying] = useState(false);

const handleVerifyAgain = () => {
setIsVerifying(true);
void onPrepareOwnershipVerification().finally(() => setIsVerifying(false));
};

return (
<Col
elementDescriptor={descriptors.configureSSOVerifyDomainCardExpired}
Expand All @@ -525,32 +551,57 @@ const ExpiredNotice = ({
}
/>

<Button
variant='bordered'
colorScheme='secondary'
size='xs'
isLoading={isVerifying}
onClick={handleVerifyAgain}
sx={t => ({ alignSelf: 'flex-start', gap: t.space.$1x5 })}
>
<Icon
icon={RotateLeftRight}
size='sm'
colorScheme='neutral'
/>
<Text
as='span'
localizationKey={localizationKeys('configureSSO.organizationDomainsStep.domainCard.verifyAgainButton')}
/>
</Button>
<VerificationRetryButton
isVerifying={isVerifying}
isThrottled={isVerificationRetryThrottled}
onClick={onVerificationRetry}
/>
</Col>
);
};

const VerificationRetryButton = ({
isVerifying,
isThrottled,
onClick,
}: {
isVerifying: boolean;
isThrottled: boolean;
onClick: () => void;
}): JSX.Element => {
return (
<Button
variant='bordered'
colorScheme='secondary'
size='xs'
isLoading={isVerifying}
isDisabled={isThrottled}
onClick={onClick}
sx={t => ({ alignSelf: 'flex-start', gap: t.space.$1x5 })}
>
<Icon
icon={RotateLeftRight}
size='sm'
colorScheme='neutral'
/>
<Text
as='span'
localizationKey={localizationKeys('configureSSO.organizationDomainsStep.domainCard.verifyAgainButton')}
/>
</Button>
);
};

const TxtRecord = ({
ownershipVerification,
isVerifying,
isVerificationRetryThrottled,
onVerificationRetry,
}: {
ownershipVerification: OrganizationDomainResource['ownershipVerification'];
isVerifying: boolean;
isVerificationRetryThrottled: boolean;
onVerificationRetry: () => void;
}): JSX.Element => {
return (
<Col
Expand Down Expand Up @@ -605,6 +656,12 @@ const TxtRecord = ({
sx={{ flex: 1, minWidth: 0 }}
/>
</Flex>

<VerificationRetryButton
isVerifying={isVerifying}
isThrottled={isVerificationRetryThrottled}
onClick={onVerificationRetry}
/>
</Col>
);
};
Expand Down
Loading