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:
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">