Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/large-chefs-move.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/resources/OrganizationSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export class OrganizationSettings extends BaseResource implements OrganizationSe
domains!: {
enabled: boolean;
enrollmentModes: OrganizationEnrollmentMode[];
defaultRole: string | null;
};

public constructor(data: OrganizationSettingsJSON) {
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -187,6 +188,8 @@ const AsyncRoleSelect = (field: ReturnType<typeof useFormControl<'role'>>) => {

const { t } = useLocalizations();

const defaultRole = useDefaultRole();

return (
<Form.ControlRow elementId={field.id}>
<Flex
Expand All @@ -195,6 +198,7 @@ const AsyncRoleSelect = (field: ReturnType<typeof useFormControl<'role'>>) => {
>
<RoleSelect
{...field.props}
value={field.props.value || (defaultRole ?? '')}
roles={options}
isDisabled={isLoading}
onChange={value => field.setValue(value)}
Expand All @@ -206,3 +210,20 @@ const AsyncRoleSelect = (field: ReturnType<typeof useFormControl<'role'>>) => {
</Form.ControlRow>
);
};

/**
* 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;
};
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
<Action.Root>
<InviteMembersScreen />
</Action.Root>,
{ 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(
<Action.Root>
<InviteMembersScreen />
</Action.Root>,
{ 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(
<Action.Root>
<InviteMembersScreen />
</Action.Root>,
{ 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();
Expand All @@ -65,6 +215,17 @@ describe('InviteMembersPage', () => {
createdAt: new Date(),
updatedAt: new Date(),
},
{
pathRoot: '',
reload: jest.fn(),
id: 'admin',
key: 'admin',
name: 'Admin',
description: '',
permissions: [],
Copy link
Member Author

Choose a reason for hiding this comment

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

Those tests were mocking with a single role, therefore initiating the field with that one instead and the assertion for "Select role" started to fail.

createdAt: new Date(),
updatedAt: new Date(),
},
],
});

Expand Down Expand Up @@ -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(),
},
],
});

Expand Down Expand Up @@ -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(),
},
],
});

Expand Down Expand Up @@ -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(),
},
],
});

Expand Down Expand Up @@ -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(),
},
],
});

Expand Down Expand Up @@ -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(),
},
],
});

Expand Down
3 changes: 2 additions & 1 deletion packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/organizationSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface OrganizationSettingsJSON extends ClerkResourceJSON {
domains: {
enabled: boolean;
enrollment_modes: OrganizationEnrollmentMode[];
default_role: string | null;
};
}

Expand All @@ -25,5 +26,6 @@ export interface OrganizationSettingsResource extends ClerkResource {
domains: {
enabled: boolean;
enrollmentModes: OrganizationEnrollmentMode[];
defaultRole: string | null;
};
}