From 2de98b2fba405d96cc9cb402902ed5658f80fd30 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Fri, 25 Sep 2026 10:38:46 +0200 Subject: [PATCH 1/3] feat: use the shared two-factor code input style Add the .two-factor-code-input class from server's guest.css to the challenge input, so it gets the larger, full-width style used by the other 2FA challenge pages. Without that server change the class has no effect. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: skjnldsv --- templates/challenge.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/challenge.php b/templates/challenge.php index 0b3d95323..daecfa428 100644 --- a/templates/challenge.php +++ b/templates/challenge.php @@ -13,7 +13,7 @@

t('Get the authentication code from the two-factor authentication app on your device.')) ?>

- + From 49c3f95e19532eba449bceb7a72f73b9bc62e1bb Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Fri, 25 Sep 2026 10:49:39 +0200 Subject: [PATCH 2/3] feat: only accept digits in the code input Strip non-digit characters while typing or pasting, so a code pasted as "123 456" becomes "123456". The input also gets pattern="[0-9]{6,10}" as native validation when JavaScript is not available. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: skjnldsv --- src/challenge.js | 28 ++++++++++++++++++++++++++ src/main-challenge.js | 11 ++++++++++ src/tests/challenge.spec.js | 40 +++++++++++++++++++++++++++++++++++++ templates/challenge.php | 3 ++- webpack.config.js | 1 + 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/challenge.js create mode 100644 src/main-challenge.js create mode 100644 src/tests/challenge.spec.js diff --git a/src/challenge.js b/src/challenge.js new file mode 100644 index 000000000..e30d945eb --- /dev/null +++ b/src/challenge.js @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Remove every non-digit character from a TOTP code + * + * @param {string} value the raw input value + * @return {string} + */ +export function sanitizeCode(value) { + return value.replace(/\D/g, '') +} + +/** + * Keep only digits in the given input while the user types or pastes + * + * @param {HTMLInputElement} input the TOTP code input + */ +export function restrictToDigits(input) { + input.addEventListener('input', () => { + const sanitized = sanitizeCode(input.value) + if (sanitized !== input.value) { + input.value = sanitized + } + }) +} diff --git a/src/main-challenge.js b/src/main-challenge.js new file mode 100644 index 000000000..9880e78d5 --- /dev/null +++ b/src/main-challenge.js @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { restrictToDigits } from './challenge.js' + +const input = document.querySelector('.totp-form input[name="challenge"]') +if (input) { + restrictToDigits(input) +} diff --git a/src/tests/challenge.spec.js b/src/tests/challenge.spec.js new file mode 100644 index 000000000..454ed6bd9 --- /dev/null +++ b/src/tests/challenge.spec.js @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { restrictToDigits, sanitizeCode } from '../challenge.js' + +describe('sanitizeCode', () => { + it.each([ + ['123456', '123456'], + ['123 456', '123456'], + ['12-34-56', '123456'], + ['abc', ''], + ['', ''], + ])('turns %p into %p', (value, expected) => { + expect(sanitizeCode(value)).to.equal(expected) + }) +}) + +describe('restrictToDigits', () => { + it('strips non-digits on input', () => { + const input = document.createElement('input') + restrictToDigits(input) + + input.value = '12a3 4' + input.dispatchEvent(new Event('input')) + + expect(input.value).to.equal('1234') + }) + + it('leaves a digit-only value untouched', () => { + const input = document.createElement('input') + restrictToDigits(input) + + input.value = '123456' + input.dispatchEvent(new Event('input')) + + expect(input.value).to.equal('123456') + }) +}) diff --git a/templates/challenge.php b/templates/challenge.php index daecfa428..e887e429a 100644 --- a/templates/challenge.php +++ b/templates/challenge.php @@ -6,6 +6,7 @@ */ style('twofactor_totp', 'style'); +script('twofactor_totp', 'twofactor_totp-main-challenge'); ?> @@ -13,7 +14,7 @@

t('Get the authentication code from the two-factor authentication app on your device.')) ?>

