Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/strong-moose-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': patch
'@clerk/shared': patch
'@clerk/ui': patch
---

Add confirmation dialog for organization domain deletion as part of self-serve SSO
12 changes: 10 additions & 2 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,6 @@ export const enUS: LocalizationResource = {
configureSSO: {
confirmation: {
configurationSection: {
certificateLabel: 'Certificate',
configureAgainLink: 'Configure again',
issuerLabel: 'Issuer',
ssoUrlLabel: 'Sign on URL',
Expand Down Expand Up @@ -283,13 +282,23 @@ export const enUS: LocalizationResource = {
badge__verified: 'Verified',
badge__unverified: 'Unverified',
verifiedAtLabel: "Verified on {{ date | shortDate('en-US') }}",
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: {
instructions: "Add this TXT record to your DNS provider. We'll verify automatically once the record is live.",
typeLabel: 'Type',
hostLabel: 'Host / Name',
valueLabel: 'Value',
},
},
removeDomainDialog: {
title: 'Removing domain',
subtitle__active:
"You're about to remove {{domain}} from this enterprise connection. Users won't be able to sign-in with {{domain}} anymore.",
subtitle__inactive: "You're about to remove {{domain}} from this enterprise connection.",
cancelButton: 'Cancel',
removeButton: 'Remove domain',
},
},
testConfigurationStep: {
title: 'Test your SSO connection',
Expand Down Expand Up @@ -1077,7 +1086,6 @@ export const enUS: LocalizationResource = {
badge__inactive: 'Inactive',
badge__inProgress: 'In Progress',
badge__unconfigured: 'Unconfigured',
certificateLabel: 'Certificate',
descriptionLine1:
'Require members to sign in through your identity provider using their domain email. Members without a matching domain are unaffected.',
descriptionLine2:
Expand Down
20 changes: 18 additions & 2 deletions packages/shared/src/react/hooks/useOrganizationDomains.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';

import { logger } from '../../logger';
import type { GetDomainsParams } from '../../types/organization';
Expand All @@ -24,6 +24,11 @@ export type UseOrganizationDomainsParams = {
* Filter the returned domains by enrollment mode.
*/
enrollmentMode?: OrganizationEnrollmentMode;
/**
* Invoked from the ownership-verification poll whenever an `attempt` resolves
* one or more domains as `verified`.
*/
onOwnershipVerified?: (verifiedDomains: OrganizationDomainResource[]) => void | Promise<void>;
};

export type UseOrganizationDomainsReturn = {
Expand Down Expand Up @@ -59,11 +64,14 @@ export type UseOrganizationDomainsReturn = {
* @internal
*/
function useOrganizationDomains(params: UseOrganizationDomainsParams = {}): UseOrganizationDomainsReturn {
const { keepPreviousData = true, enabled = true, enrollmentMode } = params;
const { keepPreviousData = true, enabled = true, enrollmentMode, onOwnershipVerified } = params;
const clerk = useClerkInstanceContext();
const organization = useOrganizationBase();
const [queryClient] = useClerkQueryClient();

const onOwnershipVerifiedRef = useRef(onOwnershipVerified);
onOwnershipVerifiedRef.current = onOwnershipVerified;

const { queryKey, stableKey, authenticated } = useOrganizationDomainsCacheKeys({
organizationId: organization?.id ?? null,
enrollmentMode,
Expand Down Expand Up @@ -171,6 +179,14 @@ function useOrganizationDomains(params: UseOrganizationDomainsParams = {}): UseO
return;
}

const verifiedDomains = result?.data.filter(domain => domain.ownershipVerification?.status === 'verified') ?? [];
if (verifiedDomains.length) {
await onOwnershipVerifiedRef.current?.(verifiedDomains);
}
if (cancelled) {
return;
}

// Stop polling once every domain in the attempt response is verified
const allVerified =
!!result?.data.length && result.data.every(domain => domain.ownershipVerification?.status === 'verified');
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/types/enterpriseConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export type MeEnterpriseConnectionOidcInput = OrganizationEnterpriseConnectionOi

export type CreateOrganizationEnterpriseConnectionParams = {
provider: OrganizationEnterpriseConnectionProvider;
name: string;
name?: string;
/** FQDN strings the connection authenticates. Required by the org-scoped create endpoint. */
domains?: string[];
organizationId?: string | null;
Expand All @@ -153,6 +153,7 @@ export type CreateMeEnterpriseConnectionParams = CreateOrganizationEnterpriseCon

export type UpdateOrganizationEnterpriseConnectionParams = {
name?: string | null;
domains?: string[];
active?: boolean | null;
syncUserAttributes?: boolean | null;
disableAdditionalIdentifications?: boolean | null;
Expand Down
11 changes: 9 additions & 2 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1153,7 +1153,6 @@ export type __internal_LocalizationResource = {
domainLabel: LocalizationValue;
signOnUrlLabel: LocalizationValue;
issuerLabel: LocalizationValue;
certificateLabel: LocalizationValue;
menuAction__edit: LocalizationValue;
menuAction__activate: LocalizationValue;
menuAction__deactivate: LocalizationValue;
Expand Down Expand Up @@ -1382,13 +1381,22 @@ export type __internal_LocalizationResource = {
badge__verified: LocalizationValue;
badge__unverified: LocalizationValue;
verifiedAtLabel: LocalizationValue<'date'>;
removeButtonTooltip__lastVerifiedDomain: LocalizationValue;
removeButtonTooltip__lastVerifiedDomainActive: LocalizationValue;
txtRecord: {
instructions: LocalizationValue;
typeLabel: LocalizationValue;
hostLabel: LocalizationValue;
valueLabel: LocalizationValue;
};
};
removeDomainDialog: {
title: LocalizationValue;
subtitle__active: LocalizationValue<'domain'>;
subtitle__inactive: LocalizationValue<'domain'>;
cancelButton: LocalizationValue;
removeButton: LocalizationValue;
};
};
testConfigurationStep: {
title: LocalizationValue;
Expand Down Expand Up @@ -1835,7 +1843,6 @@ export type __internal_LocalizationResource = {
title: LocalizationValue;
ssoUrlLabel: LocalizationValue;
issuerLabel: LocalizationValue;
certificateLabel: LocalizationValue;
configureAgainLink: LocalizationValue;
};
resetSection: {
Expand Down
109 changes: 109 additions & 0 deletions packages/ui/src/components/ConfigureSSO/RemoveDomainDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useMemo } from 'react';

import { Col, descriptors, localizationKeys } from '@/customizables';
import { Card } from '@/elements/Card';
import { useCardState, withCardStateProvider } from '@/elements/contexts';
import { Form } from '@/elements/Form';
import { FormButtonContainer } from '@/elements/FormButtons';
import { FormContainer } from '@/elements/FormContainer';
import { Modal } from '@/elements/Modal';
import { handleError } from '@/utils/errorHandler';

type RemoveDomainDialogProps = {
isOpen: boolean;
onClose: () => void;
domain: string;
isConnectionActive: boolean;
onRemove: () => Promise<unknown>;
contentRef: React.RefObject<HTMLDivElement>;
};

export const RemoveDomainDialog = (props: RemoveDomainDialogProps): JSX.Element | null => {
if (!props.isOpen) {
return null;
}

return (
<Modal
handleClose={props.onClose}
canCloseModal={false}
portalRoot={props.contentRef}
containerSx={t => ({
alignItems: 'center',
position: 'absolute',
inset: 0,
width: 'auto',
height: 'auto',
backgroundColor: 'inherit',
backdropFilter: `blur(${t.sizes.$2})`,
})}
>
<RemoveDomainDialogContent {...props} />
</Modal>
);
};

const RemoveDomainDialogContent = withCardStateProvider((props: RemoveDomainDialogProps) => {
const { onClose, onRemove } = props;
const card = useCardState();

const subtitle = useMemo(
() =>
props.isConnectionActive
? localizationKeys('configureSSO.organizationDomainsStep.removeDomainDialog.subtitle__active', {
domain: props.domain,
})
: localizationKeys('configureSSO.organizationDomainsStep.removeDomainDialog.subtitle__inactive', {
domain: props.domain,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const onSubmit = async () => {
try {
await onRemove();
onClose();
} catch (err) {
handleError(err as Error, [], card.setError);
}
};

return (
<Card.Root
elementDescriptor={descriptors.configureSSORemoveDomainDialog}
sx={t => ({ borderRadius: t.radii.$md })}
>
<Card.Content sx={t => ({ textAlign: 'start', padding: t.sizes.$5 })}>
<FormContainer
headerTitle={localizationKeys('configureSSO.organizationDomainsStep.removeDomainDialog.title')}
headerSubtitle={subtitle}
sx={t => ({ gap: t.space.$4 })}
>
<Form.Root onSubmit={onSubmit}>
<Col gap={4}>
<FormButtonContainer>
<Form.SubmitButton
elementDescriptor={descriptors.configureSSORemoveDomainDialogSubmitButton}
block={false}
colorScheme='danger'
localizationKey={localizationKeys(
'configureSSO.organizationDomainsStep.removeDomainDialog.removeButton',
)}
/>
<Form.ResetButton
elementDescriptor={descriptors.configureSSORemoveDomainDialogCancelButton}
block={false}
localizationKey={localizationKeys(
'configureSSO.organizationDomainsStep.removeDomainDialog.cancelButton',
)}
onClick={onClose}
/>
</FormButtonContainer>
</Col>
</Form.Root>
</FormContainer>
</Card.Content>
</Card.Root>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { describe, expect, it, vi } from 'vitest';

import { bindCreateFixtures } from '@/test/create-fixtures';
import { render, screen, waitFor } from '@/test/utils';
import { CardStateProvider } from '@/ui/elements/contexts';

import { RemoveDomainDialog } from '../RemoveDomainDialog';

const onRemove = vi.fn();

const { createFixtures } = bindCreateFixtures('ConfigureSSO');

const renderDialog = (
wrapper: React.ComponentType<{ children?: React.ReactNode }>,
props: {
isOpen?: boolean;
onClose?: () => void;
domain?: string;
isConnectionActive?: boolean;
} = {},
) => {
const onClose = props.onClose ?? vi.fn();
const utils = render(
<CardStateProvider>
<RemoveDomainDialog
isOpen={props.isOpen ?? true}
onClose={onClose}
domain={props.domain ?? 'acme.com'}
isConnectionActive={props.isConnectionActive ?? false}
onRemove={() => onRemove()}
contentRef={{ current: null }}
/>
</CardStateProvider>,
{ wrapper },
);
return { ...utils, onClose };
};

const resetMocks = () => {
onRemove.mockReset();
onRemove.mockResolvedValue(undefined);
};

describe('RemoveDomainDialog', () => {
it('does not render when `isOpen` is `false`', async () => {
resetMocks();
const { wrapper } = await createFixtures();
renderDialog(wrapper, { isOpen: false });

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Removing domain' })).not.toBeInTheDocument();
});

it('renders the dialog chrome and actions when isOpen is true', async () => {
resetMocks();
const { wrapper } = await createFixtures();
renderDialog(wrapper, { domain: 'acme.com' });

expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Removing domain' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove domain' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});

it('warns about sign-in impact when the connection is active', async () => {
resetMocks();
const { wrapper } = await createFixtures();
renderDialog(wrapper, { domain: 'acme.com', isConnectionActive: true });

expect(screen.getByText(/Users won't be able to sign-in with acme\.com anymore/i)).toBeInTheDocument();
});

it('shows the neutral copy when the connection is inactive', async () => {
resetMocks();
const { wrapper } = await createFixtures();
renderDialog(wrapper, { domain: 'acme.com', isConnectionActive: false });

expect(screen.getByText("You're about to remove acme.com from this enterprise connection.")).toBeInTheDocument();
expect(screen.queryByText(/Users won't be able to sign-in/i)).not.toBeInTheDocument();
});

it('invokes onClose when Cancel is clicked', async () => {
resetMocks();
const onClose = vi.fn();
const { wrapper } = await createFixtures();
const { userEvent } = renderDialog(wrapper, { onClose });

await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onRemove).not.toHaveBeenCalled();
});

it('awaits the removal and closes on a successful submit', async () => {
resetMocks();
const onClose = vi.fn();
const { wrapper } = await createFixtures();
const { userEvent } = renderDialog(wrapper, { onClose });

await userEvent.click(screen.getByRole('button', { name: 'Remove domain' }));

await waitFor(() => {
expect(onRemove).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});

it('keeps the dialog open and surfaces an error when removal fails', async () => {
resetMocks();
onRemove.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [
{
code: 'internal_server_error',
long_message: 'Something went wrong while removing the domain.',
message: 'Removal failed.',
},
],
status: 500,
}),
);
const onClose = vi.fn();
const { wrapper } = await createFixtures();
const { userEvent } = renderDialog(wrapper, { onClose });

await userEvent.click(screen.getByRole('button', { name: 'Remove domain' }));

await waitFor(() => {
expect(onRemove).toHaveBeenCalledTimes(1);
});
expect(await screen.findByText('Something went wrong while removing the domain.')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
});
Comment thread
LauraBeatris marked this conversation as resolved.
Loading
Loading