@@ -64,7 +66,7 @@ const InvitationRow = (props: { invitation: OrganizationInvitationResource; onRe
|
|
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/MemberListTable.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/MemberListTable.tsx
index 5e4fbcf820..60187d8493 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/MemberListTable.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/MemberListTable.tsx
@@ -2,23 +2,10 @@ import type { MembershipRole } from '@clerk/types';
import React from 'react';
import type { LocalizationKey } from '../../customizables';
-import {
- Col,
- descriptors,
- Flex,
- Spinner,
- Table,
- Tbody,
- Td,
- Text,
- Th,
- Thead,
- Tr,
- useLocalizations,
-} from '../../customizables';
+import { Col, descriptors, Flex, Spinner, Table, Tbody, Td, Text, Th, Thead, Tr } from '../../customizables';
import { Pagination, Select, SelectButton, SelectOptionList } from '../../elements';
-import type { PropsOfComponent } from '../../styledSystem';
-import { roleLocalizationKey } from '../../utils';
+import { useLocalizeCustomRoles } from '../../hooks/useFetchRoles';
+import type { PropsOfComponent, ThemableCssProp } from '../../styledSystem';
type MembersListTableProps = {
headers: LocalizationKey[];
@@ -139,38 +126,61 @@ export const RowContainer = (props: PropsOfComponent) => {
);
};
-export const RoleSelect = (props: { value: MembershipRole; onChange: any; isDisabled?: boolean }) => {
- const { value, onChange, isDisabled } = props;
- const { t } = useLocalizations();
+export const RoleSelect = (props: {
+ roles: { label: string; value: string }[] | undefined;
+ value: MembershipRole;
+ onChange: (params: string) => unknown;
+ isDisabled?: boolean;
+ triggerSx?: ThemableCssProp;
+ optionListSx?: ThemableCssProp;
+}) => {
+ const { value, roles, onChange, isDisabled, triggerSx, optionListSx } = props;
- const roles: Array<{ label: string; value: MembershipRole }> = [
- { label: t(roleLocalizationKey('admin')), value: 'admin' },
- { label: t(roleLocalizationKey('basic_member')), value: 'basic_member' },
+ const shouldDisplayLegacyRoles = !roles;
+
+ const legacyRoles: Array<{ label: string; value: MembershipRole }> = [
+ { label: 'admin', value: 'admin' },
+ { label: 'basic_member', value: 'basic_member' },
];
- const excludedRoles: Array<{ label: string; value: MembershipRole }> = [
- { label: t(roleLocalizationKey('guest_member')), value: 'guest_member' },
+ const legacyExcludedRoles: Array<{ label: string; value: MembershipRole }> = [
+ { label: 'guest_member', value: 'guest_member' },
];
+ const { localizeCustomRole } = useLocalizeCustomRoles();
+
+ const selectedRole = [...(roles || []), ...legacyRoles, ...legacyExcludedRoles].find(r => r.value === value);
- const selectedRole = [...roles, ...excludedRoles].find(r => r.value === value);
+ const localizedOptions = (!shouldDisplayLegacyRoles ? roles : legacyRoles).map(role => ({
+ value: role.value,
+ label: localizeCustomRole(role.value) || role.label,
+ }));
return (
);
};
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 b03d49581c..1784f139d3 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 { MembershipRole, OrganizationInvitationResource } from '@clerk/types';
import { describe } from '@jest/globals';
import { waitFor } from '@testing-library/dom';
+import { act } from '@testing-library/react';
import React from 'react';
import { ClerkAPIResponseError } from '../../../../core/resources';
@@ -12,18 +13,19 @@ const { createFixtures } = bindCreateFixtures('OrganizationProfile');
describe('InviteMembersPage', () => {
it('renders the component', async () => {
- const { wrapper } = await createFixtures(f => {
+ const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'Org1', role: 'admin' }] });
});
- const { getByText } = render(, { wrapper });
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
+ const { getByText } = await act(() => render(, { wrapper }));
expect(getByText('Invite new members to this organization')).toBeDefined();
});
describe('Submitting', () => {
it('enables the Send button when one or more email has been entered', async () => {
- const { wrapper } = await createFixtures(f => {
+ const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
@@ -31,6 +33,7 @@ describe('InviteMembersPage', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
const { getByRole, userEvent, getByTestId } = render(, { wrapper });
expect(getByRole('button', { name: 'Send invitations' })).toBeDisabled();
@@ -47,9 +50,12 @@ describe('InviteMembersPage', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]);
- const { getByRole, userEvent, getByTestId } = render(, { wrapper });
+ const { getByRole, userEvent, getByTestId, getByText } = render(, { wrapper });
await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,');
+ await userEvent.click(getByRole('button', { name: 'Select an option' }));
+ await userEvent.click(getByText('Member'));
await userEvent.click(getByRole('button', { name: 'Send invitations' }));
expect(fixtures.clerk.organization?.inviteMembers).toHaveBeenCalledWith({
emailAddresses: ['test+1@clerk.com'],
@@ -57,6 +63,43 @@ describe('InviteMembersPage', () => {
});
});
+ it('fetches custom role and sends invite to email entered and teacher role when clicking Send', 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.mockResolvedValueOnce({
+ data: [
+ {
+ pathRoot: '',
+ reload: jest.fn(),
+ id: '1',
+ description: '',
+ updatedAt: new Date(),
+ createdAt: new Date(),
+ permissions: [],
+ name: 'Teacher',
+ key: 'org:teacher',
+ },
+ ],
+ total_count: 1,
+ });
+ fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]);
+ const { getByRole, userEvent, getByTestId, getByText } = render(, { wrapper });
+ await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,');
+ await userEvent.click(getByRole('button', { name: 'Select an option' }));
+ await userEvent.click(getByText('Teacher'));
+ await userEvent.click(getByRole('button', { name: 'Send invitations' }));
+ expect(fixtures.clerk.organization?.inviteMembers).toHaveBeenCalledWith({
+ emailAddresses: ['test+1@clerk.com'],
+ role: 'org:teacher' as MembershipRole,
+ });
+ });
+
it('sends invites to multiple emails', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
@@ -66,12 +109,15 @@ describe('InviteMembersPage', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]);
- const { getByRole, userEvent, getByTestId } = render(, { wrapper });
+ const { getByRole, userEvent, getByTestId, getByText } = render(, { wrapper });
await userEvent.type(
getByTestId('tag-input'),
'test+1@clerk.com,test+2@clerk.com,test+3@clerk.com,test+4@clerk.com,',
);
+ await userEvent.click(getByRole('button', { name: 'Select an option' }));
+ await userEvent.click(getByText('Member'));
await userEvent.click(getByRole('button', { name: 'Send invitations' }));
expect(fixtures.clerk.organization?.inviteMembers).toHaveBeenCalledWith({
emailAddresses: ['test+1@clerk.com', 'test+2@clerk.com', 'test+3@clerk.com', 'test+4@clerk.com'],
@@ -88,10 +134,11 @@ describe('InviteMembersPage', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
fixtures.clerk.organization?.inviteMembers.mockResolvedValueOnce([{}] as OrganizationInvitationResource[]);
const { getByRole, userEvent, getByText, getByTestId } = render(, { wrapper });
await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,');
- await userEvent.click(getByRole('button', { name: 'Member' }));
+ await userEvent.click(getByRole('button', { name: 'Select an option' }));
await userEvent.click(getByText('Admin'));
await userEvent.click(getByRole('button', { name: 'Send invitations' }));
expect(fixtures.clerk.organization?.inviteMembers).toHaveBeenCalledWith({
@@ -109,6 +156,7 @@ describe('InviteMembersPage', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
fixtures.clerk.organization?.inviteMembers.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [
@@ -143,6 +191,7 @@ describe('InviteMembersPage', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
fixtures.clerk.organization?.inviteMembers.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [
@@ -173,6 +222,7 @@ describe('InviteMembersPage', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
fixtures.clerk.organization?.inviteMembers.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [
@@ -203,6 +253,7 @@ describe('InviteMembersPage', () => {
organization_memberships: [{ name: 'Org1', role: 'admin' }],
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
const { getByRole, userEvent } = render(, { wrapper });
await userEvent.click(getByRole('button', { name: 'Cancel' }));
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 7fa4064839..7309ab1b94 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
@@ -13,11 +13,11 @@ const { createFixtures } = bindCreateFixtures('OrganizationProfile');
describe('OrganizationMembers', () => {
it('renders the Organization Members page', async () => {
- const { wrapper } = await createFixtures(f => {
+ const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: ['Org1'] });
});
-
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
const { getByText, getByRole } = render(, { wrapper });
await waitFor(() => {
@@ -33,12 +33,14 @@ describe('OrganizationMembers', () => {
});
it('shows requests if domains is turned on', async () => {
- const { wrapper } = await createFixtures(f => {
+ const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
f.withOrganizationDomains();
f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: ['Org1'] });
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
+
const { getByRole } = render(, { wrapper });
await waitFor(() => {
@@ -47,11 +49,13 @@ describe('OrganizationMembers', () => {
});
it('shows an invite button inside invitations tab if the current user is an admin', async () => {
- const { wrapper } = await createFixtures(f => {
+ 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.mockRejectedValue(null);
+
const { getByRole, getByText } = render(, { wrapper });
await waitFor(async () => {
@@ -62,7 +66,7 @@ describe('OrganizationMembers', () => {
});
it('does not show invitations and requests if user is not an admin', async () => {
- const { wrapper } = await createFixtures(f => {
+ const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.com'],
@@ -70,6 +74,8 @@ describe('OrganizationMembers', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
+
const { queryByRole } = render(, { wrapper });
await waitFor(() => {
@@ -85,6 +91,8 @@ describe('OrganizationMembers', () => {
f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'Org1', role: 'admin' }] });
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
+
const { getByRole } = render(, { wrapper });
await waitFor(async () => {
@@ -146,6 +154,8 @@ describe('OrganizationMembers', () => {
}),
);
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
+
const { queryByText, queryAllByRole } = render(, { wrapper });
await waitFor(() => {
@@ -220,6 +230,7 @@ describe('OrganizationMembers', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
fixtures.clerk.organization?.getMembershipRequests.mockReturnValue(
Promise.resolve({
data: [],
@@ -262,6 +273,8 @@ describe('OrganizationMembers', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
+
fixtures.clerk.organization?.getInvitations.mockReturnValue(
Promise.resolve({
data: invitationList,
@@ -310,6 +323,8 @@ describe('OrganizationMembers', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
+
fixtures.clerk.organization?.getDomains.mockReturnValue(
Promise.resolve({
data: [],
@@ -347,6 +362,7 @@ describe('OrganizationMembers', () => {
});
});
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
fixtures.clerk.organization?.getMemberships.mockReturnValue(
Promise.resolve({
data: membersList,
diff --git a/packages/clerk-js/src/ui/hooks/useFetchRoles.ts b/packages/clerk-js/src/ui/hooks/useFetchRoles.ts
new file mode 100644
index 0000000000..cbe0f32499
--- /dev/null
+++ b/packages/clerk-js/src/ui/hooks/useFetchRoles.ts
@@ -0,0 +1,29 @@
+import { useCoreOrganization } from '../contexts';
+import { useLocalizations } from '../localization';
+import { customRoleLocalizationKey, roleLocalizationKey } from '../utils';
+import { useFetch } from './useFetch';
+
+const getRolesParams = {
+ /**
+ * Fetch at most 20 roles, it is not expected for an app to have more.
+ * We also prevent the creation of more than 20 roles in dashboard.
+ */
+ pageSize: 20,
+};
+export const useFetchRoles = () => {
+ const { organization } = useCoreOrganization();
+ const { data, status } = useFetch(organization?.getRoles, getRolesParams);
+
+ return {
+ isLoading: status.isLoading,
+ options: data?.data?.map(role => ({ value: role.key, label: role.name })),
+ };
+};
+
+export const useLocalizeCustomRoles = () => {
+ const { t } = useLocalizations();
+ return {
+ localizeCustomRole: (param: string | undefined) =>
+ t(customRoleLocalizationKey(param)) || t(roleLocalizationKey(param)),
+ };
+};
diff --git a/packages/clerk-js/src/ui/utils/roleLocalizationKey.ts b/packages/clerk-js/src/ui/utils/roleLocalizationKey.ts
index 26485021f9..3e4d01930d 100644
--- a/packages/clerk-js/src/ui/utils/roleLocalizationKey.ts
+++ b/packages/clerk-js/src/ui/utils/roleLocalizationKey.ts
@@ -9,6 +9,16 @@ const roleToLocalizationKey: Record = {
admin: localizationKeys('membershipRole__admin'),
};
-export const roleLocalizationKey = (role: MembershipRole): LocalizationKey => {
+export const roleLocalizationKey = (role: MembershipRole | undefined): LocalizationKey | undefined => {
+ if (!role) {
+ return undefined;
+ }
return roleToLocalizationKey[role];
};
+
+export const customRoleLocalizationKey = (role: MembershipRole | undefined): LocalizationKey | undefined => {
+ if (!role) {
+ return undefined;
+ }
+ return localizationKeys(`roles.${role}`);
+};
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index de6d253f69..90da098cfc 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -29,9 +29,11 @@ export * from './organizationMembershipRequest';
export * from './organizationSettings';
export * from './organizationSuggestion';
export * from './passwords';
+export * from './permission';
export * from './phoneNumber';
export * from './redirects';
export * from './resource';
+export * from './role';
export * from './saml';
export * from './samlAccount';
export * from './session';
diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts
index d663cb0d0a..3e213f3762 100644
--- a/packages/types/src/json.ts
+++ b/packages/types/src/json.ts
@@ -363,6 +363,34 @@ export interface OrganizationDomainJSON extends ClerkResourceJSON {
total_pending_suggestions: number;
}
+/**
+ * @experimental
+ */
+export interface RoleJSON extends ClerkResourceJSON {
+ object: 'role';
+ id: string;
+ key: string;
+ name: string;
+ description: string;
+ permissions: PermissionJSON[];
+ created_at: number;
+ updated_at: number;
+}
+
+/**
+ * @experimental
+ */
+export interface PermissionJSON extends ClerkResourceJSON {
+ object: 'permission';
+ id: string;
+ key: string;
+ name: string;
+ description: string;
+ type: 'system' | 'user';
+ created_at: number;
+ updated_at: number;
+}
+
export interface PublicOrganizationDataJSON {
id: string;
name: string;
diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts
index 3b2446d9ee..1075dee38d 100644
--- a/packages/types/src/localization.ts
+++ b/packages/types/src/localization.ts
@@ -15,6 +15,14 @@ export type LocalizationResource = DeepPartial<_LocalizationResource>;
type _LocalizationResource = {
locale: string;
+ /**
+ * @experimental
+ * Add role keys and their localized value
+ * e.g. roles:{ 'org:teacher': 'Teacher'}
+ */
+ roles: {
+ [r: string]: LocalizationValue;
+ };
socialButtonsBlockButton: LocalizationValue;
dividerText: LocalizationValue;
formFieldLabel__emailAddress: LocalizationValue;
diff --git a/packages/types/src/organization.ts b/packages/types/src/organization.ts
index 9b42c86a3a..f9ed377107 100644
--- a/packages/types/src/organization.ts
+++ b/packages/types/src/organization.ts
@@ -4,6 +4,7 @@ import type { OrganizationInvitationResource, OrganizationInvitationStatus } fro
import type { MembershipRole, OrganizationMembershipResource } from './organizationMembership';
import type { OrganizationMembershipRequestResource } from './organizationMembershipRequest';
import type { ClerkResource } from './resource';
+import type { RoleResource } from './role';
declare global {
/**
@@ -49,6 +50,10 @@ export interface OrganizationResource extends ClerkResource {
*/
getPendingInvitations: (params?: GetPendingInvitationsParams) => Promise;
getInvitations: (params?: GetInvitationsParams) => Promise>;
+ /**
+ * @experimental
+ */
+ getRoles: (params?: GetRolesParams) => Promise>;
getDomains: (params?: GetDomainsParams) => Promise>;
getMembershipRequests: (
params?: GetMembershipRequestParams,
@@ -71,6 +76,20 @@ export type GetMembershipsParams = {
role?: MembershipRole[];
} & ClerkPaginationParams;
+/**
+ * @experimental
+ */
+export type GetRolesParams = {
+ /**
+ * This is the starting point for your fetched results. The initial value persists between re-renders
+ */
+ initialPage?: number;
+ /**
+ * Maximum number of items returned per request. The initial value persists between re-renders
+ */
+ pageSize?: number;
+};
+
export type GetMembersParams = {
/**
* This is the starting point for your fetched results. The initial value persists between re-renders
diff --git a/packages/types/src/organizationMembership.ts b/packages/types/src/organizationMembership.ts
index 3ac2225de1..71f4300e40 100644
--- a/packages/types/src/organizationMembership.ts
+++ b/packages/types/src/organizationMembership.ts
@@ -40,7 +40,9 @@ export interface OrganizationMembershipResource extends ClerkResource {
update: (updateParams: UpdateOrganizationMembershipParams) => Promise;
}
-export type MembershipRole = 'admin' | 'basic_member' | 'guest_member';
+// Adding (string & {}) allows for getting eslint autocomplete but also accepts any string
+// eslint-disable-next-line
+export type MembershipRole = 'admin' | 'basic_member' | 'guest_member' | (string & {});
export type OrganizationPermission =
| 'org:sys_domains:manage'
diff --git a/packages/types/src/permission.ts b/packages/types/src/permission.ts
new file mode 100644
index 0000000000..f30f6343fd
--- /dev/null
+++ b/packages/types/src/permission.ts
@@ -0,0 +1,14 @@
+import type { ClerkResource } from './resource';
+
+/**
+ * @experimental
+ */
+export interface PermissionResource extends ClerkResource {
+ id: string;
+ key: string;
+ name: string;
+ type: 'system' | 'user';
+ description: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
diff --git a/packages/types/src/role.ts b/packages/types/src/role.ts
new file mode 100644
index 0000000000..7011c879c2
--- /dev/null
+++ b/packages/types/src/role.ts
@@ -0,0 +1,15 @@
+import type { PermissionResource } from './permission';
+import type { ClerkResource } from './resource';
+
+/**
+ * @experimental
+ */
+export interface RoleResource extends ClerkResource {
+ id: string;
+ key: string;
+ name: string;
+ description: string;
+ permissions: PermissionResource[];
+ createdAt: Date;
+ updatedAt: Date;
+}
|