- + diff --git a/webpack.config.js b/webpack.config.js index 5c12b54c1..0d0cf71a4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -12,6 +12,7 @@ const isDev = process.env.NODE_ENV === 'development' webpackConfig.entry = { 'main-settings': path.join(__dirname, 'src', 'main-settings.js'), 'main-login-setup': path.join(__dirname, 'src', 'main-login-setup.js'), + 'main-challenge': path.join(__dirname, 'src', 'main-challenge.js'), } // Generate reuse license files if not in development mode From a8320fc9355da2ee124929f6c57ab300a03e4d7e Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Fri, 25 Sep 2026 10:49:39 +0200 Subject: [PATCH 3/3] chore(assets): compile assets Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: skjnldsv --- js/twofactor_totp-main-challenge.js | 2 ++ js/twofactor_totp-main-challenge.js.license | 8 ++++++++ js/twofactor_totp-main-challenge.js.map | 1 + js/twofactor_totp-main-challenge.js.map.license | 1 + 4 files changed, 12 insertions(+) create mode 100644 js/twofactor_totp-main-challenge.js create mode 100644 js/twofactor_totp-main-challenge.js.license create mode 100644 js/twofactor_totp-main-challenge.js.map create mode 120000 js/twofactor_totp-main-challenge.js.map.license diff --git a/js/twofactor_totp-main-challenge.js b/js/twofactor_totp-main-challenge.js new file mode 100644 index 000000000..b87ed7956 --- /dev/null +++ b/js/twofactor_totp-main-challenge.js @@ -0,0 +1,2 @@ +(()=>{"use strict";const e=document.querySelector('.totp-form input[name="challenge"]');e&&function(e){e.addEventListener("input",()=>{const t=e.value.replace(/\D/g,"");t!==e.value&&(e.value=t)})}(e)})(); +//# sourceMappingURL=twofactor_totp-main-challenge.js.map?v=85242c9a4e4d172c7b4f \ No newline at end of file diff --git a/js/twofactor_totp-main-challenge.js.license b/js/twofactor_totp-main-challenge.js.license new file mode 100644 index 000000000..02786e352 --- /dev/null +++ b/js/twofactor_totp-main-challenge.js.license @@ -0,0 +1,8 @@ +SPDX-License-Identifier: AGPL-3.0-or-later +SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors + + +This file is generated from multiple sources. Included packages: +- nextcloud + - version: 1.0.0 + - license: AGPL-3.0-or-later diff --git a/js/twofactor_totp-main-challenge.js.map b/js/twofactor_totp-main-challenge.js.map new file mode 100644 index 000000000..02163b75b --- /dev/null +++ b/js/twofactor_totp-main-challenge.js.map @@ -0,0 +1 @@ +{"version":3,"file":"twofactor_totp-main-challenge.js?v=85242c9a4e4d172c7b4f","mappings":"mBAOA,MAAMA,EAAQC,SAASC,cAAc,sCACjCF,GCYG,SAA0BA,GAChCA,EAAMG,iBAAiB,QAAS,KAC/B,MAAMC,EAAyBJ,EAAMK,MAVzBC,QAAQ,MAAO,IAWvBF,IAAcJ,EAAMK,QACvBL,EAAMK,MAAQD,IAGjB,CDlBCG,CAAiBP,E","sources":["webpack:///twofactor_totp/src/main-challenge.js","webpack:///twofactor_totp/src/challenge.js"],"sourcesContent":["/*\n * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\nimport { restrictToDigits } from './challenge.js'\n\nconst input = document.querySelector('.totp-form input[name=\"challenge\"]')\nif (input) {\n\trestrictToDigits(input)\n}\n","/*\n * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors\n * SPDX-License-Identifier: AGPL-3.0-or-later\n */\n\n/**\n * Remove every non-digit character from a TOTP code\n *\n * @param {string} value the raw input value\n * @return {string}\n */\nexport function sanitizeCode(value) {\n\treturn value.replace(/\\D/g, '')\n}\n\n/**\n * Keep only digits in the given input while the user types or pastes\n *\n * @param {HTMLInputElement} input the TOTP code input\n */\nexport function restrictToDigits(input) {\n\tinput.addEventListener('input', () => {\n\t\tconst sanitized = sanitizeCode(input.value)\n\t\tif (sanitized !== input.value) {\n\t\t\tinput.value = sanitized\n\t\t}\n\t})\n}\n"],"names":["input","document","querySelector","addEventListener","sanitized","value","replace","restrictToDigits"],"sourceRoot":""} \ No newline at end of file diff --git a/js/twofactor_totp-main-challenge.js.map.license b/js/twofactor_totp-main-challenge.js.map.license new file mode 120000 index 000000000..a6600d8c4 --- /dev/null +++ b/js/twofactor_totp-main-challenge.js.map.license @@ -0,0 +1 @@ +twofactor_totp-main-challenge.js.license \ No newline at end of file