View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0012003 | Taler | merchant portal webui | public | 2026-11-10 23:41 | 2026-11-10 23:55 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | Reviewer | ||||
| Release signed by | Deployed by | ||||
| Verifier | Communicator | CM_7 | |||
| Data protection authority notification required | No | ||||
| CSIRT notification required | No | ||||
| Estimated effort (hours) | Actual effort (hours) | 0 | |||
| Fast-track | No | ||||
| Work package | none | ||||
| Priority | normal | Severity | minor | Reproducibility | have not tried |
| Status | new | Resolution | open | ||
| Summary | 0012003: Merchant portal: verification-code (PIN) steps and sign-in/sign-up/reset forms open without keyboard focus | ||||
| Description | What happens: Account creation (self-provisioning): after filling the form and pressing 'Create merchant account', the server asks for the email code and then the SMS code. Each 'Verify your email address' / 'Verify your phone number' step has a single input, but focus stays on the submit button ('Continue' / 'Complete setup'), so typing the code does nothing until the user clicks into the field (measured: typing 123456 left the field empty). The same TanCodeInputGroup is used by the sign-in 2FA step and by the 'Confirm it is you' MFA screen shown before protected actions (e.g. deleting a bank account); there nothing is focused at all (activeElement = BODY). The sign-up form, the sign-in page and the 'forgot password' page also open with nothing focused. Expected: The code field is focused as soon as a code step appears (including when moving on to the second challenge of an AND-combination). Sign-in focuses the account field, or the password when the account is remembered; sign-up and reset focus the first empty field; 'Edit email address' / 'Edit phone number' return to that field; the channel-selection step focuses the selected method. Where: - packages/taler-merchant-webui/src/ui/TanCodeInputGroup.tsx:55 - packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx - packages/taler-merchant-webui/src/screens/SignInScreen.tsx - packages/taler-merchant-webui/src/screens/ForgotPasswordScreen.tsx - packages/taler-merchant-webui/src/utils/focus.ts (new) Measured in headless Chromium (document.activeElement): before: sign-up email code: BUTTON[submit] 'Continue'; sms code: BUTTON[submit] 'Complete setup'; MFA: BODY; forgot: BODY; sign-up form: BODY after: #signup-email-code, #signup-sms-code, #signin-2fa-code, #reset-account, #signup-business; sign-in: #signin-account or #signin-password when remembered; edit phone: #signup-phone Related: 0011487 (closed) listed 'MFA should auto-focus when triggered'; its fix (e157ed6e2, a focus prop on the legacy SolveMFA component) was lost when e38ceeaba replaced the legacy portal with the new implementation, so this is a regression. Proposed fix: TanCodeInputGroup focuses its input on mount (Preact ignores autoFocus after page load); parents key it by the active challenge id so the next challenge refocuses. A small utils/focus.ts helper (first empty text field / checked radio) is used by SignInScreen, SelfProvisionScreen and ForgotPasswordScreen in a per-step effect. Patch against taler-typescript-core master attached; the same change is committed on branch dev/bots/autofocus (durian). Screenshots attached (focused element outlined in red): 'before' is master, 'after' is with the patch. Verified: tsc: no errors in touched files; real flow driven in headless Chromium (fill form, submit, solve email, reach SMS, go back) | ||||
| Tags | No tags attached. | ||||
| Attached Files | merchant-lead--auth-code-steps.diff (10,752 bytes)
diff --git a/packages/taler-merchant-webui/src/screens/ForgotPasswordScreen.tsx b/packages/taler-merchant-webui/src/screens/ForgotPasswordScreen.tsx
index ad86e8b58..541b87a2f 100644
--- a/packages/taler-merchant-webui/src/screens/ForgotPasswordScreen.tsx
+++ b/packages/taler-merchant-webui/src/screens/ForgotPasswordScreen.tsx
@@ -16,7 +16,7 @@
import { ActionFooter } from "../ui/FormActionFooter.js";
import type { VNode } from "preact";
-import { useState } from "preact/hooks";
+import { useEffect, useRef, useState } from "preact/hooks";
import { TalerMerchantApi } from "@gnu-taler/taler-util";
import { TalerLogo } from "../ui/TalerLogo.js";
import { CopyErrorButton } from "../ui/CopyErrorButton.js";
@@ -26,6 +26,7 @@ import {
} from "../stores/session.js";
import { useTranslation } from "../context/translation.js";
import { formatErrorMessage } from "../utils/errors.js";
+import { focusFirstEmptyField } from "../utils/focus.js";
import { RawLayout } from "../ui/RawLayout.js";
import { PasswordInput } from "../ui/PasswordInput.js";
@@ -64,6 +65,9 @@ export function ForgotPasswordScreen({
const [errorMsg, setErrorMsg] = useState<string>("");
const [rawError, setRawError] = useState<unknown>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+ // Start on the account, or on the new password when the account is known.
+ const form = useRef<HTMLFormElement>(null);
+ useEffect(() => focusFirstEmptyField(form.current), []);
const handleSubmit = async (e: Event) => {
e.preventDefault();
@@ -135,7 +139,7 @@ export function ForgotPasswordScreen({
}
>
<div class="bg-white py-6 px-4 shadow-sm border border-gray-200 sm:rounded-lg sm:px-8 space-y-5">
- <form onSubmit={handleSubmit} class="space-y-4">
+ <form ref={form} onSubmit={handleSubmit} class="space-y-4">
<div>
<label
htmlFor="reset-account"
diff --git a/packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx b/packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx
index b7717731d..c09868d72 100644
--- a/packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx
+++ b/packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx
@@ -32,6 +32,7 @@ import { type LoginTokenInfo } from "../stores/session.js";
import { presetMerchantBaseUrl } from "../stores/webuiConfig.js";
import { sanitizeTanCode, formatTanDigits } from "../utils/tan.js";
import { TanCodeInputGroup } from "../ui/TanCodeInputGroup.js";
+import { focusCheckedRadio, focusFirstEmptyField } from "../utils/focus.js";
import { PasswordInput } from "../ui/PasswordInput.js";
import type { ApiFailure } from "../api/failure.js";
import { normalizeUiFailure } from "../utils/errors.js";
@@ -277,6 +278,25 @@ export function SelfProvisionScreen({
(challenge) => challenge.challenge_id === challengeRound.activeChallengeId,
);
+ // Each step swaps the form's fields, so put the cursor on the one it asks
+ // for. Coming back to edit the address the code went to lands on that
+ // address; otherwise on the first field still empty. The code steps' inputs
+ // focus themselves.
+ const form = useRef<HTMLFormElement>(null);
+ const returnFocusId = useRef<string | undefined>(undefined);
+ useEffect(() => {
+ if (step === "form") {
+ const target = returnFocusId.current
+ ? document.getElementById(returnFocusId.current)
+ : null;
+ returnFocusId.current = undefined;
+ if (target) target.focus();
+ else focusFirstEmptyField(form.current);
+ } else if (step === "select_channel") {
+ focusCheckedRadio(form.current);
+ }
+ }, [step]);
+
const sendChallenge = async (challengeId: string): Promise<void> => {
try {
setResendCooldown(
@@ -701,7 +721,7 @@ export function SelfProvisionScreen({
</div>
)}
- <form onSubmit={handleSubmit} class="space-y-4">
+ <form ref={form} onSubmit={handleSubmit} class="space-y-4">
{step === "form" && (
<>
<div>
@@ -952,12 +972,14 @@ export function SelfProvisionScreen({
</div>
<TanCodeInputGroup
+ key={activeChallenge?.challenge_id}
id="signup-email-code"
value={emailCode}
onChange={setEmailCode}
resendCooldown={resendCooldown}
onResend={handleResendTanCodes}
onBack={() => {
+ returnFocusId.current = "signup-email";
setStep("form");
setErrorMsg("");
}}
@@ -992,12 +1014,14 @@ export function SelfProvisionScreen({
</div>
<TanCodeInputGroup
+ key={activeChallenge?.challenge_id}
id="signup-sms-code"
value={smsCode}
onChange={setSmsCode}
resendCooldown={resendCooldown}
onResend={handleResendTanCodes}
onBack={() => {
+ returnFocusId.current = "signup-phone";
setStep("form");
setErrorMsg("");
}}
diff --git a/packages/taler-merchant-webui/src/screens/SignInScreen.tsx b/packages/taler-merchant-webui/src/screens/SignInScreen.tsx
index 4df80b062..99446f273 100644
--- a/packages/taler-merchant-webui/src/screens/SignInScreen.tsx
+++ b/packages/taler-merchant-webui/src/screens/SignInScreen.tsx
@@ -28,6 +28,7 @@ import { CopyErrorButton } from "../ui/CopyErrorButton.js";
import { FooterControls } from "../ui/FooterControls.js";
import { useTranslation } from "../context/translation.js";
import { formatErrorMessage } from "../utils/errors.js";
+import { focusCheckedRadio, focusFirstEmptyField } from "../utils/focus.js";
import {
currentAccount,
lastMerchantAccountStore,
@@ -224,6 +225,15 @@ export function SignInScreen({
const maxAttemptsReached = attemptsCount >= 5;
+ // Each step swaps the form's fields, so put the cursor on the one it asks
+ // for: the account (or, when it is remembered, the password), or the chosen
+ // verification method. The code step's input focuses itself.
+ const form = useRef<HTMLFormElement>(null);
+ useEffect(() => {
+ if (step === "credentials") focusFirstEmptyField(form.current);
+ else if (step === "channel_select") focusCheckedRadio(form.current);
+ }, [step]);
+
/**
* Send the code when the screen opens already asking for one.
*
@@ -547,7 +557,7 @@ export function SignInScreen({
</div>
)}
- <form onSubmit={handleSubmit} class="space-y-5">
+ <form ref={form} onSubmit={handleSubmit} class="space-y-5">
{step === "credentials" && (
<>
<div>
@@ -671,6 +681,7 @@ export function SignInScreen({
</div>
<TanCodeInputGroup
+ key={activeChallengeId}
id="signin-2fa-code"
label={t`Authentication code`}
value={mfaCode}
diff --git a/packages/taler-merchant-webui/src/ui/TanCodeInputGroup.tsx b/packages/taler-merchant-webui/src/ui/TanCodeInputGroup.tsx
index f09db90cd..18763994f 100644
--- a/packages/taler-merchant-webui/src/ui/TanCodeInputGroup.tsx
+++ b/packages/taler-merchant-webui/src/ui/TanCodeInputGroup.tsx
@@ -15,6 +15,7 @@
*/
import type { VNode } from "preact";
+import { useEffect, useRef } from "preact/hooks";
import { useTranslation } from "../context/translation.js";
import { formatTanDigits } from "../utils/tan.js";
@@ -42,6 +43,14 @@ export function TanCodeInputGroup({
backLabel,
}: TanCodeInputGroupProps): VNode {
const { t } = useTranslation();
+ const input = useRef<HTMLInputElement>(null);
+ // The code is the only thing this step asks for, so typing should land in it
+ // straight away. Preact does not act on `autoFocus` for elements rendered
+ // after page load, hence the explicit call; it runs once per mount, so a
+ // parent that moves to the next challenge remounts this with a new `key`.
+ useEffect(() => {
+ input.current?.focus();
+ }, []);
return (
<div>
@@ -52,6 +61,7 @@ export function TanCodeInputGroup({
{label || t`Verification code`}
</label>
<input
+ ref={input}
id={id}
type="text"
value={value}
diff --git a/packages/taler-merchant-webui/src/utils/focus.ts b/packages/taler-merchant-webui/src/utils/focus.ts
new file mode 100644
index 000000000..df2c86d2e
--- /dev/null
+++ b/packages/taler-merchant-webui/src/utils/focus.ts
@@ -0,0 +1,45 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Focus the field a person has to fill next in `root`: the first enabled text
+ * entry that is still empty, else the first one. Prefilled values (a
+ * remembered account name, a restored draft) are skipped so the cursor lands
+ * on what is actually missing.
+ *
+ * Preact does not act on `autoFocus` for elements rendered after the initial
+ * page load, so screens call this when a step appears.
+ */
+export function focusFirstEmptyField(
+ root: ParentNode | null | undefined,
+): void {
+ if (!root) return;
+ const fields = Array.from(
+ root.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
+ 'input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]):not([type="button"]):not([type="submit"]), textarea',
+ ),
+ ).filter((field) => !field.disabled && !field.readOnly);
+ (fields.find((field) => field.value === "") ?? fields[0])?.focus();
+}
+
+/** Focus the chosen option of a radio group in `root`, else the first one. */
+export function focusCheckedRadio(root: ParentNode | null | undefined): void {
+ if (!root) return;
+ (
+ root.querySelector<HTMLInputElement>('input[type="radio"]:checked') ??
+ root.querySelector<HTMLInputElement>('input[type="radio"]')
+ )?.focus();
+}
| ||||
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-10 23:41 | bot-cg | New Issue | |
| 2026-11-10 23:41 | bot-cg | Relationship added | child of 0012002 |
| 2026-11-10 23:41 | bot-cg | Relationship added | related to 0011487 |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-lead--auth-code-steps.diff | |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-lead--signup-email-code--before.png | |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-lead--signup-email-code--after.png | |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-lead--signup-sms-code--before.png | |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-lead--signup-sms-code--after.png | |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-lead--mfa-code--before.png | |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-lead--mfa-code--after.png | |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-lead--forgot--before.png | |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-lead--forgot--after.png | |
| 2026-11-10 23:55 | bot-cg | Note Added: 0030391 |