diff --git a/.changeset/large-chefs-move.md b/.changeset/large-chefs-move.md new file mode 100644 index 0000000000..ea2bb7059f --- /dev/null +++ b/.changeset/large-chefs-move.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": patch +"@clerk/types": patch +--- + +Supports default role on `OrganizationProfile` invitations. When inviting a member, the default role will be automatically selected, otherwise it falls back to the only available role. diff --git a/packages/clerk-js/src/core/resources/OrganizationSettings.ts b/packages/clerk-js/src/core/resources/OrganizationSettings.ts index e7c0fbb407..c6f9952007 100644 --- a/packages/clerk-js/src/core/resources/OrganizationSettings.ts +++ b/packages/clerk-js/src/core/resources/OrganizationSettings.ts @@ -11,6 +11,7 @@ export class OrganizationSettings extends BaseResource implements OrganizationSe domains!: { enabled: boolean; enrollmentModes: OrganizationEnrollmentMode[]; + defaultRole: string | null; }; public constructor(data: OrganizationSettingsJSON) { @@ -26,6 +27,7 @@ export class OrganizationSettings extends BaseResource implements OrganizationSe this.domains = { enabled: domains?.enabled || false, enrollmentModes: domains?.enrollment_modes || [], + defaultRole: domains?.default_role || null, }; return this; } diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersForm.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersForm.tsx index 1a8a6beb99..56a54ddd27 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersForm.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersForm.tsx @@ -4,6 +4,7 @@ import type { ClerkAPIError } from '@clerk/types'; import type { FormEvent } from 'react'; import { useState } from 'react'; +import { useEnvironment } from '../../contexts'; import { Flex } from '../../customizables'; import { Form, FormButtonContainer, TagInput, useCardState } from '../../elements'; import { useFetchRoles } from '../../hooks/useFetchRoles'; @@ -187,6 +188,8 @@ const AsyncRoleSelect = (field: ReturnType>) => { const { t } = useLocalizations(); + const defaultRole = useDefaultRole(); + return ( >) => { > field.setValue(value)} @@ -206,3 +210,20 @@ const AsyncRoleSelect = (field: ReturnType>) => { ); }; + +/** + * Determines default role from the organization settings or fallback to + * the only available role. + */ +const useDefaultRole = () => { + const { options } = useFetchRoles(); + const { organizationSettings } = useEnvironment(); + + let defaultRole = organizationSettings.domains.defaultRole; + + if (!defaultRole && options?.length === 1) { + defaultRole = options[0].value; + } + + return defaultRole; +}; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx index 5479b8afd4..4afb176022 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx @@ -1,6 +1,7 @@ import type { OrganizationInvitationResource } from '@clerk/types'; import { describe } from '@jest/globals'; import { waitFor } from '@testing-library/dom'; +import React from 'react'; import { ClerkAPIResponseError } from '../../../../core/resources'; import { render } from '../../../../testUtils'; @@ -41,7 +42,156 @@ describe('InviteMembersPage', () => { getByText('Enter or paste one or more email addresses, separated by spaces or commas.'); }); - describe('Submitting', () => { + describe('with default role', () => { + it("initializes with the organization's default role", async () => { + const defaultRole = 'mydefaultrole'; + + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationDomains(undefined, defaultRole); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'admin' }], + }); + }); + + fixtures.clerk.organization?.getRoles.mockResolvedValue({ + total_count: 2, + data: [ + { + pathRoot: '', + reload: jest.fn(), + id: 'member', + key: 'member', + name: 'member', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + pathRoot: '', + reload: jest.fn(), + id: 'admin', + key: 'admin', + name: 'Admin', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + pathRoot: '', + reload: jest.fn(), + id: defaultRole, + key: defaultRole, + name: defaultRole, + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]); + const { getByRole, userEvent, getByTestId } = render( + + + , + { wrapper }, + ); + await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,'); + await userEvent.click(getByRole('button', { name: /mydefaultrole/i })); + }); + + it("initializes if there's only one role available", async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'admin' }], + }); + }); + + fixtures.clerk.organization?.getRoles.mockResolvedValue({ + total_count: 1, + data: [ + { + pathRoot: '', + reload: jest.fn(), + id: 'member', + key: 'member', + name: 'member', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]); + const { getByRole, userEvent, getByTestId } = render( + + + , + { wrapper }, + ); + await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,'); + await waitFor(() => expect(getByRole('button', { name: /member/i })).toBeInTheDocument()); + }); + + it("does not initialize if there's neither a default role nor a unique role", async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'admin' }], + }); + }); + + fixtures.clerk.organization?.getRoles.mockResolvedValue({ + total_count: 1, + data: [ + { + pathRoot: '', + reload: jest.fn(), + id: 'member', + key: 'member', + name: 'member', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + pathRoot: '', + reload: jest.fn(), + id: 'admin', + key: 'admin', + name: 'admin', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]); + const { getByRole, userEvent, getByTestId } = render( + + + , + { wrapper }, + ); + await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,'); + await waitFor(() => expect(getByRole('button', { name: /select role/i })).toBeInTheDocument()); + }); + }); + + describe('when submitting', () => { it('keeps the Send button disabled until a role is selected and one or more email has been entered', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withOrganizations(); @@ -65,6 +215,17 @@ describe('InviteMembersPage', () => { createdAt: new Date(), updatedAt: new Date(), }, + { + pathRoot: '', + reload: jest.fn(), + id: 'admin', + key: 'admin', + name: 'Admin', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, ], }); @@ -108,6 +269,17 @@ describe('InviteMembersPage', () => { createdAt: new Date(), updatedAt: new Date(), }, + { + pathRoot: '', + reload: jest.fn(), + id: 'admin', + key: 'admin', + name: 'Admin', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, ], }); @@ -154,6 +326,17 @@ describe('InviteMembersPage', () => { createdAt: new Date(), updatedAt: new Date(), }, + { + pathRoot: '', + reload: jest.fn(), + id: 'admin', + key: 'admin', + name: 'Admin', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, ], }); @@ -259,6 +442,17 @@ describe('InviteMembersPage', () => { createdAt: new Date(), updatedAt: new Date(), }, + { + pathRoot: '', + reload: jest.fn(), + id: 'admin', + key: 'admin', + name: 'Admin', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, ], }); @@ -318,6 +512,17 @@ describe('InviteMembersPage', () => { createdAt: new Date(), updatedAt: new Date(), }, + { + pathRoot: '', + reload: jest.fn(), + id: 'admin', + key: 'admin', + name: 'Admin', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, ], }); @@ -373,6 +578,17 @@ describe('InviteMembersPage', () => { createdAt: new Date(), updatedAt: new Date(), }, + { + pathRoot: '', + reload: jest.fn(), + id: 'admin', + key: 'admin', + name: 'Admin', + description: '', + permissions: [], + createdAt: new Date(), + updatedAt: new Date(), + }, ], }); diff --git a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts index 54bf863a19..e6f1d73fca 100644 --- a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts @@ -296,9 +296,10 @@ const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) os.max_allowed_memberships = max; }; - const withOrganizationDomains = (modes?: OrganizationEnrollmentMode[]) => { + const withOrganizationDomains = (modes?: OrganizationEnrollmentMode[], defaultRole?: string) => { os.domains.enabled = true; os.domains.enrollment_modes = modes || ['automatic_invitation', 'automatic_invitation', 'manual_invitation']; + os.domains.default_role = defaultRole ?? null; }; return { withOrganizations, withMaxAllowedMemberships, withOrganizationDomains }; }; diff --git a/packages/types/src/organizationSettings.ts b/packages/types/src/organizationSettings.ts index 93c125af4c..68743ea371 100644 --- a/packages/types/src/organizationSettings.ts +++ b/packages/types/src/organizationSettings.ts @@ -13,6 +13,7 @@ export interface OrganizationSettingsJSON extends ClerkResourceJSON { domains: { enabled: boolean; enrollment_modes: OrganizationEnrollmentMode[]; + default_role: string | null; }; } @@ -25,5 +26,6 @@ export interface OrganizationSettingsResource extends ClerkResource { domains: { enabled: boolean; enrollmentModes: OrganizationEnrollmentMode[]; + defaultRole: string | null; }; }