View Issue Details

IDProjectCategoryView StatusLast Update
0012003Talermerchant portal webuipublic2026-11-10 23:55
Reporterbot-cg [agent] Assigned ToResolution deadline
ImplementerReviewer 
Release signed byDeployed by 
VerifierCommunicatorCM_7  
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeverityminorReproducibilityhave not tried
Status newResolutionopen 
Summary0012003: Merchant portal: verification-code (PIN) steps and sign-in/sign-up/reset forms open without keyboard focus
DescriptionWhat 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)
TagsNo 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();
+}

Required notifications

Address or linkRemarkAdded byNotifiedActions

Relationships

related to 0011487 closedsebasjm UI polish found after QC 
child of 0012002 new Merchant portal: dialogs, PIN/code prompts and single-purpose forms open without keyboard focus on the obvious field 

Activities

bot-cg [agent]

2026-11-10 23:55

updater   ~0030391

Fixed by 4693f4f1d on branch dev/bots/autofocus (durian); c0c3962de, which keeps the merchant tutorial's live previews from taking focus, should land together with it.

Issue History

Date ModifiedUsernameFieldChange
2026-11-10 23:41bot-cgNew Issue
2026-11-10 23:41bot-cgRelationship addedchild of 0012002
2026-11-10 23:41bot-cgRelationship addedrelated to 0011487
2026-11-10 23:43bot-cgFile Added: merchant-lead--auth-code-steps.diff
2026-11-10 23:43bot-cgFile Added: merchant-lead--signup-email-code--before.png
2026-11-10 23:43bot-cgFile Added: merchant-lead--signup-email-code--after.png
2026-11-10 23:43bot-cgFile Added: merchant-lead--signup-sms-code--before.png
2026-11-10 23:43bot-cgFile Added: merchant-lead--signup-sms-code--after.png
2026-11-10 23:43bot-cgFile Added: merchant-lead--mfa-code--before.png
2026-11-10 23:43bot-cgFile Added: merchant-lead--mfa-code--after.png
2026-11-10 23:43bot-cgFile Added: merchant-lead--forgot--before.png
2026-11-10 23:43bot-cgFile Added: merchant-lead--forgot--after.png
2026-11-10 23:55bot-cgNote Added: 0030391