commit a8c9c9b71dccfdae2424ce0e9323970d7dddd5de
parent cf1b052ac0d1ea640e42a6191b88e7ee708718ea
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 22:14:02 +0100
merchant web UI: retain dialog validation errors
Keep validation guidance beside account, settings, refund, and report
controls when the footer summary is dismissed.
Diffstat:
7 files changed, 223 insertions(+), 103 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/AdminAccountCredentialsScreen.tsx b/packages/taler-merchant-webui/src/screens/AdminAccountCredentialsScreen.tsx
@@ -30,9 +30,12 @@ export function AdminAccountCredentialsScreen({
const {
rawError: error,
setRawError: setError,
+ errorMsg: validation,
+ setErrorMsg: setValidation,
feedbackId,
+ fieldProps,
+ fieldError,
} = useFormFeedback();
- const [validation, setValidation] = useState("");
const [pending, setPending] = useState(false);
const submit = async (event: Event) => {
@@ -40,11 +43,17 @@ export function AdminAccountCredentialsScreen({
setValidation("");
setError(undefined);
if (password.length < 8) {
- setValidation(t`Password must be at least 8 characters long.`);
+ setValidation(
+ t`Password must be at least 8 characters long.`,
+ "admin-reset-password",
+ );
return;
}
if (password !== confirm) {
- setValidation(t`Passwords do not match. Please re-type your password.`);
+ setValidation(
+ t`Passwords do not match. Please re-type your password.`,
+ "admin-reset-confirm",
+ );
return;
}
setPending(true);
@@ -73,26 +82,34 @@ export function AdminAccountCredentialsScreen({
onSubmit={(event) => void submit(event)}
class="space-y-5 rounded-xl border border-gray-200 bg-white p-5"
>
- <PasswordInput
- id="admin-reset-password"
- label={t`New password`}
- value={password}
- onInput={setPassword}
- autoComplete="new-password"
- required
- showRequiredIndicator={false}
- minLength={8}
- />
- <PasswordInput
- id="admin-reset-confirm"
- label={t`Confirm new password`}
- value={confirm}
- onInput={setConfirm}
- autoComplete="new-password"
- required
- showRequiredIndicator={false}
- minLength={8}
- />
+ <div>
+ <PasswordInput
+ {...fieldProps("admin-reset-password")}
+ id="admin-reset-password"
+ label={t`New password`}
+ value={password}
+ onInput={setPassword}
+ autoComplete="new-password"
+ required
+ showRequiredIndicator={false}
+ minLength={8}
+ />
+ {fieldError("admin-reset-password")}
+ </div>
+ <div>
+ <PasswordInput
+ {...fieldProps("admin-reset-confirm")}
+ id="admin-reset-confirm"
+ label={t`Confirm new password`}
+ value={confirm}
+ onInput={setConfirm}
+ autoComplete="new-password"
+ required
+ showRequiredIndicator={false}
+ minLength={8}
+ />
+ {fieldError("admin-reset-confirm")}
+ </div>
<FormActionFooter
error={error}
validationError={validation}
diff --git a/packages/taler-merchant-webui/src/screens/AdminAccountFormScreen.tsx b/packages/taler-merchant-webui/src/screens/AdminAccountFormScreen.tsx
@@ -122,9 +122,12 @@ function AdminAccountFormInner({
const {
rawError: error,
setRawError: setError,
+ errorMsg: validation,
+ setErrorMsg: setValidation,
feedbackId,
+ fieldProps,
+ fieldError,
} = useFormFeedback();
- const [validation, setValidation] = useState("");
const [pending, setPending] = useState(false);
const logoStatus = useImageDataUrlStatus(value.logo || "");
const showAdvanced =
@@ -144,11 +147,14 @@ function AdminAccountFormInner({
setError(undefined);
const normalizedId = normalizeMerchantInstanceId(value.id);
if (!isValidMerchantInstanceId(normalizedId)) {
- setValidation(t`The account ID contains unsupported characters.`);
+ setValidation(
+ t`The account ID contains unsupported characters.`,
+ "managed-account-id",
+ );
return;
}
if (!value.name.trim()) {
- setValidation(t`Business name is required.`);
+ setValidation(t`Business name is required.`, "managed-business-name");
return;
}
if (isPhoneInvalid) {
@@ -156,16 +162,25 @@ function AdminAccountFormInner({
return;
}
if (logoStatus === "checking" || logoStatus === "invalid") {
- setValidation(t`Remove or replace the logo before saving.`);
+ setValidation(
+ t`Remove or replace the logo before saving.`,
+ "managed-logo",
+ );
return;
}
if (mode === "create") {
if (!value.password || value.password.length < 8) {
- setValidation(t`Password must be at least 8 characters long.`);
+ setValidation(
+ t`Password must be at least 8 characters long.`,
+ "managed-password",
+ );
return;
}
if (value.password !== confirmPassword) {
- setValidation(t`Passwords do not match. Please re-type your password.`);
+ setValidation(
+ t`Passwords do not match. Please re-type your password.`,
+ "managed-password-confirm",
+ );
return;
}
}
@@ -241,6 +256,7 @@ function AdminAccountFormInner({
{t`Account ID`} *
</label>
<input
+ {...fieldProps("managed-account-id")}
id="managed-account-id"
value={value.id}
disabled={mode === "edit"}
@@ -248,6 +264,7 @@ function AdminAccountFormInner({
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm disabled:bg-gray-100"
required
/>
+ {fieldError("managed-account-id")}
</div>
<div>
<label
@@ -257,12 +274,14 @@ function AdminAccountFormInner({
{t`Business name`} *
</label>
<input
+ {...fieldProps("managed-business-name")}
id="managed-business-name"
value={value.name}
onInput={(event) => change("name", event.currentTarget.value)}
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
required
/>
+ {fieldError("managed-business-name")}
</div>
<div>
<label
@@ -303,24 +322,32 @@ function AdminAccountFormInner({
</div>
{mode === "create" && (
<div class="grid gap-4 border-t border-gray-100 pt-4 sm:grid-cols-2">
- <PasswordInput
- id="managed-password"
- label={t`Password`}
- value={value.password || ""}
- onInput={(password) => change("password", password)}
- autoComplete="new-password"
- required
- minLength={8}
- />
- <PasswordInput
- id="managed-password-confirm"
- label={t`Confirm password`}
- value={confirmPassword}
- onInput={setConfirmPassword}
- autoComplete="new-password"
- required
- minLength={8}
- />
+ <div>
+ <PasswordInput
+ {...fieldProps("managed-password")}
+ id="managed-password"
+ label={t`Password`}
+ value={value.password || ""}
+ onInput={(password) => change("password", password)}
+ autoComplete="new-password"
+ required
+ minLength={8}
+ />
+ {fieldError("managed-password")}
+ </div>
+ <div>
+ <PasswordInput
+ {...fieldProps("managed-password-confirm")}
+ id="managed-password-confirm"
+ label={t`Confirm password`}
+ value={confirmPassword}
+ onInput={setConfirmPassword}
+ autoComplete="new-password"
+ required
+ minLength={8}
+ />
+ {fieldError("managed-password-confirm")}
+ </div>
</div>
)}
</section>
@@ -355,17 +382,21 @@ function AdminAccountFormInner({
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
- <ImageUploadInput
- id="managed-logo"
- label={t`Logo`}
- helpText={t`Shown on payment pages and receipts.`}
- value={value.logo || ""}
- onChange={(next) => {
- setIgnoreInvalidLogo(false);
- setShowAdvanced(true);
- change("logo", next);
- }}
- />
+ <div>
+ <ImageUploadInput
+ {...fieldProps("managed-logo")}
+ id="managed-logo"
+ label={t`Logo`}
+ helpText={t`Shown on payment pages and receipts.`}
+ value={value.logo || ""}
+ onChange={(next) => {
+ setIgnoreInvalidLogo(false);
+ setShowAdvanced(true);
+ change("logo", next);
+ }}
+ />
+ {fieldError("managed-logo")}
+ </div>
</div>
<LocationInput
idPrefix="managed-physical-address"
diff --git a/packages/taler-merchant-webui/src/screens/BankMonitoringDialog.tsx b/packages/taler-merchant-webui/src/screens/BankMonitoringDialog.tsx
@@ -129,6 +129,8 @@ function BankMonitoringForm({
errorMsg: validation,
setErrorMsg: setValidation,
feedbackId,
+ fieldProps,
+ fieldError,
} = useFormFeedback();
const fieldClass =
"mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm";
@@ -154,6 +156,7 @@ function BankMonitoringForm({
} catch {
setValidation(
t`Enter an HTTP or HTTPS bank history URL without embedded credentials or a fragment.`,
+ "bank-monitoring-url",
);
return;
}
@@ -163,6 +166,11 @@ function BankMonitoringForm({
) {
setValidation(
t`Enter the credentials for the selected authentication method.`,
+ auth === "bearer"
+ ? "bank-monitoring-token"
+ : !username.trim()
+ ? "bank-monitoring-username"
+ : "bank-monitoring-password",
);
return;
}
@@ -208,6 +216,7 @@ function BankMonitoringForm({
>
{t`Bank history URL`}
<input
+ {...fieldProps("bank-monitoring-url")}
id="bank-monitoring-url"
type="url"
required
@@ -215,6 +224,7 @@ function BankMonitoringForm({
class={fieldClass}
onInput={(e) => setUrl(e.currentTarget.value)}
/>
+ {fieldError("bank-monitoring-url")}
</label>
<label
class="block text-sm font-medium"
@@ -242,6 +252,7 @@ function BankMonitoringForm({
>
{t`Username`}
<input
+ {...fieldProps("bank-monitoring-username")}
id="bank-monitoring-username"
required
autoComplete="off"
@@ -249,6 +260,7 @@ function BankMonitoringForm({
class={fieldClass}
onInput={(e) => setUsername(e.currentTarget.value)}
/>
+ {fieldError("bank-monitoring-username")}
</label>
<label
class="block text-sm font-medium"
@@ -256,6 +268,7 @@ function BankMonitoringForm({
>
{t`Password`}
<input
+ {...fieldProps("bank-monitoring-password")}
id="bank-monitoring-password"
type="password"
required
@@ -264,6 +277,7 @@ function BankMonitoringForm({
class={fieldClass}
onInput={(e) => setPassword(e.currentTarget.value)}
/>
+ {fieldError("bank-monitoring-password")}
</label>
</>
)}
@@ -274,6 +288,7 @@ function BankMonitoringForm({
>
{t`Access token`}
<input
+ {...fieldProps("bank-monitoring-token")}
id="bank-monitoring-token"
type="password"
required
@@ -282,6 +297,7 @@ function BankMonitoringForm({
class={fieldClass}
onInput={(e) => setToken(e.currentTarget.value)}
/>
+ {fieldError("bank-monitoring-token")}
</label>
)}
</>
diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx
@@ -17,7 +17,7 @@ import { LocationInput } from "../ui/LocationInput.js";
import { ImageUploadInput } from "../ui/ImageUploadInput.js";
import { Button } from "../ui/Button.js";
import { DisclosureChevron } from "../ui/DisclosureChevron.js";
-import { FormActionFooter } from "../ui/FormActionFooter.js";
+import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import { ReadErrorBanner } from "../ui/ReadErrorBanner.js";
import { InitialLoadingState } from "../ui/InitialLoadingState.js";
@@ -313,7 +313,12 @@ export function BusinessSettingsScreen({
const [sectionFeedbackIds, setSectionFeedbackIds] = useState<
Partial<Record<EditorSection, number>>
>({});
- const [securityValidation, setSecurityValidation] = useState("");
+ const {
+ errorMsg: securityValidation,
+ setErrorMsg: setSecurityValidation,
+ fieldProps,
+ fieldError,
+ } = useFormFeedback();
const [savedSection, setSavedSection] = useState<EditorSection | null>(null);
const [currentPassword, setCurrentPassword] = useState("");
@@ -429,21 +434,25 @@ export function BusinessSettingsScreen({
security: (ids.security ?? 0) + 1,
}));
if (!newPassword) {
- setSecurityValidation(t`Please enter a new password.`);
+ setSecurityValidation(t`Please enter a new password.`, "pwd-new");
return;
}
if (newPassword.length < 8) {
setSecurityValidation(
t`New password must be at least 8 characters long.`,
+ "pwd-new",
);
return;
}
if (newPassword !== confirmPassword) {
- setSecurityValidation(t`New passwords do not match.`);
+ setSecurityValidation(t`New passwords do not match.`, "pwd-confirm");
return;
}
if (!currentPassword) {
- setSecurityValidation(t`Please enter your current password.`);
+ setSecurityValidation(
+ t`Please enter your current password.`,
+ "pwd-current",
+ );
return;
}
pendingSection.value = "security";
@@ -1031,32 +1040,44 @@ export function BusinessSettingsScreen({
disabled={pendingSection.value === "security"}
class="min-w-0 space-y-5"
>
- <PasswordInput
- id="pwd-current"
- label={t`Current Password`}
- value={currentPassword}
- onInput={setCurrentPassword}
- autoComplete="current-password"
- required
- />
- <PasswordInput
- id="pwd-new"
- label={t`New Password`}
- value={newPassword}
- onInput={setNewPassword}
- autoComplete="new-password"
- required
- minLength={8}
- />
- <PasswordInput
- id="pwd-confirm"
- label={t`Confirm New Password`}
- value={confirmPassword}
- onInput={setConfirmPassword}
- autoComplete="new-password"
- required
- minLength={8}
- />
+ <div>
+ <PasswordInput
+ {...fieldProps("pwd-current")}
+ id="pwd-current"
+ label={t`Current Password`}
+ value={currentPassword}
+ onInput={setCurrentPassword}
+ autoComplete="current-password"
+ required
+ />
+ {fieldError("pwd-current")}
+ </div>
+ <div>
+ <PasswordInput
+ {...fieldProps("pwd-new")}
+ id="pwd-new"
+ label={t`New Password`}
+ value={newPassword}
+ onInput={setNewPassword}
+ autoComplete="new-password"
+ required
+ minLength={8}
+ />
+ {fieldError("pwd-new")}
+ </div>
+ <div>
+ <PasswordInput
+ {...fieldProps("pwd-confirm")}
+ id="pwd-confirm"
+ label={t`Confirm New Password`}
+ value={confirmPassword}
+ onInput={setConfirmPassword}
+ autoComplete="new-password"
+ required
+ minLength={8}
+ />
+ {fieldError("pwd-confirm")}
+ </div>
<EditorActions
saveBlocked={pendingSection.value !== null}
pending={pendingSection.value === "security"}
diff --git a/packages/taler-merchant-webui/src/screens/OrderRefundScreen.tsx b/packages/taler-merchant-webui/src/screens/OrderRefundScreen.tsx
@@ -146,6 +146,8 @@ export function OrderRefundScreen({
errorMsg: validationError,
setErrorMsg: setValidationError,
feedbackId,
+ fieldProps,
+ fieldError,
} = useFormFeedback();
const [submitSuccess, setSubmitSuccess] = useState<boolean>(false);
@@ -183,6 +185,7 @@ export function OrderRefundScreen({
if (!finalAmountStr) {
setValidationError(
t`Enter a positive refund in the order currency that does not exceed the remaining refundable amount.`,
+ "refund-amount-input",
);
return;
}
@@ -342,18 +345,22 @@ export function OrderRefundScreen({
{/* Refund Amount Input */}
<div>
- <AmountInput
- id="refund-amount-input"
- label={t`Refund Amount`}
- value={refundAmount}
- currencies={currency ? [currency] : undefined}
- primaryCurrency={currency || undefined}
- required
- onChange={(val) => {
- setRefundAmount(val);
- setSelectedPreset("Custom");
- }}
- />
+ <div>
+ <AmountInput
+ {...fieldProps("refund-amount-input")}
+ id="refund-amount-input"
+ label={t`Refund Amount`}
+ value={refundAmount}
+ currencies={currency ? [currency] : undefined}
+ primaryCurrency={currency || undefined}
+ required
+ onChange={(val) => {
+ setRefundAmount(val);
+ setSelectedPreset("Custom");
+ }}
+ />
+ {fieldError("refund-amount-input")}
+ </div>
{refundAmount && !validRefundAmount && (
<p class="text-xs text-red-600 mt-1">
{currency
diff --git a/packages/taler-merchant-webui/src/screens/PosScreen.tsx b/packages/taler-merchant-webui/src/screens/PosScreen.tsx
@@ -1464,12 +1464,28 @@ export function PosScreen({
step="any"
required
disabled={!refundDetails || isRefundDetailsLoading}
+ aria-invalid={
+ (!refundError && refundMsg?.type === "error") || undefined
+ }
+ aria-describedby={
+ !refundError && refundMsg?.type === "error"
+ ? "pos-refund-validation"
+ : undefined
+ }
value={refundAmountVal}
onInput={(e) =>
setRefundAmountVal((e.target as HTMLInputElement).value)
}
class="w-full px-3.5 py-2 border border-gray-300 rounded-xl text-xs focus:ring-2 focus:ring-blue-500 font-mono"
/>
+ {!refundError && refundMsg?.type === "error" && (
+ <p
+ id="pos-refund-validation"
+ class="mt-1 text-sm text-red-800"
+ >
+ {refundMsg.text}
+ </p>
+ )}
</div>
<div>
diff --git a/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx b/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx
@@ -219,6 +219,8 @@ export function ReportsScreen({
rawError: mutationError,
setRawError: setMutationError,
feedbackId,
+ fieldProps,
+ fieldError,
} = useFormFeedback();
const runMutation = async (action: () => Promise<void>) => {
@@ -269,7 +271,10 @@ export function ReportsScreen({
if (!groupName.trim()) return;
const cleanName = toSlug(groupName);
if (!isValidMerchantSlug(cleanName)) {
- setValidationError(t`Enter a valid product group identifier.`);
+ setValidationError(
+ t`Enter a valid product group identifier.`,
+ "grp_name_input",
+ );
return;
}
@@ -323,7 +328,10 @@ export function ReportsScreen({
if (!potName.trim()) return;
const cleanName = toSlug(potName);
if (!isValidMerchantSlug(cleanName)) {
- setValidationError(t`Enter a valid money pot identifier.`);
+ setValidationError(
+ t`Enter a valid money pot identifier.`,
+ "pot_name_input",
+ );
return;
}
@@ -881,6 +889,7 @@ export function ReportsScreen({
</button>
</label>
<input
+ {...fieldProps("grp_name_input")}
id="grp_name_input"
type="text"
required
@@ -891,6 +900,7 @@ export function ReportsScreen({
placeholder={groupIdentifierExample}
class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
+ {fieldError("grp_name_input")}
</div>
<div>
@@ -960,6 +970,7 @@ export function ReportsScreen({
</button>
</label>
<input
+ {...fieldProps("pot_name_input")}
id="pot_name_input"
type="text"
required
@@ -970,6 +981,7 @@ export function ReportsScreen({
placeholder={potIdentifierExample}
class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
+ {fieldError("pot_name_input")}
</div>
<div>