diff --git a/package-lock.json b/package-lock.json index cc50bb227af..affbc68272d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34724,10 +34724,10 @@ }, "packages/backend-core": { "name": "@clerk/backend-core", - "version": "1.8.0", + "version": "1.9.0-staging.2", "license": "MIT", "dependencies": { - "@clerk/types": "^2.13.0", + "@clerk/types": "^2.14.0-staging.1", "camelcase-keys": "^7.0.1", "query-string": "^7.0.1", "snakecase-keys": "^5.1.2", @@ -34754,10 +34754,10 @@ }, "packages/clerk-js": { "name": "@clerk/clerk-js", - "version": "3.11.0", + "version": "3.12.0-staging.2", "license": "MIT", "dependencies": { - "@clerk/types": "^2.13.0", + "@clerk/types": "^2.14.0-staging.1", "@popperjs/core": "^2.4.4", "browser-tabs-lock": "^1.2.15", "classnames": "^2.3.1", @@ -34777,7 +34777,7 @@ "@babel/preset-env": "^7.12.1", "@babel/preset-react": "^7.12.5", "@babel/preset-typescript": "^7.12.1", - "@clerk/shared": "^0.2.6", + "@clerk/shared": "^0.2.7-staging.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.2", "@svgr/webpack": "^6.2.1", "@testing-library/dom": "^7.28.1", @@ -34860,10 +34860,10 @@ }, "packages/edge": { "name": "@clerk/edge", - "version": "1.5.0", + "version": "1.5.1-staging.2", "license": "MIT", "dependencies": { - "@clerk/backend-core": "^1.8.0", + "@clerk/backend-core": "^1.9.0-staging.2", "@peculiar/webcrypto": "^1.2.3", "next": "^12.0.7" }, @@ -34886,15 +34886,15 @@ }, "packages/expo": { "name": "@clerk/clerk-expo", - "version": "0.9.23", + "version": "0.9.24-staging.3", "license": "MIT", "dependencies": { - "@clerk/clerk-js": "^3.11.0", - "@clerk/clerk-react": "^3.2.17", + "@clerk/clerk-js": "^3.12.0-staging.2", + "@clerk/clerk-react": "^3.2.18-staging.1", "base-64": "^1.0.0" }, "devDependencies": { - "@clerk/types": "^2.13.0", + "@clerk/types": "^2.14.0-staging.1", "@types/jest": "^27.4.0", "@types/node": "^16.11.9", "@types/react": "^17.0.39", @@ -34952,6 +34952,21 @@ "tslib": "^2.3.1" } }, + "packages/gatsby-plugin-clerk/node_modules/@clerk/clerk-react": { + "version": "3.2.17", + "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-3.2.17.tgz", + "integrity": "sha512-I564fP0ZuQJPYfMINFixmKnFc3BLjZ9cY4r4rTGVjyCzvm04jxqyttCHqg+xBwioptx5n0lZjWn/ufi9AvXYPw==", + "dependencies": { + "@clerk/types": "^2.13.0", + "tslib": "^2.3.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16" + } + }, "packages/gatsby-plugin-clerk/node_modules/@clerk/clerk-sdk-node": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@clerk/clerk-sdk-node/-/clerk-sdk-node-3.3.5.tgz", @@ -35001,6 +35016,14 @@ "node": ">=8" } }, + "packages/gatsby-plugin-clerk/node_modules/@clerk/types": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-2.13.0.tgz", + "integrity": "sha512-U6ElyVVmHzPECAhDTElLqftyniVQDCmo3YKQlfqtrBb0US35PKBmXXIiVWzjqHEmyTEI5dWNwN0HPNdgLL1X8g==", + "engines": { + "node": ">=14" + } + }, "packages/gatsby-plugin-clerk/node_modules/@types/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.0.tgz", @@ -35039,13 +35062,13 @@ }, "packages/nextjs": { "name": "@clerk/nextjs", - "version": "3.6.4", + "version": "3.6.5-staging.2", "license": "MIT", "dependencies": { - "@clerk/clerk-react": "^3.2.17", - "@clerk/clerk-sdk-node": "^3.5.0", - "@clerk/edge": "^1.5.0", - "@clerk/types": "^2.13.0", + "@clerk/clerk-react": "^3.2.18-staging.1", + "@clerk/clerk-sdk-node": "^3.6.0-staging.2", + "@clerk/edge": "^1.5.1-staging.2", + "@clerk/types": "^2.14.0-staging.1", "tslib": "^2.3.1" }, "devDependencies": { @@ -35075,10 +35098,10 @@ }, "packages/react": { "name": "@clerk/clerk-react", - "version": "3.2.17", + "version": "3.2.18-staging.1", "license": "MIT", "dependencies": { - "@clerk/types": "^2.13.0", + "@clerk/types": "^2.14.0-staging.1", "tslib": "^2.3.1" }, "devDependencies": { @@ -35115,12 +35138,12 @@ }, "packages/remix": { "name": "@clerk/remix", - "version": "0.4.4", + "version": "0.5.0-staging.0", "license": "MIT", "dependencies": { - "@clerk/clerk-react": "^3.2.17", - "@clerk/clerk-sdk-node": "^3.5.0", - "@clerk/types": "^2.13.0", + "@clerk/clerk-react": "^3.2.18-staging.1", + "@clerk/clerk-sdk-node": "^3.6.0-staging.2", + "@clerk/types": "^2.14.0-staging.1", "cookie": "^0.5.0", "tslib": "^2.3.1" }, @@ -35166,11 +35189,11 @@ }, "packages/sdk-node": { "name": "@clerk/clerk-sdk-node", - "version": "3.5.0", + "version": "3.6.0-staging.2", "license": "MIT", "dependencies": { - "@clerk/backend-core": "^1.8.0", - "@clerk/types": "^2.13.0", + "@clerk/backend-core": "^1.9.0-staging.2", + "@clerk/types": "^2.14.0-staging.1", "@peculiar/webcrypto": "^1.2.3", "camelcase-keys": "^6.2.2", "cookies": "^0.8.0", @@ -35243,12 +35266,12 @@ }, "packages/shared": { "name": "@clerk/shared", - "version": "0.2.6", + "version": "0.2.7-staging.1", "devDependencies": { "@babel/core": "^7.13.14", "@babel/preset-env": "^7.13.12", "@babel/preset-react": "^7.13.13", - "@clerk/types": "^2.13.0", + "@clerk/types": "^2.14.0-staging.1", "@popperjs/core": "^2.5.4", "@sentry/browser": "^6.3.0", "@svgr/webpack": "^6.2.1", @@ -35302,7 +35325,7 @@ }, "packages/types": { "name": "@clerk/types", - "version": "2.13.0", + "version": "2.14.0-staging.1", "license": "MIT", "devDependencies": { "@types/jest": "^27.4.0", @@ -36544,7 +36567,7 @@ "@clerk/backend-core": { "version": "file:packages/backend-core", "requires": { - "@clerk/types": "^2.13.0", + "@clerk/types": "^2.14.0-staging.1", "@peculiar/webcrypto": "^1.3.2", "@types/jest": "^27.4.0", "@types/node": "^16.11.12", @@ -36572,9 +36595,9 @@ "@clerk/clerk-expo": { "version": "file:packages/expo", "requires": { - "@clerk/clerk-js": "^3.11.0", - "@clerk/clerk-react": "^3.2.17", - "@clerk/types": "^2.13.0", + "@clerk/clerk-js": "^3.12.0-staging.2", + "@clerk/clerk-react": "^3.2.18-staging.1", + "@clerk/types": "^2.14.0-staging.1", "@types/jest": "^27.4.0", "@types/node": "^16.11.9", "@types/react": "^17.0.39", @@ -36605,8 +36628,8 @@ "@babel/preset-env": "^7.12.1", "@babel/preset-react": "^7.12.5", "@babel/preset-typescript": "^7.12.1", - "@clerk/shared": "^0.2.6", - "@clerk/types": "^2.13.0", + "@clerk/shared": "^0.2.7-staging.1", + "@clerk/types": "^2.14.0-staging.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.2", "@popperjs/core": "^2.4.4", "@svgr/webpack": "^6.2.1", @@ -36690,7 +36713,7 @@ "@clerk/clerk-react": { "version": "file:packages/react", "requires": { - "@clerk/types": "^2.13.0", + "@clerk/types": "^2.14.0-staging.1", "@testing-library/dom": "^7.28.1", "@testing-library/jest-dom": "^5.11.6", "@testing-library/react": "^11.2.1", @@ -36722,8 +36745,8 @@ "@clerk/clerk-sdk-node": { "version": "file:packages/sdk-node", "requires": { - "@clerk/backend-core": "^1.8.0", - "@clerk/types": "^2.13.0", + "@clerk/backend-core": "^1.9.0-staging.2", + "@clerk/types": "^2.14.0-staging.1", "@peculiar/webcrypto": "^1.2.3", "@types/cookies": "^0.7.7", "@types/express": "^4.17.11", @@ -36779,7 +36802,7 @@ "@clerk/edge": { "version": "file:packages/edge", "requires": { - "@clerk/backend-core": "^1.8.0", + "@clerk/backend-core": "^1.9.0-staging.2", "@peculiar/webcrypto": "^1.2.3", "@types/jest": "^27.4.0", "@types/node": "^16.11.12", @@ -36800,10 +36823,10 @@ "@clerk/nextjs": { "version": "file:packages/nextjs", "requires": { - "@clerk/clerk-react": "^3.2.17", - "@clerk/clerk-sdk-node": "^3.5.0", - "@clerk/edge": "^1.5.0", - "@clerk/types": "^2.13.0", + "@clerk/clerk-react": "^3.2.18-staging.1", + "@clerk/clerk-sdk-node": "^3.6.0-staging.2", + "@clerk/edge": "^1.5.1-staging.2", + "@clerk/types": "^2.14.0-staging.1", "@types/jest": "^27.4.0", "@types/node": "^16.11.9", "@types/react": "^17.0.39", @@ -36828,9 +36851,9 @@ "@clerk/remix": { "version": "file:packages/remix", "requires": { - "@clerk/clerk-react": "^3.2.17", - "@clerk/clerk-sdk-node": "^3.5.0", - "@clerk/types": "^2.13.0", + "@clerk/clerk-react": "^3.2.18-staging.1", + "@clerk/clerk-sdk-node": "^3.6.0-staging.2", + "@clerk/types": "^2.14.0-staging.1", "@types/cookie": "^0.5.0", "@types/jest": "^27.4.0", "@types/node": "^16.11.9", @@ -36870,7 +36893,7 @@ "@babel/core": "^7.13.14", "@babel/preset-env": "^7.13.12", "@babel/preset-react": "^7.13.13", - "@clerk/types": "^2.13.0", + "@clerk/types": "^2.14.0-staging.1", "@popperjs/core": "^2.5.4", "@sentry/browser": "^6.3.0", "@svgr/webpack": "^6.2.1", @@ -49659,6 +49682,15 @@ "tslib": "^2.3.1" } }, + "@clerk/clerk-react": { + "version": "3.2.17", + "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-3.2.17.tgz", + "integrity": "sha512-I564fP0ZuQJPYfMINFixmKnFc3BLjZ9cY4r4rTGVjyCzvm04jxqyttCHqg+xBwioptx5n0lZjWn/ufi9AvXYPw==", + "requires": { + "@clerk/types": "^2.13.0", + "tslib": "^2.3.1" + } + }, "@clerk/clerk-sdk-node": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@clerk/clerk-sdk-node/-/clerk-sdk-node-3.3.5.tgz", @@ -49698,6 +49730,11 @@ } } }, + "@clerk/types": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-2.13.0.tgz", + "integrity": "sha512-U6ElyVVmHzPECAhDTElLqftyniVQDCmo3YKQlfqtrBb0US35PKBmXXIiVWzjqHEmyTEI5dWNwN0HPNdgLL1X8g==" + }, "@types/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.0.tgz", diff --git a/packages/backend-core/API.md b/packages/backend-core/API.md index 5a458f15885..c8a6b3ae2e8 100644 --- a/packages/backend-core/API.md +++ b/packages/backend-core/API.md @@ -19,10 +19,14 @@ Reference of the methods supported in the Clerk Backend API wrapper. [API refere - [createInvitation(params)](#createinvitationparams) - [revokeInvitation(invitationId)](#revokeinvitationinvitationid) - [Organization operations](#organization-operations) + - [getOrganizationList()](#getorganizationlist) - [createOrganization(params)](#createorganizationparams) - [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) @@ -156,6 +160,20 @@ const invitation = await clerkAPI.invitations.revokeInvitation('inv_some-id'); Organization operations are exposed by the `organizations` sub-api (`clerkAPI.organizations`). +#### getOrganizationList() + +Retrieves a list of organizations for an instance. + +The instance is determined by the API key you've provided when configuring the API. You can either set the `CLERK_API_KEY` environment variable, or provide the `apiKey` property explicitly when configuring the API client. + +Results can be paginated by providing an optional `limit` and `offset` pair of parameters. + +Results will be ordered by descending creation date. Most recent organizations will be first in the list. + +```ts +const organizations = await clerkAPI.organizations.getOrganizationList(); +``` + #### createOrganization(params) Creates a new organization with the given name and optional slug. @@ -217,6 +235,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`. diff --git a/packages/backend-core/CHANGELOG.md b/packages/backend-core/CHANGELOG.md index 51365b2ff61..12d56a01842 100644 --- a/packages/backend-core/CHANGELOG.md +++ b/packages/backend-core/CHANGELOG.md @@ -3,6 +3,35 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.9.0-staging.2](https://github.com/clerkinc/javascript/compare/@clerk/backend-core@1.8.0...@clerk/backend-core@1.9.0-staging.2) (2022-05-18) + +### Features + +- **backend-core:** Create organization invitation ([cb717c6](https://github.com/clerkinc/javascript/commit/cb717c649a7d747b9754426ae769b1f628c474ec)) +- **backend-core:** Get pending organization invitations ([d2ccb00](https://github.com/clerkinc/javascript/commit/d2ccb005a4cec30f05ff5de480edec93fa10400b)) +- **backend-core:** Retrieve instance organizations ([a24c4d3](https://github.com/clerkinc/javascript/commit/a24c4d3b1459d28cd7f950864d7347a8875d9c9c)) +- **backend-core:** Revoke an organization invitation ([988701a](https://github.com/clerkinc/javascript/commit/988701a7e7d739c3fd5e447a16c37cc58e3150f4)) +- **types:** Include new organization role `guest_member` ([ba7f27b](https://github.com/clerkinc/javascript/commit/ba7f27b42be283f9b7b4126cecc8d93ab9a6f04e)) + +## [1.9.0-staging.1](https://github.com/clerkinc/javascript/compare/@clerk/backend-core@1.8.0...@clerk/backend-core@1.9.0-staging.1) (2022-05-17) + +### Features + +- **backend-core:** Create organization invitation ([cb717c6](https://github.com/clerkinc/javascript/commit/cb717c649a7d747b9754426ae769b1f628c474ec)) +- **backend-core:** Get pending organization invitations ([d2ccb00](https://github.com/clerkinc/javascript/commit/d2ccb005a4cec30f05ff5de480edec93fa10400b)) +- **backend-core:** Retrieve instance organizations ([a24c4d3](https://github.com/clerkinc/javascript/commit/a24c4d3b1459d28cd7f950864d7347a8875d9c9c)) +- **backend-core:** Revoke an organization invitation ([988701a](https://github.com/clerkinc/javascript/commit/988701a7e7d739c3fd5e447a16c37cc58e3150f4)) +- **types:** Include new organization role `guest_member` ([ba7f27b](https://github.com/clerkinc/javascript/commit/ba7f27b42be283f9b7b4126cecc8d93ab9a6f04e)) + +## [1.9.0-staging.0](https://github.com/clerkinc/javascript/compare/@clerk/backend-core@1.8.0...@clerk/backend-core@1.9.0-staging.0) (2022-05-16) + +### Features + +- **backend-core:** Create organization invitation ([cb717c6](https://github.com/clerkinc/javascript/commit/cb717c649a7d747b9754426ae769b1f628c474ec)) +- **backend-core:** Get pending organization invitations ([d2ccb00](https://github.com/clerkinc/javascript/commit/d2ccb005a4cec30f05ff5de480edec93fa10400b)) +- **backend-core:** Retrieve instance organizations ([a24c4d3](https://github.com/clerkinc/javascript/commit/a24c4d3b1459d28cd7f950864d7347a8875d9c9c)) +- **backend-core:** Revoke an organization invitation ([988701a](https://github.com/clerkinc/javascript/commit/988701a7e7d739c3fd5e447a16c37cc58e3150f4)) + ## [1.8.0](https://github.com/clerkinc/javascript/compare/@clerk/backend-core@1.7.0...@clerk/backend-core@1.8.0) (2022-05-13) ### Features diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 77d9520ee74..349dc1f49e7 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/backend-core", - "version": "1.8.0", + "version": "1.9.0-staging.2", "license": "MIT", "description": "Clerk Backend API core resources and authentication utilities for JavaScript environments.", "scripts": { @@ -12,7 +12,7 @@ "main": "dist/cjs/index.js", "module": "dist/mjs/index.js", "dependencies": { - "@clerk/types": "^2.13.0", + "@clerk/types": "^2.14.0-staging.1", "camelcase-keys": "^7.0.1", "query-string": "^7.0.1", "snakecase-keys": "^5.1.2", diff --git a/packages/backend-core/src/__tests__/apis/OrganizationApi.test.ts b/packages/backend-core/src/__tests__/apis/OrganizationApi.test.ts index 9cb8fca3d58..62f10a24346 100644 --- a/packages/backend-core/src/__tests__/apis/OrganizationApi.test.ts +++ b/packages/backend-core/src/__tests__/apis/OrganizationApi.test.ts @@ -1,9 +1,37 @@ 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('getOrganizationList() retrieves a list of organizations', async () => { + const resJSON = { + data: [ + { + object: 'organization', + id: 'org_randomid', + name: 'Acme Inc', + slug: 'acme-inc', + public_metadata: {}, + private_metadata: {}, + created_at: 1611948436, + updated_at: 1611948436, + }, + ], + }; + nock('https://api.clerk.dev').get('/v1/organizations?').reply(200, resJSON); + + const organizationList = await TestBackendAPIClient.organizations.getOrganizationList(); + expect(organizationList).toBeInstanceOf(Array); + expect(organizationList.length).toEqual(1); + expect(organizationList[0]).toBeInstanceOf(Organization); +}); + test('createOrganization() creates an organization', async () => { const name = 'Acme Inc'; const slug = 'acme-inc'; @@ -289,3 +317,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, + }), + ); +}); diff --git a/packages/backend-core/src/__tests__/utils/Deserializer.test.ts b/packages/backend-core/src/__tests__/utils/Deserializer.test.ts index d16db1a774a..2348b30aa04 100644 --- a/packages/backend-core/src/__tests__/utils/Deserializer.test.ts +++ b/packages/backend-core/src/__tests__/utils/Deserializer.test.ts @@ -4,6 +4,7 @@ import { Email, Invitation, Organization, + OrganizationInvitation, OrganizationMembership, Session, SMSMessage, @@ -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', @@ -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); diff --git a/packages/backend-core/src/api/collection/OrganizationApi.ts b/packages/backend-core/src/api/collection/OrganizationApi.ts index 2c4d9c733c3..2de456448e7 100644 --- a/packages/backend-core/src/api/collection/OrganizationApi.ts +++ b/packages/backend-core/src/api/collection/OrganizationApi.ts @@ -1,4 +1,4 @@ -import { Organization, OrganizationMembership } from '../resources'; +import { Organization, OrganizationInvitation, OrganizationMembership } from '../resources'; import { OrganizationMembershipRole } from '../resources/Enums'; import { AbstractApi } from './AbstractApi'; @@ -9,6 +9,11 @@ type OrganizationMetadataParams = { privateMetadata?: Record; }; +type GetOrganizationListParams = { + limit?: number; + offset?: number; +}; + type CreateParams = { name: string; slug?: string; @@ -45,7 +50,35 @@ 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 getOrganizationList(params?: GetOrganizationListParams) { + return this._restClient.makeRequest({ + method: 'GET', + path: basePath, + queryParams: params, + }); + } + public async createOrganization(params: CreateParams) { const { publicMetadata, privateMetadata } = params; return this._restClient.makeRequest({ @@ -134,6 +167,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({ + 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({ + method: 'POST', + path: `${basePath}/${organizationId}/invitations`, + bodyParams, + }); + } + + public async revokeOrganizationInvitation(params: RevokeOrganizationInvitationParams) { + const { organizationId, invitationId, requestingUserId } = params; + this.requireId(organizationId); + + return this._restClient.makeRequest({ + method: 'POST', + path: `${basePath}/${organizationId}/invitations/${invitationId}/revoke`, + bodyParams: { + requestingUserId, + }, + }); + } } function stringifyMetadataParams( diff --git a/packages/backend-core/src/api/resources/Enums.ts b/packages/backend-core/src/api/resources/Enums.ts index a5412e60dee..c3db6de1c5b 100644 --- a/packages/backend-core/src/api/resources/Enums.ts +++ b/packages/backend-core/src/api/resources/Enums.ts @@ -22,7 +22,9 @@ export type OAuthProvider = export type OAuthStrategy = `oauth_${OAuthProvider}`; -export type OrganizationMembershipRole = 'basic_member' | 'admin'; +export type OrganizationInvitationStatus = 'pending' | 'accepted' | 'revoked'; + +export type OrganizationMembershipRole = 'basic_member' | 'guest_member' | 'admin'; export type SignInIdentifier = 'username' | 'email_address' | 'phone_number' | 'web3_wallet' | OAuthStrategy; diff --git a/packages/backend-core/src/api/resources/JSON.ts b/packages/backend-core/src/api/resources/JSON.ts index 152c23bbadf..2f4755a34d2 100644 --- a/packages/backend-core/src/api/resources/JSON.ts +++ b/packages/backend-core/src/api/resources/JSON.ts @@ -1,4 +1,5 @@ import { + OrganizationInvitationStatus, OrganizationMembershipRole, SignInFactorStrategy, SignInIdentifier, @@ -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', @@ -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; diff --git a/packages/backend-core/src/api/resources/OrganizationInvitation.ts b/packages/backend-core/src/api/resources/OrganizationInvitation.ts new file mode 100644 index 00000000000..ed71f3a53cd --- /dev/null +++ b/packages/backend-core/src/api/resources/OrganizationInvitation.ts @@ -0,0 +1,32 @@ +import camelcaseKeys from 'camelcase-keys'; + +import filterKeys from '../utils/Filter'; +import type { OrganizationInvitationJSON } from './JSON'; +import type { OrganizationInvitationProps } from './Props'; + +export interface OrganizationInvitation extends OrganizationInvitationProps {} + +export class OrganizationInvitation { + static attributes = [ + 'id', + 'emailAddress', + 'organizationId', + 'role', + 'redirectUrl', + 'status', + 'createdAt', + 'updatedAt', + ]; + + static defaults = []; + + constructor(data: OrganizationInvitationProps) { + Object.assign(this, OrganizationInvitation.defaults, data); + } + + static fromJSON(data: OrganizationInvitationJSON): OrganizationInvitation { + const camelcased = camelcaseKeys(data); + const filtered = filterKeys(camelcased, OrganizationInvitation.attributes); + return new OrganizationInvitation(filtered as OrganizationInvitationProps); + } +} diff --git a/packages/backend-core/src/api/resources/Props.ts b/packages/backend-core/src/api/resources/Props.ts index efac21e44ff..d6d5d17856b 100644 --- a/packages/backend-core/src/api/resources/Props.ts +++ b/packages/backend-core/src/api/resources/Props.ts @@ -1,5 +1,6 @@ import { Nullable } from '../../util/nullable'; import { + OrganizationInvitationStatus, OrganizationMembershipRole, SignInFactorStrategy, SignInIdentifier, @@ -83,6 +84,16 @@ export interface OrganizationProps extends ClerkProps { updatedAt: number; } +export interface OrganizationInvitationProps extends ClerkProps { + emailAddress: string; + organizationId: string; + status: OrganizationInvitationStatus; + role: OrganizationMembershipRole; + redirectUrl: Nullable; + createdAt: number; + updatedAt: number; +} + export interface OrganizationMembershipProps extends ClerkProps { organization: OrganizationProps; publicUserData: OrganizationMembershipPublicUserDataProps; diff --git a/packages/backend-core/src/api/resources/index.ts b/packages/backend-core/src/api/resources/index.ts index c4c6704d687..7d783f8f818 100644 --- a/packages/backend-core/src/api/resources/index.ts +++ b/packages/backend-core/src/api/resources/index.ts @@ -7,6 +7,7 @@ export * from './ExternalAccount'; export * from './IdentificationLink'; export * from './Invitation'; export * from './Organization'; +export * from './OrganizationInvitation'; export * from './OrganizationMembership'; export * from './JSON'; export * from './PhoneNumber'; diff --git a/packages/backend-core/src/api/utils/Deserializer.ts b/packages/backend-core/src/api/utils/Deserializer.ts index 4426a86373c..f4904d38266 100644 --- a/packages/backend-core/src/api/utils/Deserializer.ts +++ b/packages/backend-core/src/api/utils/Deserializer.ts @@ -4,6 +4,7 @@ import { Email, Invitation, Organization, + OrganizationInvitation, OrganizationMembership, Session, SMSMessage, @@ -49,6 +50,8 @@ function jsonToObject(item: any): any { return Invitation.fromJSON(item); case ObjectType.Organization: return Organization.fromJSON(item); + case ObjectType.OrganizationInvitation: + return OrganizationInvitation.fromJSON(item); case ObjectType.OrganizationMembership: return OrganizationMembership.fromJSON(item); case ObjectType.User: diff --git a/packages/backend-core/src/types/jwt.ts b/packages/backend-core/src/types/jwt.ts index 98f76860dd8..d9e648e1d52 100644 --- a/packages/backend-core/src/types/jwt.ts +++ b/packages/backend-core/src/types/jwt.ts @@ -46,7 +46,7 @@ export interface JWTPayload { [propName: string]: unknown; } -type MembershipRole = 'admin' | 'basic_member'; +type MembershipRole = 'admin' | 'basic_member' | 'guest_member'; export interface JWT { header: JWTHeader; diff --git a/packages/clerk-js/CHANGELOG.md b/packages/clerk-js/CHANGELOG.md index c78ba25877a..08d22767b0d 100644 --- a/packages/clerk-js/CHANGELOG.md +++ b/packages/clerk-js/CHANGELOG.md @@ -3,6 +3,39 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [3.12.0-staging.2](https://github.com/clerkinc/javascript/compare/@clerk/clerk-js@3.11.0...@clerk/clerk-js@3.12.0-staging.2) (2022-05-18) + +### Features + +- **clerk-js:** Make sign up flow resumable ([bfe0d8c](https://github.com/clerkinc/javascript/commit/bfe0d8cb917d9be3441b7f8a9473e905310fe6e4)) +- **clerk-js:** Make sign up flow resumable for Web3 providers ([cf49066](https://github.com/clerkinc/javascript/commit/cf4906692cb1ae4eb41ac44a6d2dae64aba97fea)) +- **clerk-js:** Replace Error & Info component with a single Alert component ([8c34d21](https://github.com/clerkinc/javascript/commit/8c34d2176fcc9eca346b6be91ff5dd1987929c28)) +- **clerk-js:** Session touch should include the active organization ([664030c](https://github.com/clerkinc/javascript/commit/664030c3f4aedbd5e886d8ada906b64ac2ed06b5)) +- **types,clerk-js:** Enhance Web3 wallet resource with relevant operations ([a166716](https://github.com/clerkinc/javascript/commit/a166716db44db8e765e67c154093c9d3c3f24c75)) + +### Bug Fixes + +- **clerk-js:** Navigate to sign up continue in web3 ([460ba1c](https://github.com/clerkinc/javascript/commit/460ba1cc82bbad6197224ca71ad39302564408b4)) + +## [3.12.0-staging.1](https://github.com/clerkinc/javascript/compare/@clerk/clerk-js@3.11.0...@clerk/clerk-js@3.12.0-staging.1) (2022-05-17) + +### Features + +- **clerk-js:** Make sign up flow resumable ([bfe0d8c](https://github.com/clerkinc/javascript/commit/bfe0d8cb917d9be3441b7f8a9473e905310fe6e4)) +- **clerk-js:** Make sign up flow resumable for Web3 providers ([cf49066](https://github.com/clerkinc/javascript/commit/cf4906692cb1ae4eb41ac44a6d2dae64aba97fea)) +- **clerk-js:** Replace Error & Info component with a single Alert component ([8c34d21](https://github.com/clerkinc/javascript/commit/8c34d2176fcc9eca346b6be91ff5dd1987929c28)) +- **clerk-js:** Session touch should include the active organization ([664030c](https://github.com/clerkinc/javascript/commit/664030c3f4aedbd5e886d8ada906b64ac2ed06b5)) + +### Bug Fixes + +- **clerk-js:** Navigate to sign up continue in web3 ([460ba1c](https://github.com/clerkinc/javascript/commit/460ba1cc82bbad6197224ca71ad39302564408b4)) + +## [3.12.0-staging.0](https://github.com/clerkinc/javascript/compare/@clerk/clerk-js@3.11.0...@clerk/clerk-js@3.12.0-staging.0) (2022-05-16) + +### Features + +- **clerk-js:** Session touch should include the active organization ([664030c](https://github.com/clerkinc/javascript/commit/664030c3f4aedbd5e886d8ada906b64ac2ed06b5)) + ## [3.11.0](https://github.com/clerkinc/javascript/compare/@clerk/clerk-js@3.10.2...@clerk/clerk-js@3.11.0) (2022-05-13) ### Features diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 9ae6b64668c..50ae12055c2 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/clerk-js", - "version": "3.11.0", + "version": "3.12.0-staging.2", "license": "MIT", "description": "Clerk.dev JS library", "keywords": [ @@ -31,14 +31,14 @@ "build:declarations": "tsc -p tsconfig.declarations.json", "check-types": "tsc", "dev": "webpack-dev-server --config webpack.dev.js", - "prepublishOnly": "npm run test && npm run build", + "prepublishOnly": "npm run build", "postpublish": "node ./scripts/purge-cache.mjs", "start": "echo \"Noop\"", "test": "jest", "test:coverage": "jest --collectCoverage" }, "dependencies": { - "@clerk/types": "^2.13.0", + "@clerk/types": "^2.14.0-staging.1", "@popperjs/core": "^2.4.4", "browser-tabs-lock": "^1.2.15", "classnames": "^2.3.1", @@ -58,7 +58,7 @@ "@babel/preset-env": "^7.12.1", "@babel/preset-react": "^7.12.5", "@babel/preset-typescript": "^7.12.1", - "@clerk/shared": "^0.2.6", + "@clerk/shared": "^0.2.7-staging.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.2", "@svgr/webpack": "^6.2.1", "@testing-library/dom": "^7.28.1", diff --git a/packages/clerk-js/src/core/clerk.test.ts b/packages/clerk-js/src/core/clerk.test.ts index 27332a0fe69..a63012585ba 100644 --- a/packages/clerk-js/src/core/clerk.test.ts +++ b/packages/clerk-js/src/core/clerk.test.ts @@ -33,13 +33,39 @@ describe('Clerk singleton', () => { let mockNavigate = jest.fn(); const mockDisplayConfig = { - signInUrl: 'signInUrl', - signUpUrl: 'signUpUrl', - userProfileUrl: 'userProfileUrl', + signInUrl: 'http://test.host/sign-in', + signUpUrl: 'http://test.host/sign-up', + userProfileUrl: 'http://test.host/user-profile', } as DisplayConfig; + let mockWindowLocation; + let mockHref: jest.Mock; + + beforeEach(() => { + mockHref = jest.fn(); + mockWindowLocation = { + host: 'test.host', + hostname: 'test.host', + origin: 'http://test.host', + get href() { + return 'http://test.host'; + }, + set href(v: string) { + mockHref(v); + }, + } as any; + + Object.defineProperty(global.window, 'location', { + value: mockWindowLocation, + }); + + // sut = new Clerk(frontendApi); + }); + afterAll(() => { - global.window.location = location; + Object.defineProperty(global.window, 'location', { + value: oldWindowLocation, + }); }); beforeEach(() => { @@ -87,17 +113,17 @@ describe('Clerk singleton', () => { it('redirects to signInUrl', () => { sut.redirectToSignIn({ redirectUrl: 'https://www.example.com/' }); - expect(mockNavigate).toHaveBeenCalledWith('/signInUrl#/?redirect_url=https%3A%2F%2Fwww.example.com%2F'); + expect(mockNavigate).toHaveBeenCalledWith('/sign-in#/?redirect_url=https%3A%2F%2Fwww.example.com%2F'); }); it('redirects to signUpUrl', () => { sut.redirectToSignUp({ redirectUrl: 'https://www.example.com/' }); - expect(mockNavigate).toHaveBeenCalledWith('/signUpUrl#/?redirect_url=https%3A%2F%2Fwww.example.com%2F'); + expect(mockNavigate).toHaveBeenCalledWith('/sign-up#/?redirect_url=https%3A%2F%2Fwww.example.com%2F'); }); it('redirects to userProfileUrl', () => { sut.redirectToUserProfile(); - expect(mockNavigate).toHaveBeenCalledWith('/userProfileUrl'); + expect(mockNavigate).toHaveBeenCalledWith('/user-profile'); }); }); @@ -237,34 +263,15 @@ describe('Clerk singleton', () => { }); describe('.navigate(to)', () => { - let mockWindowLocation; - let mockHref: jest.Mock; let sut: Clerk; beforeEach(() => { - mockHref = jest.fn(); - mockWindowLocation = { - host: 'www.origin1.com', - hostname: 'www.origin1.com', - origin: 'https://www.origin1.com', - get href() { - return 'https://www.origin1.com'; - }, - set href(v: string) { - mockHref(v); - }, - } as any; - - Object.defineProperty(global.window, 'location', { - value: mockWindowLocation, - }); - sut = new Clerk(frontendApi); }); it('uses window location if a custom navigate is not defined', async () => { await sut.load(); - const toUrl = 'https://www.origin1.com/'; + const toUrl = 'http://test.host/'; await sut.navigate(toUrl); expect(mockHref).toHaveBeenCalledWith(toUrl); }); @@ -278,7 +285,7 @@ describe('Clerk singleton', () => { it('wraps custom navigate method in a promise if provided and it sync', async () => { await sut.load({ navigate: mockNavigate }); - const toUrl = 'https://www.origin1.com/path#hash'; + const toUrl = 'http://test.host/path#hash'; const res = sut.navigate(toUrl); expect(res.then).toBeDefined(); expect(mockHref).not.toHaveBeenCalled(); @@ -351,6 +358,61 @@ describe('Clerk singleton', () => { }); }); + it('creates a new sign up and navigates to the continue sign-up path if the user was not found during sso signup and there are missing requirements', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + onWindowLocationHost: () => false, + }), + ); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + activeSessions: [], + signIn: new SignIn({ + status: 'needs_identifier', + first_factor_verification: { + status: 'transferable', + strategy: 'oauth_google', + external_verification_redirect_url: '', + error: { + code: 'external_account_not_found', + long_message: 'The External Account was not found.', + message: 'Invalid external account', + }, + }, + second_factor_verification: null, + identifier: '', + user_data: null, + created_session_id: null, + created_user_id: null, + } as any as SignInJSON), + signUp: new SignUp(null), + }), + ); + + const mockSignUpCreate = jest.fn().mockReturnValue(Promise.resolve({ status: 'missing_requirements' })); + + const sut = new Clerk(frontendApi); + await sut.load({ + navigate: mockNavigate, + }); + if (!sut.client) { + fail('we should always have a client'); + } + sut.client.signUp.create = mockSignUpCreate; + + sut.handleRedirectCallback(); + + await waitFor(() => { + expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true }); + expect(mockNavigate).toHaveBeenCalledWith('/sign-up#/continue'); + }); + }); + it('signs the user in if the user was found during sign up', async () => { mockEnvironmentFetch.mockReturnValue( Promise.resolve({ @@ -559,7 +621,7 @@ describe('Clerk singleton', () => { sut.handleRedirectCallback(); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/' + mockDisplayConfig.signInUrl + '#/factor-two'); + expect(mockNavigate).toHaveBeenCalledWith('/sign-in#/factor-two'); }); }); @@ -761,13 +823,54 @@ describe('Clerk singleton', () => { sut.handleRedirectCallback(); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/signUpUrl'); + expect(mockNavigate).toHaveBeenCalledWith('/sign-up'); + }); + }); + + it('redirects user to the continue sign-up url if the external account was verified but there are still missing requirements', async () => { + mockEnvironmentFetch.mockReturnValue( + Promise.resolve({ + authConfig: {}, + displayConfig: mockDisplayConfig, + isSingleSession: () => false, + isProduction: () => false, + onWindowLocationHost: () => false, + }), + ); + + mockClientFetch.mockReturnValue( + Promise.resolve({ + activeSessions: [], + signIn: new SignIn(null), + signUp: new SignUp({ + status: 'missing_requirements', + verifications: { + external_account: { + status: 'verified', + strategy: 'oauth_google', + external_verification_redirect_url: '', + error: null, + }, + }, + } as any as SignUpJSON), + }), + ); + + const sut = new Clerk(frontendApi); + await sut.load({ + navigate: mockNavigate, + }); + + sut.handleRedirectCallback(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/sign-up#/continue'); }); }); }); describe('.handleMagicLinkVerification()', () => { - beforeEach(async () => { + beforeEach(() => { mockClientFetch.mockReset(); mockEnvironmentFetch.mockReset(); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 1557485e95c..a7c9b31400a 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -44,6 +44,7 @@ import { validateFrontendApi, windowNavigate, } from 'utils'; +import { buildURL } from 'utils'; import { getClerkQueryParam } from 'utils/getClerkQueryParam'; import { memoizeListenerCallback } from 'utils/memoizeStateListenerCallback'; @@ -446,7 +447,10 @@ export default class Clerk implements ClerkInterface { const navigateToSignUp = makeNavigate(displayConfig.signUpUrl); - const navigateToFactorTwo = makeNavigate(params.secondFactorUrl || displayConfig.signInUrl + '#/factor-two'); + const navigateToFactorTwo = makeNavigate( + params.secondFactorUrl || + buildURL({ base: displayConfig.signInUrl, hashPath: '/factor-two' }, { stringify: true }), + ); const navigateAfterSignIn = makeNavigate( params.afterSignInUrl || params.redirectUrl || displayConfig.afterSignInUrl, @@ -456,6 +460,10 @@ export default class Clerk implements ClerkInterface { params.afterSignUpUrl || params.redirectUrl || displayConfig.afterSignUpUrl, ); + const navigateToContinueSignUp = makeNavigate( + buildURL({ base: displayConfig.signUpUrl, hashPath: '/continue' }, { stringify: true }), + ); + const userExistsButNeedsToSignIn = su.externalAccountStatus === 'transferable' && su.externalAccountErrorCode === 'external_account_exists'; @@ -478,6 +486,8 @@ export default class Clerk implements ClerkInterface { switch (res.status) { case 'complete': return this.setSession(res.createdSessionId, navigateAfterSignUp); + case 'missing_requirements': + return navigateToContinueSignUp(); default: clerkOAuthCallbackDidNotCompleteSignInSIgnUp('sign in'); } @@ -505,6 +515,10 @@ export default class Clerk implements ClerkInterface { } } + if (su.externalAccountStatus === 'verified' && su.status == 'missing_requirements') { + return navigateToContinueSignUp(); + } + if (hasExternalAccountSignUpError(signUp)) { return navigateToSignUp(); } @@ -527,17 +541,32 @@ export default class Clerk implements ClerkInterface { return this.setSession(null); }; - public authenticateWithMetamask = async ({ redirectUrl }: AuthenticateWithMetamaskParams = {}): Promise => { - if (!this.client) { + public authenticateWithMetamask = async ({ + redirectUrl, + signUpContinueUrl, + customNavigate, + }: AuthenticateWithMetamaskParams = {}): Promise => { + if (!this.client || !this.#environment) { return; } + const navigate = (to: string) => + customNavigate && typeof customNavigate === 'function' ? customNavigate(to) : this.navigate(to); + let signInOrSignUp: SignInResource | SignUpResource; try { signInOrSignUp = await this.client.signIn.authenticateWithMetamask(); } catch (err) { if (isError(err, ERROR_CODES.FORM_IDENTIFIER_NOT_FOUND)) { signInOrSignUp = await this.client.signUp.authenticateWithMetamask(); + + if ( + signUpContinueUrl && + signInOrSignUp.status === 'missing_requirements' && + signInOrSignUp.verifications.web3Wallet.status === 'verified' + ) { + await navigate(signUpContinueUrl); + } } else { throw err; } @@ -546,7 +575,7 @@ export default class Clerk implements ClerkInterface { if (signInOrSignUp.createdSessionId) { await this.setSession(signInOrSignUp.createdSessionId, () => { if (redirectUrl) { - return this.navigate(redirectUrl); + return navigate(redirectUrl); } return Promise.resolve(); }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 99bcd8b2b7e..b40413d39de 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -49,6 +49,7 @@ export class Session extends BaseResource implements SessionResource { touch = (): Promise => { return this._basePost({ action: 'touch', + body: { active_organization_id: this.lastActiveOrganizationId }, }); }; diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index f27a320148e..d0742d389fe 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -138,7 +138,12 @@ export class SignUp extends BaseResource implements SignUpResource { }; attemptWeb3WalletVerification = async (params: AttemptWeb3WalletVerificationParams): Promise => { - const { generateSignature } = params || {}; + const { signature, generateSignature } = params || {}; + + if (signature) { + return this.attemptVerification({ signature, strategy: 'web3_metamask_signature' }); + } + if (!(typeof generateSignature === 'function')) { clerkMissingOptionError('generateSignature'); } @@ -148,8 +153,8 @@ export class SignUp extends BaseResource implements SignUpResource { clerkVerifyWeb3WalletCalledBeforeCreate('SignUp'); } - const signature = await generateSignature({ identifier: this.web3wallet!, nonce }); - return this.attemptVerification({ signature, strategy: 'web3_metamask_signature' }); + const generatedSignature = await generateSignature({ identifier: this.web3wallet!, nonce }); + return this.attemptVerification({ signature: generatedSignature, strategy: 'web3_metamask_signature' }); }; public authenticateWithWeb3 = async (params: AuthenticateWithWeb3Params): Promise => { @@ -157,7 +162,14 @@ export class SignUp extends BaseResource implements SignUpResource { const web3Wallet = identifier || this.web3wallet!; await this.create({ web3Wallet }); await this.prepareWeb3WalletVerification(); - return this.attemptWeb3WalletVerification({ generateSignature }); + + const { nonce } = this.verifications.web3Wallet; + if (!nonce) { + clerkVerifyWeb3WalletCalledBeforeCreate('SignUp'); + } + + const signature = await generateSignature({ identifier, nonce }); + return this.attemptWeb3WalletVerification({ signature }); }; public authenticateWithMetamask = async (): Promise => { diff --git a/packages/clerk-js/src/core/resources/User.test.ts b/packages/clerk-js/src/core/resources/User.test.ts index dfa20ce6fe9..34643b9fc76 100644 --- a/packages/clerk-js/src/core/resources/User.test.ts +++ b/packages/clerk-js/src/core/resources/User.test.ts @@ -1,10 +1,9 @@ -import { ExternalAccountJSON, UserJSON, VerificationJSON } from '@clerk/types'; +import { ExternalAccountJSON, UserJSON, VerificationJSON, Web3WalletJSON } from '@clerk/types'; import { BaseResource, ExternalAccount } from 'core/resources/internal'; import { User } from './User'; describe('User', () => { - it('creates an external account', async () => { const externalAccountJSON = { object: 'external_account', @@ -15,9 +14,7 @@ describe('User', () => { }; // @ts-ignore - BaseResource._fetch = jest.fn().mockReturnValue( - Promise.resolve({ response: externalAccountJSON }), - ); + BaseResource._fetch = jest.fn().mockReturnValue(Promise.resolve({ response: externalAccountJSON })); const user = new User({ email_addresses: [], @@ -32,12 +29,39 @@ describe('User', () => { expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'POST', path: '/me/external_accounts', - body: - { - redirect_url: 'https://www.example.com', - strategy: 'oauth_dropbox', - } + body: { + redirect_url: 'https://www.example.com', + strategy: 'oauth_dropbox', + }, }); }); + it('creates a web3 wallet', async () => { + const targetWeb3Wallet = '0x0000000000000000000000000000000000000000'; + const web3WalletJSON = { + object: 'web3_wallet', + web3_wallet: targetWeb3Wallet, + }; + + // @ts-ignore + BaseResource._fetch = jest.fn().mockReturnValue(Promise.resolve({ response: web3WalletJSON })); + + const user = new User({ + email_addresses: [], + phone_numbers: [], + web3_wallets: [], + external_accounts: [], + } as unknown as UserJSON); + + await user.createWeb3Wallet({ web3Wallet: targetWeb3Wallet }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/me/web3_wallets/', + body: { + web3_wallet: targetWeb3Wallet, + }, + }); + }); }); diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index 3e677d09a8d..bd2be9172ba 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -1,6 +1,7 @@ import type { CreateEmailAddressParams, CreatePhoneNumberParams, + CreateWeb3WalletParams, EmailAddressResource, ExternalAccountJSON, ExternalAccountResource, @@ -117,6 +118,16 @@ export class User extends BaseResource implements UserResource { ).create(); }; + createWeb3Wallet = (params: CreateWeb3WalletParams): Promise => { + const { web3Wallet } = params || {}; + return new Web3Wallet( + { + web3_wallet: web3Wallet, + }, + this.path() + '/web3_wallets/', + ).create(); + }; + createExternalAccount = async ({ strategy, redirect_url, diff --git a/packages/clerk-js/src/core/resources/Web3Wallet.test.ts b/packages/clerk-js/src/core/resources/Web3Wallet.test.ts new file mode 100644 index 00000000000..cb4fc08d0d8 --- /dev/null +++ b/packages/clerk-js/src/core/resources/Web3Wallet.test.ts @@ -0,0 +1,94 @@ +import { Web3WalletJSON } from '@clerk/types'; +import { BaseResource, Web3Wallet } from 'core/resources/internal'; + +describe('Web3 wallet', () => { + it('create', async () => { + const web3WalletJSON = { + object: 'web3_wallet', + web3_wallet: '0x0000000000000000000000000000000000000000', + } as Web3WalletJSON; + + // @ts-ignore + BaseResource._fetch = jest.fn().mockReturnValue(Promise.resolve({ response: web3WalletJSON })); + + const web3Wallet = new Web3Wallet(web3WalletJSON, '/me/web3_wallets'); + await web3Wallet.create(); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/me/web3_wallets', + body: { + web3_wallet: web3WalletJSON.web3_wallet, + }, + }); + }); + + it('prepareVerification', async () => { + const web3WalletJSON = { + id: 'test-id', + object: 'web3_wallet', + web3_wallet: '0x0000000000000000000000000000000000000000', + } as Web3WalletJSON; + + // @ts-ignore + BaseResource._fetch = jest.fn().mockReturnValue(Promise.resolve({ response: web3WalletJSON })); + + const web3Wallet = new Web3Wallet(web3WalletJSON, '/me/web3_wallets'); + await web3Wallet.prepareVerification({ strategy: 'web3_metamask_signature' }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: `/me/web3_wallets/${web3WalletJSON.id}/prepare_verification`, + body: { + strategy: 'web3_metamask_signature', + }, + }); + }); + + it('attemptVerification', async () => { + const web3WalletJSON = { + id: 'test-id', + object: 'web3_wallet', + web3_wallet: '0x0000000000000000000000000000000000000000', + } as Web3WalletJSON; + + // @ts-ignore + BaseResource._fetch = jest.fn().mockReturnValue(Promise.resolve({ response: web3WalletJSON })); + + const web3Wallet = new Web3Wallet(web3WalletJSON, '/me/web3_wallets'); + await web3Wallet.attemptVerification({ signature: 'mock-signature' }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: `/me/web3_wallets/${web3WalletJSON.id}/attempt_verification`, + body: { + signature: 'mock-signature', + }, + }); + }); + + it('destroy', async () => { + const targetId = 'test_id'; + + const deletedObjectJSON = { + object: 'web3_wallet', + id: targetId, + deleted: true, + }; + + // @ts-ignore + BaseResource._fetch = jest.fn().mockReturnValue(Promise.resolve({ response: deletedObjectJSON })); + + const web3Wallet = new Web3Wallet({ id: targetId }, '/me/web3_wallets'); + await web3Wallet.destroy(); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'DELETE', + path: `/me/web3_wallets/${targetId}`, + }); + }); +}); diff --git a/packages/clerk-js/src/core/resources/Web3Wallet.ts b/packages/clerk-js/src/core/resources/Web3Wallet.ts index 1afa94a1d56..8da3ec096a2 100644 --- a/packages/clerk-js/src/core/resources/Web3Wallet.ts +++ b/packages/clerk-js/src/core/resources/Web3Wallet.ts @@ -1,4 +1,11 @@ -import type { VerificationResource, Web3WalletJSON, Web3WalletResource } from '@clerk/types'; +import type { + AttemptWeb3WalletVerificationParams, + PrepareWeb3WalletVerificationParams, + VerificationResource, + Web3WalletJSON, + Web3WalletResource, +} from '@clerk/types'; +import { clerkMissingOptionError, clerkVerifyWeb3WalletCalledBeforeCreate } from 'core/errors'; import { BaseResource, Verification } from './internal'; @@ -7,12 +14,64 @@ export class Web3Wallet extends BaseResource implements Web3WalletResource { web3Wallet = ''; verification!: VerificationResource; + public constructor(data: Partial, pathRoot: string); public constructor(data: Web3WalletJSON, pathRoot: string) { super(); this.pathRoot = pathRoot; this.fromJSON(data); } + create(): Promise { + return this._basePost({ + body: { web3_wallet: this.web3Wallet }, + }); + } + + prepareVerification = (params: PrepareWeb3WalletVerificationParams): Promise => { + return this._basePost({ + action: 'prepare_verification', + body: { ...params }, + }); + }; + + attemptVerification = (params: AttemptWeb3WalletVerificationParams): Promise => { + const { signature, generateSignature } = params || {}; + + if (signature) { + return this._basePost({ + action: 'attempt_verification', + body: { signature }, + }); + } + + if (!(typeof generateSignature === 'function')) { + clerkMissingOptionError('generateSignature'); + } + + const generateSignatureForNonce = async (): Promise => { + if (!(typeof generateSignature === 'function')) { + clerkMissingOptionError('generateSignature'); + } + + const { nonce } = this.verification; + if (!nonce) { + clerkVerifyWeb3WalletCalledBeforeCreate('SignUp'); + } + + const generatedSignature = await generateSignature({ identifier: this.web3Wallet, nonce }); + return this._basePost({ + action: 'attempt_verification', + body: { signature: generatedSignature }, + }); + }; + + return generateSignatureForNonce(); + }; + + destroy(): Promise { + return this._baseDelete(); + } + toString(): string { return this.web3Wallet; } diff --git a/packages/clerk-js/src/ui/common/alert/Alert.test.tsx b/packages/clerk-js/src/ui/common/alert/Alert.test.tsx new file mode 100644 index 00000000000..1304a73cf7c --- /dev/null +++ b/packages/clerk-js/src/ui/common/alert/Alert.test.tsx @@ -0,0 +1,18 @@ +import { renderJSON } from '@clerk/shared/testUtils'; +import * as React from 'react'; + +import { Alert } from './Alert'; + +describe('alert for info', () => { + it('renders an info alert', () => { + const tree = renderJSON(Foo); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('alert for error', () => { + it('renders an error alert', () => { + const tree = renderJSON(Bar); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/packages/clerk-js/src/ui/common/alert/Alert.tsx b/packages/clerk-js/src/ui/common/alert/Alert.tsx new file mode 100644 index 00000000000..0cd545a7d42 --- /dev/null +++ b/packages/clerk-js/src/ui/common/alert/Alert.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +export type AlertType = 'info' | 'error'; + +export type AlertProps = { + type: AlertType; + children: React.ReactNode; + style?: {}; +} & React.HTMLAttributes; + +// Renders global alerts across components, will be replaced by notification snackbars. +export function Alert({ type, children, style }: AlertProps): JSX.Element { + if (!children) { + return <>; + } + + return ( +
+ {children} +
+ ); +} diff --git a/packages/clerk-js/src/ui/common/alert/__snapshots__/Alert.test.tsx.snap b/packages/clerk-js/src/ui/common/alert/__snapshots__/Alert.test.tsx.snap new file mode 100644 index 00000000000..1a90091c24b --- /dev/null +++ b/packages/clerk-js/src/ui/common/alert/__snapshots__/Alert.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`alert for error renders an error alert 1`] = ` +
+ Bar +
+`; + +exports[`alert for info renders an info alert 1`] = ` +
+ Foo +
+`; diff --git a/packages/clerk-js/src/ui/common/alert/index.ts b/packages/clerk-js/src/ui/common/alert/index.ts new file mode 100644 index 00000000000..79e3b155f44 --- /dev/null +++ b/packages/clerk-js/src/ui/common/alert/index.ts @@ -0,0 +1 @@ +export * from './Alert'; diff --git a/packages/clerk-js/src/ui/common/authForms/Header.test.tsx b/packages/clerk-js/src/ui/common/authForms/Header.test.tsx index 54a3ee37643..e549c5610cf 100644 --- a/packages/clerk-js/src/ui/common/authForms/Header.test.tsx +++ b/packages/clerk-js/src/ui/common/authForms/Header.test.tsx @@ -1,5 +1,6 @@ import { renderJSON } from '@clerk/shared/testUtils'; import * as React from 'react'; +import { Alert } from 'ui/common/alert'; import { Header } from './Header'; @@ -20,14 +21,22 @@ jest.mock('ui/contexts/EnvironmentContext', () => { jest.mock('ui/router/RouteContext'); describe('
', () => { - it('renders the header component', () => { + it('renders a header component with everything enabled', () => { const tree = renderJSON(
boom} showBack + showLogo welcomeName='Joe' />, ); + + expect(tree).toMatchSnapshot(); + }); + + it('renders a plain header', () => { + const tree = renderJSON(
); + expect(tree).toMatchSnapshot(); }); }); diff --git a/packages/clerk-js/src/ui/common/authForms/Header.tsx b/packages/clerk-js/src/ui/common/authForms/Header.tsx index 7ea29e9d976..6d2c0e0cf88 100644 --- a/packages/clerk-js/src/ui/common/authForms/Header.tsx +++ b/packages/clerk-js/src/ui/common/authForms/Header.tsx @@ -3,25 +3,24 @@ import cn from 'classnames'; import React from 'react'; import { Logo } from 'ui/common'; import { BackButton } from 'ui/common/backButton'; -import { Error } from 'ui/common/error'; export type HeaderProps = { showBack?: boolean; showLogo?: boolean; handleBack?: () => void; welcomeName?: string; - error?: string; + alert?: React.ReactNode; } & React.HTMLAttributes; export function Header({ showBack, showLogo = true, welcomeName, - error, + alert, handleBack, className, }: HeaderProps): JSX.Element { - const errorStyle = showBack ? {} : { marginTop: '-4em' }; + const alertStyle = showBack ? {} : { marginTop: '-4em' }; const containerStyle = showLogo || welcomeName ? {} : { marginBottom: '0' }; return ( @@ -36,12 +35,15 @@ export function Header({ handleClick={handleBack} /> )} - {error && {error}} + + {alert &&
{alert}
} + {showLogo && (
)} + {welcomeName && (
diff --git a/packages/clerk-js/src/ui/common/authForms/__snapshots__/Header.test.tsx.snap b/packages/clerk-js/src/ui/common/authForms/__snapshots__/Header.test.tsx.snap index 6aee660de1a..acc69869de6 100644 --- a/packages/clerk-js/src/ui/common/authForms/__snapshots__/Header.test.tsx.snap +++ b/packages/clerk-js/src/ui/common/authForms/__snapshots__/Header.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`
renders the header component 1`] = ` +exports[`
renders a header component with everything enabled 1`] = `
renders the header component 1`] = ` />
- Boom +
+ boom +
renders the header component 1`] = `
`; + +exports[`
renders a plain header 1`] = ` +
+
+
+
+
+`; diff --git a/packages/clerk-js/src/ui/common/error/Error.test.tsx b/packages/clerk-js/src/ui/common/error/Error.test.tsx deleted file mode 100644 index 9b92cf2fda4..00000000000 --- a/packages/clerk-js/src/ui/common/error/Error.test.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { renderJSON } from '@clerk/shared/testUtils'; -import * as React from 'react'; - -import { Error } from './Error'; - -describe('', () => { - it('renders the error component', () => { - const tree = renderJSON(Foo); - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/packages/clerk-js/src/ui/common/error/Error.tsx b/packages/clerk-js/src/ui/common/error/Error.tsx deleted file mode 100644 index 54c85b242da..00000000000 --- a/packages/clerk-js/src/ui/common/error/Error.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; - -export type ErrorProps = { - children: React.ReactNode; - style?: {}; -} & React.HTMLAttributes; - -// Renders global errors across components, will be replaced by notification snackbars. -export const Error: React.FC = ({ children, style }) => { - if (!children) { - return null; - } - return ( -
- {children} -
- ); -}; diff --git a/packages/clerk-js/src/ui/common/error/__snapshots__/Error.test.tsx.snap b/packages/clerk-js/src/ui/common/error/__snapshots__/Error.test.tsx.snap deleted file mode 100644 index 9c1c8c49e80..00000000000 --- a/packages/clerk-js/src/ui/common/error/__snapshots__/Error.test.tsx.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders the error component 1`] = ` -
- Foo -
-`; diff --git a/packages/clerk-js/src/ui/common/error/index.ts b/packages/clerk-js/src/ui/common/error/index.ts deleted file mode 100644 index ae6e95d01dd..00000000000 --- a/packages/clerk-js/src/ui/common/error/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Error'; diff --git a/packages/clerk-js/src/ui/common/withRedirectToHome.tsx b/packages/clerk-js/src/ui/common/withRedirectToHome.tsx index e6fc8a92fbd..ce01132f912 100644 --- a/packages/clerk-js/src/ui/common/withRedirectToHome.tsx +++ b/packages/clerk-js/src/ui/common/withRedirectToHome.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useCoreSession, useEnvironment } from 'ui/contexts'; import { useNavigate } from 'ui/hooks'; -export function withRedirectToHome

( +export function withRedirectToHome

( Component: React.ComponentType

, displayName?: string, ): (props: P) => null | JSX.Element { diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 0813c980d23..f1a279855ce 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -4,6 +4,7 @@ import { useEnvironment } from 'ui/contexts'; import { useNavigate } from 'ui/hooks'; import type { ParsedQs } from 'ui/router'; import { useRouter } from 'ui/router'; +import { buildURL } from 'utils/url'; import type { AvailableComponentCtx, @@ -22,6 +23,7 @@ export type SignUpContextType = SignUpProps & { navigateAfterSignUp: () => any; queryParams: ParsedQs; signInUrl: string; + secondFactorUrl: string; authQueryString: string | null; }; export const useSignUpContext = (): SignUpContextType => { @@ -66,9 +68,13 @@ export const useSignUpContext = (): SignUpContextType => { signInUrl += `#/?${authQs}`; } + // TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead. + const secondFactorUrl = buildURL({ base: signInUrl, hashPath: '/factor-two' }, { stringify: true }); + return { ...ctx, signInUrl, + secondFactorUrl, afterSignUpUrl, afterSignInUrl, navigateAfterSignUp, @@ -81,8 +87,10 @@ export type SignInContextType = SignInProps & { navigateAfterSignIn: () => any; queryParams: ParsedQs; signUpUrl: string; + signUpContinueUrl: string; authQueryString: string | null; }; + export const useSignInContext = (): SignInContextType => { const { componentName, ...ctx } = React.useContext(ComponentContext) as SignInCtx; const { navigate } = useNavigate(); @@ -123,12 +131,15 @@ export const useSignInContext = (): SignInContextType => { signUpUrl += `#/?${authQs}`; } + const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); + return { ...ctx, signUpUrl, afterSignInUrl, afterSignUpUrl, navigateAfterSignIn, + signUpContinueUrl, queryParams, authQueryString: authQs, }; diff --git a/packages/clerk-js/src/ui/signIn/SignInFactorTwo.tsx b/packages/clerk-js/src/ui/signIn/SignInFactorTwo.tsx index a878afc822c..d2c89146572 100644 --- a/packages/clerk-js/src/ui/signIn/SignInFactorTwo.tsx +++ b/packages/clerk-js/src/ui/signIn/SignInFactorTwo.tsx @@ -15,6 +15,7 @@ import { import { useCoreClerk, useCoreSignIn, useSignInContext } from 'ui/contexts'; import { determineSalutation } from './utils'; +import { Alert } from 'ui/common/alert'; type SignInUiProps = Pick; @@ -98,7 +99,7 @@ function _SignInFactorTwo(): JSX.Element { return ( <>

{error}} showBack welcomeName={determineSalutation(cachedSignInRef.current)} /> diff --git a/packages/clerk-js/src/ui/signIn/SignInStart.tsx b/packages/clerk-js/src/ui/signIn/SignInStart.tsx index 26fb37dc1a2..fd204a443c8 100644 --- a/packages/clerk-js/src/ui/signIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/signIn/SignInStart.tsx @@ -25,6 +25,7 @@ import { getClerkQueryParam } from 'utils/getClerkQueryParam'; import { SignUpLink } from './SignUpLink'; import { OAuth, Web3 } from './strategies'; +import { Alert } from 'ui/common/alert'; export function _SignInStart(): JSX.Element { const { userSettings } = useEnvironment(); @@ -169,13 +170,15 @@ export function _SignInStart(): JSX.Element { return ( <> -
+
{error}} /> + +
{error}} showBack welcomeName={determineSalutation(signIn)} className='cl-auth-form-header-compact' /> + {currentFactor.strategy === 'password' && (
{error}} showLogo={false} showBack /> diff --git a/packages/clerk-js/src/ui/signIn/strategies/Web3.tsx b/packages/clerk-js/src/ui/signIn/strategies/Web3.tsx index 338508e3ec5..42e3d719764 100644 --- a/packages/clerk-js/src/ui/signIn/strategies/Web3.tsx +++ b/packages/clerk-js/src/ui/signIn/strategies/Web3.tsx @@ -2,6 +2,8 @@ import { Web3Provider, Web3Strategy } from '@clerk/types'; import React from 'react'; import { ButtonSet, ButtonSetOptions, getWeb3ProviderData, handleError } from 'ui/common'; import { useCoreClerk, useEnvironment, useSignInContext } from 'ui/contexts'; +import { useNavigate } from 'ui/hooks'; +import { buildURL } from 'utils/url'; export type Web3Props = { web3Options: Web3Strategy[]; @@ -12,14 +14,16 @@ export type Web3Props = { export function Web3({ error, setError, web3Options }: Web3Props): JSX.Element | null { const clerk = useCoreClerk(); const ctx = useSignInContext(); - const { displayConfig } = useEnvironment(); + const { navigate } = useNavigate(); const startWeb3 = async (e: React.MouseEvent) => { e.preventDefault(); try { await clerk.authenticateWithMetamask({ - redirectUrl: ctx.afterSignInUrl || displayConfig.afterSignInUrl, + customNavigate: navigate, + redirectUrl: ctx.afterSignInUrl!, + signUpContinueUrl: ctx.signUpContinueUrl, }); } catch (err) { handleError(err, [], setError); diff --git a/packages/clerk-js/src/ui/signUp/SignUp.tsx b/packages/clerk-js/src/ui/signUp/SignUp.tsx index 71718bfb421..0bba0997c99 100644 --- a/packages/clerk-js/src/ui/signUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/signUp/SignUp.tsx @@ -4,7 +4,9 @@ import { VerifyMagicLink } from 'ui/common'; import { SSOCallback } from 'ui/common/SSOCallback'; import { ComponentContext, useCoreClerk, useSignUpContext, withCoreSessionSwitchGuard } from 'ui/contexts'; import { Route, Switch, VIRTUAL_ROUTER_BASE_PATH } from 'ui/router'; +import { buildURL } from 'utils/url'; +import { SignUpContinue } from './SignUpContinue'; import { SignUpStart } from './SignUpStart'; import { SignUpVerifyEmailAddress, SignUpVerifyPhoneNumber } from './SignUpVerify'; @@ -32,12 +34,15 @@ function SignUpRoutes(): JSX.Element { afterSignUpUrl={signUpContext.afterSignUpUrl} afterSignInUrl={signUpContext.afterSignInUrl} redirectUrl={signUpContext.redirectUrl} - secondFactorUrl={signUpContext.signInUrl + '#/factor-two'} + secondFactorUrl={signUpContext.secondFactorUrl} /> + + + diff --git a/packages/clerk-js/src/ui/signUp/SignUpContinue.test.tsx b/packages/clerk-js/src/ui/signUp/SignUpContinue.test.tsx new file mode 100644 index 00000000000..8ec0f3ebb77 --- /dev/null +++ b/packages/clerk-js/src/ui/signUp/SignUpContinue.test.tsx @@ -0,0 +1,380 @@ +import { render, renderJSON, screen, userEvent, waitFor } from '@clerk/shared/testUtils'; +import { UserSettingsJSON } from '@clerk/types'; +import { Session, UserSettings } from 'core/resources/internal'; +import React from 'react'; +import { useCoreSignUp } from 'ui/contexts'; + +import { SignUpContinue } from './SignUpContinue'; + +const navigateMock = jest.fn(); +const mockUpdateRequest = jest.fn(); +const mockSetSession = jest.fn(); +let mockUserSettings: UserSettings; + +jest.mock('ui/router/RouteContext'); + +jest.mock('ui/contexts', () => { + return { + useCoreSession: () => { + return { + id: 'sess_id', + } as Partial; + }, + useSignUpContext: () => { + return { + signInUrl: 'http://test.host/sign-in', + navigateAfterSignUp: jest.fn(), + }; + }, + useCoreClerk: jest.fn(() => ({ + frontendAPI: 'clerk.clerk.dev', + setSession: mockSetSession, + })), + useCoreSignUp: jest.fn(() => ({ + verifications: { + emailAddress: {}, + phoneNumber: {}, + externalAccount: {}, + }, + })), + useEnvironment: jest.fn(() => ({ + displayConfig: { + applicationName: 'My test app', + afterSignUpUrl: 'http://test.host/welcome', + signUpUrl: 'http://test.host/sign-up', + }, + userSettings: mockUserSettings, + authConfig: { singleSessionMode: false }, + })), + }; +}); + +jest.mock('ui/hooks', () => ({ + useNavigate: () => { + return { + navigate: navigateMock, + }; + }, +})); + +describe('', () => { + const { location } = window; + + beforeEach(() => { + mockUserSettings = new UserSettings({ + attributes: { + username: { + enabled: true, + required: true, + }, + first_name: { + enabled: true, + required: true, + }, + last_name: { + enabled: true, + required: true, + }, + password: { + enabled: true, + required: true, + }, + email_address: { + enabled: true, + required: true, + used_for_first_factor: true, + }, + phone_number: { + enabled: true, + required: false, + }, + }, + social: { + oauth_google: { + enabled: true, + strategy: 'oauth_google', + }, + oauth_facebook: { + enabled: true, + strategy: 'oauth_facebook', + }, + }, + } as UserSettingsJSON); + }); + + beforeEach(() => { + (useCoreSignUp as jest.Mock).mockImplementation(() => { + return { + id: 'su_perman', + update: mockUpdateRequest, + verifications: { + externalAccount: { + status: 'verified', + }, + emailAddress: { + status: 'unverified', + }, + }, + firstName: null, + lastName: null, + emailAddress: 'bryan@taken.com', + phoneNumber: '+12125551001', + username: 'bryanmills', + }; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + global.window.location = location; + }); + + it('renders the sign up continue screen', () => { + const tree = renderJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('redirects to sign-up if no current sign-up exists', () => { + (useCoreSignUp as jest.Mock).mockImplementation(() => { + return {}; + }); + + render(); + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('http://test.host/sign-up'); + }); + + it('pre-fills form with unverified email', () => { + render(); + + expect(screen.getByLabelText('Email address')).toHaveValue('bryan@taken.com'); + }); + + it('does not show oauth providers if we already have a verified external account', () => { + render(); + + expect(screen.queryByRole('button', { name: 'Sign up with Facebook' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sign up with Google' })).not.toBeInTheDocument(); + }); + + it('patches the existing signup when user submits the form', async () => { + mockUpdateRequest.mockImplementation(() => + Promise.resolve({ + firstName: 'Bryan', + lastName: 'Mills', + emailAddress: 'bryan@taken.com', + verifications: { + emailAddress: { + status: 'unverified', + }, + }, + }), + ); + + render(); + + const firstNameInput = screen.getByLabelText('First name'); + userEvent.clear(firstNameInput); + userEvent.type(firstNameInput, 'Bryan'); + + const lastNameInput = screen.getByLabelText('Last name'); + userEvent.clear(lastNameInput); + userEvent.type(lastNameInput, 'Mills'); + + userEvent.click(screen.getByRole('button', { name: 'Sign up' })); + + await waitFor(() => { + expect(mockUpdateRequest).toHaveBeenCalledTimes(1); + expect(mockUpdateRequest).toHaveBeenCalledWith({ + first_name: 'Bryan', + last_name: 'Mills', + email_address: 'bryan@taken.com', + }); + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('http://test.host/sign-up/verify-email-address'); + }); + }); + + it('skips the email input if the email is already verified', () => { + (useCoreSignUp as jest.Mock).mockImplementation(() => { + return { + id: 'su_perman', + update: mockUpdateRequest, + verifications: { + externalAccount: { + status: 'verified', + }, + emailAddress: { + status: 'verified', + }, + }, + firstName: null, + lastName: null, + emailAddress: 'bryan@taken.com', + phoneNumber: '+12125551001', + username: 'bryanmills', + }; + }); + + render(); + + expect(screen.queryByText('Email address')).not.toBeInTheDocument(); + }); + + it('redirects to the phone verification step if the user completes their sign up by providing their phone', async () => { + mockUserSettings = new UserSettings({ + attributes: { + username: { + enabled: true, + required: false, + }, + first_name: { + enabled: true, + required: false, + }, + last_name: { + enabled: true, + required: false, + }, + password: { + enabled: true, + required: true, + }, + email_address: { + enabled: true, + required: false, + used_for_first_factor: false, + }, + phone_number: { + enabled: true, + required: true, + used_for_first_factor: true, + }, + }, + social: { + oauth_google: { + enabled: true, + strategy: 'oauth_google', + }, + oauth_facebook: { + enabled: true, + strategy: 'oauth_facebook', + }, + }, + } as UserSettingsJSON); + + mockUpdateRequest.mockImplementation(() => + Promise.resolve({ + phoneNumber: '+15615551001', + verifications: { + phoneNumber: { + status: 'unverified', + }, + }, + }), + ); + + render(); + + const phoneNumberInput = screen.getByRole('textbox'); + userEvent.clear(phoneNumberInput); + userEvent.type(phoneNumberInput, '5615551001'); + + userEvent.click(screen.getByRole('button', { name: 'Sign up' })); + + await waitFor(() => { + expect(mockUpdateRequest).toHaveBeenCalledTimes(1); + expect(mockUpdateRequest).toHaveBeenCalledWith({ + phone_number: '+15615551001', + }); + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('http://test.host/sign-up/verify-phone-number'); + }); + }); + + it('skips the password input if there is a verified external account', () => { + render(); + + expect(screen.queryByText('Password')).not.toBeInTheDocument(); + }); + + it('skips already collected fields if they need no verification', () => { + (useCoreSignUp as jest.Mock).mockImplementation(() => { + return { + id: 'su_perman', + update: mockUpdateRequest, + verifications: { + externalAccount: { + status: 'verified', + }, + emailAddress: { + status: 'unverified', + }, + }, + firstName: 'Bryan', + lastName: 'Mills', + emailAddress: 'bryan@taken.com', + phoneNumber: '+12125551001', + username: 'bryanmills', + }; + }); + + render(); + + expect(screen.queryByText('First name')).not.toBeInTheDocument(); + expect(screen.queryByText('Last name')).not.toBeInTheDocument(); + expect(screen.queryByText('Username')).not.toBeInTheDocument(); + }); + + it('skips non-required fields', () => { + mockUserSettings = new UserSettings({ + attributes: { + username: { + enabled: true, + required: false, + }, + first_name: { + enabled: true, + required: false, + }, + last_name: { + enabled: true, + required: false, + }, + password: { + enabled: true, + required: true, + }, + email_address: { + enabled: true, + required: true, + used_for_first_factor: true, + }, + phone_number: { + enabled: true, + required: false, + }, + }, + social: { + oauth_google: { + enabled: true, + strategy: 'oauth_google', + }, + oauth_facebook: { + enabled: true, + strategy: 'oauth_facebook', + }, + }, + } as UserSettingsJSON); + + render(); + + expect(screen.queryByText('Phone number')).not.toBeInTheDocument(); + expect(screen.queryByText('First name')).not.toBeInTheDocument(); + expect(screen.queryByText('Last name')).not.toBeInTheDocument(); + expect(screen.queryByText('Username')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/clerk-js/src/ui/signUp/SignUpContinue.tsx b/packages/clerk-js/src/ui/signUp/SignUpContinue.tsx new file mode 100644 index 00000000000..cea4c7ea2c3 --- /dev/null +++ b/packages/clerk-js/src/ui/signUp/SignUpContinue.tsx @@ -0,0 +1,182 @@ +import { SignUpResource } from '@clerk/types'; +import React from 'react'; +import type { FieldState } from 'ui/common'; +import { + buildRequest, + Footer, + handleError, + PoweredByClerk, + Separator, + useFieldState, + withRedirectToHome, +} from 'ui/common'; +import { Alert } from 'ui/common/alert'; +import { Body, Header } from 'ui/common/authForms'; +import { useCoreClerk, useCoreSignUp, useEnvironment, useSignUpContext } from 'ui/contexts'; +import { useNavigate } from 'ui/hooks'; +import { SignUpForm } from 'ui/signUp/SignUpForm'; + +import { + ActiveIdentifier, + determineActiveFields, + emailOrPhoneUsedForFF, + getInitialActiveIdentifier, + minimizeFieldsForExistingSignup, + showFormFields, +} from './sign_up_form_helpers'; +import { SignInLink } from './SignInLink'; +import { SignUpOAuth } from './SignUpOAuth'; +import { SignUpWeb3 } from './SignUpWeb3'; + +function _SignUpContinue(): JSX.Element | null { + const { navigate } = useNavigate(); + const environment = useEnvironment(); + const { displayConfig, userSettings } = environment; + const { attributes } = userSettings; + const { setSession } = useCoreClerk(); + const { navigateAfterSignUp } = useSignUpContext(); + const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( + getInitialActiveIdentifier(attributes), + ); + const signUp = useCoreSignUp(); + + // Redirect to sign-up if there is no persisted sign-up + + if (!signUp.id) { + void navigate(displayConfig.signUpUrl); + return null; + } + + // Pre-populate fields from existing sign-up object + + const formState = { + firstName: useFieldState('first_name', signUp.firstName || ''), + lastName: useFieldState('last_name', signUp.lastName || ''), + emailAddress: useFieldState('email_address', signUp.emailAddress || ''), + username: useFieldState('username', signUp.username || ''), + phoneNumber: useFieldState('phone_number', signUp.phoneNumber || ''), + password: useFieldState('password', ''), + ticket: useFieldState('ticket', ''), + } as const; + + type FormStateKey = keyof typeof formState; + + const [error, setError] = React.useState(); + + const hasEmail = !!formState.emailAddress.value; + const hasVerifiedExternalAccount = signUp.verifications?.externalAccount?.status == 'verified'; + const hasVerifiedWeb3 = signUp.verifications?.web3Wallet?.status == 'verified'; + + const fields = determineActiveFields({ + environment, + hasEmail, + activeCommIdentifierType, + signUp, + }); + + minimizeFieldsForExistingSignup(fields, signUp); + + const oauthOptions = userSettings.socialProviderStrategies; + const web3Options = userSettings.web3FirstFactors; + + const handleChangeActive = (type: ActiveIdentifier) => (e: React.MouseEvent) => { + e.preventDefault(); + + if (!emailOrPhoneUsedForFF(attributes)) { + return; + } + + setActiveCommIdentifierType(type); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const fieldsToSubmit = Object.entries(fields).reduce( + (acc, [k, v]) => [...acc, ...(v.enabled && formState[k as FormStateKey] ? [formState[k as FormStateKey]] : [])], + [] as Array>, + ); + + try { + setError(undefined); + const req = buildRequest(fieldsToSubmit); + const res = await signUp.update(req); + return completeSignUpFlow(res); + } catch (err) { + handleError(err, fieldsToSubmit, setError); + } + }; + + const completeSignUpFlow = (su: SignUpResource) => { + if (su.status === 'complete') { + return setSession(su.createdSessionId, navigateAfterSignUp); + } else if (su.emailAddress && su.verifications.emailAddress.status !== 'verified') { + return navigate(displayConfig.signUpUrl + '/verify-email-address'); + } else if (su.phoneNumber && su.verifications.phoneNumber.status !== 'verified') { + return navigate(displayConfig.signUpUrl + '/verify-phone-number'); + } else if (su.status === 'missing_requirements') { + // do nothing + } + }; + + const showOauthProviders = !hasVerifiedExternalAccount && oauthOptions.length > 0; + const showWeb3Providers = !hasVerifiedWeb3 && web3Options.length > 0; + + return ( + <> +
+ Please fill in the following information to complete your sign-up + {error && ( + + {error} + + )} + + } + className='cl-auth-form-header-compact' + /> + + + {showOauthProviders && ( + + )} + + {showWeb3Providers && ( + + )} + + {showFormFields(userSettings) && ( + <> + {(showOauthProviders || showWeb3Providers) && } + + + + )} + +
+ + +
+ + + ); +} + +export const SignUpContinue = withRedirectToHome(_SignUpContinue); diff --git a/packages/clerk-js/src/ui/signUp/SignUpForm.tsx b/packages/clerk-js/src/ui/signUp/SignUpForm.tsx new file mode 100644 index 00000000000..c189780eb6f --- /dev/null +++ b/packages/clerk-js/src/ui/signUp/SignUpForm.tsx @@ -0,0 +1,144 @@ +import { Control } from '@clerk/shared/components/control'; +import { Form } from '@clerk/shared/components/form'; +import { Input } from '@clerk/shared/components/input'; +import { PhoneInput } from '@clerk/shared/components/phoneInput'; +import React from 'react'; + +import { ActiveIdentifier, Fields, FormState } from './sign_up_form_helpers'; + +type SignUpFormProps = { + fields: Fields; + formState: FormState; + toggleEmailPhone: boolean; + handleSubmit: (e: React.FormEvent) => Promise; + handleChangeActive: (type: ActiveIdentifier) => (e: React.MouseEvent) => void; +}; + +export function SignUpForm({ + fields, + formState, + toggleEmailPhone, + handleSubmit, + handleChangeActive, +}: SignUpFormProps): JSX.Element { + return ( +
+ <> + {(fields.firstName.enabled || fields.lastName.enabled) && ( +
+ {fields.firstName.enabled && ( + + formState.firstName.setValue(el.value || '')} + /> + + )} + + {fields.lastName.enabled && ( + + formState.lastName.setValue(el.value || '')} + /> + + )} +
+ )} + + {fields.username.enabled && ( + + formState.username.setValue(el.value || '')} + /> + + )} + + {fields.emailAddress.enabled && ( + + formState.emailAddress.setValue(el.value || '')} + disabled={fields.emailAddress.showAsDisabled} + /> + + )} + + {fields.phoneNumber.enabled && ( + + + + )} + + {fields.password.enabled && ( + + formState.password.setValue(el.value || '')} + /> + + )} + +
+ ); +} diff --git a/packages/clerk-js/src/ui/signUp/SignUpStart.test.tsx b/packages/clerk-js/src/ui/signUp/SignUpStart.test.tsx index 932583477a9..6555f3674a7 100644 --- a/packages/clerk-js/src/ui/signUp/SignUpStart.test.tsx +++ b/packages/clerk-js/src/ui/signUp/SignUpStart.test.tsx @@ -1,12 +1,11 @@ -import { mocked, render, renderJSON, screen, userEvent, waitFor } from '@clerk/shared/testUtils'; +import { render, renderJSON, screen, userEvent, waitFor } from '@clerk/shared/testUtils'; import { titleize } from '@clerk/shared/utils/string'; -import { SignInResource, UserSettingsJSON } from '@clerk/types'; +import { UserSettingsJSON } from '@clerk/types'; import { Session, UserSettings } from 'core/resources/internal'; import React from 'react'; -import { useCoreSignIn, useCoreSignUp } from 'ui/contexts'; +import { useCoreSignUp } from 'ui/contexts'; import { SignUpStart } from './SignUpStart'; -import { SignInStart } from 'ui/signIn/SignInStart'; const navigateMock = jest.fn(); const mockCreateRequest = jest.fn(); @@ -114,6 +113,7 @@ describe('', () => { }, phone_number: { enabled: true, + required: false, }, }, social: { @@ -221,14 +221,26 @@ describe('', () => { attributes: { username: { enabled: false, + required: false, + }, + first_name: { + enabled: false, + required: false, + }, + last_name: { + enabled: false, + required: false, }, email_address: { enabled: true, + required: false, }, phone_number: { enabled: true, + required: false, }, password: { + enabled: false, required: false, }, }, @@ -315,14 +327,32 @@ describe('', () => { }, phone_number: { enabled: false, + required: false, + }, + username: { + enabled: false, + required: false, + }, + first_name: { + enabled: false, + required: false, + }, + last_name: { + enabled: false, + required: false, }, password: { + enabled: false, required: false, }, }, } as UserSettingsJSON); }); + afterEach(() => { + setWindowQueryParams([]); + }); + it('it auto-completes sign up flow if sign up is complete after create', async () => { mockCreateRequest.mockImplementation(() => Promise.resolve({ @@ -347,6 +377,7 @@ describe('', () => { }, phone_number: { enabled: false, + required: false, }, username: { enabled: true, @@ -354,9 +385,11 @@ describe('', () => { }, first_name: { enabled: true, + required: false, }, last_name: { enabled: true, + required: false, }, password: { enabled: true, @@ -415,16 +448,24 @@ describe('', () => { required: true, used_for_first_factor: true, }, + phone_number: { + enabled: false, + required: false, + }, + username: { + enabled: false, + required: false, + }, first_name: { enabled: true, + required: false, }, last_name: { enabled: true, - }, - phone_number: { - enabled: false, + required: false, }, password: { + enabled: false, required: false, }, }, @@ -454,15 +495,28 @@ describe('', () => { it('does not render the phone number field', async () => { mockUserSettings = new UserSettings({ attributes: { + email_address: { + used_for_first_factor: false, + }, phone_number: { enabled: true, required: true, used_for_first_factor: true, }, - email_address: { - used_for_first_factor: false, + username: { + enabled: false, + required: false, + }, + first_name: { + enabled: false, + required: false, + }, + last_name: { + enabled: false, + required: false, }, password: { + enabled: false, required: false, }, }, @@ -485,7 +539,7 @@ describe('', () => { runTokenTests('__clerk_ticket'); }); - it('hides sign up form when no at least an oauth is enabled and no auth factor is enabled', async () => { + it('hides sign up form when no at least an oauth is enabled and no auth factor is enabled', () => { mockUserSettings = new UserSettings({ attributes: { phone_number: { @@ -497,6 +551,7 @@ describe('', () => { used_for_first_factor: false, }, password: { + enabled: false, required: false, }, username: { diff --git a/packages/clerk-js/src/ui/signUp/SignUpStart.tsx b/packages/clerk-js/src/ui/signUp/SignUpStart.tsx index 1831a99e6fb..cb739bccce0 100644 --- a/packages/clerk-js/src/ui/signUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/signUp/SignUpStart.tsx @@ -1,7 +1,3 @@ -import { Control } from '@clerk/shared/components/control'; -import { Form } from '@clerk/shared/components/form'; -import { Input } from '@clerk/shared/components/input'; -import { PhoneInput } from '@clerk/shared/components/phoneInput'; import { noop } from '@clerk/shared/utils'; import { SignUpCreateParams, SignUpResource } from '@clerk/types'; import React from 'react'; @@ -16,30 +12,40 @@ import { useFieldState, withRedirectToHome, } from 'ui/common'; +import { Alert } from 'ui/common/alert'; import { Body, Header } from 'ui/common/authForms'; import { ERROR_CODES } from 'ui/common/constants'; import { useCoreClerk, useCoreSignUp, useEnvironment, useSignUpContext } from 'ui/contexts'; import { useNavigate } from 'ui/hooks'; import { getClerkQueryParam } from 'utils/getClerkQueryParam'; +import { + ActiveIdentifier, + determineActiveFields, + emailOrPhoneUsedForFF, + getInitialActiveIdentifier, + showFormFields, +} from './sign_up_form_helpers'; import { SignInLink } from './SignInLink'; +import { SignUpForm } from './SignUpForm'; import { SignUpOAuth } from './SignUpOAuth'; import { SignUpWeb3 } from './SignUpWeb3'; -import { determineFirstPartyFields } from './utils'; - -type ActiveIdentifier = 'emailAddress' | 'phoneNumber'; function _SignUpStart(): JSX.Element { const { navigate } = useNavigate(); const environment = useEnvironment(); const { userSettings } = environment; + const { attributes } = userSettings; const { setSession } = useCoreClerk(); const { navigateAfterSignUp } = useSignUpContext(); - const [emailOrPhoneActive, setEmailOrPhoneActive] = React.useState('emailAddress'); + const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( + getInitialActiveIdentifier(attributes), + ); const signUp = useCoreSignUp(); const [isLoading, setIsLoading] = React.useState(false); const [isMissingRequirements, setMissingRequirements] = React.useState(false); - const formFields = { + + const formState = { firstName: useFieldState('first_name', ''), lastName: useFieldState('last_name', ''), emailAddress: useFieldState('email_address', ''), @@ -51,35 +57,43 @@ function _SignUpStart(): JSX.Element { getClerkQueryParam('__clerk_ticket') || getClerkQueryParam('__clerk_invitation_token') || '', ), } as const; - type FormFieldsKey = keyof typeof formFields; + + type FormStateKey = keyof typeof formState; const [error, setError] = React.useState(); - const hasTicket = !!formFields.ticket.value; + const hasTicket = !!formState.ticket.value; + const hasEmail = !!formState.emailAddress.value; + + const fields = determineActiveFields({ + environment, + hasTicket, + hasEmail, + activeCommIdentifierType, + }); - const fields = determineFirstPartyFields(environment, hasTicket); const oauthOptions = userSettings.socialProviderStrategies; const web3Options = userSettings.web3FirstFactors; - const isEmailAddressUsedForIdentification = userSettings.enabledFirstFactorIdentifiers.some( - i => i === 'email_address', - ); const handleTokenFlow = () => { - const ticket = formFields.ticket.value; + const ticket = formState.ticket.value; + if (!ticket) { return; } + const signUpParams: SignUpCreateParams = { strategy: 'ticket', ticket }; + setIsLoading(true); signUp .create(signUpParams) .then(res => { - formFields.emailAddress.setValue(res.emailAddress || ''); + formState.emailAddress.setValue(res.emailAddress || ''); void completeSignUpFlow(res); }) .catch(err => { /* Clear ticket values when an error occurs in the initial sign up attempt */ - formFields.ticket.setValue(''); + formState.ticket.setValue(''); handleError(err, [], setError); }) .finally(() => { @@ -88,9 +102,10 @@ function _SignUpStart(): JSX.Element { }; React.useLayoutEffect(() => { - if (Object.values(fields).filter(f => f === 'on').length) { + if (Object.values(fields).filter(f => f.enabled && !f.required).length) { return; } + void handleTokenFlow(); }, []); @@ -122,31 +137,25 @@ function _SignUpStart(): JSX.Element { const handleChangeActive = (type: ActiveIdentifier) => (e: React.MouseEvent) => { e.preventDefault(); - if (!fields.emailOrPhone) { + + if (!emailOrPhoneUsedForFF(attributes)) { return; } - setEmailOrPhoneActive(type); + + setActiveCommIdentifierType(type); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const reqFields = Object.entries(fields).reduce( - (acc, [k, v]) => [...acc, ...(v && formFields[k as FormFieldsKey] ? [formFields[k as FormFieldsKey]] : [])], + const fieldsToSubmit = Object.entries(fields).reduce( + (acc, [k, v]) => [...acc, ...(v.enabled && formState[k as FormStateKey] ? [formState[k as FormStateKey]] : [])], [] as Array>, ); - if (fields.emailOrPhone && emailOrPhoneActive === 'emailAddress') { - reqFields.push(formFields.emailAddress); - } - - if (fields.emailOrPhone && emailOrPhoneActive === 'phoneNumber') { - reqFields.push(formFields.phoneNumber); - } - - if (fields.ticket) { - // FIXME: Constructing a fake fields object for strategy. - reqFields.push({ + if (fields.ticket.enabled) { + // fieldsToSubmit: Constructing a fake fields object for strategy. + fieldsToSubmit.push({ name: 'strategy', value: 'ticket', setError: noop, @@ -157,10 +166,10 @@ function _SignUpStart(): JSX.Element { try { setError(undefined); - const res = await signUp.create(buildRequest(reqFields)); + const res = await signUp.create(buildRequest(fieldsToSubmit)); return completeSignUpFlow(res); } catch (err) { - handleError(err, reqFields, setError); + handleError(err, fieldsToSubmit, setError); } }; @@ -180,136 +189,13 @@ function _SignUpStart(): JSX.Element { return ; } - const firstNameField = fields.firstName ? ( - - formFields.firstName.setValue(el.value || '')} - /> - - ) : null; - - const lastNameField = fields.lastName ? ( - - formFields.lastName.setValue(el.value || '')} - /> - - ) : null; - - const nameField = (fields.firstName || fields.lastName) && ( -
- {firstNameField} - {lastNameField} -
- ); - - const usernameField = fields.username ? ( - - formFields.username.setValue(el.value || '')} - /> - - ) : null; - - const passwordField = fields.password ? ( - - formFields.password.setValue(el.value || '')} - /> - - ) : null; - - const shouldShowEmailAddressField = - (isEmailAddressUsedForIdentification && hasTicket && !!formFields.emailAddress.value) || - fields.emailAddress || - (fields.emailOrPhone && emailOrPhoneActive === 'emailAddress'); - - const disabledEmailField = hasTicket && !!formFields.emailAddress.value; - - const emailAddressField = shouldShowEmailAddressField && ( - - formFields.emailAddress.setValue(el.value || '')} - disabled={disabledEmailField} - /> - - ); - - const phoneNumberField = - fields.phoneNumber || (fields.emailOrPhone && emailOrPhoneActive === 'phoneNumber') ? ( - - - - ) : null; - - const showFormFields = userSettings.hasValidAuthFactor || (!oauthOptions.length && !web3Options.length); - return ( <>
{error}} className='cl-auth-form-header-compact' /> + {(!hasTicket || (hasTicket && isMissingRequirements)) && oauthOptions.length > 0 && ( )} + {!hasTicket && web3Options.length > 0 && ( )} - {showFormFields && ( + + {showFormFields(userSettings) && ( <> {(oauthOptions.length > 0 || web3Options.length > 0) && } - {/* @ts-ignore */} -
- <> - {nameField} - {usernameField} - {emailAddressField} - {phoneNumberField} - {passwordField} - -
+ handleChangeActive={handleChangeActive} + /> )} +