taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit ac94e4b30f380d9ad469ca586be0bf607c6a97f7
parent 667d6e8d126968042cdf10700b123b2e86d9f3bf
Author: Florian Dold <dold@taler.net>
Date:   Tue,  3 Nov 2026 21:51:31 +0100

merchant web UI: pin auth and consent errors

Keep authentication and consent failures visible in sticky action
footers.

Diffstat:
Mpackages/taler-merchant-webui/src/screens/BootstrapInstanceScreen.tsx | 53++++++++++++++++++++++++++++++++---------------------
Mpackages/taler-merchant-webui/src/screens/ChangeServerUrlScreen.tsx | 75+++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mpackages/taler-merchant-webui/src/screens/ForgotPasswordScreen.tsx | 77++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mpackages/taler-merchant-webui/src/screens/KycAuthInstructionsScreen.tsx | 100+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx | 95++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mpackages/taler-merchant-webui/src/screens/SignInScreen.tsx | 124+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
6 files changed, 296 insertions(+), 228 deletions(-)

diff --git a/packages/taler-merchant-webui/src/screens/BootstrapInstanceScreen.tsx b/packages/taler-merchant-webui/src/screens/BootstrapInstanceScreen.tsx @@ -7,6 +7,7 @@ Foundation; either version 3, or (at your option) any later version. */ +import { ActionFooter } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useRef, useState } from "preact/hooks"; import type { TalerMerchantApi } from "@gnu-taler/taler-util"; @@ -109,19 +110,6 @@ export function BootstrapInstanceScreen({ <strong class="block mb-1">{t`Create the first merchant instance`}</strong> {t`This server has no merchant instances yet. Its first instance must be the administrator account, which can create and manage other merchant accounts.`} </div> - <ApiErrorBanner - error={error} - title={t`Could not create the administrator account`} - /> - {validation && ( - <div - data-error-banner - role="alert" - class="mb-4 rounded-md border border-red-200 bg-red-50 p-3 text-xs font-semibold text-red-800" - > - {validation} - </div> - )} <form onSubmit={(event) => void submit(event)} class="space-y-5"> <div> <label @@ -186,15 +174,38 @@ export function BootstrapInstanceScreen({ required minLength={8} /> - <button - type="submit" - disabled={pending} - class="w-full py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-bold text-white bg-taler-brand hover:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed" + <ActionFooter + feedback={ + <> + <ApiErrorBanner + compact + error={error} + title={t`Could not create the administrator account`} + /> + {validation && ( + <div + data-error-banner + role="alert" + class="mb-4 rounded-md border border-red-200 bg-red-50 p-3 text-xs font-semibold text-red-800" + > + {validation} + </div> + )} + </> + } > - {pending - ? t`Creating administrator account...` - : t`Create administrator account`} - </button> + <div class="w-full"> + <button + type="submit" + disabled={pending} + class="w-full py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-bold text-white bg-taler-brand hover:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed" + > + {pending + ? t`Creating administrator account...` + : t`Create administrator account`} + </button> + </div> + </ActionFooter> </form> </div> </div> diff --git a/packages/taler-merchant-webui/src/screens/ChangeServerUrlScreen.tsx b/packages/taler-merchant-webui/src/screens/ChangeServerUrlScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { ActionFooter } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState } from "preact/hooks"; import { TalerLogo } from "../ui/TalerLogo.js"; @@ -176,20 +177,6 @@ export function ChangeServerUrlScreen({ : "bg-white py-8 px-4 shadow-sm rounded-lg sm:px-10 border border-gray-200 space-y-5" } > - {errorMsg && ( - <div - data-error-banner - role="alert" - class="p-2.5 bg-red-50 border border-red-200 text-red-800 text-xs font-semibold rounded-md flex items-center justify-between" - > - <div>⚠️ {errorMsg}</div> - <CopyErrorButton - textToCopy={errorMsg} - className="ml-2 shrink-0" - /> - </div> - )} - <div class="p-3 bg-amber-50 border border-amber-200 text-amber-900 text-xs rounded-md"> <p class="font-bold">{t`Changing server changes which merchant account you access.`}</p> <p class="mt-1 leading-relaxed"> @@ -242,24 +229,48 @@ export function ChangeServerUrlScreen({ </div> </div> - <div class="pt-1 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> - <button - type="button" - onClick={onCancel} - class="rounded border border-gray-300 px-4 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50" - > - {t`Cancel`} - </button> - <button - type="submit" - disabled={isVerifying} - class="rounded border border-transparent bg-taler-brand px-4 py-2 text-xs font-bold text-white shadow-sm transition-colors hover:bg-blue-900 focus:outline-none disabled:opacity-50" - > - {isVerifying - ? t`Verifying /config...` - : t`Save & Apply Server URL`} - </button> - </div> + <ActionFooter + feedback={ + <> + {errorMsg && ( + <div + data-error-banner + role="alert" + class="p-2.5 bg-red-50 border border-red-200 text-red-800 text-xs font-semibold rounded-md flex items-center justify-between" + > + <div class="min-w-0 max-h-[20dvh] overflow-auto [overflow-wrap:anywhere]"> + ⚠️ {errorMsg} + </div> + <CopyErrorButton + textToCopy={errorMsg} + className="ml-2 shrink-0" + /> + </div> + )} + </> + } + > + <div class="w-full"> + <div class="pt-1 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> + <button + type="button" + onClick={onCancel} + class="rounded border border-gray-300 px-4 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50" + > + {t`Cancel`} + </button> + <button + type="submit" + disabled={isVerifying} + class="rounded border border-transparent bg-taler-brand px-4 py-2 text-xs font-bold text-white shadow-sm transition-colors hover:bg-blue-900 focus:outline-none disabled:opacity-50" + > + {isVerifying + ? t`Verifying /config...` + : t`Save & Apply Server URL`} + </button> + </div> + </div> + </ActionFooter> </form> </div> </div> diff --git a/packages/taler-merchant-webui/src/screens/ForgotPasswordScreen.tsx b/packages/taler-merchant-webui/src/screens/ForgotPasswordScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { ActionFooter } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState } from "preact/hooks"; import { TalerMerchantApi } from "@gnu-taler/taler-util"; @@ -134,21 +135,6 @@ 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"> - {errorMsg && ( - <div - data-error-banner - role="alert" - class="p-3 bg-red-50 border border-red-200 text-red-800 text-xs rounded-md font-semibold flex items-center justify-between" - > - <div>⚠️ {errorMsg}</div> - <CopyErrorButton - textToCopy={errorMsg} - errorDetail={rawError} - className="ml-2 shrink-0" - /> - </div> - )} - <form onSubmit={handleSubmit} class="space-y-4"> <div> <label @@ -189,26 +175,51 @@ export function ForgotPasswordScreen({ minLength={8} /> - <div class="pt-2 space-y-2"> - <button - type="submit" - disabled={isSubmitting} - class="w-full py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-bold text-white bg-taler-brand hover:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50" - > - {isSubmitting - ? t`Requesting reset...` - : t`Continue to Verification`} - </button> + <ActionFooter + feedback={ + <> + {errorMsg && ( + <div + data-error-banner + role="alert" + class="p-3 bg-red-50 border border-red-200 text-red-800 text-xs rounded-md font-semibold flex items-center justify-between" + > + <div class="min-w-0 max-h-[20dvh] overflow-auto [overflow-wrap:anywhere]"> + ⚠️ {errorMsg} + </div> + <CopyErrorButton + textToCopy={errorMsg} + errorDetail={rawError} + className="ml-2 shrink-0" + /> + </div> + )} + </> + } + > + <div class="w-full"> + <div class="pt-2 space-y-2"> + <button + type="submit" + disabled={isSubmitting} + class="w-full py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-bold text-white bg-taler-brand hover:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50" + > + {isSubmitting + ? t`Requesting reset...` + : t`Continue to Verification`} + </button> - <div class="text-center pt-2"> - <a - href="#/signin" - class="text-xs font-semibold text-taler-brand hover:underline" - > - {t`← Back to Sign In`} - </a> + <div class="text-center pt-2"> + <a + href="#/signin" + class="text-xs font-semibold text-taler-brand hover:underline" + > + {t`← Back to Sign In`} + </a> + </div> + </div> </div> - </div> + </ActionFooter> </form> </div> </div> diff --git a/packages/taler-merchant-webui/src/screens/KycAuthInstructionsScreen.tsx b/packages/taler-merchant-webui/src/screens/KycAuthInstructionsScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { ActionFooter } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState, useEffect, useRef } from "preact/hooks"; import { @@ -446,53 +447,60 @@ export function KycAuthInstructionsScreen({ )} </div> - {tosError && ( - <ApiErrorBanner - error={tosError} - title={t`The terms acceptance could not be recorded`} - actionLabel={t`Retry`} - onAction={() => - void (tosErrorState?.operation === "load" - ? handleReadTerms() - : handleAcceptTos()) - } - isActionPending={submittingTos} - /> - )} + <ActionFooter + feedback={ + <> + {tosError && ( + <ApiErrorBanner + compact + error={tosError} + title={t`The terms acceptance could not be recorded`} + actionLabel={t`Retry`} + onAction={() => + void (tosErrorState?.operation === "load" + ? handleReadTerms() + : handleAcceptTos()) + } + isActionPending={submittingTos} + /> + )} + </> + } + > + <div class="pt-3 border-t border-amber-200 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> + <label class="flex items-center space-x-2 cursor-pointer text-xs font-medium text-amber-950 select-none"> + <input + type="checkbox" + checked={tosCheckbox} + disabled={!termsVersion || loadingTerms} + onChange={(e) => + setTosCheckboxFor( + (e.target as HTMLInputElement).checked + ? currentConsentGeneration + : undefined, + ) + } + class="h-4 w-4 text-amber-600 rounded border-amber-400 focus:ring-amber-500" + /> + <span>{t`I have read and agree to the Terms of Service for ${exchangeHost}`}</span> + </label> - <div class="pt-3 border-t border-amber-200 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> - <label class="flex items-center space-x-2 cursor-pointer text-xs font-medium text-amber-950 select-none"> - <input - type="checkbox" - checked={tosCheckbox} - disabled={!termsVersion || loadingTerms} - onChange={(e) => - setTosCheckboxFor( - (e.target as HTMLInputElement).checked - ? currentConsentGeneration - : undefined, - ) - } - class="h-4 w-4 text-amber-600 rounded border-amber-400 focus:ring-amber-500" - /> - <span>{t`I have read and agree to the Terms of Service for ${exchangeHost}`}</span> - </label> - - <button - type="button" - disabled={!tosCheckbox || !termsVersion || submittingTos} - onClick={handleAcceptTos} - class={`px-4 py-2 text-xs font-semibold rounded-md shadow-xs transition-colors shrink-0 ${ - tosCheckbox && termsVersion && !submittingTos - ? "bg-amber-600 hover:bg-amber-700 text-white cursor-pointer" - : "bg-amber-200 text-amber-500 cursor-not-allowed" - }`} - > - {submittingTos - ? t`Recording your acceptance…` - : t`Accept the terms`} - </button> - </div> + <button + type="button" + disabled={!tosCheckbox || !termsVersion || submittingTos} + onClick={handleAcceptTos} + class={`px-4 py-2 text-xs font-semibold rounded-md shadow-xs transition-colors shrink-0 ${ + tosCheckbox && termsVersion && !submittingTos + ? "bg-amber-600 hover:bg-amber-700 text-white cursor-pointer" + : "bg-amber-200 text-amber-500 cursor-not-allowed" + }`} + > + {submittingTos + ? t`Recording your acceptance…` + : t`Accept the terms`} + </button> + </div> + </ActionFooter> </div> )} diff --git a/packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx b/packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { ActionFooter } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState, useEffect, useRef } from "preact/hooks"; import { @@ -700,21 +701,6 @@ export function SelfProvisionScreen({ </div> )} - {errorMsg && ( - <div - data-error-banner - role="alert" - class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 text-xs font-medium rounded-md flex items-center justify-between" - > - <div>⚠️ {errorMsg}</div> - <CopyErrorButton - textToCopy={errorMsg} - errorDetail={rawError} - className="ml-2 shrink-0" - /> - </div> - )} - <form onSubmit={handleSubmit} class="space-y-4"> {step === "form" && ( <> @@ -1020,34 +1006,59 @@ export function SelfProvisionScreen({ </div> )} - <div class="space-y-2 pt-2"> - <button - type="submit" - disabled={isSubmitting || isEmbedded} - class="w-full py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-bold text-white bg-taler-brand hover:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed" - > - {isSubmitting - ? t`Creating account...` - : step === "verify_email" || step === "select_channel" - ? t`Continue` - : step === "verify_sms" - ? t`Complete setup` - : t`Create merchant account`} - </button> + <ActionFooter + feedback={ + <> + {errorMsg && ( + <div + data-error-banner + role="alert" + class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 text-xs font-medium rounded-md flex items-center justify-between" + > + <div class="min-w-0 max-h-[20dvh] overflow-auto [overflow-wrap:anywhere]"> + ⚠️ {errorMsg} + </div> + <CopyErrorButton + textToCopy={errorMsg} + errorDetail={rawError} + className="ml-2 shrink-0" + /> + </div> + )} + </> + } + > + <div class="w-full"> + <div class="space-y-2 pt-2"> + <button + type="submit" + disabled={isSubmitting || isEmbedded} + class="w-full py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-bold text-white bg-taler-brand hover:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed" + > + {isSubmitting + ? t`Creating account...` + : step === "verify_email" || step === "select_channel" + ? t`Continue` + : step === "verify_sms" + ? t`Complete setup` + : t`Create merchant account`} + </button> - {step !== "form" && ( - <button - type="button" - onClick={() => { - setStep("form"); - setErrorMsg(""); - }} - class="w-full text-center text-xs text-gray-600 hover:text-gray-900 font-medium py-1" - > - {t`Cancel`} - </button> - )} - </div> + {step !== "form" && ( + <button + type="button" + onClick={() => { + setStep("form"); + setErrorMsg(""); + }} + class="w-full text-center text-xs text-gray-600 hover:text-gray-900 font-medium py-1" + > + {t`Cancel`} + </button> + )} + </div> + </div> + </ActionFooter> {step === "form" && ( <div class="text-center pt-2"> diff --git a/packages/taler-merchant-webui/src/screens/SignInScreen.tsx b/packages/taler-merchant-webui/src/screens/SignInScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { ActionFooter } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState, useEffect, useRef } from "preact/hooks"; import { @@ -545,22 +546,6 @@ export function SignInScreen({ {notice} </div> )} - {errorMsg && ( - <div - data-error-banner - role="alert" - class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 text-xs font-medium rounded-md flex items-center justify-between" - > - <div>⚠️ {errorMsg}</div> - <CopyErrorButton - textToCopy={errorMsg} - errorDetail={ - rawError || { status: 401, code: 2001, hint: errorMsg } - } - className="ml-2 shrink-0" - /> - </div> - )} <form onSubmit={handleSubmit} class="space-y-5"> {step === "credentials" && ( @@ -713,45 +698,76 @@ export function SignInScreen({ </div> )} - <div class="space-y-2 pt-2"> - <button - type="submit" - disabled={ - isVerifying || - (step === "code_entry" && maxAttemptsReached) || - isEmbedded - } - class="w-full py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-bold text-white bg-taler-brand hover:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed" - > - {isVerifying - ? t`Verifying...` - : submitButtonText - ? submitButtonText - : step === "channel_select" - ? t`Continue` - : step === "code_entry" - ? t`Confirm` - : t`Sign in`} - </button> - - {(onCancel || step !== "credentials") && ( - <button - type="button" - onClick={() => { - if (onCancel) { - onCancel(); - } else { - setStep("credentials"); - setErrorMsg(""); - setAttemptsCount(0); + <ActionFooter + feedback={ + <> + {errorMsg && ( + <div + data-error-banner + role="alert" + class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 text-xs font-medium rounded-md flex items-center justify-between" + > + <div class="min-w-0 max-h-[20dvh] overflow-auto [overflow-wrap:anywhere]"> + ⚠️ {errorMsg} + </div> + <CopyErrorButton + textToCopy={errorMsg} + errorDetail={ + rawError || { + status: 401, + code: 2001, + hint: errorMsg, + } + } + className="ml-2 shrink-0" + /> + </div> + )} + </> + } + > + <div class="w-full"> + <div class="space-y-2 pt-2"> + <button + type="submit" + disabled={ + isVerifying || + (step === "code_entry" && maxAttemptsReached) || + isEmbedded } - }} - class="w-full text-center text-xs text-gray-600 hover:text-gray-900 font-medium py-1" - > - {t`Cancel`} - </button> - )} - </div> + class="w-full py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-bold text-white bg-taler-brand hover:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed" + > + {isVerifying + ? t`Verifying...` + : submitButtonText + ? submitButtonText + : step === "channel_select" + ? t`Continue` + : step === "code_entry" + ? t`Confirm` + : t`Sign in`} + </button> + + {(onCancel || step !== "credentials") && ( + <button + type="button" + onClick={() => { + if (onCancel) { + onCancel(); + } else { + setStep("credentials"); + setErrorMsg(""); + setAttemptsCount(0); + } + }} + class="w-full text-center text-xs text-gray-600 hover:text-gray-900 font-medium py-1" + > + {t`Cancel`} + </button> + )} + </div> + </div> + </ActionFooter> {step === "credentials" && ( <div class="text-center pt-1 flex items-center justify-between text-xs font-semibold">