Skip to content

Commit

Permalink
feat(clerk-js,types): Fetch custom roles and localize them (#2004)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
panteliselef authored Nov 3, 2023
1 parent 7644b74 commit 0293f29
Show file tree
Hide file tree
Showing 23 changed files with 416 additions and 111 deletions.
8 changes: 8 additions & 0 deletions .changeset/famous-forks-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Add support for custom roles in `<OrganizationProfile/>`.

The previous roles (`admin` and `basic_member`), are still kept as a fallback.
18 changes: 18 additions & 0 deletions packages/clerk-js/src/core/resources/Organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
GetMembershipRequestParams,
GetMemberships,
GetPendingInvitationsParams,
GetRolesParams,
InviteMemberParams,
InviteMembersParams,
OrganizationDomainJSON,
Expand All @@ -20,6 +21,7 @@ import type {
OrganizationMembershipRequestJSON,
OrganizationMembershipRequestResource,
OrganizationResource,
RoleJSON,
SetOrganizationLogoParams,
UpdateMembershipParams,
UpdateOrganizationParams,
Expand All @@ -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';
Expand Down Expand Up @@ -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<RoleJSON>;

return {
total_count,
data: roles.map(role => new Role(role)),
};
});
};

getDomains = async (
getDomainParams?: GetDomainsParams,
): Promise<ClerkPaginatedResponse<OrganizationDomainResource>> => {
Expand Down
37 changes: 37 additions & 0 deletions packages/clerk-js/src/core/resources/Permission.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
38 changes: 38 additions & 0 deletions packages/clerk-js/src/core/resources/Role.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Organization {
"getMembershipRequests": [Function],
"getMemberships": [Function],
"getPendingInvitations": [Function],
"getRoles": [Function],
"hasImage": true,
"id": "test_id",
"imageUrl": "https://clerk.com",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ OrganizationMembership {
"getMembershipRequests": [Function],
"getMemberships": [Function],
"getPendingInvitations": [Function],
"getRoles": [Function],
"hasImage": true,
"id": "test_org_id",
"imageUrl": "https://clerk.com",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ export const CreateOrganizationForm = (props: CreateOrganizationFormProps) => {
>
{organization && (
<InviteMembersForm
organization={organization}
resetButtonLabel={localizationKeys('createOrganization.invitePage.formButtonReset')}
onSuccess={wizard.nextStep}
onReset={completeFlow}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { MembershipRole, OrganizationMembershipResource } from '@clerk/types';
import type { OrganizationMembershipResource } from '@clerk/types';

import { Gate } from '../../common/Gate';
import { useCoreOrganization, useCoreUser } from '../../contexts';
import { Badge, localizationKeys, Td, Text } from '../../customizables';
import { ThreeDotsMenu, useCardState, UserPreview } from '../../elements';
import { handleError, roleLocalizationKey } from '../../utils';
import { useFetchRoles, useLocalizeCustomRoles } from '../../hooks/useFetchRoles';
import { handleError } from '../../utils';
import { DataTable, RoleSelect, RowContainer } from './MemberListTable';

export const ActiveMembersList = () => {
Expand All @@ -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 });
Expand All @@ -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'),
Expand All @@ -53,6 +56,7 @@ export const ActiveMembersList = () => {
<MemberRow
key={m.id}
membership={m}
options={options}
onRoleChange={handleRoleChange(m)}
onRemove={handleRemove(m)}
/>
Expand All @@ -65,9 +69,11 @@ export const ActiveMembersList = () => {
const MemberRow = (props: {
membership: OrganizationMembershipResource;
onRemove: () => unknown;
onRoleChange?: (role: MembershipRole) => unknown;
options: Parameters<typeof RoleSelect>[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();

Expand All @@ -94,17 +100,13 @@ const MemberRow = (props: {
<Td>
<Gate
permission={'org:sys_memberships:manage'}
fallback={
<Text
sx={t => ({ opacity: t.opacity.$inactive })}
localizationKey={roleLocalizationKey(membership.role)}
/>
}
fallback={<Text sx={t => ({ opacity: t.opacity.$inactive })}>{localizeCustomRole(membership.role)}</Text>}
>
<RoleSelect
isDisabled={card.isLoading || !onRoleChange}
value={membership.role}
onChange={onRoleChange}
roles={options}
/>
</Gate>
</Td>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -29,22 +24,18 @@ 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;
}

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'),
Expand All @@ -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<HTMLFormElement>) => {
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)) {
Expand Down Expand Up @@ -132,25 +122,7 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
/>
</Flex>
</Form.ControlRow>
<Form.ControlRow elementId={roleField.id}>
<Flex
direction='col'
gap={2}
>
<Text localizationKey={roleField.label} />
{/*@ts-expect-error Select expects options to be an array but useFormControl returns an optional field. */}
<Select
elementId='role'
{...roleField.props}
onChange={option => roleField.setValue(option.value)}
>
<SelectButton sx={t => ({ width: t.sizes.$48, justifyContent: 'space-between', display: 'flex' })}>
{roleField.props.options?.find(o => o.value === roleField.value)?.label}
</SelectButton>
<SelectOptionList sx={t => ({ minWidth: t.sizes.$48 })} />
</Select>
</Flex>
</Form.ControlRow>
<AsyncRoleSelect />
<FormButtonContainer>
<Form.SubmitButton
block={false}
Expand All @@ -166,3 +138,29 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
</Form.Root>
);
};

const AsyncRoleSelect = () => {
const { options, isLoading } = useFetchRoles();
const roleField = useFormControl('role', '', {
label: localizationKeys('formFieldLabel__role'),
});

return (
<Form.ControlRow elementId={roleField.id}>
<Flex
direction='col'
gap={2}
>
<Text localizationKey={roleField.label} />
<RoleSelect
{...roleField.props}
roles={options}
isDisabled={isLoading}
onChange={value => roleField.setValue(value)}
triggerSx={t => ({ width: t.sizes.$48, justifyContent: 'space-between', display: 'flex' })}
optionListSx={t => ({ minWidth: t.sizes.$48 })}
/>
</Flex>
</Form.ControlRow>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,7 @@ export const InviteMembersPage = withCardStateProvider(() => {
__unstable_manageBillingMembersLimit={__unstable_manageBillingMembersLimit}
/>
)}
<InviteMembersForm
organization={organization}
onSuccess={wizard.nextStep}
/>
<InviteMembersForm onSuccess={wizard.nextStep} />
</ContentPage>
<SuccessPage
title={title}
Expand Down
Loading

0 comments on commit 0293f29

Please sign in to comment.