diff --git a/.changeset/nervous-lizards-repeat.md b/.changeset/nervous-lizards-repeat.md
new file mode 100644
index 00000000000..abd5df1546b
--- /dev/null
+++ b/.changeset/nervous-lizards-repeat.md
@@ -0,0 +1,10 @@
+---
+'@clerk/localizations': patch
+'@clerk/clerk-js': patch
+'@clerk/types': patch
+---
+
+Introduces domains and invitations in
+
+- The "Members" page now accommodates Domain and Individual invitations
+- The "Settings" page allows for the addition, edit and removal of a domain
diff --git a/packages/clerk-js/src/core/resources/OrganizationDomain.ts b/packages/clerk-js/src/core/resources/OrganizationDomain.ts
index a63a70fe251..6fb81651da4 100644
--- a/packages/clerk-js/src/core/resources/OrganizationDomain.ts
+++ b/packages/clerk-js/src/core/resources/OrganizationDomain.ts
@@ -37,7 +37,7 @@ export class OrganizationDomain extends BaseResource implements OrganizationDoma
return new OrganizationDomain(json);
}
- prepareDomainAffiliationVerification = async (
+ prepareAffiliationVerification = async (
params: PrepareAffiliationVerificationParams,
): Promise => {
return this._basePost({
diff --git a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationDomain.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationDomain.test.ts.snap
index a8083943b8b..85a8c0eb05c 100644
--- a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationDomain.test.ts.snap
+++ b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationDomain.test.ts.snap
@@ -10,7 +10,7 @@ OrganizationDomain {
"name": "clerk.dev",
"organizationId": "test_org_id",
"pathRoot": "",
- "prepareDomainAffiliationVerification": [Function],
+ "prepareAffiliationVerification": [Function],
"update": [Function],
"verification": null,
}
@@ -26,7 +26,7 @@ OrganizationDomain {
"name": "clerk.dev",
"organizationId": "test_org_id",
"pathRoot": "",
- "prepareDomainAffiliationVerification": [Function],
+ "prepareAffiliationVerification": [Function],
"update": [Function],
"verification": {
"attempts": 1,
diff --git a/packages/clerk-js/src/ui/common/CalloutWithAction.tsx b/packages/clerk-js/src/ui/common/CalloutWithAction.tsx
new file mode 100644
index 00000000000..ed58a8977f7
--- /dev/null
+++ b/packages/clerk-js/src/ui/common/CalloutWithAction.tsx
@@ -0,0 +1,47 @@
+import type { MouseEvent } from 'react';
+
+import { Col, Flex, Link, Text } from '../customizables';
+import type { LocalizationKey } from '../localization';
+
+type CalloutWithActionProps = {
+ text: LocalizationKey;
+ actionLabel?: LocalizationKey;
+ onClick?: (e: MouseEvent) => Promise;
+};
+export const CalloutWithAction = (props: CalloutWithActionProps) => {
+ const { text, actionLabel, onClick: onClickProp } = props;
+
+ const onClick = (e: MouseEvent) => {
+ if (onClickProp) {
+ void onClickProp?.(e);
+ }
+ };
+
+ return (
+ ({
+ background: theme.colors.$blackAlpha50,
+ padding: theme.space.$4,
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ borderRadius: theme.radii.$md,
+ })}
+ >
+
+ ({
+ lineHeight: t.lineHeights.$tall,
+ })}
+ localizationKey={text}
+ />
+
+
+
+
+ );
+};
diff --git a/packages/clerk-js/src/ui/common/index.ts b/packages/clerk-js/src/ui/common/index.ts
index 234414736d6..44d4a3be3da 100644
--- a/packages/clerk-js/src/ui/common/index.ts
+++ b/packages/clerk-js/src/ui/common/index.ts
@@ -1,5 +1,6 @@
export * from './BlockButtons';
export * from './constants';
+export * from './CalloutWithAction';
export * from './forms';
export * from './redirects';
export * from './verification';
@@ -10,5 +11,6 @@ export * from './EmailLinkStatusCard';
export * from './Wizard';
export * from './RemoveResourcePage';
export * from './PrintableComponent';
+export * from './RemoveResourcePage';
export * from './withOrganizationsEnabledGuard';
export * from './QRCode';
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx
new file mode 100644
index 00000000000..bc4a7e54bad
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+
+import { useCoreOrganization } from '../../contexts';
+import { localizationKeys } from '../../customizables';
+import { ContentPage, Form, FormButtons, useCardState, withCardStateProvider } from '../../elements';
+import { useRouter } from '../../router';
+import { handleError, useFormControl } from '../../utils';
+import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar';
+
+export const AddDomainPage = withCardStateProvider(() => {
+ const title = localizationKeys('organizationProfile.createDomainPage.title');
+ const subtitle = localizationKeys('organizationProfile.createDomainPage.subtitle');
+ const card = useCardState();
+ const { organization } = useCoreOrganization();
+ const { navigate } = useRouter();
+
+ const nameField = useFormControl('name', '', {
+ type: 'text',
+ label: localizationKeys('formFieldLabel__organizationEmailDomain'),
+ placeholder: localizationKeys('formFieldInputPlaceholder__organizationName'),
+ });
+
+ if (!organization) {
+ return null;
+ }
+
+ const canSubmit = organization.name !== nameField.value;
+
+ const onSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ return organization
+ .createDomain(nameField.value)
+ .then(res => {
+ if (res.verification && res.verification.status === 'verified') {
+ return navigate(`../domain/${res.id}`);
+ }
+ return navigate(`../domain/${res.id}/verify`);
+ })
+ .catch(err => {
+ handleError(err, [nameField], card.setError);
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx
new file mode 100644
index 00000000000..e950b8183ed
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx
@@ -0,0 +1,133 @@
+import type { GetDomainsParams, OrganizationEnrollmentMode } from '@clerk/types';
+import type { OrganizationDomainVerificationStatus } from '@clerk/types';
+import React, { useMemo } from 'react';
+
+import { useCoreOrganization } from '../../contexts';
+import { Badge, Box, Col, descriptors, Spinner } from '../../customizables';
+import { ArrowBlockButton } from '../../elements';
+import { useInView } from '../../hooks';
+import { useRouter } from '../../router';
+
+type DomainListProps = GetDomainsParams & {
+ verificationStatus?: OrganizationDomainVerificationStatus;
+ enrollmentMode?: OrganizationEnrollmentMode;
+ /**
+ * Enables internal links to navigate to the correct page
+ * based on when this component is used
+ */
+ redirectSubPath: string;
+ fallback?: React.ReactNode;
+};
+
+export const DomainList = (props: DomainListProps) => {
+ const { verificationStatus, enrollmentMode, redirectSubPath, fallback, ...rest } = props;
+ const { organization, membership, domains } = useCoreOrganization({
+ domains: {
+ infinite: true,
+ ...rest,
+ },
+ });
+
+ const { ref } = useInView({
+ threshold: 0,
+ onChange: inView => {
+ if (inView) {
+ void domains?.fetchNext?.();
+ }
+ },
+ });
+ const { navigate } = useRouter();
+
+ const isAdmin = membership?.role === 'admin';
+
+ const domainList = useMemo(() => {
+ if (!domains?.data) {
+ return [];
+ }
+
+ return domains.data.filter(d => {
+ let matchesStatus = true;
+ let matchesMode = true;
+ if (verificationStatus) {
+ matchesStatus = !!d.verification && d.verification.status === verificationStatus;
+ }
+ if (enrollmentMode) {
+ matchesMode = d.enrollmentMode === enrollmentMode;
+ }
+
+ return matchesStatus && matchesMode;
+ });
+ }, [domains?.data]);
+
+ if (!organization || !isAdmin) {
+ return null;
+ }
+
+ // TODO: Split this to smaller components
+ return (
+
+ {domainList.length === 0 && !domains?.isLoading && fallback}
+ {domainList.map(d => (
+ Verified
+ ) : (
+
+ Unverified
+
+ )
+ ) : undefined
+ }
+ sx={t => ({
+ padding: `${t.space.$3} ${t.space.$4}`,
+ minHeight: t.sizes.$10,
+ })}
+ onClick={() => {
+ d.verification && d.verification.status === 'verified'
+ ? void navigate(`${redirectSubPath}${d.id}`)
+ : void navigate(`${redirectSubPath}${d.id}/verify`);
+ }}
+ >
+ {d.name}
+
+ ))}
+ {(domains?.hasNextPage || domains?.isFetching) && (
+ ({
+ width: '100%',
+ height: t.space.$10,
+ position: 'relative',
+ }),
+ ]}
+ >
+
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembers.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembers.tsx
index 6caffaa4d79..c5d563f54b8 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembers.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembers.tsx
@@ -1,3 +1,4 @@
+import { CalloutWithAction } from '../../common';
import { useCoreOrganization, useOrganizationProfileContext } from '../../contexts';
import { Col, descriptors, Flex, Icon, localizationKeys } from '../../customizables';
import {
@@ -16,6 +17,7 @@ import {
import { UserAdd } from '../../icons';
import { useRouter } from '../../router';
import { ActiveMembersList } from './ActiveMembersList';
+import { DomainList } from './DomainList';
import { InvitedMembersList } from './InvitedMembersList';
import { MembershipWidget } from './MembershipWidget';
@@ -39,39 +41,20 @@ export const OrganizationMembers = withCardStateProvider(() => {
elementId={descriptors.profilePage.setId('organizationMembers')}
gap={8}
>
-
-
-
-
-
- {isAdmin && (
- navigate('invite-members')}
- icon={
- ({ marginRight: t.space.$2 })}
- />
- }
- textVariant='buttonExtraSmallBold'
- localizationKey={localizationKeys('organizationProfile.membersPage.action__invite')}
- />
- )}
-
+
+
+
+
-
+
{isAdmin && (
-
+
)}
@@ -89,16 +72,96 @@ export const OrganizationMembers = withCardStateProvider(() => {
{isAdmin && (
-
{isAdmin && __unstable_manageBillingUrl && }
-
-
+
+
+
+
+
+ navigate('organization-settings/domain')}
+ />
+ }
+ redirectSubPath={'organization-settings/domain/'}
+ verificationStatus={'verified'}
+ enrollmentMode={'automatic_invitation'}
+ />
+
+
+
+
+
+
+
+
+ {isAdmin && (
+ navigate('invite-members')}
+ icon={
+ ({ marginRight: t.space.$2 })}
+ />
+ }
+ textVariant='buttonExtraSmallBold'
+ localizationKey={localizationKeys('organizationProfile.membersPage.action__invite')}
+ />
+ )}
+
+
+
+
)}
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx
index 5abac279b68..fff24f3b43a 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx
@@ -51,6 +51,8 @@ export const OrganizationProfileNavbar = (
const pageToRootNavbarRouteMap = {
'invite-members': organizationProfileRoutes.find(r => r.id === 'members'),
+ 'add-domain': organizationProfileRoutes.find(r => r.id === 'settings'),
+ 'verify-domain/:id': organizationProfileRoutes.find(r => r.id === 'settings'),
profile: organizationProfileRoutes.find(r => r.id === 'settings'),
leave: organizationProfileRoutes.find(r => r.id === 'settings'),
};
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx
index dc7fdb791bc..d718c1795b9 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx
@@ -2,10 +2,14 @@ import { ProfileCardContent } from '../../elements';
import { Route, Switch } from '../../router';
import type { PropsOfComponent } from '../../styledSystem';
import { DeleteOrganizationPage, LeaveOrganizationPage } from './ActionConfirmationPage';
+import { AddDomainPage } from './AddDomainPage';
import { InviteMembersPage } from './InviteMembersPage';
import { OrganizationMembers } from './OrganizationMembers';
import { OrganizationSettings } from './OrganizationSettings';
import { ProfileSettingsPage } from './ProfileSettingsPage';
+import { RemoveDomainPage } from './RemoveDomainPage';
+import { VerifiedDomainPage } from './VerifiedDomainPage';
+import { VerifyDomainPage } from './VerifyDomainPage';
export const OrganizationProfileRoutes = (props: PropsOfComponent) => {
return (
@@ -18,6 +22,25 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
return (
@@ -25,6 +26,7 @@ export const OrganizationSettings = () => {
+
@@ -57,6 +59,32 @@ const OrganizationProfileSection = () => {
);
};
+const OrganizationDomainsSection = () => {
+ const { organization, membership } = useCoreOrganization();
+
+ const { navigate } = useRouter();
+ const isAdmin = membership?.role === 'admin';
+
+ if (!organization || !isAdmin) {
+ return null;
+ }
+
+ return (
+
+
+
+ navigate('domain')}
+ />
+
+ );
+};
+
const OrganizationDangerSection = () => {
const {
organization,
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx
new file mode 100644
index 00000000000..484adc9b085
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/RemoveDomainPage.tsx
@@ -0,0 +1,62 @@
+import type { OrganizationDomainResource } from '@clerk/types';
+import React from 'react';
+
+import { RemoveResourcePage } from '../../common';
+import { useCoreOrganization } from '../../contexts';
+import { Flex, Spinner } from '../../customizables';
+import { useFetch } from '../../hooks';
+import { localizationKeys } from '../../localization';
+import { useRouter } from '../../router';
+import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar';
+
+export const RemoveDomainPage = () => {
+ const { organization } = useCoreOrganization();
+ const { params } = useRouter();
+
+ const ref = React.useRef();
+ const { data: domain, status: domainStatus } = useFetch(
+ organization?.getDomain,
+ {
+ domainId: params.id,
+ },
+ {
+ onSuccess(domain) {
+ ref.current = { ...domain };
+ },
+ },
+ );
+
+ if (domainStatus.isLoading || !domain) {
+ return (
+ ({
+ height: '100%',
+ minHeight: t.sizes.$120,
+ })}
+ >
+
+
+ );
+ }
+
+ return (
+ Promise.resolve(domain?.delete())}
+ Breadcrumbs={OrganizationProfileBreadcrumbs}
+ />
+ );
+};
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx
new file mode 100644
index 00000000000..6d3725f55e0
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx
@@ -0,0 +1,133 @@
+import type { OrganizationEnrollmentMode } from '@clerk/types';
+
+import { useCoreOrganization } from '../../contexts';
+import { Badge, Col, descriptors, Flex, localizationKeys, Spinner } from '../../customizables';
+import {
+ BlockWithAction,
+ ContentPage,
+ Form,
+ FormButtons,
+ Header,
+ useCardState,
+ withCardStateProvider,
+} from '../../elements';
+import { useFetch } from '../../hooks';
+import { useRouter } from '../../router';
+import { handleError, useFormControl } from '../../utils';
+import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar';
+
+export const VerifiedDomainPage = withCardStateProvider(() => {
+ const card = useCardState();
+ const { organization } = useCoreOrganization();
+ const { params, navigate } = useRouter();
+
+ const title = localizationKeys('organizationProfile.verifiedDomainPage.title');
+
+ const enrollmentMode = useFormControl('enrollmentMode', '', {
+ type: 'radio',
+ radioOptions: [
+ {
+ value: 'automatic_invitation',
+ // TODO: Add labels
+ label: 'Automatic invitation',
+ },
+ {
+ value: 'manual_invitation',
+ label: 'Manual invitation',
+ },
+ ],
+ });
+
+ const { data: domain, status: domainStatus } = useFetch(
+ organization?.getDomain,
+ {
+ domainId: params.id,
+ },
+ {
+ onSuccess(d) {
+ enrollmentMode.setValue(d.enrollmentMode);
+ },
+ },
+ );
+
+ const updateEnrollmentMode = async () => {
+ if (!domain || !organization) {
+ return;
+ }
+
+ try {
+ await domain.update({
+ enrollmentMode: enrollmentMode.value as OrganizationEnrollmentMode,
+ });
+
+ await navigate('../../');
+ } catch (e) {
+ handleError(e, [enrollmentMode], card.setError);
+ }
+ };
+
+ if (!organization) {
+ return null;
+ }
+
+ if (domainStatus.isLoading || !domain) {
+ return (
+ ({
+ height: '100%',
+ minHeight: t.sizes.$120,
+ })}
+ >
+
+
+ );
+ }
+
+ return (
+
+ Verified}
+ sx={t => ({
+ backgroundColor: t.colors.$blackAlpha50,
+ padding: `${t.space.$3} ${t.space.$4}`,
+ minHeight: t.sizes.$10,
+ })}
+ actionLabel={localizationKeys('organizationProfile.verifiedDomainPage.actionLabel__remove')}
+ onActionClick={() => navigate(`../../domain/${domain.id}/remove`)}
+ >
+ {domain.name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx
new file mode 100644
index 00000000000..952c34eed70
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx
@@ -0,0 +1,193 @@
+import React from 'react';
+
+import { useWizard, Wizard } from '../../common';
+import { useCoreOrganization } from '../../contexts';
+import { Badge, Button, descriptors, Flex, localizationKeys, Spinner } from '../../customizables';
+import type { VerificationCodeCardProps } from '../../elements';
+import {
+ BlockWithAction,
+ ContentPage,
+ Form,
+ FormButtonContainer,
+ FormButtons,
+ useCardState,
+ useCodeControl,
+ withCardStateProvider,
+} from '../../elements';
+import { CodeForm } from '../../elements/CodeForm';
+import { useFetch, useLoadingStatus, useNavigateToFlowStart } from '../../hooks';
+import { useRouter } from '../../router';
+import { handleError, sleep, useFormControl } from '../../utils';
+import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar';
+
+export const VerifyDomainPage = withCardStateProvider(() => {
+ const card = useCardState();
+ const { organization } = useCoreOrganization();
+ const { params, navigate } = useRouter();
+ const { navigateToFlowStart } = useNavigateToFlowStart();
+
+ const [success, setSuccess] = React.useState(false);
+
+ const { data: domain, status: domainStatus } = useFetch(organization?.getDomain, {
+ domainId: params.id,
+ });
+ const title = localizationKeys('organizationProfile.verifyDomainPage.title');
+ const subtitle = localizationKeys('organizationProfile.verifyDomainPage.subtitle', {
+ domainName: domain?.name ?? '',
+ });
+ const status = useLoadingStatus();
+
+ const codeControlState = useFormControl('code', '');
+ const codeControl = useCodeControl(codeControlState);
+
+ const wizard = useWizard({ onNextStep: () => card.setError(undefined) });
+
+ const emailField = useFormControl('emailAddress', '', {
+ type: 'email',
+ label: localizationKeys('formFieldLabel__organizationEmailDomain'),
+ placeholder: localizationKeys('formFieldInputPlaceholder__organizationName'),
+ });
+
+ const resolve = async () => {
+ setSuccess(true);
+ await sleep(750);
+ };
+
+ const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => {
+ domain
+ ?.attemptAffiliationVerification?.({ code })
+ .then(async res => {
+ await resolve();
+ if (res.verification?.status === 'verified') {
+ return navigateToFlowStart();
+ }
+ return;
+ })
+ .catch(err => reject(err));
+ };
+
+ const reject = async (err: any) => {
+ handleError(err, [codeControlState], card.setError);
+ status.setIdle();
+ await sleep(750);
+ codeControl.reset();
+ };
+
+ codeControl.onCodeEntryFinished(code => {
+ status.setLoading();
+ codeControlState.setError(undefined);
+ action(code, resolve, reject);
+ });
+
+ if (!organization) {
+ return null;
+ }
+ const handleResend = () => {
+ codeControl.reset();
+ };
+
+ const dataChanged = organization.name !== emailField.value;
+ const canSubmit = dataChanged;
+
+ const onSubmitPrepare = (e: React.FormEvent) => {
+ e.preventDefault();
+ return domain
+ ?.prepareAffiliationVerification({
+ affiliationEmailAddress: emailField.value,
+ })
+ .then(wizard.nextStep)
+ .catch(err => {
+ handleError(err, [emailField], card.setError);
+ });
+ };
+
+ if (domainStatus.isLoading || !domain) {
+ return (
+ ({
+ height: '100%',
+ minHeight: t.sizes.$120,
+ })}
+ >
+
+
+ );
+ }
+
+ return (
+
+
+
+ Unverified
+
+ }
+ sx={t => ({
+ backgroundColor: t.colors.$blackAlpha50,
+ padding: `${t.space.$3} ${t.space.$4}`,
+ minHeight: t.sizes.$10,
+ })}
+ actionLabel={localizationKeys('organizationProfile.verifyDomainPage.actionLabel__remove')}
+ onActionClick={() => navigate(`../../../domain/${domain.id}/remove`)}
+ >
+ {domain.name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx
index 4a7e1e0b015..46a605165c8 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx
@@ -1,4 +1,4 @@
-import { render, userEvent, waitFor } from '@clerk/shared/testUtils';
+import { render, waitFor } from '@clerk/shared/testUtils';
import type { OrganizationMembershipResource } from '@clerk/types';
import { describe, it } from '@jest/globals';
@@ -15,28 +15,35 @@ describe('OrganizationMembers', () => {
f.withUser({ email_addresses: ['test@clerk.dev'], organization_memberships: ['Org1'] });
});
- const { getByText } = render(, { wrapper });
+ const { getByText, getByRole } = render(, { wrapper });
await waitFor(() => {
- expect(getByText('Members')).toBeDefined();
- expect(getByText('View and manage organization members')).toBeDefined();
+ expect(getByRole('heading', { name: /members/i })).toBeInTheDocument();
+ expect(getByText('View and manage organization members')).toBeInTheDocument();
});
- });
-
- it('shows an invite button and invited tab if the current user is an admin', async () => {
- const { wrapper } = await createFixtures(f => {
- f.withOrganizations();
- f.withUser({ email_addresses: ['test@clerk.dev'], organization_memberships: [{ name: 'Org1', role: 'admin' }] });
- });
-
- const { getByText, getByRole } = render(, { wrapper });
await waitFor(() => {
- expect(getByText('Invited')).toBeDefined();
- expect(getByRole('button', { name: 'Invite' })).toBeDefined();
+ // Tabs
+ expect(getByRole('tab', { name: 'Members' })).toBeInTheDocument();
+ expect(getByRole('tab', { name: 'Invitations' })).toBeInTheDocument();
});
});
+ // TODO: to be removed no longer valid
+ // it('shows an invite button and invited tab if the current user is an admin', async () => {
+ // const { wrapper } = await createFixtures(f => {
+ // f.withOrganizations();
+ // f.withUser({ email_addresses: ['test@clerk.dev'], organization_memberships: [{ name: 'Org1', role: 'admin' }] });
+ // });
+ //
+ // const { getByText, getByRole } = render(, { wrapper });
+ //
+ // await waitFor(() => {
+ // expect(getByText('Invited')).toBeDefined();
+ // expect(getByRole('button', { name: 'Invite' })).toBeDefined();
+ // });
+ // });
+
it('does not show an invite button and invited tab if the current user is not an admin', async () => {
const { wrapper } = await createFixtures(f => {
f.withOrganizations();
@@ -54,19 +61,20 @@ describe('OrganizationMembers', () => {
});
});
- it('navigates to invite screen when user clicks on Invite button', async () => {
- const { wrapper, fixtures } = await createFixtures(f => {
- f.withOrganizations();
- f.withUser({ email_addresses: ['test@clerk.dev'], organization_memberships: [{ name: 'Org1', role: 'admin' }] });
- });
-
- const { getByRole } = render(, { wrapper });
- await userEvent.click(getByRole('button', { name: 'Invite' }));
-
- await waitFor(() => {
- expect(fixtures.router.navigate).toHaveBeenCalledWith('invite-members');
- });
- });
+ // TODO: to be removed no longer valid
+ // it('navigates to invite screen when user clicks on Invite button', async () => {
+ // const { wrapper, fixtures } = await createFixtures(f => {
+ // f.withOrganizations();
+ // f.withUser({ email_addresses: ['test@clerk.dev'], organization_memberships: [{ name: 'Org1', role: 'admin' }] });
+ // });
+ //
+ // const { getByRole } = render(, { wrapper });
+ // await userEvent.click(getByRole('button', { name: 'Invite' }));
+ //
+ // await waitFor(() => {
+ // expect(fixtures.router.navigate).toHaveBeenCalledWith('invite-members');
+ // });
+ // });
it('lists all the members of the Organization', async () => {
const membersList: OrganizationMembershipResource[] = [
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationSettings.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationSettings.test.tsx
index cba8930640a..c417295de4c 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationSettings.test.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationSettings.test.tsx
@@ -1,16 +1,19 @@
import { render, userEvent, waitFor } from '@clerk/shared/testUtils';
-import type { OrganizationMembershipResource } from '@clerk/types';
+import type { OrganizationDomainResource, OrganizationMembershipResource } from '@clerk/types';
import { describe, it } from '@jest/globals';
import { bindCreateFixtures } from '../../../utils/test/createFixtures';
import { OrganizationSettings } from '../OrganizationSettings';
-import { createFakeMember } from './utils';
+import { createFakeDomain, createFakeMember } from './utils';
const { createFixtures } = bindCreateFixtures('OrganizationProfile');
describe('OrganizationSettings', () => {
it('enables organization profile button and disables leave when user is the only admin', async () => {
const adminsList: OrganizationMembershipResource[] = [createFakeMember({ id: '1', orgId: '1', role: 'admin' })];
+ const domainList: OrganizationDomainResource[] = [
+ createFakeDomain({ id: '1', organizationId: '1', name: 'clerk.dev' }),
+ ];
const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
@@ -18,6 +21,12 @@ describe('OrganizationSettings', () => {
});
fixtures.clerk.organization?.getMemberships.mockReturnValue(Promise.resolve(adminsList));
+ fixtures.clerk.organization?.getDomains.mockReturnValue(
+ Promise.resolve({
+ data: domainList,
+ total_count: 1,
+ }),
+ );
const { getByText } = render(, { wrapper });
await waitFor(() => {
expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalled();
@@ -32,6 +41,9 @@ describe('OrganizationSettings', () => {
createFakeMember({ id: '1', orgId: '1', role: 'admin' }),
createFakeMember({ id: '2', orgId: '1', role: 'admin' }),
];
+ const domainList: OrganizationDomainResource[] = [
+ createFakeDomain({ id: '1', organizationId: '1', name: 'clerk.dev' }),
+ ];
const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
@@ -39,6 +51,12 @@ describe('OrganizationSettings', () => {
});
fixtures.clerk.organization?.getMemberships.mockReturnValue(Promise.resolve(adminsList));
+ fixtures.clerk.organization?.getDomains.mockReturnValue(
+ Promise.resolve({
+ data: domainList,
+ total_count: 1,
+ }),
+ );
const { getByText } = render(, { wrapper });
await waitFor(() => {
expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalled();
@@ -79,6 +97,12 @@ describe('OrganizationSettings', () => {
});
});
+ fixtures.clerk.organization?.getDomains.mockReturnValue(
+ Promise.resolve({
+ data: [],
+ total_count: 0,
+ }),
+ );
const { getByText } = render(, { wrapper });
await userEvent.click(getByText('Org1', { exact: false }));
expect(fixtures.router.navigate).toHaveBeenCalledWith('profile');
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/utils.ts b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/utils.ts
index 255d55ce69e..915fd5916d1 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/utils.ts
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/utils.ts
@@ -1,4 +1,11 @@
-import type { MembershipRole, OrganizationMembershipResource, OrganizationResource } from '@clerk/types';
+import type {
+ MembershipRole,
+ OrganizationDomainResource,
+ OrganizationDomainVerification,
+ OrganizationEnrollmentMode,
+ OrganizationMembershipResource,
+ OrganizationResource,
+} from '@clerk/types';
import { jest } from '@jest/globals';
type FakeMemberParams = {
@@ -33,3 +40,29 @@ export const createFakeMember = (params: FakeMemberParams): OrganizationMembersh
},
} as any;
};
+
+type FakeDomainParams = {
+ id: string;
+ name: string;
+ organizationId: string;
+ enrollmentMode?: OrganizationEnrollmentMode;
+ verification?: OrganizationDomainVerification | null;
+ createdAt?: Date;
+ affiliationEmailAddress?: string | null;
+};
+
+export const createFakeDomain = (params: FakeDomainParams): OrganizationDomainResource => {
+ return {
+ destroy: jest.fn() as any,
+ update: jest.fn() as any,
+ id: params.id,
+ name: params.name,
+ verification: params.verification ?? null,
+ organizationId: params.organizationId,
+ enrollmentMode: params.enrollmentMode ?? 'manual_invitation',
+ prepareAffiliationVerification: jest.fn() as any,
+ attemptAffiliationVerification: jest.fn() as any,
+ createdAt: params?.createdAt || new Date(),
+ updatedAt: new Date(),
+ } as any;
+};
diff --git a/packages/clerk-js/src/ui/elements/BlockWithAction.tsx b/packages/clerk-js/src/ui/elements/BlockWithAction.tsx
new file mode 100644
index 00000000000..6adb5c0bb91
--- /dev/null
+++ b/packages/clerk-js/src/ui/elements/BlockWithAction.tsx
@@ -0,0 +1,100 @@
+import type { MouseEvent } from 'react';
+import React from 'react';
+
+import type { LocalizationKey } from '../customizables';
+import { Box, Button, Flex, Text } from '../customizables';
+import type { ElementDescriptor, ElementId } from '../customizables/elementDescriptors';
+import type { PropsOfComponent, ThemableCssProp } from '../styledSystem';
+
+type ArrowBlockButtonProps = PropsOfComponent & {
+ actionSx?: ThemableCssProp;
+ badge?: React.ReactElement;
+ textElementDescriptor?: ElementDescriptor;
+ textElementId?: ElementId;
+ textLocalizationKey?: LocalizationKey;
+ actionLabel?: LocalizationKey;
+ onActionClick?: (e: MouseEvent) => void | Promise;
+};
+
+/**
+ * Similar to ArrowBlockButton but just a container
+ * - An action is expected instead of an icon
+ */
+export const BlockWithAction = (props: ArrowBlockButtonProps) => {
+ const {
+ actionSx,
+ isLoading,
+ children,
+ textElementDescriptor,
+ textElementId,
+ textLocalizationKey,
+ badge,
+ onActionClick,
+ actionLabel,
+ ...rest
+ } = props;
+
+ return (
+ [
+ {
+ borderRadius: theme.radii.$md,
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: theme.space.$4,
+ position: 'relative',
+ justifyContent: 'flex-start',
+ borderColor: theme.colors.$blackAlpha200,
+ '--action-opacity': '0',
+ '--action-transform': `translateX(-${theme.space.$2});`,
+ '&:hover,&:focus ': {
+ '--action-opacity': '1',
+ '--action-transform': 'translateX(0px);',
+ },
+ },
+ props.sx,
+ ]}
+ >
+
+
+ {children}
+
+ {badge}
+
+
+ );
+};
diff --git a/packages/clerk-js/src/ui/elements/index.ts b/packages/clerk-js/src/ui/elements/index.ts
index c296a36c3d8..5e796da2025 100644
--- a/packages/clerk-js/src/ui/elements/index.ts
+++ b/packages/clerk-js/src/ui/elements/index.ts
@@ -3,6 +3,7 @@ export * from './Header';
export * from './Footer';
export * from './Alert';
export * from './Form';
+export * from './BlockWithAction';
export * from './BackLink';
export * from './IdentityPreview';
export * from './Avatar';
diff --git a/packages/clerk-js/src/ui/hooks/index.ts b/packages/clerk-js/src/ui/hooks/index.ts
index 579be2932ff..e68d3360978 100644
--- a/packages/clerk-js/src/ui/hooks/index.ts
+++ b/packages/clerk-js/src/ui/hooks/index.ts
@@ -4,6 +4,7 @@ export * from './useWindowEventListener';
export * from './useMagicLink';
export * from './useClipboard';
export * from './useEnabledThirdPartyProviders';
+export * from './useFetch';
export * from './useInView';
export * from './useLoadingStatus';
export * from './usePassword';
diff --git a/packages/clerk-js/src/ui/hooks/useFetch.ts b/packages/clerk-js/src/ui/hooks/useFetch.ts
new file mode 100644
index 00000000000..514ac87f7e3
--- /dev/null
+++ b/packages/clerk-js/src/ui/hooks/useFetch.ts
@@ -0,0 +1,44 @@
+import { useEffect, useRef } from 'react';
+
+import { useLoadingStatus } from './useLoadingStatus';
+import { useSafeState } from './useSafeState';
+
+export const useFetch = (
+ fetcher: ((...args: any) => Promise) | undefined,
+ params: any,
+ callbacks?: {
+ onSuccess?: (data: T) => void;
+ },
+) => {
+ const [data, setData] = useSafeState(null);
+ const requestStatus = useLoadingStatus({
+ status: 'loading',
+ });
+
+ const fetcherRef = useRef(fetcher);
+
+ useEffect(() => {
+ if (!fetcherRef.current) {
+ return;
+ }
+ requestStatus.setLoading();
+ fetcherRef
+ .current(params)
+ .then(domain => {
+ requestStatus.setIdle();
+ if (domain) {
+ setData({ ...domain });
+ callbacks?.onSuccess?.({ ...domain });
+ }
+ })
+ .catch(() => {
+ requestStatus.setError();
+ setData(null);
+ });
+ }, []);
+
+ return {
+ status: requestStatus,
+ data,
+ };
+};
diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts
index 1e9dfd68d9e..e75c723fef8 100644
--- a/packages/localizations/src/en-US.ts
+++ b/packages/localizations/src/en-US.ts
@@ -29,11 +29,13 @@ export const enUS: LocalizationResource = {
formFieldLabel__newPassword: 'New password',
formFieldLabel__confirmPassword: 'Confirm password',
formFieldLabel__signOutOfOtherSessions: 'Sign out of all other devices',
+ formFieldLabel__automaticInvitations: 'Enable automatic invitations for this domain',
formFieldLabel__firstName: 'First name',
formFieldLabel__lastName: 'Last name',
formFieldLabel__backupCode: 'Backup code',
formFieldLabel__organizationName: 'Organization name',
formFieldLabel__organizationSlug: 'Slug URL',
+ formFieldLabel__organizationEmailDomain: 'Email domain',
formFieldLabel__confirmDeletion: 'Confirmation',
formFieldLabel__role: 'Role',
formFieldInputPlaceholder__emailAddress: '',
@@ -51,6 +53,7 @@ export const enUS: LocalizationResource = {
formFieldInputPlaceholder__backupCode: '',
formFieldInputPlaceholder__organizationName: '',
formFieldInputPlaceholder__organizationSlug: '',
+ formFieldInputPlaceholder__organizationEmailDomain: '',
formFieldError__notMatchingPasswords: `Passwords don't match.`,
formFieldError__matchingPasswords: 'Passwords match.',
formFieldAction__forgotPassword: 'Forgot password?',
@@ -561,6 +564,32 @@ export const enUS: LocalizationResource = {
successMessage: 'You have deleted the organization.',
},
},
+ domainSection: {
+ title: 'Verified Domains',
+ subtitle:
+ 'Setup a domain so everyone with email addresses at this domain can automatically join your organization.',
+ primaryButton: 'Add verified domain',
+ },
+ },
+ createDomainPage: {
+ title: 'Add verified domain',
+ subtitle:
+ 'Add a domain so new users signing up with email addresses at this domain can automatically join your organization as a member.',
+ },
+ verifyDomainPage: {
+ title: 'Verify domain',
+ actionLabel__remove: 'Remove unverified domain',
+ subtitle: 'Add an email address under {{domainName}} to receive a code and verify this domain.',
+ formTitle: 'Verification code',
+ formSubtitle: 'Enter the verification code sent to your email address',
+ resendButton: "Didn't receive a code? Resend",
+ },
+ verifiedDomainPage: {
+ title: 'Verified domain',
+ actionLabel__remove: 'Remove verified domain',
+ formTitle: 'Enrollment method',
+ formSubtitle:
+ 'Define the method for automatically adding new and existing users who are associated with this domain to the organization.',
},
invitePage: {
title: 'Invite members',
@@ -569,12 +598,20 @@ export const enUS: LocalizationResource = {
detailsTitle__inviteFailed: 'The invitations could not be sent. Fix the following and try again:',
formButtonPrimary__continue: 'Send invitations',
},
+ removeDomainPage: {
+ title: 'Remove domain',
+ messageLine1: 'The email domain {{domain}} will be removed.',
+ messageLine2: 'Users won’t be able to join the organization automatically after this.',
+ successMessage: '{{domain}} has been removed.',
+ },
membersPage: {
detailsTitle__emptyRow: 'No members to display',
action__invite: 'Invite',
start: {
headerTitle__active: 'Active',
+ headerTitle__members: 'Members',
headerTitle__invited: 'Invited',
+ headerTitle__invitations: 'Invitations',
},
activeMembersTab: {
tableHeader__user: 'User',
@@ -587,6 +624,19 @@ export const enUS: LocalizationResource = {
tableHeader__invited: 'Invited',
menuAction__revoke: 'Revoke invitation',
},
+ invitationsTab: {
+ manualInvitations: {
+ headerTitle: 'Individual invitations',
+ headerSubtitle: 'Browse and manage invited members.',
+ },
+ autoInvitations: {
+ headerTitle: 'Domain invitations',
+ headerSubtitle:
+ 'New and existing users will be able to join anytime as members and will get notified in-app.',
+ calloutTextLabel: 'Invite users to your organization using verified email domains.',
+ calloutActionLabel: 'Setup verified domain',
+ },
+ },
},
},
createOrganization: {
diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts
index 7615e514d1a..8db3e1d734b 100644
--- a/packages/types/src/appearance.ts
+++ b/packages/types/src/appearance.ts
@@ -73,7 +73,8 @@ export type FieldId =
| 'code'
| 'role'
| 'deleteConfirmation'
- | 'deleteOrganizationConfirmation';
+ | 'deleteOrganizationConfirmation'
+ | 'enrollmentMode';
export type ProfileSectionId =
| 'profile'
| 'username'
@@ -87,7 +88,8 @@ export type ProfileSectionId =
| 'danger'
| 'activeDevices'
| 'organizationProfile'
- | 'organizationDanger';
+ | 'organizationDanger'
+ | 'organizationDomains';
export type ProfilePageId = 'account' | 'security' | 'organizationSettings' | 'organizationMembers';
export type NavbarItemId = 'account' | 'security' | 'members' | 'settings';
diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts
index 6cedec83873..9f5787c4af6 100644
--- a/packages/types/src/localization.ts
+++ b/packages/types/src/localization.ts
@@ -39,11 +39,13 @@ type _LocalizationResource = {
formFieldLabel__newPassword: LocalizationValue;
formFieldLabel__confirmPassword: LocalizationValue;
formFieldLabel__signOutOfOtherSessions: LocalizationValue;
+ formFieldLabel__automaticInvitations: LocalizationValue;
formFieldLabel__firstName: LocalizationValue;
formFieldLabel__lastName: LocalizationValue;
formFieldLabel__backupCode: LocalizationValue;
formFieldLabel__organizationName: LocalizationValue;
formFieldLabel__organizationSlug: LocalizationValue;
+ formFieldLabel__organizationEmailDomain: LocalizationValue;
formFieldLabel__confirmDeletion: LocalizationValue;
formFieldLabel__role: LocalizationValue;
formFieldInputPlaceholder__emailAddress: LocalizationValue;
@@ -69,6 +71,7 @@ type _LocalizationResource = {
formFieldInputPlaceholder__backupCode: LocalizationValue;
formFieldInputPlaceholder__organizationName: LocalizationValue;
formFieldInputPlaceholder__organizationSlug: LocalizationValue;
+ formFieldInputPlaceholder__organizationEmailDomain: LocalizationValue;
formFieldError__notMatchingPasswords: LocalizationValue;
formFieldError__matchingPasswords: LocalizationValue;
formFieldAction__forgotPassword: LocalizationValue;
@@ -581,6 +584,35 @@ type _LocalizationResource = {
successMessage: LocalizationValue;
};
};
+ domainSection: {
+ title: LocalizationValue;
+ subtitle: LocalizationValue;
+ primaryButton: LocalizationValue;
+ };
+ };
+ createDomainPage: {
+ title: LocalizationValue;
+ subtitle: LocalizationValue;
+ };
+ verifyDomainPage: {
+ title: LocalizationValue;
+ actionLabel__remove: LocalizationValue;
+ subtitle: LocalizationValue;
+ formTitle: LocalizationValue;
+ formSubtitle: LocalizationValue;
+ resendButton: LocalizationValue;
+ };
+ verifiedDomainPage: {
+ title: LocalizationValue;
+ actionLabel__remove: LocalizationValue;
+ formTitle: LocalizationValue;
+ formSubtitle: LocalizationValue;
+ };
+ removeDomainPage: {
+ title: LocalizationValue;
+ messageLine1: LocalizationValue;
+ messageLine2: LocalizationValue;
+ successMessage: LocalizationValue;
};
invitePage: {
title: LocalizationValue;
@@ -593,8 +625,16 @@ type _LocalizationResource = {
detailsTitle__emptyRow: LocalizationValue;
action__invite: LocalizationValue;
start: {
+ /**
+ * @deprecated use `headerTitle__members` instead
+ */
headerTitle__active: LocalizationValue;
+ headerTitle__members: LocalizationValue;
+ /**
+ * @deprecated use `headerTitle__invitations` instead
+ */
headerTitle__invited: LocalizationValue;
+ headerTitle__invitations: LocalizationValue;
};
activeMembersTab: {
tableHeader__user: LocalizationValue;
@@ -607,6 +647,18 @@ type _LocalizationResource = {
tableHeader__invited: LocalizationValue;
menuAction__revoke: LocalizationValue;
};
+ invitationsTab: {
+ manualInvitations: {
+ headerTitle: LocalizationValue;
+ headerSubtitle: LocalizationValue;
+ };
+ autoInvitations: {
+ headerTitle: LocalizationValue;
+ headerSubtitle: LocalizationValue;
+ calloutTextLabel: LocalizationValue;
+ calloutActionLabel: LocalizationValue;
+ };
+ };
};
};
createOrganization: {
diff --git a/packages/types/src/organizationDomain.ts b/packages/types/src/organizationDomain.ts
index f91ec4f3722..5e3ca613c06 100644
--- a/packages/types/src/organizationDomain.ts
+++ b/packages/types/src/organizationDomain.ts
@@ -20,9 +20,7 @@ export interface OrganizationDomainResource extends ClerkResource {
createdAt: Date;
updatedAt: Date;
affiliationEmailAddress: string | null;
- prepareDomainAffiliationVerification: (
- params: PrepareAffiliationVerificationParams,
- ) => Promise;
+ prepareAffiliationVerification: (params: PrepareAffiliationVerificationParams) => Promise;
attemptAffiliationVerification: (params: AttemptAffiliationVerificationParams) => Promise;
delete: () => Promise;