Skip to content

Commit 0293f29

Browse files
authoredNov 3, 2023
feat(clerk-js,types): Fetch custom roles and localize them (#2004)
* feat(clerk-js,types): Fetch custom roles and localize them * test(clerk-js): Fetch custom roles and localize them * feat(clerk-js,types): Create PermissionResource * chore(clerk-js): Add changeset * chore(clerk-js): Add experimental tags * test(clerk-js): Add test case for displaying custom roles in select menu * chore(clerk-js): Improve types & add comments * chore(clerk-js): Address PR comments
1 parent 7644b74 commit 0293f29

23 files changed

+416
-111
lines changed
 

‎.changeset/famous-forks-buy.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
Add support for custom roles in `<OrganizationProfile/>`.
7+
8+
The previous roles (`admin` and `basic_member`), are still kept as a fallback.

‎packages/clerk-js/src/core/resources/Organization.ts

+18
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
GetMembershipRequestParams,
1010
GetMemberships,
1111
GetPendingInvitationsParams,
12+
GetRolesParams,
1213
InviteMemberParams,
1314
InviteMembersParams,
1415
OrganizationDomainJSON,
@@ -20,6 +21,7 @@ import type {
2021
OrganizationMembershipRequestJSON,
2122
OrganizationMembershipRequestResource,
2223
OrganizationResource,
24+
RoleJSON,
2325
SetOrganizationLogoParams,
2426
UpdateMembershipParams,
2527
UpdateOrganizationParams,
@@ -31,6 +33,7 @@ import { convertPageToOffset } from '../../utils/pagesToOffset';
3133
import { BaseResource, OrganizationInvitation, OrganizationMembership } from './internal';
3234
import { OrganizationDomain } from './OrganizationDomain';
3335
import { OrganizationMembershipRequest } from './OrganizationMembershipRequest';
36+
import { Role } from './Role';
3437

3538
export class Organization extends BaseResource implements OrganizationResource {
3639
pathRoot = '/organizations';
@@ -105,6 +108,21 @@ export class Organization extends BaseResource implements OrganizationResource {
105108
});
106109
};
107110

111+
getRoles = async (getRolesParams?: GetRolesParams) => {
112+
return await BaseResource._fetch({
113+
path: `/organizations/${this.id}/roles`,
114+
method: 'GET',
115+
search: convertPageToOffset(getRolesParams) as any,
116+
}).then(res => {
117+
const { data: roles, total_count } = res?.response as unknown as ClerkPaginatedResponse<RoleJSON>;
118+
119+
return {
120+
total_count,
121+
data: roles.map(role => new Role(role)),
122+
};
123+
});
124+
};
125+
108126
getDomains = async (
109127
getDomainParams?: GetDomainsParams,
110128
): Promise<ClerkPaginatedResponse<OrganizationDomainResource>> => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { PermissionJSON, PermissionResource } from '@clerk/types';
2+
3+
import { unixEpochToDate } from '../../utils/date';
4+
import { BaseResource } from './internal';
5+
6+
/**
7+
* @experimental
8+
*/
9+
export class Permission extends BaseResource implements PermissionResource {
10+
id!: string;
11+
key!: string;
12+
name!: string;
13+
description!: string;
14+
type!: 'system' | 'user';
15+
createdAt!: Date;
16+
updatedAt!: Date;
17+
18+
constructor(data: PermissionJSON) {
19+
super();
20+
this.fromJSON(data);
21+
}
22+
23+
protected fromJSON(data: PermissionJSON | null): this {
24+
if (!data) {
25+
return this;
26+
}
27+
28+
this.id = data.id;
29+
this.key = data.key;
30+
this.name = data.name;
31+
this.description = data.description;
32+
this.type = data.type;
33+
this.createdAt = unixEpochToDate(data.created_at);
34+
this.updatedAt = unixEpochToDate(data.updated_at);
35+
return this;
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { RoleJSON, RoleResource } from '@clerk/types';
2+
3+
import { unixEpochToDate } from '../../utils/date';
4+
import { BaseResource } from './internal';
5+
import { Permission } from './Permission';
6+
7+
/**
8+
* @experimental
9+
*/
10+
export class Role extends BaseResource implements RoleResource {
11+
id!: string;
12+
key!: string;
13+
name!: string;
14+
description!: string;
15+
permissions: Permission[] = [];
16+
createdAt!: Date;
17+
updatedAt!: Date;
18+
19+
constructor(data: RoleJSON) {
20+
super();
21+
this.fromJSON(data);
22+
}
23+
24+
protected fromJSON(data: RoleJSON | null): this {
25+
if (!data) {
26+
return this;
27+
}
28+
29+
this.id = data.id;
30+
this.key = data.key;
31+
this.name = data.name;
32+
this.description = data.description;
33+
this.permissions = data.permissions.map(perm => new Permission(perm));
34+
this.createdAt = unixEpochToDate(data.created_at);
35+
this.updatedAt = unixEpochToDate(data.updated_at);
36+
return this;
37+
}
38+
}

‎packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Organization {
1313
"getMembershipRequests": [Function],
1414
"getMemberships": [Function],
1515
"getPendingInvitations": [Function],
16+
"getRoles": [Function],
1617
"hasImage": true,
1718
"id": "test_id",
1819
"imageUrl": "https://clerk.com",

‎packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ OrganizationMembership {
1717
"getMembershipRequests": [Function],
1818
"getMemberships": [Function],
1919
"getPendingInvitations": [Function],
20+
"getRoles": [Function],
2021
"hasImage": true,
2122
"id": "test_org_id",
2223
"imageUrl": "https://clerk.com",

‎packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,6 @@ export const CreateOrganizationForm = (props: CreateOrganizationFormProps) => {
165165
>
166166
{organization && (
167167
<InviteMembersForm
168-
organization={organization}
169168
resetButtonLabel={localizationKeys('createOrganization.invitePage.formButtonReset')}
170169
onSuccess={wizard.nextStep}
171170
onReset={completeFlow}

‎packages/clerk-js/src/ui/components/OrganizationProfile/ActiveMembersList.tsx

+14-12
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type { MembershipRole, OrganizationMembershipResource } from '@clerk/types';
1+
import type { OrganizationMembershipResource } from '@clerk/types';
22

33
import { Gate } from '../../common/Gate';
44
import { useCoreOrganization, useCoreUser } from '../../contexts';
55
import { Badge, localizationKeys, Td, Text } from '../../customizables';
66
import { ThreeDotsMenu, useCardState, UserPreview } from '../../elements';
7-
import { handleError, roleLocalizationKey } from '../../utils';
7+
import { useFetchRoles, useLocalizeCustomRoles } from '../../hooks/useFetchRoles';
8+
import { handleError } from '../../utils';
89
import { DataTable, RoleSelect, RowContainer } from './MemberListTable';
910

1011
export const ActiveMembersList = () => {
@@ -13,11 +14,13 @@ export const ActiveMembersList = () => {
1314
memberships: true,
1415
});
1516

17+
const { options, isLoading: loadingRoles } = useFetchRoles();
18+
1619
if (!organization) {
1720
return null;
1821
}
1922

20-
const handleRoleChange = (membership: OrganizationMembershipResource) => (newRole: MembershipRole) => {
23+
const handleRoleChange = (membership: OrganizationMembershipResource) => (newRole: string) => {
2124
return card
2225
.runAsync(async () => {
2326
return await membership.update({ role: newRole });
@@ -41,7 +44,7 @@ export const ActiveMembersList = () => {
4144
onPageChange={n => memberships?.fetchPage?.(n)}
4245
itemCount={memberships?.count || 0}
4346
pageCount={memberships?.pageCount || 0}
44-
isLoading={memberships?.isLoading}
47+
isLoading={memberships?.isLoading || loadingRoles}
4548
emptyStateLocalizationKey={localizationKeys('organizationProfile.membersPage.detailsTitle__emptyRow')}
4649
headers={[
4750
localizationKeys('organizationProfile.membersPage.activeMembersTab.tableHeader__user'),
@@ -53,6 +56,7 @@ export const ActiveMembersList = () => {
5356
<MemberRow
5457
key={m.id}
5558
membership={m}
59+
options={options}
5660
onRoleChange={handleRoleChange(m)}
5761
onRemove={handleRemove(m)}
5862
/>
@@ -65,9 +69,11 @@ export const ActiveMembersList = () => {
6569
const MemberRow = (props: {
6670
membership: OrganizationMembershipResource;
6771
onRemove: () => unknown;
68-
onRoleChange?: (role: MembershipRole) => unknown;
72+
options: Parameters<typeof RoleSelect>[0]['roles'];
73+
onRoleChange: (role: string) => unknown;
6974
}) => {
70-
const { membership, onRemove, onRoleChange } = props;
75+
const { membership, onRemove, onRoleChange, options } = props;
76+
const { localizeCustomRole } = useLocalizeCustomRoles();
7177
const card = useCardState();
7278
const user = useCoreUser();
7379

@@ -94,17 +100,13 @@ const MemberRow = (props: {
94100
<Td>
95101
<Gate
96102
permission={'org:sys_memberships:manage'}
97-
fallback={
98-
<Text
99-
sx={t => ({ opacity: t.opacity.$inactive })}
100-
localizationKey={roleLocalizationKey(membership.role)}
101-
/>
102-
}
103+
fallback={<Text sx={t => ({ opacity: t.opacity.$inactive })}>{localizeCustomRole(membership.role)}</Text>}
103104
>
104105
<RoleSelect
105106
isDisabled={card.isLoading || !onRoleChange}
106107
value={membership.role}
107108
onChange={onRoleChange}
109+
roles={options}
108110
/>
109111
</Gate>
110112
</Td>

‎packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersForm.tsx

+45-47
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,21 @@
11
import { isClerkAPIResponseError } from '@clerk/shared/error';
2-
import type { ClerkAPIError, MembershipRole, OrganizationResource } from '@clerk/types';
3-
import React from 'react';
2+
import type { ClerkAPIError, MembershipRole } from '@clerk/types';
3+
import type { FormEvent } from 'react';
4+
import { useState } from 'react';
45

6+
import { useCoreOrganization } from '../../contexts';
57
import { Flex, Text } from '../../customizables';
6-
import {
7-
Form,
8-
FormButtonContainer,
9-
Select,
10-
SelectButton,
11-
SelectOptionList,
12-
TagInput,
13-
useCardState,
14-
} from '../../elements';
8+
import { Form, FormButtonContainer, TagInput, useCardState } from '../../elements';
9+
import { useFetchRoles } from '../../hooks/useFetchRoles';
1510
import type { LocalizationKey } from '../../localization';
1611
import { localizationKeys, useLocalizations } from '../../localization';
1712
import { useRouter } from '../../router';
18-
import { createListFormat, handleError, roleLocalizationKey, useFormControl } from '../../utils';
13+
import { createListFormat, handleError, useFormControl } from '../../utils';
14+
import { RoleSelect } from './MemberListTable';
1915

2016
const isEmail = (str: string) => /^\S+@\S+\.\S+$/.test(str);
2117

2218
type InviteMembersFormProps = {
23-
organization: OrganizationResource;
2419
onSuccess: () => void;
2520
onReset?: () => void;
2621
primaryButtonLabel?: LocalizationKey;
@@ -29,22 +24,18 @@ type InviteMembersFormProps = {
2924

3025
export const InviteMembersForm = (props: InviteMembersFormProps) => {
3126
const { navigate } = useRouter();
32-
const { onSuccess, onReset = () => navigate('..'), resetButtonLabel, organization } = props;
27+
const { onSuccess, onReset = () => navigate('..'), resetButtonLabel } = props;
28+
const { organization } = useCoreOrganization();
3329
const card = useCardState();
3430
const { t, locale } = useLocalizations();
35-
const [isValidUnsubmittedEmail, setIsValidUnsubmittedEmail] = React.useState(false);
31+
const [isValidUnsubmittedEmail, setIsValidUnsubmittedEmail] = useState(false);
3632

3733
if (!organization) {
3834
return null;
3935
}
4036

4137
const validateUnsubmittedEmail = (value: string) => setIsValidUnsubmittedEmail(isEmail(value));
4238

43-
const roles: Array<{ label: string; value: MembershipRole }> = [
44-
{ label: t(roleLocalizationKey('admin')), value: 'admin' },
45-
{ label: t(roleLocalizationKey('basic_member')), value: 'basic_member' },
46-
];
47-
4839
const emailAddressField = useFormControl('emailAddress', '', {
4940
type: 'text',
5041
label: localizationKeys('formFieldLabel__emailAddresses'),
@@ -67,18 +58,17 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
6758
},
6859
} = emailAddressField;
6960

70-
const roleField = useFormControl('role', 'basic_member', {
71-
options: roles,
72-
label: localizationKeys('formFieldLabel__role'),
73-
placeholder: '',
74-
});
75-
7661
const canSubmit = !!emailAddressField.value.length || isValidUnsubmittedEmail;
7762

78-
const onSubmit = async (e: React.FormEvent) => {
63+
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
7964
e.preventDefault();
65+
66+
const submittedData = new FormData(e.currentTarget);
8067
return organization
81-
.inviteMembers({ emailAddresses: emailAddressField.value.split(','), role: roleField.value as MembershipRole })
68+
.inviteMembers({
69+
emailAddresses: emailAddressField.value.split(','),
70+
role: submittedData.get('role') as MembershipRole,
71+
})
8272
.then(onSuccess)
8373
.catch(err => {
8474
if (isClerkAPIResponseError(err)) {
@@ -132,25 +122,7 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
132122
/>
133123
</Flex>
134124
</Form.ControlRow>
135-
<Form.ControlRow elementId={roleField.id}>
136-
<Flex
137-
direction='col'
138-
gap={2}
139-
>
140-
<Text localizationKey={roleField.label} />
141-
{/*@ts-expect-error Select expects options to be an array but useFormControl returns an optional field. */}
142-
<Select
143-
elementId='role'
144-
{...roleField.props}
145-
onChange={option => roleField.setValue(option.value)}
146-
>
147-
<SelectButton sx={t => ({ width: t.sizes.$48, justifyContent: 'space-between', display: 'flex' })}>
148-
{roleField.props.options?.find(o => o.value === roleField.value)?.label}
149-
</SelectButton>
150-
<SelectOptionList sx={t => ({ minWidth: t.sizes.$48 })} />
151-
</Select>
152-
</Flex>
153-
</Form.ControlRow>
125+
<AsyncRoleSelect />
154126
<FormButtonContainer>
155127
<Form.SubmitButton
156128
block={false}
@@ -166,3 +138,29 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
166138
</Form.Root>
167139
);
168140
};
141+
142+
const AsyncRoleSelect = () => {
143+
const { options, isLoading } = useFetchRoles();
144+
const roleField = useFormControl('role', '', {
145+
label: localizationKeys('formFieldLabel__role'),
146+
});
147+
148+
return (
149+
<Form.ControlRow elementId={roleField.id}>
150+
<Flex
151+
direction='col'
152+
gap={2}
153+
>
154+
<Text localizationKey={roleField.label} />
155+
<RoleSelect
156+
{...roleField.props}
157+
roles={options}
158+
isDisabled={isLoading}
159+
onChange={value => roleField.setValue(value)}
160+
triggerSx={t => ({ width: t.sizes.$48, justifyContent: 'space-between', display: 'flex' })}
161+
optionListSx={t => ({ minWidth: t.sizes.$48 })}
162+
/>
163+
</Flex>
164+
</Form.ControlRow>
165+
);
166+
};

‎packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersPage.tsx

+1-4
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,7 @@ export const InviteMembersPage = withCardStateProvider(() => {
4040
__unstable_manageBillingMembersLimit={__unstable_manageBillingMembersLimit}
4141
/>
4242
)}
43-
<InviteMembersForm
44-
organization={organization}
45-
onSuccess={wizard.nextStep}
46-
/>
43+
<InviteMembersForm onSuccess={wizard.nextStep} />
4744
</ContentPage>
4845
<SuccessPage
4946
title={title}

‎packages/clerk-js/src/ui/components/OrganizationProfile/InvitedMembersList.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import type { OrganizationInvitationResource } from '@clerk/types';
33
import { useCoreOrganization } from '../../contexts';
44
import { localizationKeys, Td, Text } from '../../customizables';
55
import { ThreeDotsMenu, useCardState, UserPreview } from '../../elements';
6-
import { handleError, roleLocalizationKey } from '../../utils';
6+
import { useLocalizeCustomRoles } from '../../hooks/useFetchRoles';
7+
import { handleError } from '../../utils';
78
import { DataTable, RowContainer } from './MemberListTable';
89

910
export const InvitedMembersList = () => {
@@ -52,6 +53,7 @@ export const InvitedMembersList = () => {
5253

5354
const InvitationRow = (props: { invitation: OrganizationInvitationResource; onRevoke: () => unknown }) => {
5455
const { invitation, onRevoke } = props;
56+
const { localizeCustomRole } = useLocalizeCustomRoles();
5557
return (
5658
<RowContainer>
5759
<Td>
@@ -64,7 +66,7 @@ const InvitationRow = (props: { invitation: OrganizationInvitationResource; onRe
6466
<Td>
6567
<Text
6668
colorScheme={'neutral'}
67-
localizationKey={roleLocalizationKey(invitation.role)}
69+
localizationKey={localizeCustomRole(invitation.role)}
6870
/>
6971
</Td>
7072
<Td>

‎packages/clerk-js/src/ui/components/OrganizationProfile/MemberListTable.tsx

+42-32
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,10 @@ import type { MembershipRole } from '@clerk/types';
22
import React from 'react';
33

44
import type { LocalizationKey } from '../../customizables';
5-
import {
6-
Col,
7-
descriptors,
8-
Flex,
9-
Spinner,
10-
Table,
11-
Tbody,
12-
Td,
13-
Text,
14-
Th,
15-
Thead,
16-
Tr,
17-
useLocalizations,
18-
} from '../../customizables';
5+
import { Col, descriptors, Flex, Spinner, Table, Tbody, Td, Text, Th, Thead, Tr } from '../../customizables';
196
import { Pagination, Select, SelectButton, SelectOptionList } from '../../elements';
20-
import type { PropsOfComponent } from '../../styledSystem';
21-
import { roleLocalizationKey } from '../../utils';
7+
import { useLocalizeCustomRoles } from '../../hooks/useFetchRoles';
8+
import type { PropsOfComponent, ThemableCssProp } from '../../styledSystem';
229

2310
type MembersListTableProps = {
2411
headers: LocalizationKey[];
@@ -139,38 +126,61 @@ export const RowContainer = (props: PropsOfComponent<typeof Tr>) => {
139126
);
140127
};
141128

142-
export const RoleSelect = (props: { value: MembershipRole; onChange: any; isDisabled?: boolean }) => {
143-
const { value, onChange, isDisabled } = props;
144-
const { t } = useLocalizations();
129+
export const RoleSelect = (props: {
130+
roles: { label: string; value: string }[] | undefined;
131+
value: MembershipRole;
132+
onChange: (params: string) => unknown;
133+
isDisabled?: boolean;
134+
triggerSx?: ThemableCssProp;
135+
optionListSx?: ThemableCssProp;
136+
}) => {
137+
const { value, roles, onChange, isDisabled, triggerSx, optionListSx } = props;
145138

146-
const roles: Array<{ label: string; value: MembershipRole }> = [
147-
{ label: t(roleLocalizationKey('admin')), value: 'admin' },
148-
{ label: t(roleLocalizationKey('basic_member')), value: 'basic_member' },
139+
const shouldDisplayLegacyRoles = !roles;
140+
141+
const legacyRoles: Array<{ label: string; value: MembershipRole }> = [
142+
{ label: 'admin', value: 'admin' },
143+
{ label: 'basic_member', value: 'basic_member' },
149144
];
150145

151-
const excludedRoles: Array<{ label: string; value: MembershipRole }> = [
152-
{ label: t(roleLocalizationKey('guest_member')), value: 'guest_member' },
146+
const legacyExcludedRoles: Array<{ label: string; value: MembershipRole }> = [
147+
{ label: 'guest_member', value: 'guest_member' },
153148
];
149+
const { localizeCustomRole } = useLocalizeCustomRoles();
150+
151+
const selectedRole = [...(roles || []), ...legacyRoles, ...legacyExcludedRoles].find(r => r.value === value);
154152

155-
const selectedRole = [...roles, ...excludedRoles].find(r => r.value === value);
153+
const localizedOptions = (!shouldDisplayLegacyRoles ? roles : legacyRoles).map(role => ({
154+
value: role.value,
155+
label: localizeCustomRole(role.value) || role.label,
156+
}));
156157

157158
return (
158159
<Select
159160
elementId='role'
160-
options={roles}
161+
options={localizedOptions}
161162
value={value}
162163
onChange={role => onChange(role.value)}
163164
>
165+
{/*Store value inside an input in order to be accessible as form data*/}
166+
<input
167+
name='role'
168+
type='hidden'
169+
value={value}
170+
/>
164171
<SelectButton
165-
sx={t => ({
166-
color: t.colors.$colorTextSecondary,
167-
backgroundColor: 'transparent',
168-
})}
172+
sx={
173+
triggerSx ||
174+
(t => ({
175+
color: t.colors.$colorTextSecondary,
176+
backgroundColor: 'transparent',
177+
}))
178+
}
169179
isDisabled={isDisabled}
170180
>
171-
{selectedRole?.label}
181+
{localizeCustomRole(selectedRole?.value) || selectedRole?.label}
172182
</SelectButton>
173-
<SelectOptionList />
183+
<SelectOptionList sx={optionListSx} />
174184
</Select>
175185
);
176186
};

‎packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx

+57-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { MembershipRole, OrganizationInvitationResource } from '@clerk/types';
22
import { describe } from '@jest/globals';
33
import { waitFor } from '@testing-library/dom';
4+
import { act } from '@testing-library/react';
45
import React from 'react';
56

67
import { ClerkAPIResponseError } from '../../../../core/resources';
@@ -12,25 +13,27 @@ const { createFixtures } = bindCreateFixtures('OrganizationProfile');
1213

1314
describe('InviteMembersPage', () => {
1415
it('renders the component', async () => {
15-
const { wrapper } = await createFixtures(f => {
16+
const { wrapper, fixtures } = await createFixtures(f => {
1617
f.withOrganizations();
1718
f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'Org1', role: 'admin' }] });
1819
});
1920

20-
const { getByText } = render(<InviteMembersPage />, { wrapper });
21+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
22+
const { getByText } = await act(() => render(<InviteMembersPage />, { wrapper }));
2123
expect(getByText('Invite new members to this organization')).toBeDefined();
2224
});
2325

2426
describe('Submitting', () => {
2527
it('enables the Send button when one or more email has been entered', async () => {
26-
const { wrapper } = await createFixtures(f => {
28+
const { wrapper, fixtures } = await createFixtures(f => {
2729
f.withOrganizations();
2830
f.withUser({
2931
email_addresses: ['test@clerk.com'],
3032
organization_memberships: [{ name: 'Org1', role: 'admin' }],
3133
});
3234
});
3335

36+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
3437
const { getByRole, userEvent, getByTestId } = render(<InviteMembersPage />, { wrapper });
3538
expect(getByRole('button', { name: 'Send invitations' })).toBeDisabled();
3639

@@ -47,16 +50,56 @@ describe('InviteMembersPage', () => {
4750
});
4851
});
4952

53+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
5054
fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]);
51-
const { getByRole, userEvent, getByTestId } = render(<InviteMembersPage />, { wrapper });
55+
const { getByRole, userEvent, getByTestId, getByText } = render(<InviteMembersPage />, { wrapper });
5256
await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,');
57+
await userEvent.click(getByRole('button', { name: 'Select an option' }));
58+
await userEvent.click(getByText('Member'));
5359
await userEvent.click(getByRole('button', { name: 'Send invitations' }));
5460
expect(fixtures.clerk.organization?.inviteMembers).toHaveBeenCalledWith({
5561
emailAddresses: ['test+1@clerk.com'],
5662
role: 'basic_member' as MembershipRole,
5763
});
5864
});
5965

66+
it('fetches custom role and sends invite to email entered and teacher role when clicking Send', async () => {
67+
const { wrapper, fixtures } = await createFixtures(f => {
68+
f.withOrganizations();
69+
f.withUser({
70+
email_addresses: ['test@clerk.com'],
71+
organization_memberships: [{ name: 'Org1', role: 'admin' }],
72+
});
73+
});
74+
75+
fixtures.clerk.organization?.getRoles.mockResolvedValueOnce({
76+
data: [
77+
{
78+
pathRoot: '',
79+
reload: jest.fn(),
80+
id: '1',
81+
description: '',
82+
updatedAt: new Date(),
83+
createdAt: new Date(),
84+
permissions: [],
85+
name: 'Teacher',
86+
key: 'org:teacher',
87+
},
88+
],
89+
total_count: 1,
90+
});
91+
fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]);
92+
const { getByRole, userEvent, getByTestId, getByText } = render(<InviteMembersPage />, { wrapper });
93+
await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,');
94+
await userEvent.click(getByRole('button', { name: 'Select an option' }));
95+
await userEvent.click(getByText('Teacher'));
96+
await userEvent.click(getByRole('button', { name: 'Send invitations' }));
97+
expect(fixtures.clerk.organization?.inviteMembers).toHaveBeenCalledWith({
98+
emailAddresses: ['test+1@clerk.com'],
99+
role: 'org:teacher' as MembershipRole,
100+
});
101+
});
102+
60103
it('sends invites to multiple emails', async () => {
61104
const { wrapper, fixtures } = await createFixtures(f => {
62105
f.withOrganizations();
@@ -66,12 +109,15 @@ describe('InviteMembersPage', () => {
66109
});
67110
});
68111

112+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
69113
fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]);
70-
const { getByRole, userEvent, getByTestId } = render(<InviteMembersPage />, { wrapper });
114+
const { getByRole, userEvent, getByTestId, getByText } = render(<InviteMembersPage />, { wrapper });
71115
await userEvent.type(
72116
getByTestId('tag-input'),
73117
'test+1@clerk.com,test+2@clerk.com,test+3@clerk.com,test+4@clerk.com,',
74118
);
119+
await userEvent.click(getByRole('button', { name: 'Select an option' }));
120+
await userEvent.click(getByText('Member'));
75121
await userEvent.click(getByRole('button', { name: 'Send invitations' }));
76122
expect(fixtures.clerk.organization?.inviteMembers).toHaveBeenCalledWith({
77123
emailAddresses: ['test+1@clerk.com', 'test+2@clerk.com', 'test+3@clerk.com', 'test+4@clerk.com'],
@@ -88,10 +134,11 @@ describe('InviteMembersPage', () => {
88134
});
89135
});
90136

137+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
91138
fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]);
92139
const { getByRole, userEvent, getByText, getByTestId } = render(<InviteMembersPage />, { wrapper });
93140
await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,');
94-
await userEvent.click(getByRole('button', { name: 'Member' }));
141+
await userEvent.click(getByRole('button', { name: 'Select an option' }));
95142
await userEvent.click(getByText('Admin'));
96143
await userEvent.click(getByRole('button', { name: 'Send invitations' }));
97144
expect(fixtures.clerk.organization?.inviteMembers).toHaveBeenCalledWith({
@@ -109,6 +156,7 @@ describe('InviteMembersPage', () => {
109156
});
110157
});
111158

159+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
112160
fixtures.clerk.organization?.inviteMembers.mockRejectedValueOnce(
113161
new ClerkAPIResponseError('Error', {
114162
data: [
@@ -143,6 +191,7 @@ describe('InviteMembersPage', () => {
143191
});
144192
});
145193

194+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
146195
fixtures.clerk.organization?.inviteMembers.mockRejectedValueOnce(
147196
new ClerkAPIResponseError('Error', {
148197
data: [
@@ -173,6 +222,7 @@ describe('InviteMembersPage', () => {
173222
});
174223
});
175224

225+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
176226
fixtures.clerk.organization?.inviteMembers.mockRejectedValueOnce(
177227
new ClerkAPIResponseError('Error', {
178228
data: [
@@ -203,6 +253,7 @@ describe('InviteMembersPage', () => {
203253
organization_memberships: [{ name: 'Org1', role: 'admin' }],
204254
});
205255
});
256+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
206257

207258
const { getByRole, userEvent } = render(<InviteMembersPage />, { wrapper });
208259
await userEvent.click(getByRole('button', { name: 'Cancel' }));

‎packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx

+21-5
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ const { createFixtures } = bindCreateFixtures('OrganizationProfile');
1313

1414
describe('OrganizationMembers', () => {
1515
it('renders the Organization Members page', async () => {
16-
const { wrapper } = await createFixtures(f => {
16+
const { wrapper, fixtures } = await createFixtures(f => {
1717
f.withOrganizations();
1818
f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: ['Org1'] });
1919
});
20-
20+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
2121
const { getByText, getByRole } = render(<OrganizationMembers />, { wrapper });
2222

2323
await waitFor(() => {
@@ -33,12 +33,14 @@ describe('OrganizationMembers', () => {
3333
});
3434

3535
it('shows requests if domains is turned on', async () => {
36-
const { wrapper } = await createFixtures(f => {
36+
const { wrapper, fixtures } = await createFixtures(f => {
3737
f.withOrganizations();
3838
f.withOrganizationDomains();
3939
f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: ['Org1'] });
4040
});
4141

42+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
43+
4244
const { getByRole } = render(<OrganizationMembers />, { wrapper });
4345

4446
await waitFor(() => {
@@ -47,11 +49,13 @@ describe('OrganizationMembers', () => {
4749
});
4850

4951
it('shows an invite button inside invitations tab if the current user is an admin', async () => {
50-
const { wrapper } = await createFixtures(f => {
52+
const { wrapper, fixtures } = await createFixtures(f => {
5153
f.withOrganizations();
5254
f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'Org1', role: 'admin' }] });
5355
});
5456

57+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
58+
5559
const { getByRole, getByText } = render(<OrganizationMembers />, { wrapper });
5660

5761
await waitFor(async () => {
@@ -62,14 +66,16 @@ describe('OrganizationMembers', () => {
6266
});
6367

6468
it('does not show invitations and requests if user is not an admin', async () => {
65-
const { wrapper } = await createFixtures(f => {
69+
const { wrapper, fixtures } = await createFixtures(f => {
6670
f.withOrganizations();
6771
f.withUser({
6872
email_addresses: ['test@clerk.com'],
6973
organization_memberships: [{ name: 'Org1', permissions: [] }],
7074
});
7175
});
7276

77+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
78+
7379
const { queryByRole } = render(<OrganizationMembers />, { wrapper });
7480

7581
await waitFor(() => {
@@ -85,6 +91,8 @@ describe('OrganizationMembers', () => {
8591
f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'Org1', role: 'admin' }] });
8692
});
8793

94+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
95+
8896
const { getByRole } = render(<OrganizationMembers />, { wrapper });
8997

9098
await waitFor(async () => {
@@ -146,6 +154,8 @@ describe('OrganizationMembers', () => {
146154
}),
147155
);
148156

157+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
158+
149159
const { queryByText, queryAllByRole } = render(<OrganizationMembers />, { wrapper });
150160

151161
await waitFor(() => {
@@ -220,6 +230,7 @@ describe('OrganizationMembers', () => {
220230
});
221231
});
222232

233+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
223234
fixtures.clerk.organization?.getMembershipRequests.mockReturnValue(
224235
Promise.resolve({
225236
data: [],
@@ -262,6 +273,8 @@ describe('OrganizationMembers', () => {
262273
});
263274
});
264275

276+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
277+
265278
fixtures.clerk.organization?.getInvitations.mockReturnValue(
266279
Promise.resolve({
267280
data: invitationList,
@@ -310,6 +323,8 @@ describe('OrganizationMembers', () => {
310323
});
311324
});
312325

326+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
327+
313328
fixtures.clerk.organization?.getDomains.mockReturnValue(
314329
Promise.resolve({
315330
data: [],
@@ -347,6 +362,7 @@ describe('OrganizationMembers', () => {
347362
});
348363
});
349364

365+
fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
350366
fixtures.clerk.organization?.getMemberships.mockReturnValue(
351367
Promise.resolve({
352368
data: membersList,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useCoreOrganization } from '../contexts';
2+
import { useLocalizations } from '../localization';
3+
import { customRoleLocalizationKey, roleLocalizationKey } from '../utils';
4+
import { useFetch } from './useFetch';
5+
6+
const getRolesParams = {
7+
/**
8+
* Fetch at most 20 roles, it is not expected for an app to have more.
9+
* We also prevent the creation of more than 20 roles in dashboard.
10+
*/
11+
pageSize: 20,
12+
};
13+
export const useFetchRoles = () => {
14+
const { organization } = useCoreOrganization();
15+
const { data, status } = useFetch(organization?.getRoles, getRolesParams);
16+
17+
return {
18+
isLoading: status.isLoading,
19+
options: data?.data?.map(role => ({ value: role.key, label: role.name })),
20+
};
21+
};
22+
23+
export const useLocalizeCustomRoles = () => {
24+
const { t } = useLocalizations();
25+
return {
26+
localizeCustomRole: (param: string | undefined) =>
27+
t(customRoleLocalizationKey(param)) || t(roleLocalizationKey(param)),
28+
};
29+
};

‎packages/clerk-js/src/ui/utils/roleLocalizationKey.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ const roleToLocalizationKey: Record<MembershipRole, LocalizationKey> = {
99
admin: localizationKeys('membershipRole__admin'),
1010
};
1111

12-
export const roleLocalizationKey = (role: MembershipRole): LocalizationKey => {
12+
export const roleLocalizationKey = (role: MembershipRole | undefined): LocalizationKey | undefined => {
13+
if (!role) {
14+
return undefined;
15+
}
1316
return roleToLocalizationKey[role];
1417
};
18+
19+
export const customRoleLocalizationKey = (role: MembershipRole | undefined): LocalizationKey | undefined => {
20+
if (!role) {
21+
return undefined;
22+
}
23+
return localizationKeys(`roles.${role}`);
24+
};

‎packages/types/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ export * from './organizationMembershipRequest';
2929
export * from './organizationSettings';
3030
export * from './organizationSuggestion';
3131
export * from './passwords';
32+
export * from './permission';
3233
export * from './phoneNumber';
3334
export * from './redirects';
3435
export * from './resource';
36+
export * from './role';
3537
export * from './saml';
3638
export * from './samlAccount';
3739
export * from './session';

‎packages/types/src/json.ts

+28
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,34 @@ export interface OrganizationDomainJSON extends ClerkResourceJSON {
363363
total_pending_suggestions: number;
364364
}
365365

366+
/**
367+
* @experimental
368+
*/
369+
export interface RoleJSON extends ClerkResourceJSON {
370+
object: 'role';
371+
id: string;
372+
key: string;
373+
name: string;
374+
description: string;
375+
permissions: PermissionJSON[];
376+
created_at: number;
377+
updated_at: number;
378+
}
379+
380+
/**
381+
* @experimental
382+
*/
383+
export interface PermissionJSON extends ClerkResourceJSON {
384+
object: 'permission';
385+
id: string;
386+
key: string;
387+
name: string;
388+
description: string;
389+
type: 'system' | 'user';
390+
created_at: number;
391+
updated_at: number;
392+
}
393+
366394
export interface PublicOrganizationDataJSON {
367395
id: string;
368396
name: string;

‎packages/types/src/localization.ts

+8
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ export type LocalizationResource = DeepPartial<_LocalizationResource>;
1515

1616
type _LocalizationResource = {
1717
locale: string;
18+
/**
19+
* @experimental
20+
* Add role keys and their localized value
21+
* e.g. roles:{ 'org:teacher': 'Teacher'}
22+
*/
23+
roles: {
24+
[r: string]: LocalizationValue;
25+
};
1826
socialButtonsBlockButton: LocalizationValue;
1927
dividerText: LocalizationValue;
2028
formFieldLabel__emailAddress: LocalizationValue;

‎packages/types/src/organization.ts

+19
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { OrganizationInvitationResource, OrganizationInvitationStatus } fro
44
import type { MembershipRole, OrganizationMembershipResource } from './organizationMembership';
55
import type { OrganizationMembershipRequestResource } from './organizationMembershipRequest';
66
import type { ClerkResource } from './resource';
7+
import type { RoleResource } from './role';
78

89
declare global {
910
/**
@@ -49,6 +50,10 @@ export interface OrganizationResource extends ClerkResource {
4950
*/
5051
getPendingInvitations: (params?: GetPendingInvitationsParams) => Promise<OrganizationInvitationResource[]>;
5152
getInvitations: (params?: GetInvitationsParams) => Promise<ClerkPaginatedResponse<OrganizationInvitationResource>>;
53+
/**
54+
* @experimental
55+
*/
56+
getRoles: (params?: GetRolesParams) => Promise<ClerkPaginatedResponse<RoleResource>>;
5257
getDomains: (params?: GetDomainsParams) => Promise<ClerkPaginatedResponse<OrganizationDomainResource>>;
5358
getMembershipRequests: (
5459
params?: GetMembershipRequestParams,
@@ -71,6 +76,20 @@ export type GetMembershipsParams = {
7176
role?: MembershipRole[];
7277
} & ClerkPaginationParams;
7378

79+
/**
80+
* @experimental
81+
*/
82+
export type GetRolesParams = {
83+
/**
84+
* This is the starting point for your fetched results. The initial value persists between re-renders
85+
*/
86+
initialPage?: number;
87+
/**
88+
* Maximum number of items returned per request. The initial value persists between re-renders
89+
*/
90+
pageSize?: number;
91+
};
92+
7493
export type GetMembersParams = {
7594
/**
7695
* This is the starting point for your fetched results. The initial value persists between re-renders

‎packages/types/src/organizationMembership.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ export interface OrganizationMembershipResource extends ClerkResource {
4040
update: (updateParams: UpdateOrganizationMembershipParams) => Promise<OrganizationMembershipResource>;
4141
}
4242

43-
export type MembershipRole = 'admin' | 'basic_member' | 'guest_member';
43+
// Adding (string & {}) allows for getting eslint autocomplete but also accepts any string
44+
// eslint-disable-next-line
45+
export type MembershipRole = 'admin' | 'basic_member' | 'guest_member' | (string & {});
4446

4547
export type OrganizationPermission =
4648
| 'org:sys_domains:manage'

‎packages/types/src/permission.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { ClerkResource } from './resource';
2+
3+
/**
4+
* @experimental
5+
*/
6+
export interface PermissionResource extends ClerkResource {
7+
id: string;
8+
key: string;
9+
name: string;
10+
type: 'system' | 'user';
11+
description: string;
12+
createdAt: Date;
13+
updatedAt: Date;
14+
}

‎packages/types/src/role.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { PermissionResource } from './permission';
2+
import type { ClerkResource } from './resource';
3+
4+
/**
5+
* @experimental
6+
*/
7+
export interface RoleResource extends ClerkResource {
8+
id: string;
9+
key: string;
10+
name: string;
11+
description: string;
12+
permissions: PermissionResource[];
13+
createdAt: Date;
14+
updatedAt: Date;
15+
}

0 commit comments

Comments
 (0)
Please sign in to comment.