diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js
index 31ea47637..6cf7629b4 100644
--- a/Parse-Dashboard/app.js
+++ b/Parse-Dashboard/app.js
@@ -1213,6 +1213,7 @@ You have direct access to the Parse database through function calls, so you can
Parse Dashboard
diff --git a/README.md b/README.md
index 774838729..6f7070623 100644
--- a/README.md
+++ b/README.md
@@ -189,6 +189,7 @@ This section provides a comprehensive reference for all Parse Dashboard configur
| `iconsFolder` | String | No | - | - | - | `"icons"` | Folder for app icons (relative or absolute path) | - |
| `agent` | Object | No | - | - | `PARSE_DASHBOARD_AGENT` (JSON) | `{...}` | AI agent configuration | [AI Agent Configuration](#ai-agent) |
| `enableResourceCache` | Boolean | No | `false` | - | - | `true` | Enable browser caching of dashboard resources | - |
+| `otpAutoSubmit` | Boolean | No | `true` | - | - | `false` | Automatically submit the login form once a complete one-time password has been entered | [Multi-Factor Authentication](#multi-factor-authentication-one-time-password) |
##### App Options
@@ -779,6 +780,16 @@ If you create a new user by running `parse-dashboard --createUser`, you will be
Parse Dashboard follows the industry standard and supports the common OTP algorithm `SHA-1` by default, to be compatible with most authenticator apps. If you have specific security requirements regarding TOTP characteristics (algorithm, digit length, time period) you can customize them by using the guided configuration mentioned above.
+By default, the login form is submitted automatically as soon as a one-time password of the expected length has been entered, so the user does not have to click the login button. If you prefer the user to review the entered one-time password and submit the form manually, set the `otpAutoSubmit` option to `false`:
+
+```json
+{
+ "apps": [{"...": "..."}],
+ "otpAutoSubmit": false,
+ "users": [{"...": "..."}]
+}
+```
+
### Running Multiple Dashboard Replicas
When deploying Parse Dashboard with multiple replicas behind a load balancer, you need to use a shared session store to ensure that CSRF tokens and user sessions work correctly across all replicas. Without a shared session store, login attempts may fail with "CSRF token validation failed" errors when requests are distributed across different replicas.
diff --git a/src/lib/tests/OtpAutoSubmit.test.js b/src/lib/tests/OtpAutoSubmit.test.js
new file mode 100644
index 000000000..cc5ea66a9
--- /dev/null
+++ b/src/lib/tests/OtpAutoSubmit.test.js
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2016-present, Parse, LLC
+ * All rights reserved.
+ *
+ * This source code is licensed under the license found in the LICENSE file in
+ * the root directory of this source tree.
+ */
+jest.dontMock('../../../Parse-Dashboard/Authentication.js');
+jest.dontMock('../../../Parse-Dashboard/app.js');
+
+const express = require('express');
+const http = require('http');
+
+const SESSION_SECRET = 'test-secret';
+
+/**
+ * Fetch the login page, which is the only page that carries the OTP
+ * auto-submit flag.
+ */
+function getLoginPage(port) {
+ return new Promise((resolve, reject) => {
+ const req = http.request(
+ { hostname: '127.0.0.1', port, path: '/login', method: 'GET' },
+ res => {
+ let data = '';
+ res.on('data', chunk => (data += chunk));
+ res.on('end', () => resolve({ status: res.statusCode, raw: data }));
+ }
+ );
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+/**
+ * Build a dashboard config with users, since the login page is only served
+ * when users are configured.
+ */
+function configWithUsers(overrides = {}) {
+ return {
+ apps: [
+ {
+ serverURL: 'http://localhost:1337/parse',
+ appId: 'testAppId',
+ masterKey: 'testMasterKey',
+ appName: 'TestApp',
+ },
+ ],
+ users: [{ user: 'admin', pass: 'admin' }],
+ ...overrides,
+ };
+}
+
+function startDashboard(config) {
+ return new Promise(resolve => {
+ const parseDashboard = require('../../../Parse-Dashboard/app.js');
+ const parentApp = express();
+ parentApp.use('/', parseDashboard(config, { cookieSessionSecret: SESSION_SECRET }));
+
+ const server = parentApp.listen(0, () => resolve({ server, port: server.address().port }));
+ });
+}
+
+function stopDashboard(server) {
+ return new Promise(resolve => (server ? server.close(resolve) : resolve()));
+}
+
+describe('OTP auto-submit option', () => {
+ let server;
+
+ afterEach(async () => {
+ await stopDashboard(server);
+ server = undefined;
+ });
+
+ it('enables auto-submit when the option is not set', async () => {
+ let port;
+ ({ server, port } = await startDashboard(configWithUsers()));
+
+ const res = await getLoginPage(port);
+
+ expect(res.status).toBe(200);
+ expect(res.raw).toContain('PARSE_DASHBOARD_OTP_AUTO_SUBMIT = true;');
+ });
+
+ it('enables auto-submit when the option is set to true', async () => {
+ let port;
+ ({ server, port } = await startDashboard(configWithUsers({ otpAutoSubmit: true })));
+
+ const res = await getLoginPage(port);
+
+ expect(res.raw).toContain('PARSE_DASHBOARD_OTP_AUTO_SUBMIT = true;');
+ });
+
+ it('disables auto-submit when the option is set to false', async () => {
+ let port;
+ ({ server, port } = await startDashboard(configWithUsers({ otpAutoSubmit: false })));
+
+ const res = await getLoginPage(port);
+
+ expect(res.raw).toContain('PARSE_DASHBOARD_OTP_AUTO_SUBMIT = false;');
+ });
+});
diff --git a/src/login/Login.js b/src/login/Login.js
index 15d00b872..70bfd58ec 100644
--- a/src/login/Login.js
+++ b/src/login/Login.js
@@ -42,6 +42,10 @@ export default class Login extends React.Component {
this.inputRefPass = React.createRef();
this.inputRefMfa = React.createRef();
this.otpLength = otpLength;
+ // Auto-submitting the login form once the full one-time password has been
+ // entered is enabled by default and can be turned off via the
+ // `otpAutoSubmit` dashboard option.
+ this.otpAutoSubmit = window.PARSE_DASHBOARD_OTP_AUTO_SUBMIT !== false;
}
componentDidMount() {
@@ -65,7 +69,7 @@ export default class Login extends React.Component {
const { path } = this.props;
const updateField = (field, e) => {
this.setState({ [field]: e.target.value });
- if (field === 'otp' && e.target.value.length >= this.otpLength) {
+ if (this.otpAutoSubmit && field === 'otp' && e.target.value.length >= this.otpLength) {
const input = document.querySelectorAll('input');
for (const field of input) {
if (field.type === 'submit') {