diff --git a/README.md b/README.md index 169c823..1b21c99 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Userfront.init("myTenantId"); | `` | `` | | `` | `` | -Note: when using them in plain HTML, Web Components are not self-closing and must have the full closing tag. +**Note**: when using them in plain HTML, Web Components are not self-closing and must have the full closing tag. When using in Vue, they can be written in self-closing form: ``. The Vue components are `` because they are Web Components under the hood, and Web Components are required to be in kebab-case. @@ -104,13 +104,23 @@ In React, props are `camelCase`. In Vue and Web Components, props are `kebab-cas All props are optional. -- `tenantId` / `tenant-id`: your tenant ID, from the Userfront dashboard. If you call `Userfront.init("yourTenantId")` before using the components, it's not necessary to provide this prop. -- `compact`: if `true` and username/password is an allowed factor in your tenant's [authentication flow](https://userfront.com/dashboard/authentication), show a "Password" button. If `false`, show the username and password fields directly in the form's sign-on method selection view. +- `tenantId` / `tenant-id`: your workspace ID + - This prop is not necessary if you call `Userfront.init("workspace_id")` before using the components. + - Your workspace ID can be found on the [**Overview** page in your Userfront dashboard](https://userfront.com/dashboard). +- `compact`: - Default: `false` -- `redirect`: controls if and where the form should redirect **after** sign-on. If `false`, the form does not redirect. If set to a path, redirects to that path. If empty, redirects [as configured in your Userfront dashboard](https://userfront.com/dashboard/paths). + - `true`: hide the email & password inputs in favor of a "Username and password" button. Clicking this button will display the necessary inputs. + - **Note**: The **Password** factor must be an enabled factor in your workspace's authentication flow configured on the [**Authentication** page in your Userfront dashboard](https://userfront.com/dashboard/authentication). + - `false`: show the username and password fields directly in the form's sign-on method selection view. +- `redirect`: controls if and where the form should redirect **after** sign-on. - Default: `undefined` -- `redirectOnLoadIfLoggedIn` / `redirect-on-load-if-logged-in`: if `true` and the user is already logged in when they load the form, redirects per the `redirect` parameter. If `false`, do not redirect if the user is already logged in when they load the form. + - `false`: the form does not redirect. + - If set to a path, redirect to that path. + - If empty, redirect to the path configured on the [**Paths & routing** page in your Userfront dashboard](https://userfront.com/dashboard/paths). +- `redirectOnLoadIfLoggedIn` / `redirect-on-load-if-logged-in`: - Default: `false` + - `true`: redirects per the `redirect` parameter if the user is already logged in when the form is loaded. + - `false`: do not redirect if the user is already logged in when the form is loaded. ## Development @@ -129,24 +139,16 @@ The repo is configured as an npm workspace to enable sharing of libraries and dy 1. Clone this repo. 2. Install: - -- `npm install` - -4. Run dev servers: - -- `npm run dev` - -This will run the live dev servers for both the package and the site. - -5. Run unit test: - -- `npm run test` - -6. Run Storybook: - -- `npm run storybook -w package` - - Find the link to the local Storybook server in the output. - - Storybook should hot reload on changes to the package. Each UI state has its own component, so changes should show immediately and shouldn't require reloading the page. + - `npm install` +3. Run dev servers: + - `npm run dev` + - This will run the live dev servers for both the package and the site. +4. Run unit test: + - `npm run test` +5. Run Storybook: + - `npm run storybook -w package` + - Find the link to the local Storybook server in the output. + - Storybook should hot reload on changes to the package. Each UI state has its own component, so changes should show immediately and shouldn't require reloading the page. ### Architecture diff --git a/package-lock.json b/package-lock.json index 3a91801..940e58f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18707,7 +18707,7 @@ }, "package": { "name": "@userfront/toolkit", - "version": "1.1.0-alpha.2", + "version": "1.1.0-alpha.3", "license": "MIT", "dependencies": { "@r2wc/react-to-web-component": "^2.0.2", diff --git a/package/package-lock.json b/package/package-lock.json index d7a9347..99de527 100644 --- a/package/package-lock.json +++ b/package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@userfront/react", - "version": "1.1.0-alpha.2", + "version": "1.1.0-alpha.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@userfront/react", - "version": "1.1.0-alpha.2", + "version": "1.1.0-alpha.3", "license": "MIT", "dependencies": { "@r2wc/react-to-web-component": "^2.0.2", diff --git a/package/package.json b/package/package.json index adc4f46..f2db301 100644 --- a/package/package.json +++ b/package/package.json @@ -1,6 +1,6 @@ { "name": "@userfront/toolkit", - "version": "1.1.0-alpha.2", + "version": "1.1.0-alpha.3", "description": "Bindings and components for authentication with Userfront with React, Vue, other frameworks, and plain JS + HTML", "type": "module", "directories": { diff --git a/package/src/models/views/setUpTotp.ts b/package/src/models/views/setUpTotp.ts index 534a74a..2aa6da1 100644 --- a/package/src/models/views/setUpTotp.ts +++ b/package/src/models/views/setUpTotp.ts @@ -1,4 +1,5 @@ import { callUserfront } from "../../services/userfront"; +import { hasValue } from "../config/utils"; import { AuthContext, AuthMachineConfig, @@ -51,19 +52,32 @@ const setUpTotpConfig: AuthMachineConfig = { entry: "clearError", invoke: { // Set the code and call the API method - src: (context: AuthContext, event: AuthMachineEvent) => - callUserfront({ + src: (context: AuthContext, event: AuthMachineEvent) => { + const arg: Record = { + method: "totp", + }; + + if (hasValue((event).totpCode)) { + arg.totpCode = (event).totpCode; + } + + // API only requires email/emailOrUsername when logging in via first factor + if (!context.isSecondFactor) { + if (hasValue(context.user.email)) { + arg.email = context.user.email; + } else if (hasValue(context.user.emailOrUsername)) { + arg.emailOrUsername = context.user.emailOrUsername; + } + + arg.redirect = false; + } + + return callUserfront({ // Should ALWAYS be Userfront.login here method: "login", - args: [ - { - method: "totp", - totpCode: (event).totpCode, - email: context.user.email, - redirect: false, - }, - ], - }), + args: [arg], + }); + }, // On error, show the error message and return to the form onError: { actions: "setErrorFromApiError", @@ -98,8 +112,7 @@ const setUpTotpConfig: AuthMachineConfig = { ], }, }, - // Show an error message only - if there's a problem getting - // the QR code. + // Show an error message — only if there's a problem getting the QR code showErrorMessage: { on: { retry: "getQrCode", diff --git a/package/src/views/LogInWithPassword.jsx b/package/src/views/LogInWithPassword.jsx index 38b1636..3a235ca 100644 --- a/package/src/views/LogInWithPassword.jsx +++ b/package/src/views/LogInWithPassword.jsx @@ -33,20 +33,14 @@ const LogInWithPassword = ({ onEvent, allowBack, error }) => { }); } }; + return (
- - - At least 16 characters OR at least 8 characters including a number and - a letter. - +
diff --git a/package/test/model-based/models/mfa/setUpTotpCode.test.ts b/package/test/model-based/models/mfa/setUpTotpCode.test.ts new file mode 100644 index 0000000..dafa31f --- /dev/null +++ b/package/test/model-based/models/mfa/setUpTotpCode.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from "vitest"; +import setUpTotpConfig from "../../../../src/models/views/setUpTotp"; +import { createMachine, interpret } from "xstate"; +import { createTestMachine, createTestModel } from "@xstate/test"; +import * as actions from "../../../../src/models/config/actions"; +import * as guards from "../../../../src/models/config/guards"; +import { useMockUserfront, addGlobalStates } from "../../../utils"; +import { defaultAuthContext } from "../../../../src/models/forms/universal"; + +const machineOptions = { + actions, + guards, +}; + +const setUpTotpCodeMachine = createMachine( + addGlobalStates(setUpTotpConfig), + machineOptions +).withContext({ + config: defaultAuthContext.config, + user: { + email: "", + }, + action: "setup", + isSecondFactor: true, + activeFactor: { + channel: "authenticator", + strategy: "totp", + isConfiguredByUser: true, + }, + allowBack: true, +}); + +const testMachine = createTestMachine({ + initial: "gettingQrCode", + states: { + gettingQrCode: { + on: { + succeedGettingQrCode: "showingQrCode", + }, + }, + showingQrCode: { + on: { + submitQrCode: "confirmingQrCode", + back: "returnedToFactorSelection", + }, + }, + showingQrCodeWithError: { + on: { + submitQrCode: "confirmingQrCode", + back: "returnedToFactorSelection", + }, + }, + confirmingQrCode: { + on: { + failConfirmingCode: "showingQrCodeWithError", + succeedConfirmingCode: "showingBackupCodes", + }, + }, + showingBackupCodes: { + on: { + finish: "showingComplete", + }, + }, + showingComplete: {}, + returnedToFactorSelection: {}, + }, +}); + +const testModel = createTestModel(testMachine); + +/** + * Tests for setting up TOTP as a second factor to resolve reported bug: + * https://linear.app/userfront/issue/DEV-1046/bug-in-mfa-setup + * + * NOTE: Mock does not detect calls for store.user.getTotp(), so we are not + * able to resolve/reject like we do with the other `Userfront` methods. + * The userfront mock has been modified below to resolve the API's response. + */ +describe("model-based: models/mfa/setUpTotpCode", () => { + testModel.getPaths().forEach((path) => { + it(path.description, async () => { + const qrCode = "data:image/png;base64,testqrcode=="; + const backupCodes = [ + "60bb6-9393a", + "1b8ef-e3e4b", + "1488f-7cd2e", + "3169e-fa7e3", + ]; + // Mock must be initialized before starting machine + const mockUserfront = useMockUserfront({ + user: { + getTotp: () => { + return new Promise((resolve) => { + resolve({ + totpSecret: "testtotpsecret", + qrCode, + backupCodes, + }); + }); + }, + }, + }); + + const setUpTotpCodeService = interpret(setUpTotpCodeMachine); + setUpTotpCodeService.start(); + + const expected = { + totpCode: "123456", + // Requests + getQrCodeReq: { + success: { + qrCode, + backupCodes, + }, + }, + confirmQrCodeReq: { + error: { + message: "Confirm code error", + error: { + type: "ConfirmCodeError", + }, + }, + success: { + isMfaRequired: false, + }, + }, + }; + + await path.test({ + states: { + gettingQrCode: () => { + const state = setUpTotpCodeService.getSnapshot(); + expect(state.value).toEqual("getQrCode"); + expect(state.context.error).toBeFalsy(); + }, + showingQrCode: () => { + const state = setUpTotpCodeService.getSnapshot(); + expect(state.value).toEqual("showQrCode"); + expect(state.context.error).toBeFalsy(); + expect(state.context.view).toEqual({ + qrCode: expected.getQrCodeReq.success.qrCode, + backupCodes: expected.getQrCodeReq.success.backupCodes, + }); + }, + confirmingQrCode: () => { + const state = setUpTotpCodeService.getSnapshot(); + expect(state.value).toEqual("confirmTotpCode"); + expect(state.context.error).toBeFalsy(); + expect(mockUserfront.lastCall?.method).toEqual("login"); + + const arg = mockUserfront.lastCall?.args[0]; + expect(arg).not.toHaveProperty("email"); + expect(arg.method).toEqual("totp"); + expect(arg.totpCode).toEqual(expected.totpCode); + }, + showingQrCodeWithError: () => { + const state = setUpTotpCodeService.getSnapshot(); + expect(state.value).toEqual("showQrCode"); + expect(state.context.error).toEqual( + expected.confirmQrCodeReq.error + ); + }, + showingBackupCodes: () => { + const state = setUpTotpCodeService.getSnapshot(); + expect(state.value).toEqual("showBackupCodes"); + expect(state.context.error).toBeFalsy(); + }, + showingComplete: () => { + const state = setUpTotpCodeService.getSnapshot(); + expect(state.value).toEqual("showBackupCodes"); + expect(state.context.error).toBeFalsy(); + }, + returnedToFactorSelection: () => { + const state = setUpTotpCodeService.getSnapshot(); + expect(state.value).toEqual("backToFactors"); + expect(state.context.error).toBeFalsy(); + }, + }, + events: { + succeedGettingQrCode: async () => { + // Mock does not detect call for store.user.getTotp(); do not + // resolve the getQrCode response here + }, + submitQrCode: () => { + const { totpCode } = expected; + setUpTotpCodeService.send("submit", { totpCode }); + }, + back: () => { + setUpTotpCodeService.send("back"); + }, + failConfirmingCode: async () => { + try { + await mockUserfront.reject(expected.confirmQrCodeReq.error); + } catch (error) { + await Promise.resolve(); + return; + } + }, + succeedConfirmingCode: async () => { + try { + await mockUserfront.resolve(expected.confirmQrCodeReq.success); + } catch (error) { + await Promise.resolve(); + return; + } + }, + }, + }); + }); + }); +}); diff --git a/package/test/utils/index.ts b/package/test/utils/index.ts index 81b646b..24d7eab 100644 --- a/package/test/utils/index.ts +++ b/package/test/utils/index.ts @@ -80,11 +80,12 @@ const unexpectedReject = async () => { * * @returns {MockUserfrontService} - the mock Userfront service. Use service.proxy as the mock singleton. */ -export const createMockUserfront = () => { +export const createMockUserfront = (modifiedStore?: object) => { const calls: Call[] = []; const singleton = { store: { tenantId: "tenantId", + ...modifiedStore, }, }; const service = { @@ -210,8 +211,8 @@ export const createMockUserfront = () => { * * @returns {MockUserfrontService} - the mock service; the Userfront singleton has already been overridden with service.proxy. */ -export const useMockUserfront = () => { - const service = createMockUserfront(); +export const useMockUserfront = (modifiedStore?: object) => { + const service = createMockUserfront(modifiedStore); overrideUserfrontSingleton(service.proxy); return service; };