Skip to content

Commit

Permalink
Merge pull request #240 from clerkinc/org-invitations-backend-core
Browse files Browse the repository at this point in the history
Support for organization invitations in NodeJS SDK
  • Loading branch information
gkats authored May 16, 2022
2 parents 658391a + 07ac214 commit b07167f
Show file tree
Hide file tree
Showing 12 changed files with 325 additions and 3 deletions.
63 changes: 63 additions & 0 deletions packages/backend-core/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ Reference of the methods supported in the Clerk Backend API wrapper. [API refere
- [updateOrganization(organizationId, params)](#updateorganizationorganizationid-params)
- [updateOrganizationMetadata(organizationId, params)](#updateorganizationmetadataorganizationid-params)
- [deleteOrganization(organizationId)](#deleteorganizationorganizationid)
- [getPendingOrganizationInvitationList(params)](#getpendingorganizationinvitationlistparams)
- [createOrganizationInvitation(params)](#createorganizationinvitationparams)
- [revokeOrganizationInvitation(params)](#revokeorganizationinvitationparams)
- [getOrganizationMembershipList(params)](#getorganizationmembershiplistparams)
- [createOrganizationMembership(params)](#createorganizationmembershipparams)
- [updateOrganizationMembership(params)](#updateorganizationmembershipparams)
Expand Down Expand Up @@ -217,6 +220,66 @@ Delete an organization with the provided `organizationId`. This action cannot be
await clerkAPI.organizations.deleteOrganization(organizationId);
```

#### getPendingOrganizationInvitationList(params)

Retrieve a list of pending organization invitations for the organization specified by `organizationId`.

The method supports pagination via optional `limit` and `offset` parameters. The method parameters are:

- _organizationId_ The unique ID of the organization to retrieve the pending invitations for
- _limit_ Optionally put a limit on the number of results returned
- _offset_ Optionally skip some results

```ts
const invitations = await clerkAPI.organizations.getPendingOrganizationInvitationList({
organizationId: 'org_1o4q123qMeCkKKIXcA9h8',
});
```

#### createOrganizationInvitation(params)

Create an invitation to join an organization and send an email to the email address of the invited member.

You must pass the ID of the user that invites the new member as `inviterUserId`. The inviter user must be an administrator in the organization.

Available parameters:

- _organizationId_ The unique ID of the organization the invitation is about.
- _emailAddress_ The email address of the member that's going to be invited to join the organization.
- _role_ The new member's role in the organization.
- _inviterUserId_ The ID of the organization administrator that invites the new member.
- _redirectUrl_ An optional URL to redirect to after the invited member clicks the link from the invitation email.

```js
const invitation = await clerkAPI.organizations.createOrganizationInvitation({
organizationId: 'org_1o4q123qMeCkKKIXcA9h8',
inviterUserId: 'user_1o4q123qMeCkKKIXcA9h8',
emailAddress: 'invited@example.org',
role: 'basic_member',
redirectUrl: 'https://example.org',
});
```

#### revokeOrganizationInvitation(params)

Revoke a pending organization invitation for the organization specified by `organizationId`.

The requesting user must be an administrator in the organization.

The method parameters are:

- _organizationId_ The ID of the organization that the invitation belongs to.
- _invitationId_ The ID of the pending organization invitation to be revoked.
- _requestingUserId_ The ID of the user that revokes the invitation. Must be an administrator.

```ts
const invitation = await clerkAPI.organizations.revokeOrganizationInvitation({
organizationId: 'org_1o4q123qMeCkKKIXcA9h8',
invitationId: 'orginv_4o4q9883qMeFggTKIXcAArr',
requestingUserId: 'user_1o4q123qMeCkKKIXcA9h8',
});
```

#### getOrganizationMembershipList(params)

Get a list of memberships for the organization with the provided `organizationId`.
Expand Down
115 changes: 113 additions & 2 deletions packages/backend-core/src/__tests__/apis/OrganizationApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import nock from 'nock';

import { Organization, OrganizationMembership, OrganizationMembershipPublicUserData } from '../../api/resources';
import { OrganizationMembershipRole } from '../../api/resources/Enums';
import {
Organization,
OrganizationInvitation,
OrganizationMembership,
OrganizationMembershipPublicUserData,
} from '../../api/resources';
import { OrganizationInvitationStatus, OrganizationMembershipRole } from '../../api/resources/Enums';
import { TestBackendAPIClient } from '../TestBackendAPI';

test('createOrganization() creates an organization', async () => {
Expand Down Expand Up @@ -289,3 +294,109 @@ test('deleteOrganizationMembership() deletes an organization', async () => {
nock('https://api.clerk.dev').delete(`/v1/organizations/${organizationId}/memberships/${userId}`).reply(200, {});
await TestBackendAPIClient.organizations.deleteOrganizationMembership({ organizationId, userId });
});

test('createOrganizationInvitation() creates an invitation for an organization', async () => {
const organizationId = 'org_randomid';
const role: OrganizationMembershipRole = 'basic_member';
const status: OrganizationInvitationStatus = 'pending';
const emailAddress = 'invitation@example.com';
const redirectUrl = 'https://example.com';
const resJSON = {
object: 'organization_invitation',
id: 'orginv_randomid',
role,
status,
email_address: emailAddress,
redirect_url: redirectUrl,
organization_id: organizationId,
created_at: 1612378465,
updated_at: 1612378465,
};

nock('https://api.clerk.dev').post(`/v1/organizations/${organizationId}/invitations`).reply(200, resJSON);

const orgInvitation = await TestBackendAPIClient.organizations.createOrganizationInvitation({
organizationId,
emailAddress,
role,
redirectUrl,
inviterUserId: 'user_randomid',
});
expect(orgInvitation).toEqual(
new OrganizationInvitation({
id: resJSON.id,
role: resJSON.role,
organizationId,
emailAddress,
redirectUrl,
status: resJSON.status,
createdAt: resJSON.created_at,
updatedAt: resJSON.updated_at,
}),
);
});

test('getPendingOrganizationInvitationList() returns a list of organization memberships', async () => {
const organizationId = 'org_randomid';
const resJSON = [
{
object: 'organization_invitation',
id: 'orginv_randomid',
role: 'basic_member',
email_address: 'invited@example.org',
organization_id: organizationId,
status: 'pending',
redirect_url: null,
created_at: 1612378465,
updated_at: 1612378465,
},
];

nock('https://api.clerk.dev')
.get(new RegExp(`/v1/organizations/${organizationId}/invitations/pending`))
.reply(200, resJSON);

const organizationInvitationList = await TestBackendAPIClient.organizations.getPendingOrganizationInvitationList({
organizationId,
});
expect(organizationInvitationList).toBeInstanceOf(Array);
expect(organizationInvitationList.length).toEqual(1);
expect(organizationInvitationList[0]).toBeInstanceOf(OrganizationInvitation);
});

test('revokeOrganizationInvitation() revokes an organization invitation', async () => {
const organizationId = 'org_randomid';
const invitationId = 'orginv_randomid';
const resJSON = {
object: 'organization_invitation',
id: invitationId,
role: 'basic_member' as OrganizationMembershipRole,
email_address: 'invited@example.org',
organization_id: organizationId,
status: 'revoked' as OrganizationInvitationStatus,
redirect_url: null,
created_at: 1612378465,
updated_at: 1612378465,
};
nock('https://api.clerk.dev')
.post(`/v1/organizations/${organizationId}/invitations/${invitationId}/revoke`)
.reply(200, resJSON);

const orgInvitation = await TestBackendAPIClient.organizations.revokeOrganizationInvitation({
organizationId,
invitationId,
requestingUserId: 'user_randomid',
});
expect(orgInvitation).toEqual(
new OrganizationInvitation({
id: resJSON.id,
role: resJSON.role,
organizationId,
emailAddress: resJSON.email_address,
redirectUrl: resJSON.redirect_url,
status: resJSON.status,
createdAt: resJSON.created_at,
updatedAt: resJSON.updated_at,
}),
);
});
32 changes: 32 additions & 0 deletions packages/backend-core/src/__tests__/utils/Deserializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Email,
Invitation,
Organization,
OrganizationInvitation,
OrganizationMembership,
Session,
SMSMessage,
Expand Down Expand Up @@ -58,6 +59,16 @@ const organizationJSON = {
updated_at: 1612378465,
};

const organizationInvitationJSON = {
object: 'organization_invitation',
id: 'orginv_randomid',
email_address: 'invitation@example.com',
organization_id: 'org_randomid',
role: 'basic_member',
redirectUrl: null,
status: 'pending',
};

const organizationMembershipJSON = {
object: 'organization_membership',
id: 'orgmem_randomid',
Expand Down Expand Up @@ -148,6 +159,27 @@ test('deserializes an array of Organization objects', () => {
expect(organizations[0]).toBeInstanceOf(Organization);
});

test('deserializes an OrganizationInvitation object', () => {
const organizationInvitation = deserialize(organizationInvitationJSON);
expect(organizationInvitation).toBeInstanceOf(OrganizationInvitation);
});

test('deserializes an array of OrganizationInvitation objects', () => {
const organizationInvitations = deserialize([organizationInvitationJSON]);
expect(organizationInvitations).toBeInstanceOf(Array);
expect(organizationInvitations.length).toBe(1);
expect(organizationInvitations[0]).toBeInstanceOf(OrganizationInvitation);
});

test('deserializes a paginated response of OrganizationInvitation objects', () => {
const organizationInvitations = deserialize({
data: [organizationInvitationJSON],
});
expect(organizationInvitations).toBeInstanceOf(Array);
expect(organizationInvitations.length).toBe(1);
expect(organizationInvitations[0]).toBeInstanceOf(OrganizationInvitation);
});

test('deserializes an OrganizationMembership object', () => {
const organizationMembership = deserialize(organizationMembershipJSON);
expect(organizationMembership).toBeInstanceOf(OrganizationMembership);
Expand Down
57 changes: 56 additions & 1 deletion packages/backend-core/src/api/collection/OrganizationApi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Organization, OrganizationMembership } from '../resources';
import { Organization, OrganizationInvitation, OrganizationMembership } from '../resources';
import { OrganizationMembershipRole } from '../resources/Enums';
import { AbstractApi } from './AbstractApi';

Expand Down Expand Up @@ -45,6 +45,26 @@ type DeleteOrganizationMembershipParams = {
userId: string;
};

type CreateOrganizationInvitationParams = {
organizationId: string;
inviterUserId: string;
emailAddress: string;
role: OrganizationMembershipRole;
redirectUrl?: string;
};

type GetPendingOrganizationInvitationListParams = {
organizationId: string;
limit?: number;
offset?: number;
};

type RevokeOrganizationInvitationParams = {
organizationId: string;
invitationId: string;
requestingUserId: string;
};

export class OrganizationApi extends AbstractApi {
public async createOrganization(params: CreateParams) {
const { publicMetadata, privateMetadata } = params;
Expand Down Expand Up @@ -134,6 +154,41 @@ export class OrganizationApi extends AbstractApi {
path: `${basePath}/${organizationId}/memberships/${userId}`,
});
}

public async getPendingOrganizationInvitationList(params: GetPendingOrganizationInvitationListParams) {
const { organizationId, limit, offset } = params;
this.requireId(organizationId);

return this._restClient.makeRequest<OrganizationInvitation[]>({
method: 'GET',
path: `${basePath}/${organizationId}/invitations/pending`,
queryParams: { limit, offset },
});
}

public async createOrganizationInvitation(params: CreateOrganizationInvitationParams) {
const { organizationId, ...bodyParams } = params;
this.requireId(organizationId);

return this._restClient.makeRequest<OrganizationInvitation>({
method: 'POST',
path: `${basePath}/${organizationId}/invitations`,
bodyParams,
});
}

public async revokeOrganizationInvitation(params: RevokeOrganizationInvitationParams) {
const { organizationId, invitationId, requestingUserId } = params;
this.requireId(organizationId);

return this._restClient.makeRequest<OrganizationInvitation>({
method: 'POST',
path: `${basePath}/${organizationId}/invitations/${invitationId}/revoke`,
bodyParams: {
requestingUserId,
},
});
}
}

function stringifyMetadataParams(
Expand Down
2 changes: 2 additions & 0 deletions packages/backend-core/src/api/resources/Enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type OAuthProvider =

export type OAuthStrategy = `oauth_${OAuthProvider}`;

export type OrganizationInvitationStatus = 'pending' | 'accepted' | 'revoked';

export type OrganizationMembershipRole = 'basic_member' | 'admin';

export type SignInIdentifier = 'username' | 'email_address' | 'phone_number' | 'web3_wallet' | OAuthStrategy;
Expand Down
10 changes: 10 additions & 0 deletions packages/backend-core/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
OrganizationInvitationStatus,
OrganizationMembershipRole,
SignInFactorStrategy,
SignInIdentifier,
Expand All @@ -18,6 +19,7 @@ export enum ObjectType {
GoogleAccount = 'google_account',
Invitation = 'invitation',
Organization = 'organization',
OrganizationInvitation = 'organization_invitation',
OrganizationMembership = 'organization_membership',
PhoneNumber = 'phone_number',
Session = 'session',
Expand Down Expand Up @@ -142,6 +144,14 @@ export interface OrganizationJSON extends ClerkResourceJSON {
updated_at: number;
}

export interface OrganizationInvitationJSON extends ClerkResourceJSON {
email_address: string;
organization_id: string;
role: OrganizationMembershipRole;
redirect_url: string | null;
status: OrganizationInvitationStatus;
}

export interface OrganizationMembershipJSON extends ClerkResourceJSON {
object: ObjectType.OrganizationMembership;
organization: OrganizationJSON;
Expand Down
Loading

0 comments on commit b07167f

Please sign in to comment.