commit f8f5fac48f12ad4c9844f062a143e5fb7aa92539
parent 7a88f274dd3016a86e789d0a4fb45d7aa886ed37
Author: Florian Dold <dold@taler.net>
Date: Fri, 11 Sep 2026 09:50:51 +0200
exchange-aml-webui: clear the previous error when retrying unlock
Clear the old unlock error before starting a new attempt. A successful
unlock unmounts the form before operation completion callbacks run, so
clearing notifications in the success callback left a stale error on
the unlocked dashboard.
Diffstat:
2 files changed, 71 insertions(+), 3 deletions(-)
diff --git a/packages/taler-exchange-aml-webui/src/components/UnlockSession.test.tsx b/packages/taler-exchange-aml-webui/src/components/UnlockSession.test.tsx
@@ -8,16 +8,19 @@
*/
import {
+ HttpStatusCode,
OfficerId,
Password,
opFixedSuccess,
+ opKnownFailure,
setupI18n,
} from "@gnu-taler/taler-util";
import { dummyHttpResponse } from "@gnu-taler/taler-util/http";
-import { NotificationProvider } from "@gnu-taler/web-util/browser";
+import { NotificationProvider, ToastBanner } from "@gnu-taler/web-util/browser";
import assert from "node:assert/strict";
import test from "node:test";
-import { h, render } from "preact";
+import { Fragment, h, render } from "preact";
+import { useState } from "preact/hooks";
import { OfficerLocked } from "../hooks/officer.js";
import { UnlockSession } from "./UnlockSession.js";
@@ -64,6 +67,70 @@ async function eventually(assertion: () => void): Promise<void> {
throw lastError;
}
+test("successful unlock removes the previous error when the form unmounts", async () => {
+ const window = await installDom();
+ const container = window.document.createElement("div");
+ window.document.body.append(container);
+
+ function App() {
+ const [ready, setReady] = useState(false);
+ const officer: OfficerLocked = {
+ state: "locked",
+ tryUnlock: async (password) => {
+ if (password.__pwd !== "correct password") {
+ return opKnownFailure(dummyHttpResponse, HttpStatusCode.Forbidden);
+ }
+ setReady(true);
+ // Match the real officer hook: changing session state unmounts the
+ // unlock form before its async operation reports success.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ return opFixedSuccess(dummyHttpResponse, undefined);
+ },
+ forget: () => opFixedSuccess(dummyHttpResponse, undefined),
+ };
+ return (
+ <>
+ <ToastBanner compact />
+ {ready ? (
+ <p>Unlocked dashboard</p>
+ ) : (
+ <UnlockSession officer={officer} />
+ )}
+ </>
+ );
+ }
+
+ try {
+ render(
+ <NotificationProvider>
+ <App />
+ </NotificationProvider>,
+ container as Element,
+ );
+ const input = container.querySelector("input[type=password]")!;
+ const button = container.querySelector("button[type=submit]")!;
+ for (const password of ["wrong password", "correct password"]) {
+ input.value = password;
+ input.dispatchEvent(new window.Event("input", { bubbles: true }));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ button.click();
+ if (password === "wrong password") {
+ await eventually(() =>
+ assert.match(container.textContent, /Failed to unlock the session/),
+ );
+ }
+ }
+ await eventually(() =>
+ assert.match(container.textContent, /Unlocked dashboard/),
+ );
+ assert.doesNotMatch(container.textContent, /Failed to unlock the session/);
+ } finally {
+ render(null, container as Element);
+ container.remove();
+ await window.happyDOM.abort();
+ }
+});
+
test("locked session identifies the officer and confirms forgetting", async () => {
const window = await installDom();
const publicKey =
diff --git a/packages/taler-exchange-aml-webui/src/components/UnlockSession.tsx b/packages/taler-exchange-aml-webui/src/components/UnlockSession.tsx
@@ -66,7 +66,6 @@ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode {
Awaited<ReturnType<OfficerLocked["tryUnlock"]>>,
[Password]
>((_ct, password: Password) => officer.tryUnlock(password), {
- onSuccess: () => clearErrors(),
onFail: showError(i18n.str`Failed to unlock the session.`, (fail) => {
switch (fail.case) {
case HttpStatusCode.Forbidden:
@@ -83,6 +82,8 @@ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode {
const submitUnlock = () => {
if (status.status === "fail") return Promise.resolve();
+ // Unlocking unmounts this form before operation completion callbacks run.
+ clearErrors();
return unlock.run(asPassword(status.result.password));
};