taler-typescript-core

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

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:
Mpackages/taler-merchant-webui/src/screens/AdminAccountCredentialsScreen.tsx | 63++++++++++++++++++++++++++++++++++++++++-----------------------
Mpackages/taler-merchant-webui/src/screens/AdminAccountFormScreen.tsx | 101+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/taler-merchant-webui/src/screens/BankMonitoringDialog.tsx | 16++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx | 83+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/taler-merchant-webui/src/screens/OrderRefundScreen.tsx | 31+++++++++++++++++++------------
Mpackages/taler-merchant-webui/src/screens/PosScreen.tsx | 16++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/ReportsScreen.tsx | 16++++++++++++++--
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>