Skip to content
This repository was archived by the owner on Mar 19, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 25 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Userfront.init("myTenantId");
| `<PasswordResetForm />` | `<password-reset-form></password-reset-form>` |
| `<LogoutButton />` | `<logout-button></logout-button>` |

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: `<signup-form />`.

The Vue components are `<kebab-case>` because they are Web Components under the hood, and Web Components are required to be in kebab-case.
Expand All @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
39 changes: 26 additions & 13 deletions package/src/models/views/setUpTotp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { callUserfront } from "../../services/userfront";
import { hasValue } from "../config/utils";
import {
AuthContext,
AuthMachineConfig,
Expand Down Expand Up @@ -51,19 +52,32 @@ const setUpTotpConfig: AuthMachineConfig = {
entry: "clearError",
invoke: {
// Set the code and call the API method
src: (context: AuthContext<any>, event: AuthMachineEvent) =>
callUserfront({
src: (context: AuthContext<any>, event: AuthMachineEvent) => {
const arg: Record<string, any> = {
method: "totp",
};

if (hasValue((<TotpCodeSubmitEvent>event).totpCode)) {
arg.totpCode = (<TotpCodeSubmitEvent>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: (<TotpCodeSubmitEvent>event).totpCode,
email: context.user.email,
redirect: false,
},
],
}),
args: [arg],
});
},
Comment on lines +55 to +80
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The email/emailOrUsername check has been added here.

// On error, show the error message and return to the form
onError: {
actions: "setErrorFromApiError",
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 2 additions & 8 deletions package/src/views/LogInWithPassword.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,14 @@ const LogInWithPassword = ({ onEvent, allowBack, error }) => {
});
}
};

return (
<form onSubmit={handleSubmit} className="userfront-form">
<div className="userfront-form-row">
<Input.EmailOrUsername showError={emailOrUsernameError} />
</div>
<div className="userfront-form-row">
<Input.Password showError={passwordError} />
<span
className="userfront-secondary-text"
id="userfront-password-rules"
>
At least 16 characters OR at least 8 characters including a number and
a letter.
</span>
<Input.Password label="Password" showError={passwordError} />
</div>
<ErrorMessage error={error} />
<div className="userfront-button-row">
Expand Down
211 changes: 211 additions & 0 deletions package/test/model-based/models/mfa/setUpTotpCode.test.ts
Original file line number Diff line number Diff line change
@@ -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),
<any>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;
}
},
},
});
});
});
});
Loading