diff --git a/.changeset/ripe-ways-greet.md b/.changeset/ripe-ways-greet.md
new file mode 100644
index 00000000000..036c1af89b8
--- /dev/null
+++ b/.changeset/ripe-ways-greet.md
@@ -0,0 +1,8 @@
+---
+'@clerk/localizations': minor
+'@clerk/clerk-js': minor
+'@clerk/shared': minor
+'@clerk/ui': minor
+---
+
+Handle expired organization domains on self-serve SSO flow, allowing to trigger a new verification
diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts
index f28044450e1..128731b292a 100644
--- a/packages/localizations/src/en-US.ts
+++ b/packages/localizations/src/en-US.ts
@@ -649,8 +649,12 @@ export const enUS: LocalizationResource = {
},
organizationDomainsStep: {
domainCard: {
+ badge__expired: 'Expired',
badge__unverified: 'Unverified',
badge__verified: 'Verified',
+ expiredAtLabel:
+ "Domain verification expired on {{ date | shortDate('en-US') }}. Verify again to generate a new DNS record.",
+ expiredLabel: 'Domain verification expired. Verify again to generate a new DNS record.',
removeButtonTooltip__lastVerifiedDomain: 'At least one verified domain is required to set up SSO.',
removeButtonTooltip__lastVerifiedDomainActive: 'At least one verified domain is required to keep SSO enabled.',
txtRecord: {
@@ -660,6 +664,7 @@ export const enUS: LocalizationResource = {
valueLabel: 'Value',
},
verifiedAtLabel: "Verified on {{ date | shortDate('en-US') }}",
+ verifyAgainButton: 'Verify again',
},
domainSuggestion: {
formButtonPrimary__add: 'Add {{domain}}',
diff --git a/packages/shared/src/react/hooks/useOrganizationDomains.tsx b/packages/shared/src/react/hooks/useOrganizationDomains.tsx
index 9965b31b6fd..427f90a9e8b 100644
--- a/packages/shared/src/react/hooks/useOrganizationDomains.tsx
+++ b/packages/shared/src/react/hooks/useOrganizationDomains.tsx
@@ -137,10 +137,7 @@ function useOrganizationDomains(params: UseOrganizationDomainsParams = {}): UseO
const unverifiedOwnershipDomainIds = useMemo(
() =>
(response?.data ?? [])
- .filter(
- (domain: OrganizationDomainResource) =>
- domain.ownershipVerification && domain.ownershipVerification.status !== 'verified',
- )
+ .filter((domain: OrganizationDomainResource) => domain.ownershipVerification?.status === 'unverified')
.map((domain: OrganizationDomainResource) => domain.id),
[response?.data],
);
diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts
index 765ca1d6208..24743561cde 100644
--- a/packages/shared/src/types/json.ts
+++ b/packages/shared/src/types/json.ts
@@ -20,6 +20,7 @@ import type { EmailAddressIdentifier, UsernameIdentifier } from './identifiers';
import type { ActClaim } from './jwtv2';
import type { OAuthProvider } from './oauth';
import type {
+ OrganizationDomainOwnershipVerificationStatus,
OrganizationDomainOwnershipVerificationStrategy,
OrganizationDomainVerificationStatus,
OrganizationEnrollmentMode,
@@ -436,7 +437,7 @@ export interface OrganizationDomainVerificationJSON {
}
export interface OrganizationDomainOwnershipVerificationJSON {
- status: OrganizationDomainVerificationStatus;
+ status: OrganizationDomainOwnershipVerificationStatus;
strategy: OrganizationDomainOwnershipVerificationStrategy;
attempts: number | null;
expire_at: number | null;
diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts
index 89859af9e7c..7e5910e24c4 100644
--- a/packages/shared/src/types/localization.ts
+++ b/packages/shared/src/types/localization.ts
@@ -1390,7 +1390,11 @@ export type __internal_LocalizationResource = {
domainCard: {
badge__verified: LocalizationValue;
badge__unverified: LocalizationValue;
+ badge__expired: LocalizationValue;
verifiedAtLabel: LocalizationValue<'date'>;
+ expiredAtLabel: LocalizationValue<'date'>;
+ expiredLabel: LocalizationValue;
+ verifyAgainButton: LocalizationValue;
removeButtonTooltip__lastVerifiedDomain: LocalizationValue;
removeButtonTooltip__lastVerifiedDomainActive: LocalizationValue;
txtRecord: {
diff --git a/packages/shared/src/types/organizationDomain.ts b/packages/shared/src/types/organizationDomain.ts
index 45b4d6d8d16..25e3b87a598 100644
--- a/packages/shared/src/types/organizationDomain.ts
+++ b/packages/shared/src/types/organizationDomain.ts
@@ -34,30 +34,51 @@ type OrganizationDomainVerificationStrategy = 'email_code';
/**
* The current status of an Organization domain verification.
*
+ *
+ *
`unverified`: Verification has not been completed yet. An attempt may be pending.
+ *
`verified`: Verification has been completed.
+ *
`failed`: Too many verification attempts were made without success.
+ *
`expired`: The pending verification attempt expired before it could be completed.
+ *
+ *
+ * @inline
+ */
+export type OrganizationDomainVerificationStatus = 'unverified' | 'verified' | 'failed' | 'expired';
+
+/**
+ * The current status of an Organization domain ownership verification.
+ *
+ *
+ *
`unverified`: Ownership has not been established yet. A TXT challenge is pending.
+ *
`verified`: Ownership has been verified.
+ *
`expired`: The pending ownership verification attempt expired before ownership could be confirmed. A new TXT challenge must be issued (via `prepareOwnershipVerification()`) to retry.
+ *
+ *
* @inline
*/
-export type OrganizationDomainVerificationStatus = 'unverified' | 'verified';
+export type OrganizationDomainOwnershipVerificationStatus = 'unverified' | 'verified' | 'expired';
/**
* The strategy used to verify ownership of an Organization's domain.
* @inline
*/
-export type OrganizationDomainOwnershipVerificationStrategy = 'txt' | 'legacy' | 'manual_override';
+export type OrganizationDomainOwnershipVerificationStrategy = 'txt' | 'legacy' | 'manual_override' | 'parent_domain';
/**
* Holds the ownership verification details of an Organization's [Verified Domain](https://clerk.com/docs/guides/organizations/add-members/verified-domains). Ownership proves control of the underlying DNS domain, typically by publishing a TXT record, and is required before the domain can be used for enterprise SSO.
*/
export interface OrganizationDomainOwnershipVerification {
/**
- * The current status of the domain ownership verification.
+ * The current status of the domain ownership verification. A status of `expired` means the pending TXT challenge expired before ownership could be confirmed and a new challenge must be issued.
*/
- status: OrganizationDomainVerificationStatus;
+ status: OrganizationDomainOwnershipVerificationStatus;
/**
* The strategy used to verify ownership of the domain.
*
*
`txt`: Ownership is proven by publishing a DNS TXT record (see `txtRecordName` and `txtRecordValue` on `OrganizationDomainOwnershipVerification`).
*
`legacy`: Ownership was implicitly granted to domains that predate the TXT verification flow, so no per-attempt proof exists.
*
`manual_override`: Ownership was granted manually by a Clerk admin via the Backend API or Dashboard, bypassing the DNS challenge.
+ *
`parent_domain`: Ownership was inherited from a parent domain that has already been verified, so no per-attempt proof exists.
*
*/
strategy: OrganizationDomainOwnershipVerificationStrategy;
@@ -66,7 +87,7 @@ export interface OrganizationDomainOwnershipVerification {
*/
attempts: number | null;
/**
- * The date and time when the current ownership verification attempt expires, or `null` when there is no pending attempt.
+ * The date and time when the current ownership verification attempt expires, or `null` when there is no pending attempt. When `status` is `expired`, this is the date and time at which the attempt expired.
*/
expiresAt: Date | null;
/**
diff --git a/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx
index 60306b0b86c..8c449089080 100644
--- a/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx
+++ b/packages/ui/src/components/ConfigureSSO/steps/OrganizationDomainsStep.tsx
@@ -25,7 +25,7 @@ import { useCardState } from '@/elements/contexts';
import { Field } from '@/elements/FieldControl';
import { Form } from '@/elements/Form';
import { Tooltip } from '@/elements/Tooltip';
-import { Checkmark, Clipboard, Close } from '@/icons';
+import { Checkmark, Clipboard, Close, RotateLeftRight } from '@/icons';
import { common } from '@/styledSystem';
import { useFormControl } from '@/ui/utils/useFormControl';
import { getFieldError, getGlobalError } from '@/utils/errorHandler';
@@ -43,7 +43,7 @@ export const OrganizationDomainsStep = (): JSX.Element => {
organizationDomains,
organizationEnterpriseConnection,
contentRef,
- organizationDomainMutations: { createDomain, revalidate },
+ organizationDomainMutations: { createDomain, revalidate, prepareOwnershipVerification },
enterpriseConnectionMutations: { updateConnection },
} = useConfigureSSO();
const { goPrev, goNext, isFirstStep, isLastStep } = useWizard();
@@ -81,6 +81,17 @@ export const OrganizationDomainsStep = (): JSX.Element => {
}
};
+ const handlePrepareOwnershipVerification = async (domain: OrganizationDomainResource) => {
+ card.setError(undefined);
+
+ try {
+ await prepareOwnershipVerification([domain]);
+ } catch (err: any) {
+ const apiError = getFieldError(err) ?? getGlobalError(err);
+ card.setError(apiError);
+ }
+ };
+
const handleRemoveDomain = async (domain: OrganizationDomainResource) => {
if (enterpriseConnection) {
const domains = enterpriseConnection.domains.filter(name => name !== domain.name);
@@ -158,6 +169,7 @@ export const OrganizationDomainsStep = (): JSX.Element => {
key={domain.id}
domain={domain}
onRemove={() => setDomainToRemove(domain)}
+ onPrepareOwnershipVerification={() => handlePrepareOwnershipVerification(domain)}
isRemoveDisabled={isRemoveDisabled}
removeDisabledTooltip={lastVerifiedDomainTooltip}
/>
@@ -353,11 +365,13 @@ const DomainSuggestion = ({ onSubmit }: { onSubmit: (domain: string) => Promise<
const DomainCard = ({
domain,
onRemove,
+ onPrepareOwnershipVerification,
isRemoveDisabled = false,
removeDisabledTooltip,
}: {
domain: OrganizationDomainResource;
onRemove: () => void;
+ onPrepareOwnershipVerification: () => Promise;
isRemoveDisabled?: boolean;
removeDisabledTooltip?: ReturnType;
}): JSX.Element | null => {
@@ -367,7 +381,8 @@ const DomainCard = ({
const ownershipVerification = domain.ownershipVerification;
const isVerified = ownershipVerification?.status === 'verified';
- const cardId = isVerified ? 'verified' : 'unverified';
+ const isExpired = ownershipVerification?.status === 'expired';
+ const cardId = ownershipVerification?.status ?? 'unverified';
const removeButton = (
- {!isVerified && (
+ {!isVerified && !isExpired && (
- {ownershipVerification?.verifiedAt ? (
+ {isExpired ? (
+
+ ) : ownershipVerification?.verifiedAt ? (
Promise;
+}): JSX.Element => {
+ const [isVerifying, setIsVerifying] = useState(false);
+
+ const handleVerifyAgain = () => {
+ setIsVerifying(true);
+ void onPrepareOwnershipVerification().finally(() => setIsVerifying(false));
+ };
+
+ return (
+