taler-typescript-core

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

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:
Mpackages/taler-exchange-aml-webui/src/components/UnlockSession.test.tsx | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-exchange-aml-webui/src/components/UnlockSession.tsx | 3++-
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)); };