diff --git a/.changeset/famous-forks-buy.md b/.changeset/famous-forks-buy.md new file mode 100644 index 0000000000..054e7b0d24 --- /dev/null +++ b/.changeset/famous-forks-buy.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Add support for custom roles in ``. + +The previous roles (`admin` and `basic_member`), are still kept as a fallback. diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 5a8e8c2b6b..ddb9b8f1ce 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -9,6 +9,7 @@ import type { GetMembershipRequestParams, GetMemberships, GetPendingInvitationsParams, + GetRolesParams, InviteMemberParams, InviteMembersParams, OrganizationDomainJSON, @@ -20,6 +21,7 @@ import type { OrganizationMembershipRequestJSON, OrganizationMembershipRequestResource, OrganizationResource, + RoleJSON, SetOrganizationLogoParams, UpdateMembershipParams, UpdateOrganizationParams, @@ -31,6 +33,7 @@ import { convertPageToOffset } from '../../utils/pagesToOffset'; import { BaseResource, OrganizationInvitation, OrganizationMembership } from './internal'; import { OrganizationDomain } from './OrganizationDomain'; import { OrganizationMembershipRequest } from './OrganizationMembershipRequest'; +import { Role } from './Role'; export class Organization extends BaseResource implements OrganizationResource { pathRoot = '/organizations'; @@ -105,6 +108,21 @@ export class Organization extends BaseResource implements OrganizationResource { }); }; + getRoles = async (getRolesParams?: GetRolesParams) => { + return await BaseResource._fetch({ + path: `/organizations/${this.id}/roles`, + method: 'GET', + search: convertPageToOffset(getRolesParams) as any, + }).then(res => { + const { data: roles, total_count } = res?.response as unknown as ClerkPaginatedResponse; + + return { + total_count, + data: roles.map(role => new Role(role)), + }; + }); + }; + getDomains = async ( getDomainParams?: GetDomainsParams, ): Promise> => { diff --git a/packages/clerk-js/src/core/resources/Permission.ts b/packages/clerk-js/src/core/resources/Permission.ts new file mode 100644 index 0000000000..310eb7694f --- /dev/null +++ b/packages/clerk-js/src/core/resources/Permission.ts @@ -0,0 +1,37 @@ +import type { PermissionJSON, PermissionResource } from '@clerk/types'; + +import { unixEpochToDate } from '../../utils/date'; +import { BaseResource } from './internal'; + +/** + * @experimental + */ +export class Permission extends BaseResource implements PermissionResource { + id!: string; + key!: string; + name!: string; + description!: string; + type!: 'system' | 'user'; + createdAt!: Date; + updatedAt!: Date; + + constructor(data: PermissionJSON) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: PermissionJSON | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.key = data.key; + this.name = data.name; + this.description = data.description; + this.type = data.type; + this.createdAt = unixEpochToDate(data.created_at); + this.updatedAt = unixEpochToDate(data.updated_at); + return this; + } +} diff --git a/packages/clerk-js/src/core/resources/Role.ts b/packages/clerk-js/src/core/resources/Role.ts new file mode 100644 index 0000000000..f009597d14 --- /dev/null +++ b/packages/clerk-js/src/core/resources/Role.ts @@ -0,0 +1,38 @@ +import type { RoleJSON, RoleResource } from '@clerk/types'; + +import { unixEpochToDate } from '../../utils/date'; +import { BaseResource } from './internal'; +import { Permission } from './Permission'; + +/** + * @experimental + */ +export class Role extends BaseResource implements RoleResource { + id!: string; + key!: string; + name!: string; + description!: string; + permissions: Permission[] = []; + createdAt!: Date; + updatedAt!: Date; + + constructor(data: RoleJSON) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: RoleJSON | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.key = data.key; + this.name = data.name; + this.description = data.description; + this.permissions = data.permissions.map(perm => new Permission(perm)); + this.createdAt = unixEpochToDate(data.created_at); + this.updatedAt = unixEpochToDate(data.updated_at); + return this; + } +} diff --git a/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap index 88d97e3313..0a8593327a 100644 --- a/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap @@ -13,6 +13,7 @@ Organization { "getMembershipRequests": [Function], "getMemberships": [Function], "getPendingInvitations": [Function], + "getRoles": [Function], "hasImage": true, "id": "test_id", "imageUrl": "https://clerk.com", diff --git a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap index 1ab0ec074f..fcf8be1c4d 100644 --- a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap @@ -17,6 +17,7 @@ OrganizationMembership { "getMembershipRequests": [Function], "getMemberships": [Function], "getPendingInvitations": [Function], + "getRoles": [Function], "hasImage": true, "id": "test_org_id", "imageUrl": "https://clerk.com", diff --git a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx index 0d89db40c5..9bd8725c14 100644 --- a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx +++ b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationForm.tsx @@ -165,7 +165,6 @@ export const CreateOrganizationForm = (props: CreateOrganizationFormProps) => { > {organization && ( { @@ -13,11 +14,13 @@ export const ActiveMembersList = () => { memberships: true, }); + const { options, isLoading: loadingRoles } = useFetchRoles(); + if (!organization) { return null; } - const handleRoleChange = (membership: OrganizationMembershipResource) => (newRole: MembershipRole) => { + const handleRoleChange = (membership: OrganizationMembershipResource) => (newRole: string) => { return card .runAsync(async () => { return await membership.update({ role: newRole }); @@ -41,7 +44,7 @@ export const ActiveMembersList = () => { onPageChange={n => memberships?.fetchPage?.(n)} itemCount={memberships?.count || 0} pageCount={memberships?.pageCount || 0} - isLoading={memberships?.isLoading} + isLoading={memberships?.isLoading || loadingRoles} emptyStateLocalizationKey={localizationKeys('organizationProfile.membersPage.detailsTitle__emptyRow')} headers={[ localizationKeys('organizationProfile.membersPage.activeMembersTab.tableHeader__user'), @@ -53,6 +56,7 @@ export const ActiveMembersList = () => { @@ -65,9 +69,11 @@ export const ActiveMembersList = () => { const MemberRow = (props: { membership: OrganizationMembershipResource; onRemove: () => unknown; - onRoleChange?: (role: MembershipRole) => unknown; + options: Parameters[0]['roles']; + onRoleChange: (role: string) => unknown; }) => { - const { membership, onRemove, onRoleChange } = props; + const { membership, onRemove, onRoleChange, options } = props; + const { localizeCustomRole } = useLocalizeCustomRoles(); const card = useCardState(); const user = useCoreUser(); @@ -94,17 +100,13 @@ const MemberRow = (props: { ({ opacity: t.opacity.$inactive })} - localizationKey={roleLocalizationKey(membership.role)} - /> - } + fallback={ ({ opacity: t.opacity.$inactive })}>{localizeCustomRole(membership.role)}} > diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersForm.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersForm.tsx index c8e0e8e232..dfd49f3fae 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersForm.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersForm.tsx @@ -1,26 +1,21 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; -import type { ClerkAPIError, MembershipRole, OrganizationResource } from '@clerk/types'; -import React from 'react'; +import type { ClerkAPIError, MembershipRole } from '@clerk/types'; +import type { FormEvent } from 'react'; +import { useState } from 'react'; +import { useCoreOrganization } from '../../contexts'; import { Flex, Text } from '../../customizables'; -import { - Form, - FormButtonContainer, - Select, - SelectButton, - SelectOptionList, - TagInput, - useCardState, -} from '../../elements'; +import { Form, FormButtonContainer, TagInput, useCardState } from '../../elements'; +import { useFetchRoles } from '../../hooks/useFetchRoles'; import type { LocalizationKey } from '../../localization'; import { localizationKeys, useLocalizations } from '../../localization'; import { useRouter } from '../../router'; -import { createListFormat, handleError, roleLocalizationKey, useFormControl } from '../../utils'; +import { createListFormat, handleError, useFormControl } from '../../utils'; +import { RoleSelect } from './MemberListTable'; const isEmail = (str: string) => /^\S+@\S+\.\S+$/.test(str); type InviteMembersFormProps = { - organization: OrganizationResource; onSuccess: () => void; onReset?: () => void; primaryButtonLabel?: LocalizationKey; @@ -29,10 +24,11 @@ type InviteMembersFormProps = { export const InviteMembersForm = (props: InviteMembersFormProps) => { const { navigate } = useRouter(); - const { onSuccess, onReset = () => navigate('..'), resetButtonLabel, organization } = props; + const { onSuccess, onReset = () => navigate('..'), resetButtonLabel } = props; + const { organization } = useCoreOrganization(); const card = useCardState(); const { t, locale } = useLocalizations(); - const [isValidUnsubmittedEmail, setIsValidUnsubmittedEmail] = React.useState(false); + const [isValidUnsubmittedEmail, setIsValidUnsubmittedEmail] = useState(false); if (!organization) { return null; @@ -40,11 +36,6 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { const validateUnsubmittedEmail = (value: string) => setIsValidUnsubmittedEmail(isEmail(value)); - const roles: Array<{ label: string; value: MembershipRole }> = [ - { label: t(roleLocalizationKey('admin')), value: 'admin' }, - { label: t(roleLocalizationKey('basic_member')), value: 'basic_member' }, - ]; - const emailAddressField = useFormControl('emailAddress', '', { type: 'text', label: localizationKeys('formFieldLabel__emailAddresses'), @@ -67,18 +58,17 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { }, } = emailAddressField; - const roleField = useFormControl('role', 'basic_member', { - options: roles, - label: localizationKeys('formFieldLabel__role'), - placeholder: '', - }); - const canSubmit = !!emailAddressField.value.length || isValidUnsubmittedEmail; - const onSubmit = async (e: React.FormEvent) => { + const onSubmit = (e: FormEvent) => { e.preventDefault(); + + const submittedData = new FormData(e.currentTarget); return organization - .inviteMembers({ emailAddresses: emailAddressField.value.split(','), role: roleField.value as MembershipRole }) + .inviteMembers({ + emailAddresses: emailAddressField.value.split(','), + role: submittedData.get('role') as MembershipRole, + }) .then(onSuccess) .catch(err => { if (isClerkAPIResponseError(err)) { @@ -132,25 +122,7 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { /> - - - - {/*@ts-expect-error Select expects options to be an array but useFormControl returns an optional field. */} - - - + { ); }; + +const AsyncRoleSelect = () => { + const { options, isLoading } = useFetchRoles(); + const roleField = useFormControl('role', '', { + label: localizationKeys('formFieldLabel__role'), + }); + + return ( + + + + roleField.setValue(value)} + triggerSx={t => ({ width: t.sizes.$48, justifyContent: 'space-between', display: 'flex' })} + optionListSx={t => ({ minWidth: t.sizes.$48 })} + /> + + + ); +}; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersPage.tsx index ed6c72a0f5..4f63cf210b 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/InviteMembersPage.tsx @@ -40,10 +40,7 @@ export const InviteMembersPage = withCardStateProvider(() => { __unstable_manageBillingMembersLimit={__unstable_manageBillingMembersLimit} /> )} - + { @@ -52,6 +53,7 @@ export const InvitedMembersList = () => { const InvitationRow = (props: { invitation: OrganizationInvitationResource; onRevoke: () => unknown }) => { const { invitation, onRevoke } = props; + const { localizeCustomRole } = useLocalizeCustomRoles(); return ( @@ -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 ( ({ - color: t.colors.$colorTextSecondary, - backgroundColor: 'transparent', - })} + sx={ + triggerSx || + (t => ({ + color: t.colors.$colorTextSecondary, + backgroundColor: 'transparent', + })) + } isDisabled={isDisabled} > - {selectedRole?.label} + {localizeCustomRole(selectedRole?.value) || selectedRole?.label} - + ); }; 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; +}