commit e5f6e43e1a23c75a0a44484b7393ac055e26a048
parent b843f0f73e0fa1d2dfa429077f77c28c01536e76
Author: Florian Dold <dold@taler.net>
Date: Sun, 23 Aug 2026 12:56:10 +0200
web-util: decouple async actions from notifications
Diffstat:
40 files changed, 2022 insertions(+), 3052 deletions(-)
diff --git a/packages/challenger-webui/src/pages/AnswerChallenge.tsx b/packages/challenger-webui/src/pages/AnswerChallenge.tsx
@@ -24,12 +24,13 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
RouteDefinition,
ShowInputErrorLabel,
Time,
useChallengerApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -95,7 +96,7 @@ export function AnswerChallenge({
const { config, lib } = useChallengerApiContext();
const { i18n } = useTranslationContext();
const { sent, failed, completed } = useSessionState();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const [pin, setPin] = useState<string | undefined>();
const errors = undefinedIfEmpty({
@@ -131,25 +132,25 @@ export function AnswerChallenge({
const contact = lastStatus?.last_address;
// i18n.str`create challenge`,
- const sendAgain = actionHandler(
- (ct, n, b) => lib.challenger.challenge(n, b),
+ const sendAgainArgs =
contact === undefined ||
- lastStatus === undefined ||
- lastStatus.pin_transmissions_left === 0 ||
- !AbsoluteTime.isExpired(deadline)
+ lastStatus === undefined ||
+ lastStatus.pin_transmissions_left === 0 ||
+ !AbsoluteTime.isExpired(deadline)
? undefined
- : ([session.nonce, contact] as const),
- );
- sendAgain.onSuccess = (success) => {
- if (success.type === "completed") {
- completed(success);
- } else {
- sent(success);
- }
- };
- sendAgain.onFail = showError(
- i18n.str`Failed to create a new challenge.`,
- (fail) => {
+ : ([session.nonce, contact] as const);
+ const sendAgain = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.challenger.challenge>>,
+ [string, Record<string, string>]
+ >((ct, n, b) => lib.challenger.challenge(n, b), {
+ onSuccess(success) {
+ if (success.type === "completed") {
+ completed(success);
+ } else {
+ sent(success);
+ }
+ },
+ onFail: showError(i18n.str`Failed to create a new challenge.`, (fail) => {
switch (fail.case) {
case HttpStatusCode.BadRequest:
return i18n.str`The request was not accepted. Try reloading the app.`;
@@ -164,48 +165,51 @@ export function AnswerChallenge({
default:
assertUnreachable(fail);
}
- },
- );
+ }),
+ });
// i18n.str`solve challenge`,
- const check = actionHandler(
- (ct, n, b) => lib.challenger.solve(n, b),
+ const checkArgs =
errors !== undefined ||
- lastStatus == undefined ||
- lastStatus.auth_attempts_left === 0 ||
- !pin
+ lastStatus == undefined ||
+ lastStatus.auth_attempts_left === 0 ||
+ !pin
? undefined
- : ([session.nonce, { pin }] as const),
- );
- check.onSuccess = (success) => {
- if (success.type === "completed") {
- completed(success);
- } else {
- failed(success);
- }
- onComplete();
- };
- check.onFail = showError(i18n.str`Failed to solve the challenge.`, (fail) => {
- switch (fail.case) {
- case HttpStatusCode.BadRequest:
- return i18n.str`The request was not accepted. Try reloading the app.`;
- case HttpStatusCode.Forbidden: {
- revalidateChallengeSession();
- return i18n.str`Invalid TAN code.`;
+ : ([session.nonce, { pin }] as const);
+ const check = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.challenger.solve>>,
+ [string, Record<string, string>]
+ >((ct, n, b) => lib.challenger.solve(n, b), {
+ onSuccess(success) {
+ if (success.type === "completed") {
+ completed(success);
+ } else {
+ failed(success);
}
- case HttpStatusCode.NotFound:
- return i18n.str`Challenge not found.`;
- case HttpStatusCode.NotAcceptable:
- return i18n.str`Server templates are missing due to misconfiguration.`;
- case HttpStatusCode.TooManyRequests: {
- revalidateChallengeSession();
- return i18n.str`There have been too many attempts to send the TAN code.`;
+ onComplete();
+ },
+ onFail: showError(i18n.str`Failed to solve the challenge.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`The request was not accepted. Try reloading the app.`;
+ case HttpStatusCode.Forbidden: {
+ revalidateChallengeSession();
+ return i18n.str`Invalid TAN code.`;
+ }
+ case HttpStatusCode.NotFound:
+ return i18n.str`Challenge not found.`;
+ case HttpStatusCode.NotAcceptable:
+ return i18n.str`Server templates are missing due to misconfiguration.`;
+ case HttpStatusCode.TooManyRequests: {
+ revalidateChallengeSession();
+ return i18n.str`There have been too many attempts to send the TAN code.`;
+ }
+ case HttpStatusCode.InternalServerError:
+ return i18n.str`Server is unable to respond due to internal problems.`;
+ default:
+ assertUnreachable(fail);
}
- case HttpStatusCode.InternalServerError:
- return i18n.str`Server is unable to respond due to internal problems.`;
- default:
- assertUnreachable(fail);
- }
+ }),
});
const cantTryAnymore = lastStatus?.auth_attempts_left === 0;
@@ -263,13 +267,15 @@ export function AnswerChallenge({
)}
</div>
<div>
- <Button
+ <AsyncButton
submit
class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={sendAgain}
+ onClick={
+ sendAgainArgs ? () => sendAgain.run(...sendAgainArgs) : undefined
+ }
>
<i18n.Translate>Send new code</i18n.Translate>
- </Button>
+ </AsyncButton>
{lastStatus === undefined ? undefined : (
<p class="mt-2 text-sm leading-6 text-gray-400">
{lastStatus.pin_transmissions_left < 1 ? (
@@ -383,13 +389,13 @@ export function AnswerChallenge({
</div>
<div class="mt-10">
- <Button
+ <AsyncButton
submit
class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={check}
+ onClick={checkArgs ? () => check.run(...checkArgs) : undefined}
>
<i18n.Translate>Check</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
diff --git a/packages/challenger-webui/src/pages/AskChallenge.tsx b/packages/challenger-webui/src/pages/AskChallenge.tsx
@@ -25,7 +25,7 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
countryNameList,
ErrorLoading,
FormDesign,
@@ -33,6 +33,7 @@ import {
RouteDefinition,
useChallengerApiContext,
useForm,
+ useNotifiedOperation,
useNotificationContext,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
@@ -135,7 +136,7 @@ function AskChallengeInternal({
const { lib, config } = useChallengerApiContext();
const { i18n } = useTranslationContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const initial = lastStatus.last_address ?? {};
if (config.address_type === "postal-ch") {
@@ -166,33 +167,36 @@ function AskChallengeInternal({
const info = lastStatus.fix_address ? lastStatus.last_address! : contact;
// i18n.str`create challenge`,
- const send = actionHandler(
- (ct, n, i) => lib.challenger.challenge(n, i),
- form.status.errors || !info ? undefined : ([session.nonce, info] as const),
- );
- send.onSuccess = (ok) => {
- if (ok.type === "completed") {
- completed(ok);
- } else {
- sent(ok);
- }
- onSendSuccesful();
- };
- send.onFail = showError(i18n.str`Failed to create a challenge.`, (fail) => {
- switch (fail.case) {
- case HttpStatusCode.BadRequest:
- return i18n.str`The request was not accepted. Try reloading the app.`;
- case HttpStatusCode.NotFound:
- return i18n.str`Challenge not found.`;
- case HttpStatusCode.NotAcceptable:
- return i18n.str`Server templates are missing due to misconfiguration.`;
- case HttpStatusCode.TooManyRequests:
- return i18n.str`There have been too many attempts to send the TAN code.`;
- case HttpStatusCode.InternalServerError:
- return i18n.str`Server is unable to respond due to internal problems.`;
- default:
- assertUnreachable(fail);
- }
+ const sendArgs =
+ form.status.errors || !info ? undefined : ([session.nonce, info] as const);
+ const send = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.challenger.challenge>>,
+ [string, Record<string, string>]
+ >((ct, n, i) => lib.challenger.challenge(n, i), {
+ onSuccess(ok) {
+ if (ok.type === "completed") {
+ completed(ok);
+ } else {
+ sent(ok);
+ }
+ onSendSuccesful();
+ },
+ onFail: showError(i18n.str`Failed to create a challenge.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`The request was not accepted. Try reloading the app.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Challenge not found.`;
+ case HttpStatusCode.NotAcceptable:
+ return i18n.str`Server templates are missing due to misconfiguration.`;
+ case HttpStatusCode.TooManyRequests:
+ return i18n.str`There have been too many attempts to send the TAN code.`;
+ case HttpStatusCode.InternalServerError:
+ return i18n.str`Server is unable to respond due to internal problems.`;
+ default:
+ assertUnreachable(fail);
+ }
+ }),
});
return (
@@ -243,7 +247,11 @@ function AskChallengeInternal({
)}
<div class="mx-auto mt-4 max-w-xl ">
- <FormUI design={design} model={form.model} onSubmit={send.call} />
+ <FormUI
+ design={design}
+ model={form.model}
+ onSubmit={sendArgs ? () => send.run(...sendArgs) : undefined}
+ />
</div>
{lastStatus === undefined ? undefined : (
@@ -268,10 +276,10 @@ function AskChallengeInternal({
<div class="mx-auto mt-4 max-w-xl ">
{!prevAddr ? (
<div class="mt-10">
- <Button
+ <AsyncButton
submit
class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={send}
+ onClick={sendArgs ? () => send.run(...sendArgs) : undefined}
>
{(function (): TranslatedString {
switch (config.address_type) {
@@ -284,14 +292,14 @@ function AskChallengeInternal({
return i18n.str`Send SMS`;
}
})()}
- </Button>
+ </AsyncButton>
</div>
) : (
<div class="mt-10">
- <Button
+ <AsyncButton
submit
class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={send}
+ onClick={sendArgs ? () => send.run(...sendArgs) : undefined}
>
{(function (): TranslatedString {
switch (config.address_type) {
@@ -310,7 +318,7 @@ function AskChallengeInternal({
: i18n.str`Change phone`;
}
})()}
- </Button>
+ </AsyncButton>
</div>
)}
</div>
diff --git a/packages/challenger-webui/src/pages/Setup.tsx b/packages/challenger-webui/src/pages/Setup.tsx
@@ -22,9 +22,10 @@ import {
randomBytes,
} from "@gnu-taler/taler-util";
import {
- Button,
+ AsyncButton,
ShowInputErrorLabel,
useChallengerApiContext,
+ useNotifiedOperation,
useNotificationContext,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
@@ -51,7 +52,7 @@ export function Setup({
}: Props): VNode {
const { i18n } = useTranslationContext();
const { lib } = useChallengerApiContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const { start } = useSessionState();
const [password, setPassword] = useState<string | undefined>(secret);
const [url, setUrl] = useState<string | undefined>(redirectURL?.href);
@@ -65,36 +66,34 @@ export function Setup({
: undefined,
});
- const doStart = actionHandler(
- (ct, token: AccessToken, url) => lib.challenger.setup(clientId, token),
+ const startArgs =
!!errors || password === undefined || url === undefined
? undefined
- : [createRFC8959AccessTokenEncoded(password), url],
- );
-
- doStart.onSuccess = (ok, token, redirect_uri) => {
- start();
- const redirect = new URL(window.location.href);
- redirect.searchParams.set("client_id", clientId);
- redirect.searchParams.set("redirect_uri", redirect_uri);
- redirect.searchParams.set("state", encodeCrock(randomBytes(32)));
- redirect.searchParams.set("nonce", ok.nonce);
- redirect.hash = "";
- window.location.href = redirect.href;
- onCreated();
- };
-
- doStart.onFail = showError(
- i18n.str`Failed to setup a new challenge.`,
- (fail) => {
+ : ([createRFC8959AccessTokenEncoded(password), url] as const);
+ const doStart = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.challenger.setup>>,
+ [AccessToken, string]
+ >((ct, token, url) => lib.challenger.setup(clientId, token), {
+ onSuccess(ok, token, redirect_uri) {
+ start();
+ const redirect = new URL(window.location.href);
+ redirect.searchParams.set("client_id", clientId);
+ redirect.searchParams.set("redirect_uri", redirect_uri);
+ redirect.searchParams.set("state", encodeCrock(randomBytes(32)));
+ redirect.searchParams.set("nonce", ok.nonce);
+ redirect.hash = "";
+ window.location.href = redirect.href;
+ onCreated();
+ },
+ onFail: showError(i18n.str`Failed to setup a new challenge.`, (fail) => {
switch (fail.case) {
case HttpStatusCode.NotFound:
return i18n.str`The server doesn't know about this client. Either the URL or the secret is wrong.`;
default:
assertUnreachable(fail.case);
}
- },
- );
+ }),
+ });
return (
<Fragment>
@@ -171,13 +170,13 @@ export function Setup({
</div>
</div>
<div class="mt-10">
- <Button
+ <AsyncButton
submit
class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={doStart}
+ onClick={startArgs ? () => doStart.run(...startArgs) : undefined}
>
<i18n.Translate>Start</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
</div>
diff --git a/packages/libeufin-bank-webui/src/Routing.tsx b/packages/libeufin-bank-webui/src/Routing.tsx
@@ -20,6 +20,7 @@ import {
useCurrentLocation,
useNavigationContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -114,7 +115,7 @@ function PublicRounting({
const { navigateTo } = useNavigationContext();
const { config, lib } = useBankCoreApiContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const mfa = useBankChallengeHandlerContext();
@@ -131,7 +132,10 @@ function PublicRounting({
} as TokenRequest;
// i18n.str`login`,
- const login = actionHandler(
+ const login = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.bank.createAccessToken>>,
+ [string, string, string[]]
+ >(
(ct, username: string, password: string, challengeIds: string[]) =>
lib.bank.createAccessToken(
username,
@@ -139,39 +143,47 @@ function PublicRounting({
tokenRequest,
{ challengeIds },
),
- );
-
- login.onSuccess = (success, username) =>
- onLoggedUser(
- username,
- createRFC8959AccessTokenEncoded(success.access_token),
- AbsoluteTime.fromProtocolTimestamp(success.expiration),
- );
-
- login.onFail = showError(i18n.str`Failed to login.`, (fail, username) => {
- switch (fail.case) {
- case HttpStatusCode.Accepted:
- mfa.onNewChallenge(
- i18n.str`Identity verification.`,
+ {
+ onSuccess: (success, username) => {
+ mfa.cancel();
+ onLoggedUser(
username,
- fail.body,
- login.lambda((prev, next) =>
- !prev ? undefined : [prev[0], prev[1], next[0]],
- ),
+ createRFC8959AccessTokenEncoded(success.access_token),
+ AbsoluteTime.fromProtocolTimestamp(success.expiration),
);
- return undefined;
- case HttpStatusCode.Unauthorized:
- return i18n.str`Wrong credentials for "${username}"`;
- case TalerErrorCode.GENERIC_FORBIDDEN:
- return i18n.str`You do not have permission to access this account.`;
- case TalerErrorCode.BANK_ACCOUNT_LOCKED:
- return i18n.str`This account is locked. If you have an active session you can change the password or contact the administrator.`;
- case HttpStatusCode.NotFound:
- return i18n.str`Account not found`;
- default:
- assertUnreachable(fail);
- }
- });
+ },
+ onFail: showError(
+ i18n.str`Failed to login.`,
+ (fail, username, password) => {
+ switch (fail.case) {
+ case HttpStatusCode.Accepted:
+ mfa.onNewChallenge(
+ i18n.str`Identity verification.`,
+ username,
+ fail.body,
+ {
+ running: login.running,
+ cancel: login.cancel,
+ run: (challengeIds) =>
+ login.run(username, password, challengeIds),
+ },
+ );
+ return undefined;
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Wrong credentials for "${username}"`;
+ case TalerErrorCode.GENERIC_FORBIDDEN:
+ return i18n.str`You do not have permission to access this account.`;
+ case TalerErrorCode.BANK_ACCOUNT_LOCKED:
+ return i18n.str`This account is locked. If you have an active session you can change the password or contact the administrator.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Account not found`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
+ },
+ );
switch (location.name) {
case undefined:
@@ -203,7 +215,7 @@ function PublicRounting({
<Fragment>
<RegistrationPage
onRegistrationSuccesful={(usr, pwd) => {
- login.withArgs(usr, pwd, []).call();
+ login.run(usr, pwd, []);
}}
routeCancel={publicPages.login}
/>
diff --git a/packages/libeufin-bank-webui/src/context/challenge.ts b/packages/libeufin-bank-webui/src/context/challenge.ts
@@ -21,7 +21,7 @@ import {
TranslatedString,
} from "@gnu-taler/taler-util";
import {
- SafeHandler,
+ AsyncAction,
useBankCoreApiContext,
} from "@gnu-taler/web-util/browser";
import { ComponentChildren, createContext, h, VNode } from "preact";
@@ -39,7 +39,7 @@ export type ContextType = {
operation: TranslatedString,
username: string,
challenge: ChallengeResponse,
- handler: SafeHandler<[string[]], any>,
+ handler: AsyncAction<[string[]]>,
): void;
};
@@ -61,7 +61,7 @@ type MfaState = {
loadingFirstChallenge: boolean;
username: string;
title: TranslatedString;
- retry: SafeHandler<[string[]], any>;
+ retry: AsyncAction<[string[]]>;
initial?: { request: Challenge; response?: ChallengeRequestResponse };
};
@@ -88,17 +88,11 @@ export const BankChallengeHandlerProvider = ({
operation: TranslatedString,
username: string,
requirement: ChallengeResponse,
- handler: SafeHandler<[string[]], any>,
+ handler: AsyncAction<[string[]]>,
) {
const loadingFirstChallenge =
requirement.combi_and === true || requirement.challenges.length === 1;
- handler.addListener((ev) => {
- if (ev === "success") {
- setState(undefined);
- }
- });
-
// Set the sate now, if "LFC" is true "initial" is undefined it means "loading"
setState({
username,
diff --git a/packages/libeufin-bank-webui/src/hooks/account.ts b/packages/libeufin-bank-webui/src/hooks/account.ts
@@ -31,7 +31,6 @@ import { dummyHttpResponse } from "@gnu-taler/taler-util/http";
import {
LONG_POLL_DELAY,
PaginatedResult,
- useAsync,
useBankCoreApiContext,
useLongPolling,
} from "@gnu-taler/web-util/browser";
diff --git a/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx b/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx
@@ -1,6 +1,7 @@
import {
Amounts,
assertUnreachable,
+ CancellationToken,
HttpStatusCode,
InternationalizationAPI,
RoundingMode,
@@ -10,7 +11,7 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
ErrorLoading,
InputText,
InputToggle,
@@ -20,6 +21,7 @@ import {
ShowInputErrorLabel,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
@@ -143,7 +145,7 @@ function Form({
const { state: credentials } = useSessionState();
const creds = credentials.status !== "loggedIn" ? undefined : credentials;
const { lib, config } = useBankCoreApiContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const [section, setSection] = useState<
"detail" | "cashout" | "cashin" | "users" | "test" | "delete"
>("detail");
@@ -173,29 +175,31 @@ function Form({
);
// i18n.str`delete conversion rate class`,
- const deleteClass = actionHandler(
+ const deleteClass = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.bank.deleteConversionRateClass>>,
+ [AccessToken]
+ >(
(ct, token: AccessToken) =>
lib.bank.deleteConversionRateClass(token, classId),
- !creds || section !== "delete" || detailsResult.num_users > 0
- ? undefined
- : [creds.token],
- );
- deleteClass.onSuccess = onClassDeleted;
- deleteClass.onFail = showError(
- i18n.str`Failed to delete conversion rate class`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Unauthorized:
- return i18n.str`Unauthorized`;
- case HttpStatusCode.Forbidden:
- return i18n.str`Forbidden`;
- case HttpStatusCode.NotFound:
- return i18n.str`NotFound`;
- case HttpStatusCode.NotImplemented:
- return i18n.str`NotImplemented`;
- default:
- assertUnreachable(fail);
- }
+ {
+ onSuccess: onClassDeleted,
+ onFail: showError(
+ i18n.str`Failed to delete conversion rate class`,
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Unauthorized`;
+ case HttpStatusCode.Forbidden:
+ return i18n.str`Forbidden`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`NotFound`;
+ case HttpStatusCode.NotImplemented:
+ return i18n.str`NotImplemented`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
},
);
@@ -218,35 +222,39 @@ function Form({
};
// i18n.str`update conversion rate class`,
- const updateClass = actionHandler(
+ const updateClass = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.bank.updateConversionRateClass>>,
+ [AccessToken, number, TalerCorebankApi.ConversionRateClassInput]
+ >(
(
ct,
t: AccessToken,
id: number,
i: TalerCorebankApi.ConversionRateClassInput,
) => lib.bank.updateConversionRateClass(t, id, i),
- // !creds || !input ? undefined : ([creds.token, classId, input] as const),
- );
- updateClass.onSuccess = () => {
- setSection("detail");
- };
- updateClass.onFail = showError(
- i18n.str`Failed to update conversion rate class.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Unauthorized:
- return i18n.str`Unauthorized`;
- case HttpStatusCode.Forbidden:
- return i18n.str`Forbidden`;
- case HttpStatusCode.NotFound:
- return i18n.str`Not Found`;
- case HttpStatusCode.NotImplemented:
- return i18n.str`Not implemented`;
- case TalerErrorCode.BANK_NAME_REUSE:
- return i18n.str`The name of the conversion is already used.`;
- default:
- assertUnreachable(fail);
- }
+ {
+ onSuccess: () => {
+ setSection("detail");
+ },
+ onFail: showError(
+ i18n.str`Failed to update conversion rate class.`,
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Unauthorized`;
+ case HttpStatusCode.Forbidden:
+ return i18n.str`Forbidden`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Not Found`;
+ case HttpStatusCode.NotImplemented:
+ return i18n.str`Not implemented`;
+ case TalerErrorCode.BANK_NAME_REUSE:
+ return i18n.str`The name of the conversion is already used.`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
},
);
@@ -268,7 +276,7 @@ function Form({
cashout_rounding_mode: status.result.conv.cashout_rounding_mode,
};
- const updateDetails =
+ const updateDetailsDisabled = !!(
!creds ||
!updateRequest ||
section !== "detail" ||
@@ -276,10 +284,9 @@ function Form({
status.errors?.description ||
(status.result.name === initalState.name &&
status.result.description === initalState.description)
- ? updateClass
- : updateClass.withArgs(creds.token, classId, updateRequest);
+ );
- const updateCashin =
+ const updateCashinDisabled = !!(
!creds ||
!updateRequest ||
section !== "cashin" ||
@@ -287,10 +294,9 @@ function Form({
status.errors?.conv?.cashin_min_amount ||
status.errors?.conv?.cashin_ratio ||
status.errors?.conv?.cashin_rounding_mode
- ? updateClass
- : updateClass.withArgs(creds.token, classId, updateRequest);
+ );
- const updateCashout =
+ const updateCashoutDisabled = !!(
!creds ||
!updateRequest ||
section !== "cashout" ||
@@ -306,8 +312,7 @@ function Form({
status.result?.conv?.cashout_ratio === initalState.conv.cashout_ratio &&
status.result?.conv?.cashout_rounding_mode ===
initalState.conv.cashout_rounding_mode)
- ? updateClass
- : updateClass.withArgs(creds.token, classId, updateRequest);
+ );
const default_rate = conversionInfo.conversion_rate;
@@ -672,50 +677,64 @@ function Form({
</a>
{section == "cashin" ? (
<Fragment>
- <Button
+ <AsyncButton
submit
name="update conversion"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={updateCashin}
+ disabled={updateCashinDisabled}
+ onClick={() =>
+ updateClass.run(creds!.token, classId, updateRequest!)
+ }
>
<i18n.Translate>Update</i18n.Translate>
- </Button>
+ </AsyncButton>
</Fragment>
) : undefined}
{section == "cashout" ? (
<Fragment>
- <Button
+ <AsyncButton
submit
name="update conversion"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={updateCashout}
+ disabled={updateCashoutDisabled}
+ onClick={() =>
+ updateClass.run(creds!.token, classId, updateRequest!)
+ }
>
<i18n.Translate>Update</i18n.Translate>
- </Button>
+ </AsyncButton>
</Fragment>
) : undefined}
{section == "detail" ? (
<Fragment>
- <Button
+ <AsyncButton
submit
name="update conversion"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={updateDetails}
+ disabled={updateDetailsDisabled}
+ onClick={() =>
+ updateClass.run(creds!.token, classId, updateRequest!)
+ }
>
<i18n.Translate>Update</i18n.Translate>
- </Button>
+ </AsyncButton>
</Fragment>
) : undefined}
{section == "delete" ? (
<Fragment>
- <Button
+ <AsyncButton
submit
name="update conversion"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
- onClick={deleteClass}
+ disabled={
+ !creds ||
+ section !== "delete" ||
+ detailsResult.num_users > 0
+ }
+ onClick={() => deleteClass.run(creds!.token)}
>
<i18n.Translate>Delete</i18n.Translate>
- </Button>
+ </AsyncButton>
</Fragment>
) : undefined}
</div>
@@ -849,7 +868,7 @@ function TestConversionClass({
info: TalerBankConversionApi.TalerConversionInfoConfig;
}): VNode {
const { i18n } = useTranslationContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const { estimateByDebit: calculateCashoutFromDebit } =
useCashoutEstimatorForClass(classId);
@@ -871,50 +890,54 @@ function TestConversionClass({
const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee);
const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee);
- // i18n.str`calculate cashout fee`,
- const calculate = actionHandler(
- async (ct, amount: AmountJson) => {
- const respCashin = await calculateCashinFromDebit(amount, in_fee);
- if (respCashin.type === "fail") {
- return respCashin;
- }
- const cashin = respCashin.body;
- const respCashout = await calculateCashoutFromDebit(
- cashin.credit,
- out_fee,
- );
- if (respCashout.type === "fail") {
- return respCashout;
- }
- const cashout = respCashout.body;
- return opFixedSuccess(dummyHttpResponse, { cashin, cashout });
- },
- !in_amount || !!error ? undefined : [in_amount],
- );
+ async function calculateOperation(
+ _ct: CancellationToken,
+ amount: AmountJson,
+ ) {
+ const respCashin = await calculateCashinFromDebit(amount, in_fee);
+ if (respCashin.type === "fail") {
+ return respCashin;
+ }
+ const cashin = respCashin.body;
+ const respCashout = await calculateCashoutFromDebit(cashin.credit, out_fee);
+ if (respCashout.type === "fail") {
+ return respCashout;
+ }
+ const cashout = respCashout.body;
+ return opFixedSuccess(dummyHttpResponse, { cashin, cashout });
+ }
- calculate.onSuccess = (resp) => setCalc(resp);
- calculate.onFail = showError(
- i18n.str`Failed to calculate the cashout fee.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Conflict:
- return i18n.str`The amount is too small`;
- case HttpStatusCode.NotImplemented:
- return i18n.str`Conversion is not implemented.`;
- case TalerErrorCode.GENERIC_PARAMETER_MISSING:
- return i18n.str`At least debit or credit needs to be provided`;
- case TalerErrorCode.GENERIC_PARAMETER_MALFORMED:
- return i18n.str`The amount is malformed`;
- case TalerErrorCode.GENERIC_CURRENCY_MISMATCH:
- return i18n.str`The currency is not supported`;
- default:
- assertUnreachable(fail);
- }
- },
- );
+ // i18n.str`calculate cashout fee`,
+ const calculate = useNotifiedOperation<
+ Awaited<ReturnType<typeof calculateOperation>>,
+ [AmountJson]
+ >(calculateOperation, {
+ onSuccess: (resp) => setCalc(resp),
+ onFail: showError(
+ i18n.str`Failed to calculate the cashout fee.`,
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Conflict:
+ return i18n.str`The amount is too small`;
+ case HttpStatusCode.NotImplemented:
+ return i18n.str`Conversion is not implemented.`;
+ case TalerErrorCode.GENERIC_PARAMETER_MISSING:
+ return i18n.str`At least debit or credit needs to be provided`;
+ case TalerErrorCode.GENERIC_PARAMETER_MALFORMED:
+ return i18n.str`The amount is malformed`;
+ case TalerErrorCode.GENERIC_CURRENCY_MISMATCH:
+ return i18n.str`The currency is not supported`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
+ });
useEffect(() => {
- calculate.call();
+ if (in_amount && !error) {
+ calculate.run(in_amount);
+ }
}, [amount]);
const cashinCalc = calculationResult?.cashin;
diff --git a/packages/libeufin-bank-webui/src/pages/LoginForm.tsx b/packages/libeufin-bank-webui/src/pages/LoginForm.tsx
@@ -27,11 +27,12 @@ import {
import { dummyHttpResponse } from "@gnu-taler/taler-util/http";
import {
Attention,
- Button,
+ AsyncButton,
RouteDefinition,
ShowInputErrorLabel,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
@@ -77,7 +78,7 @@ export function LoginForm({
config,
lib: { bank: api },
} = useBankCoreApiContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const mfa = useBankChallengeHandlerContext();
@@ -91,11 +92,13 @@ export function LoginForm({
});
// i18n.str`logout`,
- const logout = actionHandler(async () => {
- session.logOut();
- return opEmptySuccess(dummyHttpResponse);
- }, []);
- logout.onSuccess = session.logOut;
+ const logout = useNotifiedOperation(
+ async () => {
+ session.logOut();
+ return opEmptySuccess(dummyHttpResponse);
+ },
+ { onSuccess: session.logOut },
+ );
const tokenRequest = {
scope: "readwrite",
@@ -104,7 +107,10 @@ export function LoginForm({
} as TokenRequest;
// i18n.str`login`,
- const login = actionHandler(
+ const login = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.createAccessToken>>,
+ [string, string, challengeIds?: string[]]
+ >(
(ct, username: string, password: string, challengeIds?: string[]) =>
api.createAccessToken(
username,
@@ -112,41 +118,47 @@ export function LoginForm({
tokenRequest,
{ challengeIds },
),
- !!errors ? undefined : [username!, password!],
- );
-
- login.onSuccess = (result, username) => {
- session.logIn({
- username,
- token: createRFC8959AccessTokenEncoded(result.access_token),
- expiration: AbsoluteTime.fromProtocolTimestamp(result.expiration),
- });
- };
-
- login.onFail = showError(i18n.str`Failed to login.`, (fail, username) => {
- switch (fail.case) {
- case HttpStatusCode.Accepted:
- mfa.onNewChallenge(
- i18n.str`Identity verification.`,
+ {
+ onSuccess: (result, username) => {
+ mfa.cancel();
+ session.logIn({
username,
- fail.body,
- login.lambda((prev, next) =>
- !prev ? undefined : [prev[0], prev[1], next[0]],
- ),
- );
- return undefined;
- case TalerErrorCode.GENERIC_FORBIDDEN:
- return i18n.str`The account has no rights to login.`;
- case TalerErrorCode.BANK_ACCOUNT_LOCKED:
- return i18n.str`The account is locked and cannot login. Contact administrator.`;
- case HttpStatusCode.Unauthorized:
- return i18n.str`Wrong credentials for "${username}"`;
- case HttpStatusCode.NotFound:
- return i18n.str`Account not found`;
- default:
- assertUnreachable(fail);
- }
- });
+ token: createRFC8959AccessTokenEncoded(result.access_token),
+ expiration: AbsoluteTime.fromProtocolTimestamp(result.expiration),
+ });
+ },
+ onFail: showError(
+ i18n.str`Failed to login.`,
+ (fail, username, password) => {
+ switch (fail.case) {
+ case HttpStatusCode.Accepted:
+ mfa.onNewChallenge(
+ i18n.str`Identity verification.`,
+ username,
+ fail.body,
+ {
+ running: login.running,
+ cancel: login.cancel,
+ run: (challengeIds) =>
+ login.run(username, password, challengeIds),
+ },
+ );
+ return undefined;
+ case TalerErrorCode.GENERIC_FORBIDDEN:
+ return i18n.str`The account has no rights to login.`;
+ case TalerErrorCode.BANK_ACCOUNT_LOCKED:
+ return i18n.str`The account is locked and cannot login. Contact administrator.`;
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Wrong credentials for "${username}"`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Account not found`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
+ },
+ );
const onlyThisUser = fixedUser || session.state.status !== "loggedOut";
return (
@@ -232,37 +244,39 @@ export function LoginForm({
{session.state.status !== "loggedOut" ? (
<div class="flex justify-between">
- <Button
+ <AsyncButton
name="cancel"
class="rounded-md bg-white-600 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
- onClick={logout}
+ onClick={() => logout.run()}
>
{session.state.status === "loggedIn" ? (
<i18n.Translate>Log out</i18n.Translate>
) : (
<i18n.Translate>Clear</i18n.Translate>
)}
- </Button>
+ </AsyncButton>
- <Button
+ <AsyncButton
submit
name="check"
class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={login}
+ disabled={!!errors}
+ onClick={() => login.run(username!, password!)}
>
<i18n.Translate>Verify</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
) : (
<div>
- <Button
+ <AsyncButton
submit
name="login"
class="flex w-full justify-center rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 data-[failed=true]:hover:bg-error data-[failed=true]:bg-error"
- onClick={login}
+ disabled={!!errors}
+ onClick={() => login.run(username!, password!)}
>
<i18n.Translate>Log in</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
)}
</form>
diff --git a/packages/libeufin-bank-webui/src/pages/NewConversionRateClass.tsx b/packages/libeufin-bank-webui/src/pages/NewConversionRateClass.tsx
@@ -6,10 +6,11 @@ import {
TalerErrorCode,
} from "@gnu-taler/taler-util";
import {
- Button,
+ AsyncButton,
RouteDefinition,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact";
@@ -34,39 +35,43 @@ export function NewConversionRateClass({
lib: { bank: api },
} = useBankCoreApiContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const [submitData, setSubmitData] = useState<
TalerCorebankApi.ConversionRateClassInput | undefined
>();
// i18n.str`create conversion rate class`,
- const create = actionHandler(
+ const create = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.createConversionRateClass>>,
+ [AccessToken, TalerCorebankApi.ConversionRateClassInput]
+ >(
(ct, token: AccessToken, data: TalerCorebankApi.ConversionRateClassInput) =>
api.createConversionRateClass(token, data),
- !submitData || !token ? undefined : [token, submitData],
- );
- create.onSuccess = (success) => {
- // notifyInfo(i18n.str`Conversion rate class created.`);
- onCreated(success.conversion_rate_class_id);
- };
- create.onFail = showError(
- i18n.str`Failed to create conversion class.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Unauthorized:
- return i18n.str`The rights to change the account are not sufficient`;
- case HttpStatusCode.Forbidden:
- return i18n.str`Wrong credentials`;
- case HttpStatusCode.NotFound:
- return i18n.str`Account not found`;
- case HttpStatusCode.NotImplemented:
- return i18n.str`Not implemented`;
- case TalerErrorCode.BANK_NAME_REUSE:
- return i18n.str`The name of the conversion is already used.`;
- default:
- assertUnreachable(fail);
- }
+ {
+ onSuccess: (success) => {
+ // notifyInfo(i18n.str`Conversion rate class created.`);
+ onCreated(success.conversion_rate_class_id);
+ },
+ onFail: showError(
+ i18n.str`Failed to create conversion class.`,
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`The rights to change the account are not sufficient`;
+ case HttpStatusCode.Forbidden:
+ return i18n.str`Wrong credentials`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Account not found`;
+ case HttpStatusCode.NotImplemented:
+ return i18n.str`Not implemented`;
+ case TalerErrorCode.BANK_NAME_REUSE:
+ return i18n.str`The name of the conversion is already used.`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
},
);
@@ -87,14 +92,15 @@ export function NewConversionRateClass({
>
<i18n.Translate>Cancel</i18n.Translate>
</a>
- <Button
+ <AsyncButton
submit
name="create"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={create}
+ disabled={!submitData || !token}
+ onClick={() => create.run(token!, submitData!)}
>
<i18n.Translate>Create</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</ConversionRateClassForm>
</div>
diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx b/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx
@@ -25,10 +25,11 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
RenderAmount,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTalerWalletIntegrationAPI,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
@@ -67,7 +68,7 @@ export function NeedConfirmationView({
}: State.NeedConfirmation) {
const { i18n } = useTranslationContext();
const [settings] = usePreferences();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const mfa = useBankChallengeHandlerContext();
const { state: credentials } = useSessionState();
@@ -82,14 +83,12 @@ export function NeedConfirmationView({
: Amounts.parseOrThrow(config.wire_transfer_fees);
// i18n.str`abort withdrawal`,
- const abort = actionHandler(
- (ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId),
- !creds ? undefined : ([creds] as const),
- );
- abort.onSuccess = onAbort;
- abort.onFail = showError(
- i18n.str`Failed to abort the withdrawal.`,
- (fail) => {
+ const abort = useNotifiedOperation<
+ Awaited<ReturnType<typeof bank.abortWithdrawalById>>,
+ [LoggedIn]
+ >((ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), {
+ onSuccess: onAbort,
+ onFail: showError(i18n.str`Failed to abort the withdrawal.`, (fail) => {
switch (fail.case) {
case HttpStatusCode.BadRequest:
return i18n.str`The server did not understand the request.`;
@@ -100,52 +99,59 @@ export function NeedConfirmationView({
default:
assertUnreachable(fail);
}
- },
- );
+ }),
+ });
// i18n.str`confirm withdrawal`,
- const confirm = actionHandler(
+ const confirm = useNotifiedOperation<
+ Awaited<ReturnType<typeof bank.confirmWithdrawalById>>,
+ [LoggedIn, challengeIds?: string[]]
+ >(
(ct, creds: LoggedIn, challengeIds?: string[]) =>
bank.confirmWithdrawalById(creds, {}, operationId, { challengeIds }),
- !creds ? undefined : ([creds, undefined as string[] | undefined] as const),
- );
- confirm.onSuccess = () => {
- if (!settings.showWithdrawalSuccess) {
- // notifyInfo(i18n.str`Wire transfer completed!`);
- }
- onAbort();
- };
- confirm.onFail = showError(
- i18n.str`Failed to confirm the withdrawal.`,
- (fail, creds) => {
- switch (fail.case) {
- case HttpStatusCode.Accepted:
- mfa.onNewChallenge(
- i18n.str`Withdrawal confirmation`,
- creds.username,
- fail.body,
- confirm.lambda((prev, next) =>
- !prev ? undefined : [prev[0], next[0]],
- ),
- );
- return undefined;
- case HttpStatusCode.BadRequest:
- return i18n.str`The server did not understand the request.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The operation was not found.`;
- case TalerErrorCode.BANK_UNALLOWED_DEBIT:
- return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`;
- case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT:
- return i18n.str`The withdrawal has been aborted and can not be confirmed.`;
- case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
- return i18n.str`The withdrawal has no exchange and reserve public selected.`;
- case TalerErrorCode.BANK_AMOUNT_DIFFERS:
- return i18n.str`The starting withdrawal amount and the confirmation amount differ.`;
- case TalerErrorCode.BANK_AMOUNT_REQUIRED:
- return i18n.str`The bank requires a bank account which has not been specified yet.`;
- default:
- assertUnreachable(fail);
- }
+ {
+ onSuccess: () => {
+ mfa.cancel();
+ if (!settings.showWithdrawalSuccess) {
+ // notifyInfo(i18n.str`Wire transfer completed!`);
+ }
+ onAbort();
+ },
+ onFail: showError(
+ i18n.str`Failed to confirm the withdrawal.`,
+ (fail, creds) => {
+ switch (fail.case) {
+ case HttpStatusCode.Accepted:
+ mfa.onNewChallenge(
+ i18n.str`Withdrawal confirmation`,
+ creds.username,
+ fail.body,
+ {
+ running: confirm.running,
+ cancel: confirm.cancel,
+ run: (challengeIds) => confirm.run(creds, challengeIds),
+ },
+ );
+ return undefined;
+ case HttpStatusCode.BadRequest:
+ return i18n.str`The server did not understand the request.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The operation was not found.`;
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`;
+ case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT:
+ return i18n.str`The withdrawal has been aborted and can not be confirmed.`;
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return i18n.str`The withdrawal has no exchange and reserve public selected.`;
+ case TalerErrorCode.BANK_AMOUNT_DIFFERS:
+ return i18n.str`The starting withdrawal amount and the confirmation amount differ.`;
+ case TalerErrorCode.BANK_AMOUNT_REQUIRED:
+ return i18n.str`The bank requires a bank account which has not been specified yet.`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
},
);
@@ -344,7 +350,7 @@ export function NeedConfirmationView({
);
}
case PaytoType.Void:
- return <p>void account not suported</p>
+ return <p>void account not suported</p>;
default: {
assertUnreachable(details.account);
}
@@ -388,21 +394,23 @@ export function NeedConfirmationView({
</div>
</div>
<div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
- <Button
+ <AsyncButton
name="cancel"
class="text-sm font-semibold leading-6 text-gray-900"
- onClick={abort}
+ disabled={!creds}
+ onClick={() => abort.run(creds!)}
>
<i18n.Translate>Cancel</i18n.Translate>
- </Button>
- <Button
+ </AsyncButton>
+ <AsyncButton
submit
name="transfer"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={confirm}
+ disabled={!creds}
+ onClick={() => confirm.run(creds!)}
>
<i18n.Translate>Transfer</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
</ShouldBeSameUser>
@@ -551,7 +559,7 @@ export function ReadyView({
}: State.Ready): VNode {
const { i18n } = useTranslationContext();
const walletInegrationApi = useTalerWalletIntegrationAPI();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const { state: credentials } = useSessionState();
const creds = credentials.status !== "loggedIn" ? undefined : credentials;
@@ -570,22 +578,23 @@ export function ReadyView({
}, []);
// i18n.str`abort withdrawal`,
- const abort = actionHandler(
- (ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId),
- !creds ? undefined : ([creds] as const),
- );
- abort.onSuccess = onAbort;
- abort.onFail = showError(i18n.str`Failed to abort the withdrawal`, (fail) => {
- switch (fail.case) {
- case HttpStatusCode.BadRequest:
- return i18n.str`The server did not understand the request.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The operation was not found.`;
- case HttpStatusCode.Conflict:
- return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`;
- default:
- assertUnreachable(fail);
- }
+ const abort = useNotifiedOperation<
+ Awaited<ReturnType<typeof bank.abortWithdrawalById>>,
+ [LoggedIn]
+ >((ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), {
+ onSuccess: onAbort,
+ onFail: showError(i18n.str`Failed to abort the withdrawal`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`The server did not understand the request.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The operation was not found.`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`;
+ default:
+ assertUnreachable(fail);
+ }
+ }),
});
return (
@@ -615,14 +624,15 @@ export function ReadyView({
</p>
</div>
<div class="flex items-center justify-between gap-x-6 pt-2 mt-2 ">
- <Button
+ <AsyncButton
name="cancel"
class="text-sm font-semibold leading-6 text-gray-900"
// class="inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-black shadow-sm "
- onClick={abort}
+ disabled={!creds}
+ onClick={() => abort.run(creds!)}
>
<i18n.Translate>Cancel</i18n.Translate>
- </Button>
+ </AsyncButton>
<a
href={talerWithdrawUri}
@@ -652,12 +662,13 @@ export function ReadyView({
</div>
</div>
<div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
- <Button
+ <AsyncButton
class="text-sm font-semibold leading-6 text-gray-900"
- onClick={abort}
+ disabled={!creds}
+ onClick={() => abort.run(creds!)}
>
<i18n.Translate>Cancel</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</div>
</Fragment>
diff --git a/packages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx b/packages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx
@@ -29,13 +29,14 @@ import {
assertUnreachable,
} from "@gnu-taler/taler-util";
import {
- Button,
+ AsyncButton,
InternationalizationAPI,
RenderAmount,
RouteDefinition,
ShowInputErrorLabel,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { ComponentChildren, Fragment, Ref, VNode, h } from "preact";
@@ -74,6 +75,7 @@ export function PaytoWireTransferForm({
const isRawPayto = inputType !== "form";
const { state: credentials } = useSessionState();
+ const creds = credentials.status === "loggedIn" ? credentials : undefined;
const {
lib: { bank: api },
config,
@@ -102,7 +104,7 @@ export function PaytoWireTransferForm({
const parsedAmount = Amounts.parse(
`${limitWithFee.currency}:${trimmedAmountStr}`,
);
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const mfa = useBankChallengeHandlerContext();
const paytoType =
@@ -173,9 +175,17 @@ export function PaytoWireTransferForm({
`${limitWithFee.currency}:${trimmedAmountStr}` as AmountString;
}
const sAmount = sendingAmount;
+ const sendDisabled =
+ (isRawPayto ? !!errorsPayto : !!errorsWire) ||
+ !sAmount ||
+ !parsedURI ||
+ !creds;
// i18n.str`send transaction`,
- const send = actionHandler(
+ const send = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.createTransaction>>,
+ [LoggedIn, AmountString, Paytos.URI, challengeIds?: string[]]
+ >(
(
ct,
creds: LoggedIn,
@@ -188,61 +198,54 @@ export function PaytoWireTransferForm({
{ payto_uri: Paytos.toFullString(uri), amount },
{ challengeIds },
),
- (isRawPayto ? !!errorsPayto : !!errorsWire) ||
- !sAmount ||
- !parsedURI ||
- credentials.status !== "loggedIn"
- ? undefined
- : ([
- credentials,
- sAmount,
- parsedURI,
- undefined as string[] | undefined,
- ] as const),
- );
-
- send.onSuccess = (success) => {
- // notifyInfo(i18n.str`The wire transfer was successfully completed!`);
- if (onSuccess) onSuccess();
- setAmount(undefined);
- setAccount(undefined);
- setSubject(undefined);
- rawPaytoInputSetter(undefined);
- };
-
- send.onFail = showError(
- i18n.str`Failed to create the transactions.`,
- (fail, creds, amount, uri) => {
- switch (fail.case) {
- case HttpStatusCode.BadRequest:
- return i18n.str`The request was invalid or the payto://-URI used unacceptable features.`;
- case HttpStatusCode.Unauthorized:
- return i18n.str`Not enough permission to complete the operation.`;
- case TalerErrorCode.BANK_ADMIN_CREDITOR:
- return i18n.str`The bank administrator cannot be the transfer creditor.`;
- case TalerErrorCode.BANK_UNKNOWN_CREDITOR:
- return i18n.str`The destination account "${uri.displayName}" was not found.`;
- case TalerErrorCode.BANK_SAME_ACCOUNT:
- return i18n.str`The origin and the destination of the transfer can't be the same.`;
- case TalerErrorCode.BANK_UNALLOWED_DEBIT:
- return i18n.str`Your balance is not sufficient for the operation.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The origin account "${uri.displayName}" was not found.`;
- case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
- return i18n.str`The attempt to create the transaction has failed. Please try again.`;
- case HttpStatusCode.Accepted:
- mfa.onNewChallenge(
- i18n.str`Confirm transaction.`,
- creds.username,
- fail.body,
- send.lambda((prev, next) =>
- !prev ? undefined : [prev[0], prev[1], prev[2], next[0]],
- ),
- );
- return undefined;
- default:
- assertUnreachable(fail);
- }
+ {
+ onSuccess: () => {
+ mfa.cancel();
+ // notifyInfo(i18n.str`The wire transfer was successfully completed!`);
+ if (onSuccess) onSuccess();
+ setAmount(undefined);
+ setAccount(undefined);
+ setSubject(undefined);
+ rawPaytoInputSetter(undefined);
+ },
+ onFail: showError(
+ i18n.str`Failed to create the transactions.`,
+ (fail, creds, amount, uri) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`The request was invalid or the payto://-URI used unacceptable features.`;
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Not enough permission to complete the operation.`;
+ case TalerErrorCode.BANK_ADMIN_CREDITOR:
+ return i18n.str`The bank administrator cannot be the transfer creditor.`;
+ case TalerErrorCode.BANK_UNKNOWN_CREDITOR:
+ return i18n.str`The destination account "${uri.displayName}" was not found.`;
+ case TalerErrorCode.BANK_SAME_ACCOUNT:
+ return i18n.str`The origin and the destination of the transfer can't be the same.`;
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return i18n.str`Your balance is not sufficient for the operation.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The origin account "${uri.displayName}" was not found.`;
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
+ return i18n.str`The attempt to create the transaction has failed. Please try again.`;
+ case HttpStatusCode.Accepted:
+ mfa.onNewChallenge(
+ i18n.str`Confirm transaction.`,
+ creds.username,
+ fail.body,
+ {
+ running: send.running,
+ cancel: send.cancel,
+ run: (challengeIds) =>
+ send.run(creds, amount, uri, challengeIds),
+ },
+ );
+ return undefined;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
},
);
@@ -648,14 +651,15 @@ export function PaytoWireTransferForm({
) : (
<div />
)}
- <Button
+ <AsyncButton
submit
name="send"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={send}
+ disabled={sendDisabled}
+ onClick={() => send.run(creds!, sAmount!, parsedURI!)}
>
<i18n.Translate>Send</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
</div>
diff --git a/packages/libeufin-bank-webui/src/pages/QrCodeSection.tsx b/packages/libeufin-bank-webui/src/pages/QrCodeSection.tsx
@@ -22,9 +22,10 @@ import {
TalerWithdrawUri,
} from "@gnu-taler/taler-util";
import {
- Button,
+ AsyncButton,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTalerWalletIntegrationAPI,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
@@ -52,33 +53,36 @@ export function QrCodeSection({
walletInegrationApi.publishTalerAction(withdrawUri);
}, []);
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const {
lib: { bank: api },
} = useBankCoreApiContext();
// i18n.str`abort withdrawal`,
- const abort = actionHandler(
+ const abort = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.abortWithdrawalById>>,
+ [UserAndToken]
+ >(
(ct, creds: UserAndToken) =>
api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId),
- !creds ? undefined : [creds],
+ {
+ onSuccess: onAborted,
+ onFail: showError(i18n.str`Failed to abort withdrawal.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`The operation ID is invalid.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The operation was not found.`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`The reserve operation has been confirmed previously and can't be aborted`;
+ default:
+ assertUnreachable(fail);
+ }
+ }),
+ },
);
- abort.onSuccess = onAborted;
- abort.onFail = showError(i18n.str`Failed to abort withdrawal.`, (fail) => {
- switch (fail.case) {
- case HttpStatusCode.BadRequest:
- return i18n.str`The operation ID is invalid.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The operation was not found.`;
- case HttpStatusCode.Conflict:
- return i18n.str`The reserve operation has been confirmed previously and can't be aborted`;
- default:
- assertUnreachable(fail);
- }
- });
-
return (
<Fragment>
<div class="bg-white shadow-xl sm:rounded-lg">
@@ -106,13 +110,14 @@ export function QrCodeSection({
</p>
</div>
<div class="flex items-center justify-between gap-x-6 pt-2 mt-2 ">
- <Button
+ <AsyncButton
name="cancel"
class="text-sm font-semibold leading-6 text-gray-900"
- onClick={abort}
+ disabled={!creds}
+ onClick={() => abort.run(creds!)}
>
<i18n.Translate>Cancel</i18n.Translate>
- </Button>
+ </AsyncButton>
<a
href={talerWithdrawUri}
name="withdraw"
@@ -141,12 +146,13 @@ export function QrCodeSection({
</div>
</div>
<div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
- <Button
+ <AsyncButton
class="text-sm font-semibold leading-6 text-gray-900"
- onClick={abort}
+ disabled={!creds}
+ onClick={() => abort.run(creds!)}
>
<i18n.Translate>Cancel</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</div>
</Fragment>
diff --git a/packages/libeufin-bank-webui/src/pages/RegistrationPage.tsx b/packages/libeufin-bank-webui/src/pages/RegistrationPage.tsx
@@ -20,11 +20,12 @@ import {
TalerErrorCode,
} from "@gnu-taler/taler-util";
import {
- Button,
+ AsyncButton,
RouteDefinition,
ShowInputErrorLabel,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
@@ -81,7 +82,7 @@ function RegistrationForm({
// const [phone, setPhone] = useState<string | undefined>();
// const [email, setEmail] = useState<string | undefined>();
const [repeatPassword, setRepeatPassword] = useState<string | undefined>();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const settings = useSettingsContext();
const [pref] = usePreferences();
@@ -120,59 +121,58 @@ function RegistrationForm({
};
// i18n.str`register new account`,
- const register = actionHandler(
+ const register = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.createAccount>>,
+ [TalerCorebankApi.RegisterAccountRequest]
+ >(
(ct, account: TalerCorebankApi.RegisterAccountRequest) =>
api.createAccount(undefined, account),
- !!errors || !reg ? undefined : [reg],
- );
-
- register.onSuccess = (success, acc) => {
- setUsername(undefined);
- setPassword(undefined);
- setRepeatPassword(undefined);
- setName(undefined);
- onRegistrationSuccesful(acc.username, acc.password);
- };
-
- register.onFail = showError(
- i18n.str`Failed to create a new account.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.BadRequest:
- return i18n.str`Server replied with invalid phone or email.`;
- case HttpStatusCode.Unauthorized:
- return i18n.str`You are not authorized to create this account.`;
- case TalerErrorCode.BANK_UNALLOWED_DEBIT:
- return i18n.str`Registration is disabled because the bank ran out of bonus credit.`;
- case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
- return i18n.str`That username can't be used because it is reserved.`;
- case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE:
- return i18n.str`That username is already taken.`;
- case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE:
- return i18n.str`That account ID is already taken.`;
- case TalerErrorCode.BANK_MISSING_TAN_INFO:
- return i18n.str`No information for the selected authentication channel.`;
- case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED:
- return i18n.str`Authentication channel is not supported.`;
- case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
- return i18n.str`Only an administrator is allowed to set the debt limit.`;
- case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS:
- return i18n.str`Only the administrator can change the conversion rate.`;
- case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN:
- return i18n.str`The conversion rate class doesn't exist.`;
- case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL:
- return i18n.str`Only admin can create accounts with second factor authentication.`;
- case TalerErrorCode.BANK_PASSWORD_TOO_SHORT:
- return i18n.str`The password is too short. Can't have less than 8 characters.`;
- case TalerErrorCode.BANK_PASSWORD_TOO_LONG:
- return i18n.str`The password is too long. Can't have more than 64 characters.`;
- default:
- assertUnreachable(fail);
- }
+ {
+ onSuccess: (success, acc) => {
+ setUsername(undefined);
+ setPassword(undefined);
+ setRepeatPassword(undefined);
+ setName(undefined);
+ onRegistrationSuccesful(acc.username, acc.password);
+ },
+ onFail: showError(i18n.str`Failed to create a new account.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`Server replied with invalid phone or email.`;
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`You are not authorized to create this account.`;
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return i18n.str`Registration is disabled because the bank ran out of bonus credit.`;
+ case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
+ return i18n.str`That username can't be used because it is reserved.`;
+ case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE:
+ return i18n.str`That username is already taken.`;
+ case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE:
+ return i18n.str`That account ID is already taken.`;
+ case TalerErrorCode.BANK_MISSING_TAN_INFO:
+ return i18n.str`No information for the selected authentication channel.`;
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED:
+ return i18n.str`Authentication channel is not supported.`;
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
+ return i18n.str`Only an administrator is allowed to set the debt limit.`;
+ case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS:
+ return i18n.str`Only the administrator can change the conversion rate.`;
+ case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN:
+ return i18n.str`The conversion rate class doesn't exist.`;
+ case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL:
+ return i18n.str`Only admin can create accounts with second factor authentication.`;
+ case TalerErrorCode.BANK_PASSWORD_TOO_SHORT:
+ return i18n.str`The password is too short. Can't have less than 8 characters.`;
+ case TalerErrorCode.BANK_PASSWORD_TOO_LONG:
+ return i18n.str`The password is too long. Can't have more than 64 characters.`;
+ default:
+ assertUnreachable(fail);
+ }
+ }),
},
);
- const registerRandom = register.lambda(() => {
+ const registerRandom = () => {
const user = getRandomUsername();
const password = "12345678";
@@ -180,8 +180,8 @@ function RegistrationForm({
const name = `${capitalizeFirstLetter(user.first)} ${capitalizeFirstLetter(
user.second,
)}`;
- return [{ name, username, password }];
- }, []);
+ return register.run({ name, username, password });
+ };
return (
<Fragment>
@@ -340,27 +340,28 @@ function RegistrationForm({
>
<i18n.Translate>Cancel</i18n.Translate>
</a>
- <Button
+ <AsyncButton
submit
name="register"
class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={register}
+ disabled={!!errors || !reg}
+ onClick={() => register.run(reg!)}
>
<i18n.Translate>Register</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
{settings.allowRandomAccountCreation && (
<p class="mt-10 text-center text-sm text-gray-500 border-t">
- <Button
+ <AsyncButton
submit
name="create random"
class="flex mt-4 w-full disabled:bg-gray-300 justify-center rounded-md bg-green-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
- onClick={registerRandom}
+ onClick={() => registerRandom()}
>
<i18n.Translate>Create a random temporary user</i18n.Translate>
- </Button>
+ </AsyncButton>
</p>
)}
</div>
diff --git a/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx b/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx
@@ -11,13 +11,14 @@ import {
} from "@gnu-taler/taler-util";
import { dummyHttpResponse } from "@gnu-taler/taler-util/http";
import {
- Button,
- SafeHandler,
+ AsyncAction,
+ AsyncButton,
ShowInputErrorLabel,
Time,
undefinedIfEmpty,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { ComponentChildren, Fragment, h, VNode } from "preact";
@@ -27,7 +28,7 @@ import { doAutoFocus } from "./PaytoWireTransferForm.js";
const TALER_SCREEN_ID = 9;
export interface Props {
- onCompleted: SafeHandler<[challenges: string[]], any>;
+ onCompleted: AsyncAction<[challenges: string[]]>;
username: string;
onCancel(): void;
description: TranslatedString;
@@ -53,7 +54,7 @@ function SolveChallenge({
const {
lib: { bank: api },
} = useBankCoreApiContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const [showExpired, setExpired] = useState(
expiration !== undefined && AbsoluteTime.isExpired(expiration),
@@ -76,31 +77,32 @@ function SolveChallenge({
}, []);
// i18n.str`confirm MFA challenge`,
- const doVerification = actionHandler(
+ const doVerification = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.confirmChallenge>>,
+ [string]
+ >(
(ct, tan: string) =>
api.confirmChallenge(username, challenge.challenge_id, { tan }),
- !errors ? [tanCode!] : undefined,
- );
- doVerification.onFail = showError(
- i18n.str`Faild to verify the code.`,
- (fail) => {
- switch (fail.case) {
- case TalerErrorCode.BANK_TRANSACTION_NOT_FOUND:
- return i18n.str`Unknown challenge.`;
- case HttpStatusCode.Unauthorized:
- return i18n.str`Failed to validate the verification code.`;
- case HttpStatusCode.TooManyRequests:
- return i18n.str`Too many challenges are active right now, you must wait or confirm current challenges.`;
- case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED:
- return i18n.str`Wrong authentication number.`;
- case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED:
- return i18n.str`Expired challenge.`;
- default:
- assertUnreachable(fail);
- }
+ {
+ onSuccess: onSolved,
+ onFail: showError(i18n.str`Faild to verify the code.`, (fail) => {
+ switch (fail.case) {
+ case TalerErrorCode.BANK_TRANSACTION_NOT_FOUND:
+ return i18n.str`Unknown challenge.`;
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Failed to validate the verification code.`;
+ case HttpStatusCode.TooManyRequests:
+ return i18n.str`Too many challenges are active right now, you must wait or confirm current challenges.`;
+ case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED:
+ return i18n.str`Wrong authentication number.`;
+ case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED:
+ return i18n.str`Expired challenge.`;
+ default:
+ assertUnreachable(fail);
+ }
+ }),
},
);
- doVerification.onSuccess = onSolved;
return (
<Fragment>
@@ -207,14 +209,15 @@ function SolveChallenge({
<i18n.Translate>Back</i18n.Translate>
</button>
- <Button
+ <AsyncButton
submit
name="send again"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={doVerification}
+ disabled={!!errors}
+ onClick={() => doVerification.run(tanCode!)}
>
<i18n.Translate>Verify</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</div>
</div>
@@ -278,7 +281,7 @@ function SolveMFAChallenges({
ch: Challenge;
expiration: AbsoluteTime;
}>();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const {
lib: { bank: api },
@@ -289,6 +292,54 @@ function SolveMFAChallenges({
Record<string, AbsoluteTime | undefined>
>({});
+ // i18n.str`send MFA challenge`,
+ const sendMessage = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.sendChallenge>>,
+ [Challenge]
+ >((ct, ch: Challenge) => api.sendChallenge(username, ch.challenge_id), {
+ onSuccess: (success, ch) => {
+ if (success.earliest_retransmission) {
+ setRetransmission({
+ ...retransmission,
+ [ch.challenge_id]: AbsoluteTime.fromProtocolTimestamp(
+ success.earliest_retransmission,
+ ),
+ });
+ }
+ setSelected({
+ ch,
+ expiration: !success.solve_expiration
+ ? AbsoluteTime.never()
+ : AbsoluteTime.fromProtocolTimestamp(success.solve_expiration),
+ });
+ },
+ onFail: showError(i18n.str`Failed to start the challenge.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Unable to send the verification code.`;
+ case HttpStatusCode.Forbidden:
+ return i18n.str`The request was valid, but the server is refusing action.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The backend is not aware of the specified MFA challenge.`;
+ case HttpStatusCode.TooManyRequests:
+ return i18n.str`It is too early to request another transmission of the challenge.`;
+ case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
+ return i18n.str`Code transmission failed.`;
+ default:
+ assertUnreachable(fail);
+ }
+ }),
+ });
+
+ // i18n.str`select challenge`,
+ const selectChallenge = useNotifiedOperation(async (ct, ch: Challenge) => {
+ setSelected({
+ ch,
+ expiration: AbsoluteTime.never(),
+ });
+ return opEmptySuccess(dummyHttpResponse);
+ });
+
if (selected) {
return (
<SolveChallenge
@@ -305,7 +356,7 @@ function SolveMFAChallenges({
if (enough) {
setSolved(total);
- await onCompleted.withArgs(total).call();
+ await onCompleted.run(total);
} else {
setSolved(total);
}
@@ -321,58 +372,6 @@ function SolveMFAChallenges({
? currentSolved.length === currentChallenge.challenges.length
: currentSolved.length > 0;
- // i18n.str`send MFA challenge`,
- const sendMessage = actionHandler((ct, ch: Challenge) =>
- api.sendChallenge(username, ch.challenge_id),
- );
- sendMessage.onSuccess = (success, ch) => {
- if (success.earliest_retransmission) {
- setRetransmission({
- ...retransmission,
- [ch.challenge_id]: AbsoluteTime.fromProtocolTimestamp(
- success.earliest_retransmission,
- ),
- });
- }
- setSelected({
- ch,
- expiration: !success.solve_expiration
- ? AbsoluteTime.never()
- : AbsoluteTime.fromProtocolTimestamp(success.solve_expiration),
- });
- };
-
- sendMessage.onFail = showError(
- i18n.str`Failed to start the challenge.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Unauthorized:
- return i18n.str`Unable to send the verification code.`;
- case HttpStatusCode.Forbidden:
- return i18n.str`The request was valid, but the server is refusing action.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The backend is not aware of the specified MFA challenge.`;
- case HttpStatusCode.TooManyRequests:
- return i18n.str`It is too early to request another transmission of the challenge.`;
- case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
- return i18n.str`Code transmission failed.`;
- default:
- assertUnreachable(fail);
- }
- },
- );
-
- const complete = onCompleted.withArgs(solved);
-
- // i18n.str`select challenge`,
- const selectChallenge = actionHandler(async (ct, ch: Challenge) => {
- setSelected({
- ch,
- expiration: AbsoluteTime.never(),
- });
- return opEmptySuccess(dummyHttpResponse);
- });
-
return (
<Fragment>
<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
@@ -436,15 +435,6 @@ function SolveMFAChallenges({
hasSolvedEnough ||
solved.indexOf(challenge.challenge_id) !== -1;
- const doSelect = noNeedToComplete
- ? selectChallenge
- : selectChallenge.withArgs(challenge);
-
- const doSend =
- alreadySent || noNeedToComplete
- ? sendMessage
- : sendMessage.withArgs(challenge);
-
return (
<div key={idx} class="rounded-xl border px-2 my-2">
<dl class="divide-y divide-gray-100">
@@ -470,22 +460,24 @@ function SolveMFAChallenges({
</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:mt-0">
<div class="flex justify-between">
- <Button
+ <AsyncButton
name="cancel"
class="text-sm font-semibold leading-6 text-gray-900"
- onClick={doSelect}
+ disabled={noNeedToComplete}
+ onClick={() => selectChallenge.run(challenge)}
>
<i18n.Translate>I have a code</i18n.Translate>
- </Button>
+ </AsyncButton>
- <Button
+ <AsyncButton
submit
name="send again"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={doSend}
+ disabled={alreadySent || noNeedToComplete}
+ onClick={() => sendMessage.run(challenge)}
>
<i18n.Translate>Send me a message</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</dd>
{alreadySent && time.t_ms !== "never" ? (
@@ -513,14 +505,14 @@ function SolveMFAChallenges({
<i18n.Translate>Cancel</i18n.Translate>
</button>
- <Button
+ <AsyncButton
submit
name="send again"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={complete}
+ onClick={() => onCompleted.run(solved)}
>
<i18n.Translate>Complete</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</div>
</div>
diff --git a/packages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx b/packages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx
@@ -26,12 +26,13 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
RenderAmount,
RouteDefinition,
ShowInputErrorLabel,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
@@ -79,7 +80,7 @@ function OldWithdrawalForm({
const [amountStr, setAmountStr] = useState<string | undefined>(
`${settings.defaultSuggestedAmount ?? 1}`,
);
- const { actionHandler, showError, displayError } = useNotificationContext();
+ const { showError, displayError } = useNotificationContext();
const trimmedAmountStr = amountStr?.trim();
@@ -99,7 +100,10 @@ function OldWithdrawalForm({
});
// i18n.str`create withdrawal`,
- const start = actionHandler(
+ const start = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.createWithdrawal>>,
+ [UserAndToken, AmountString]
+ >(
(ct, creds: UserAndToken, amount: AmountString) =>
api.createWithdrawal(
creds,
@@ -107,41 +111,35 @@ function OldWithdrawalForm({
? { suggested_amount: amount }
: { amount: amount },
),
- !parsedAmount || !creds || !!errors
- ? undefined
- : [creds, Amounts.stringify(parsedAmount)],
- );
-
- start.onSuccess = (success) => {
- const uri = TalerUris.parse(success.taler_withdraw_uri);
- if (uri.tag === "error" || uri.value.type !== TalerUriAction.Withdraw) {
- displayError(
- i18n.str`The server replied with an invalid taler://withdraw URI`,
- i18n.str`Withdraw URI: ${success.taler_withdraw_uri}`,
- );
- return;
- } else {
- updateBankState(
- "currentWithdrawalOperationId",
- uri.value.withdrawalOperationId,
- );
- onOperationCreated(uri.value.withdrawalOperationId);
- }
- };
-
- start.onFail = showError(
- i18n.str`Failed to create the withdrawal.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Conflict:
- return i18n.str`The operation was rejected due to insufficient funds`;
- case HttpStatusCode.Unauthorized:
- return i18n.str`The operation was rejected due to insufficient funds`;
- case HttpStatusCode.NotFound:
- return i18n.str`Account not found`;
- default:
- assertUnreachable(fail);
- }
+ {
+ onSuccess: (success) => {
+ const uri = TalerUris.parse(success.taler_withdraw_uri);
+ if (uri.tag === "error" || uri.value.type !== TalerUriAction.Withdraw) {
+ displayError(
+ i18n.str`The server replied with an invalid taler://withdraw URI`,
+ i18n.str`Withdraw URI: ${success.taler_withdraw_uri}`,
+ );
+ return;
+ } else {
+ updateBankState(
+ "currentWithdrawalOperationId",
+ uri.value.withdrawalOperationId,
+ );
+ onOperationCreated(uri.value.withdrawalOperationId);
+ }
+ },
+ onFail: showError(i18n.str`Failed to create the withdrawal.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Conflict:
+ return i18n.str`The operation was rejected due to insufficient funds`;
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`The operation was rejected due to insufficient funds`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Account not found`;
+ default:
+ assertUnreachable(fail);
+ }
+ }),
},
);
@@ -252,15 +250,16 @@ function OldWithdrawalForm({
>
<i18n.Translate>Cancel</i18n.Translate>
</a>
- <Button
+ <AsyncButton
submit
name="continue"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
// disabled={isRawPayto ? !!errorsPayto : !!errorsWire}
- onClick={start}
+ disabled={!parsedAmount || !creds || !!errors}
+ onClick={() => start.run(creds!, Amounts.stringify(parsedAmount!))}
>
<i18n.Translate>Continue</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
);
diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx
@@ -26,10 +26,11 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
RenderAmount,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { ComponentChildren, Fragment, VNode, h } from "preact";
@@ -53,7 +54,9 @@ interface Props {
function useComponentState(opid: string) {
const { state: credentials } = useSessionState();
const creds = credentials.status !== "loggedIn" ? undefined : credentials;
- const { actionHandler } = useNotificationContext();
+ const { i18n } = useTranslationContext();
+ const { showError } = useNotificationContext();
+ const mfa = useBankChallengeHandlerContext();
const {
config,
@@ -66,33 +69,85 @@ function useComponentState(opid: string) {
: Amounts.parseOrThrow(config.wire_transfer_fees);
// i18n.str`confirm withdrawal`,
- const confirm = actionHandler(
+ const confirm = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.confirmWithdrawalById>>,
+ [LoggedIn, challengeIds?: string[]]
+ >(
(ct, creds: LoggedIn, challengeIds?: string[]) =>
api.confirmWithdrawalById(creds, {}, opid, {
challengeIds,
}),
- !creds ? undefined : ([creds, undefined as string[] | undefined] as const),
+ {
+ onSuccess: () => {
+ mfa.cancel();
+ mutate(() => true); // clean any info that we have
+ },
+ onFail: showError(
+ i18n.str`Failed to confirm the withdrawal.`,
+ (fail, creds) => {
+ switch (fail.case) {
+ case HttpStatusCode.Accepted:
+ mfa.onNewChallenge(
+ i18n.str`Withdrawal confirmation`,
+ creds.username,
+ fail.body,
+ {
+ running: confirm.running,
+ cancel: confirm.cancel,
+ run: (challengeIds) => confirm.run(creds, challengeIds),
+ },
+ );
+ return undefined;
+ case HttpStatusCode.BadRequest:
+ return i18n.str`The server did not understand the request.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The operation was not found.`;
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`;
+ case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT:
+ return i18n.str`The withdrawal has been aborted and can not be confirmed.`;
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return i18n.str`The withdrawal has no exchange and reserve public selected.`;
+ case TalerErrorCode.BANK_AMOUNT_DIFFERS:
+ return i18n.str`The starting withdrawal amount and the confirmation amount differ.`;
+ case TalerErrorCode.BANK_AMOUNT_REQUIRED:
+ return i18n.str`The bank requires a bank account which has not been specified yet.`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
+ },
);
- confirm.onSuccess = () => {
- mutate(() => true); // clean any info that we have
- };
-
// i18n.str`abort withdrawal`,
- const abort = actionHandler(
- (ct, s, id) => api.abortWithdrawalById(s, id),
- !creds ? undefined : ([creds, opid] as const),
- );
-
- abort.onSuccess = () => {
- mutate(() => true); // clean any info that we have
- };
+ const abort = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.abortWithdrawalById>>,
+ [LoggedIn, string]
+ >((ct, s, id) => api.abortWithdrawalById(s, id), {
+ onSuccess: () => {
+ mutate(() => true); // clean any info that we have
+ },
+ onFail: showError(i18n.str`Failed to abort the withdrawal.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`The server did not understand the request.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The operation was not found.`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`;
+ default:
+ assertUnreachable(fail);
+ }
+ }),
+ });
const spec = config.currency_specification;
return {
wireFee,
spec,
+ creds,
abort,
confirm,
};
@@ -107,62 +162,10 @@ export function WithdrawalConfirmationQuestion({
withdrawUri,
}: Props): VNode {
const { i18n } = useTranslationContext();
- const { wireFee, spec, abort, confirm } = useComponentState(
+ const { wireFee, spec, creds, abort, confirm } = useComponentState(
withdrawUri.withdrawalOperationId,
);
- const mfa = useBankChallengeHandlerContext();
- const { showError } = useNotificationContext();
-
- confirm.onFail = showError(
- i18n.str`Failed to confirm the withdrawal.`,
- (fail, creds) => {
- switch (fail.case) {
- case HttpStatusCode.Accepted:
- mfa.onNewChallenge(
- i18n.str`Withdrawal confirmation`,
- creds.username,
- fail.body,
- confirm.lambda((prev, next) =>
- !prev ? undefined : [prev[0], next[0]],
- ),
- );
- return undefined;
- case HttpStatusCode.BadRequest:
- return i18n.str`The server did not understand the request.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The operation was not found.`;
- case TalerErrorCode.BANK_UNALLOWED_DEBIT:
- return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`;
- case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT:
- return i18n.str`The withdrawal has been aborted and can not be confirmed.`;
- case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
- return i18n.str`The withdrawal has no exchange and reserve public selected.`;
- case TalerErrorCode.BANK_AMOUNT_DIFFERS:
- return i18n.str`The starting withdrawal amount and the confirmation amount differ.`;
- case TalerErrorCode.BANK_AMOUNT_REQUIRED:
- return i18n.str`The bank requires a bank account which has not been specified yet.`;
- default:
- assertUnreachable(fail);
- }
- },
- );
- abort.onFail = showError(
- i18n.str`Failed to abort the withdrawal.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.BadRequest:
- return i18n.str`The server did not understand the request.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The operation was not found.`;
- case HttpStatusCode.Conflict:
- return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`;
- default:
- assertUnreachable(fail);
- }
- },
- );
-
return (
<Fragment>
<div class="bg-white shadow sm:rounded-lg">
@@ -420,21 +423,25 @@ export function WithdrawalConfirmationQuestion({
</div>
<div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
- <Button
+ <AsyncButton
name="cancel"
class="text-sm font-semibold leading-6 text-gray-900"
- onClick={abort}
+ disabled={!creds}
+ onClick={() =>
+ abort.run(creds!, withdrawUri.withdrawalOperationId)
+ }
>
<i18n.Translate>Cancel</i18n.Translate>
- </Button>
- <Button
+ </AsyncButton>
+ <AsyncButton
submit
name="transfer"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={confirm}
+ disabled={!creds}
+ onClick={() => confirm.run(creds!)}
>
<i18n.Translate>Transfer</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
</div>
diff --git a/packages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx b/packages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx
@@ -23,13 +23,14 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
CopyButton,
ErrorLoading,
Loading,
RouteDefinition,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -83,9 +84,80 @@ export function ShowAccountDetails({
const [submitAccount, setSubmitAccount] = useState<
TalerCorebankApi.AccountReconfiguration | undefined
>();
- const { actionHandler, showError, displayInfo } = useNotificationContext();
+ const { showError, displayInfo } = useNotificationContext();
const result = useAccountDetails(account);
+
+ // i18n.str`update account`,
+ const update = useNotifiedOperation<
+ Awaited<ReturnType<typeof bank.updateAccount>>,
+ [
+ string,
+ AccessToken,
+ TalerCorebankApi.AccountReconfiguration,
+ challengeIds?: string[],
+ ]
+ >(
+ (
+ ct,
+ username: string,
+ token: AccessToken,
+ account: TalerCorebankApi.AccountReconfiguration,
+ challengeIds?: string[],
+ ) => bank.updateAccount({ username, token }, account, { challengeIds }),
+ {
+ onSuccess: () => {
+ mfa.cancel();
+ displayInfo(i18n.str`Account updated`);
+ // onUpdateSuccess();
+ },
+ onFail: showError(
+ i18n.str`Failed to update the account.`,
+ (fail, username, token, account) => {
+ switch (fail.case) {
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`The rights to change the account are not sufficient`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The username was not found`;
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME:
+ return i18n.str`You can't change the legal name, please contact your account administrator.`;
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
+ return i18n.str`You can't change the debt limit, please contact your account administrator.`;
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT:
+ return i18n.str`You can't change the cashout address, please contact your account administrator.`;
+ case TalerErrorCode.BANK_MISSING_TAN_INFO:
+ return i18n.str`No information for the selected authentication channel.`;
+ case HttpStatusCode.Accepted:
+ mfa.onNewChallenge(
+ i18n.str`Account update`,
+ username,
+ fail.body,
+ {
+ running: update.running,
+ cancel: update.cancel,
+ run: (challengeIds) =>
+ update.run(username, token, account, challengeIds),
+ },
+ );
+ return undefined;
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED:
+ return i18n.str`Authentication channel is not supported.`;
+ case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS:
+ return i18n.str`Only the administrator can change the conversion rate.`;
+ case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN:
+ return i18n.str`The conversion rate class doesn't exist.`;
+ case TalerErrorCode.BANK_PASSWORD_TOO_SHORT:
+ return i18n.str`The password is too short. Can't have less than 8 characters.`;
+ case TalerErrorCode.BANK_PASSWORD_TOO_LONG:
+ return i18n.str`The password is too long. Can't have more than 64 characters.`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
+ },
+ );
+
if (!result) {
return <Loading />;
}
@@ -110,72 +182,6 @@ export function ShowAccountDetails({
}
}
- // i18n.str`update account`,
- const update = actionHandler(
- (
- ct,
- username: string,
- token: AccessToken,
- account: TalerCorebankApi.AccountReconfiguration,
- challengeIds?: string[],
- ) => bank.updateAccount({ username, token }, account, { challengeIds }),
- !sessionToken || !submitAccount
- ? undefined
- : ([
- account,
- sessionToken,
- submitAccount,
- undefined as string[] | undefined,
- ] as const),
- );
-
- update.onSuccess = (success) => {
- displayInfo(i18n.str`Account updated`);
- // onUpdateSuccess();
- };
-
- update.onFail = showError(
- i18n.str`Failed to update the account.`,
- (fail, username) => {
- switch (fail.case) {
- case HttpStatusCode.Unauthorized:
- return i18n.str`The rights to change the account are not sufficient`;
- case HttpStatusCode.NotFound:
- return i18n.str`The username was not found`;
- case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME:
- return i18n.str`You can't change the legal name, please contact your account administrator.`;
- case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
- return i18n.str`You can't change the debt limit, please contact your account administrator.`;
- case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT:
- return i18n.str`You can't change the cashout address, please contact your account administrator.`;
- case TalerErrorCode.BANK_MISSING_TAN_INFO:
- return i18n.str`No information for the selected authentication channel.`;
- case HttpStatusCode.Accepted:
- mfa.onNewChallenge(
- i18n.str`Account update`,
- username,
- fail.body,
- update.lambda((prev, next) =>
- !prev ? undefined : [prev[0], prev[1], prev[2], next[0]],
- ),
- );
- return undefined;
- case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED:
- return i18n.str`Authentication channel is not supported.`;
- case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS:
- return i18n.str`Only the administrator can change the conversion rate.`;
- case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN:
- return i18n.str`The conversion rate class doesn't exist.`;
- case TalerErrorCode.BANK_PASSWORD_TOO_SHORT:
- return i18n.str`The password is too short. Can't have less than 8 characters.`;
- case TalerErrorCode.BANK_PASSWORD_TOO_LONG:
- return i18n.str`The password is too long. Can't have more than 64 characters.`;
- default:
- assertUnreachable(fail);
- }
- },
- );
-
const url = bank.getRevenueAPI(account);
const baseURL = url.href;
const revenueURL = new URL(baseURL);
@@ -238,14 +244,15 @@ export function ShowAccountDetails({
>
<i18n.Translate>Cancel</i18n.Translate>
</a>
- <Button
+ <AsyncButton
submit
name="update"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={update}
+ disabled={!sessionToken || !submitAccount}
+ onClick={() => update.run(account, sessionToken!, submitAccount!)}
>
<i18n.Translate>Update</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</AccountForm>
</div>
diff --git a/packages/libeufin-bank-webui/src/pages/account/UpdateAccountPassword.tsx b/packages/libeufin-bank-webui/src/pages/account/UpdateAccountPassword.tsx
@@ -21,11 +21,12 @@ import {
TalerErrorCode,
} from "@gnu-taler/taler-util";
import {
- Button,
+ AsyncButton,
RouteDefinition,
ShowInputErrorLabel,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
@@ -92,10 +93,19 @@ export function UpdateAccountPassword({
? i18n.str`Repeated password doesn't match`
: undefined,
});
- const { actionHandler, showError } = useNotificationContext();
+ const passwordChange = !password
+ ? undefined
+ : {
+ old_password: current,
+ new_password: password,
+ };
+ const { showError } = useNotificationContext();
// i18n.str`update password`,
- const update = actionHandler(
+ const update = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.updatePassword>>,
+ [string, AccessToken, TalerCorebankApi.AccountPasswordChange, string[]]
+ >(
(
ct,
username: string,
@@ -106,54 +116,48 @@ export function UpdateAccountPassword({
api.updatePassword({ username, token }, request, {
challengeIds,
}),
- !password || !token || !!errors
- ? undefined
- : ([
- accountName,
- token,
- {
- old_password: current,
- new_password: password,
- },
- [],
- ] as const),
- );
-
- update.onSuccess = (success) => {
- // notifyInfo(i18n.str`Password changed`);
- onUpdateSuccess();
- };
- update.onFail = showError(
- i18n.str`Failed to update the password.`,
- (fail, username) => {
- switch (fail.case) {
- case HttpStatusCode.Unauthorized:
- return i18n.str`Not authorized to change the password, maybe the session is invalid.`;
- case HttpStatusCode.NotFound:
- return i18n.str`Account not found`;
- case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD:
- return i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`;
- case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD:
- return i18n.str`Your current password doesn't match, can't change to a new password.`;
- case HttpStatusCode.Accepted:
- mfa.onNewChallenge(
- i18n.str`Password update`,
- username,
- fail.body,
- update.lambda((prev, next) =>
- !prev ? undefined : [prev[0], prev[1], prev[2], next[0]],
- ),
- );
- return undefined;
- case HttpStatusCode.Forbidden:
- return i18n.str`You don't have the rights to change the password.`;
- case TalerErrorCode.BANK_PASSWORD_TOO_SHORT:
- return i18n.str`The password is too short. Can't have less than 8 characters.`;
- case TalerErrorCode.BANK_PASSWORD_TOO_LONG:
- return i18n.str`The password is too long. Can't have more than 64 characters.`;
- default:
- assertUnreachable(fail);
- }
+ {
+ onSuccess: () => {
+ mfa.cancel();
+ // notifyInfo(i18n.str`Password changed`);
+ onUpdateSuccess();
+ },
+ onFail: showError(
+ i18n.str`Failed to update the password.`,
+ (fail, username, token, request) => {
+ switch (fail.case) {
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Not authorized to change the password, maybe the session is invalid.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Account not found`;
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD:
+ return i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`;
+ case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD:
+ return i18n.str`Your current password doesn't match, can't change to a new password.`;
+ case HttpStatusCode.Accepted:
+ mfa.onNewChallenge(
+ i18n.str`Password update`,
+ username,
+ fail.body,
+ {
+ running: update.running,
+ cancel: update.cancel,
+ run: (challengeIds) =>
+ update.run(username, token, request, challengeIds),
+ },
+ );
+ return undefined;
+ case HttpStatusCode.Forbidden:
+ return i18n.str`You don't have the rights to change the password.`;
+ case TalerErrorCode.BANK_PASSWORD_TOO_SHORT:
+ return i18n.str`The password is too short. Can't have less than 8 characters.`;
+ case TalerErrorCode.BANK_PASSWORD_TOO_LONG:
+ return i18n.str`The password is too long. Can't have more than 64 characters.`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
},
);
return (
@@ -294,14 +298,17 @@ export function UpdateAccountPassword({
>
<i18n.Translate>Cancel</i18n.Translate>
</a>
- <Button
+ <AsyncButton
submit
name="change"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={update}
+ disabled={!passwordChange || !token || !!errors}
+ onClick={() =>
+ update.run(accountName, token!, passwordChange!, [])
+ }
>
<i18n.Translate>Change</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
</div>
diff --git a/packages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx b/packages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx
@@ -21,10 +21,11 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
RouteDefinition,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -53,23 +54,18 @@ export function CreateNewAccount({
TalerCorebankApi.RegisterAccountRequest | undefined
>();
- const { actionHandler, showError, showSuccess } = useNotificationContext();
+ const { showError, showSuccess } = useNotificationContext();
// i18n.str`create account`,
- const create = actionHandler(
- (ct, t, ac) => api.createAccount(t, ac),
- !submitAccount || !token
- ? undefined
- : ([{ type: "bearer", token }, submitAccount] as const),
- );
- create.onSuccess = showSuccess((success, token, account) => {
- onCreateSuccess();
- return i18n.str`Account created with password "${account.password}".`;
- });
-
- create.onFail = showError(
- i18n.str`Failed to create a new account.`,
- (fail) => {
+ const create = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.createAccount>>,
+ Parameters<typeof api.createAccount>
+ >((ct, t, ac) => api.createAccount(t, ac), {
+ onSuccess: showSuccess((success, token, account) => {
+ onCreateSuccess();
+ return i18n.str`Account created with password "${account.password}".`;
+ }),
+ onFail: showError(i18n.str`Failed to create a new account.`, (fail) => {
switch (fail.case) {
case HttpStatusCode.BadRequest:
return i18n.str`Server replied that phone or email is invalid`;
@@ -102,8 +98,8 @@ export function CreateNewAccount({
default:
assertUnreachable(fail);
}
- },
- );
+ }),
+ });
if (!(credentials.status === "loggedIn" && credentials.isUserAdministrator)) {
return (
@@ -148,14 +144,17 @@ export function CreateNewAccount({
>
<i18n.Translate>Cancel</i18n.Translate>
</a>
- <Button
+ <AsyncButton
submit
name="create"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={create}
+ disabled={!submitAccount || !token}
+ onClick={() =>
+ create.run({ type: "bearer", token: token! }, submitAccount!)
+ }
>
<i18n.Translate>Create</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</AccountForm>
</div>
diff --git a/packages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx b/packages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx
@@ -25,10 +25,10 @@ import {
import { dummyHttpResponse } from "@gnu-taler/taler-util/http";
import {
Attention,
- Button,
+ AsyncButton,
RouteDefinition,
useBankCoreApiContext,
- useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
@@ -79,10 +79,11 @@ export function DownloadStats({ routeCancel }: Props): VNode {
const [lastStep, setLastStep] = useState<{ step: number; total: number }>();
const [downloaded, setDownloaded] = useState<string>();
const referenceDates = [new Date()];
- const { actionHandler, showError } = useNotificationContext();
-
// i18n.str`download statistics`,
- const download = actionHandler(
+ const download = useNotifiedOperation<
+ Awaited<ReturnType<typeof fetchAllStatus>>,
+ [Parameters<typeof fetchAllStatus>[1]]
+ >(
async (ct, token) => {
setDownloaded(undefined);
return fetchAllStatus(
@@ -95,12 +96,13 @@ export function DownloadStats({ routeCancel }: Props): VNode {
},
);
},
- lastStep !== undefined || !creds ? undefined : ([creds.token] as const),
+ {
+ onSuccess: (success) => {
+ setDownloaded(success);
+ setLastStep(undefined);
+ },
+ },
);
- download.onSuccess = (success) => {
- setDownloaded(success);
- setLastStep(undefined);
- };
if (!creds) {
return <i18n.Translate>only admin can download stats</i18n.Translate>;
@@ -373,14 +375,15 @@ export function DownloadStats({ routeCancel }: Props): VNode {
>
<i18n.Translate>Cancel</i18n.Translate>
</a>
- <Button
+ <AsyncButton
submit
name="download"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={download}
+ disabled={lastStep !== undefined || !creds}
+ onClick={() => download.run(creds!.token)}
>
<i18n.Translate>Download</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
</div>
diff --git a/packages/libeufin-bank-webui/src/pages/admin/RemoveAccount.tsx b/packages/libeufin-bank-webui/src/pages/admin/RemoveAccount.tsx
@@ -22,13 +22,14 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
ErrorLoading,
Loading,
RouteDefinition,
ShowInputErrorLabel,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -66,9 +67,62 @@ export function RemoveAccount({
const {
lib: { bank: api },
} = useBankCoreApiContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const mfa = useBankChallengeHandlerContext();
+ const errors = undefinedIfEmpty({
+ accountName: !accountName
+ ? i18n.str`Required`
+ : account !== accountName
+ ? i18n.str`Name doesn't match`
+ : undefined,
+ });
+
+ // i18n.str`delete account`,
+ const deleteAccount = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.deleteAccount>>,
+ [UserAndToken, challengeIds?: string[]]
+ >(
+ (ct, auth: UserAndToken, challengeIds?: string[]) =>
+ api.deleteAccount(auth, { challengeIds }),
+ {
+ onSuccess: () => {
+ mfa.cancel();
+ // notifyInfo(i18n.str`Account removed`);
+ onUpdateSuccess();
+ },
+ onFail: showError(
+ i18n.str`Faild to delete the account.`,
+ (fail, creds) => {
+ switch (fail.case) {
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Not enough permission to delete the account.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The username was not found.`;
+ case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
+ return i18n.str`Can't delete a reserved username.`;
+ case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO:
+ return i18n.str`Can't delete an account with balance different than zero.`;
+ case HttpStatusCode.Accepted:
+ mfa.onNewChallenge(
+ i18n.str`Account deletion`,
+ creds.username,
+ fail.body,
+ {
+ running: deleteAccount.running,
+ cancel: deleteAccount.cancel,
+ run: (challengeIds) => deleteAccount.run(creds, challengeIds),
+ },
+ );
+ return undefined;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
+ },
+ );
+
if (!result) {
return <Loading />;
}
@@ -123,59 +177,6 @@ export function RemoveAccount({
);
}
- const errors = undefinedIfEmpty({
- accountName: !accountName
- ? i18n.str`Required`
- : account !== accountName
- ? i18n.str`Name doesn't match`
- : undefined,
- });
-
- // i18n.str`delete account`,
- const deleteAccount = actionHandler(
- (ct, auth: UserAndToken, challengeIds?: string[]) =>
- api.deleteAccount(auth, { challengeIds }),
- !!errors || !token
- ? undefined
- : ([
- { username: account, token },
- undefined as string[] | undefined,
- ] as const),
- );
-
- deleteAccount.onSuccess = (success) => {
- // notifyInfo(i18n.str`Account removed`);
- onUpdateSuccess();
- };
-
- deleteAccount.onFail = showError(
- i18n.str`Faild to delete the account.`,
- (fail, creds) => {
- switch (fail.case) {
- case HttpStatusCode.Unauthorized:
- return i18n.str`Not enough permission to delete the account.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The username was not found.`;
- case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
- return i18n.str`Can't delete a reserved username.`;
- case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO:
- return i18n.str`Can't delete an account with balance different than zero.`;
- case HttpStatusCode.Accepted:
- mfa.onNewChallenge(
- i18n.str`Account deletion`,
- creds.username,
- fail.body,
- deleteAccount.lambda((prev, next) =>
- !prev ? undefined : [prev[0], next[0]],
- ),
- );
- return undefined;
- default:
- assertUnreachable(fail);
- }
- },
- );
-
return (
<div>
<Attention
@@ -246,14 +247,17 @@ export function RemoveAccount({
>
<i18n.Translate>Cancel</i18n.Translate>
</a>
- <Button
+ <AsyncButton
submit
name="delete"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
- onClick={deleteAccount}
+ disabled={!!errors || !token}
+ onClick={() =>
+ deleteAccount.run({ username: account, token: token! })
+ }
>
<i18n.Translate>Delete</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
</div>
diff --git a/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx b/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx
@@ -17,6 +17,7 @@
import {
AmountJson,
Amounts,
+ CancellationToken,
HttpStatusCode,
TalerBankConversionApi,
TalerError,
@@ -24,7 +25,7 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
ErrorLoading,
InternationalizationAPI,
Loading,
@@ -33,6 +34,7 @@ import {
ShowInputErrorLabel,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
utils,
} from "@gnu-taler/web-util/browser";
@@ -129,12 +131,12 @@ function useComponentState({
}
const info = resp.body;
- return function afterComponentLoads() {
+ return function AfterComponentLoads() {
const {
lib: { conversion },
} = useBankCoreApiContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const initalState: FormValues<FormType> = {
amount: "100",
@@ -177,32 +179,33 @@ function useComponentState({
const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee);
const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee);
+ async function calculateOperation(
+ _ct: CancellationToken,
+ amount: AmountJson,
+ ) {
+ const respCashin = await calculateCashinFromDebit(amount, in_fee);
+ if (respCashin.type === "fail") {
+ return respCashin;
+ }
+ const cashin = respCashin.body;
+ const respCashout = await calculateCashoutFromDebit(
+ cashin.credit,
+ out_fee,
+ );
+ if (respCashout.type === "fail") {
+ return respCashout;
+ }
+ const cashout = respCashout.body;
+ return opFixedSuccess(dummyHttpResponse, { cashin, cashout });
+ }
+
// i18n.str`calculate cashout fee`,
- const calculate = actionHandler(
- async (ct, amount: AmountJson) => {
- const respCashin = await calculateCashinFromDebit(amount, in_fee);
- if (respCashin.type === "fail") {
- return respCashin;
- }
- const cashin = respCashin.body;
- const respCashout = await calculateCashoutFromDebit(
- cashin.credit,
- out_fee,
- );
- if (respCashout.type === "fail") {
- return respCashout;
- }
- const cashout = respCashout.body;
- return opFixedSuccess(dummyHttpResponse, { cashin, cashout });
- },
- !in_amount || status.status === "fail"
- ? undefined
- : ([in_amount] as const),
- );
- calculate.onSuccess = (resp) => setCalc(resp);
- calculate.onFail = showError(
- i18n.str`Failed to calculate cashout fee.`,
- (fail) => {
+ const calculate = useNotifiedOperation<
+ Awaited<ReturnType<typeof calculateOperation>>,
+ [AmountJson]
+ >(calculateOperation, {
+ onSuccess: (resp) => setCalc(resp),
+ onFail: showError(i18n.str`Failed to calculate cashout fee.`, (fail) => {
switch (fail.case) {
case HttpStatusCode.Conflict:
return i18n.str`The amount is too small`;
@@ -217,11 +220,13 @@ function useComponentState({
default:
assertUnreachable(fail);
}
- },
- );
+ }),
+ });
useEffect(() => {
- calculate.call();
+ if (in_amount && status.status !== "fail") {
+ calculate.run(in_amount);
+ }
}, [
form.amount?.value,
form.conv?.cashin_fee?.value,
@@ -235,32 +240,27 @@ function useComponentState({
const cashoutCalc = calculationResult?.cashout;
// i18n.str`update conversion rate`,
- const update = actionHandler(
- (ct, s, c) => conversion.updateConversionRate(s, c),
- !creds || status.status === "fail"
- ? undefined
- : ([
- { type: "bearer", token: creds.token },
- status.result.conv,
- ] as const),
- );
-
- update.onSuccess = () => {
- setSection("detail");
- };
- update.onFail = showError(
- i18n.str`Failed to update the conversion rate.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Unauthorized:
- return i18n.str`Wrong credentials`;
- case HttpStatusCode.NotImplemented:
- return i18n.str`Conversion is disabled`;
- default:
- assertUnreachable(fail);
- }
+ const update = useNotifiedOperation<
+ Awaited<ReturnType<typeof conversion.updateConversionRate>>,
+ Parameters<typeof conversion.updateConversionRate>
+ >((ct, s, c) => conversion.updateConversionRate(s, c), {
+ onSuccess: () => {
+ setSection("detail");
},
- );
+ onFail: showError(
+ i18n.str`Failed to update the conversion rate.`,
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Wrong credentials`;
+ case HttpStatusCode.NotImplemented:
+ return i18n.str`Conversion is disabled`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
+ });
const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio);
const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio);
@@ -599,14 +599,22 @@ function useComponentState({
<i18n.Translate>Cancel</i18n.Translate>
</a>
{section == "cashin" || section == "cashout" ? (
- <Button
+ <AsyncButton
submit
name="update conversion"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={update}
+ disabled={!creds || status.status === "fail"}
+ onClick={() =>
+ update.run(
+ { type: "bearer", token: creds.token },
+ status.status === "fail"
+ ? undefined!
+ : status.result.conv,
+ )
+ }
>
<i18n.Translate>Update</i18n.Translate>
- </Button>
+ </AsyncButton>
) : (
<div />
)}
diff --git a/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx b/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx
@@ -16,6 +16,7 @@
import {
AmountJson,
Amounts,
+ CancellationToken,
ConversionRate,
HttpStatusCode,
TalerConversionInfoConfig,
@@ -27,7 +28,7 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
ErrorLoading,
Loading,
RenderAmount,
@@ -35,6 +36,7 @@ import {
ShowInputErrorLabel,
useBankCoreApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -235,7 +237,7 @@ function CreateCashoutInternal({
} = useCashoutEstimatorByUser(accountName);
const [form, setForm] = useState<Partial<FormType>>({ isDebit: true });
const [requestUid] = useState(() => encodeCrock(getRandomBytes(32)));
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const mfa = useBankChallengeHandlerContext();
const { i18n } = useTranslationContext();
@@ -281,42 +283,50 @@ function CreateCashoutInternal({
: true;
const notZero = Amounts.isNonZero(inputAmount);
+ async function calculateOperation(
+ _ct: CancellationToken,
+ isDebit: boolean,
+ input: AmountJson,
+ fee: AmountJson,
+ ) {
+ if (notZero && higerThanMin) {
+ return isDebit
+ ? calculateFromDebit(input, fee)
+ : calculateFromCredit(input, fee);
+ } else {
+ return opFixedSuccess(dummyHttpResponse, zeroCalc);
+ }
+ }
+
// i18n.str`calculate conversion fee`,
- const conversionCalculator = actionHandler(
- async (ct, isDebit: boolean, input: AmountJson, fee: AmountJson) => {
- if (notZero && higerThanMin) {
- return isDebit
- ? calculateFromDebit(input, fee)
- : calculateFromCredit(input, fee);
- } else {
- return opFixedSuccess(dummyHttpResponse, zeroCalc);
- }
- },
- [form.isDebit ?? false, inputAmount, sellFee],
- );
- conversionCalculator.onSuccess = (success) => setCalculation(success);
- conversionCalculator.onFail = showError(
- i18n.str`Failed to calculate the conversion fee.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Conflict:
- return i18n.str`The amount is too small`;
- case HttpStatusCode.NotImplemented:
- return i18n.str`Conversion is not implemented.`;
- case TalerErrorCode.GENERIC_PARAMETER_MISSING:
- return i18n.str`At least debit or credit needs to be provided`;
- case TalerErrorCode.GENERIC_PARAMETER_MALFORMED:
- return i18n.str`The amount is malformed`;
- case TalerErrorCode.GENERIC_CURRENCY_MISMATCH:
- return i18n.str`The currency is not supported`;
- default:
- assertUnreachable(fail);
- }
- },
- );
+ const conversionCalculator = useNotifiedOperation<
+ Awaited<ReturnType<typeof calculateOperation>>,
+ [boolean, AmountJson, AmountJson]
+ >(calculateOperation, {
+ onSuccess: (success) => setCalculation(success),
+ onFail: showError(
+ i18n.str`Failed to calculate the conversion fee.`,
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Conflict:
+ return i18n.str`The amount is too small`;
+ case HttpStatusCode.NotImplemented:
+ return i18n.str`Conversion is not implemented.`;
+ case TalerErrorCode.GENERIC_PARAMETER_MISSING:
+ return i18n.str`At least debit or credit needs to be provided`;
+ case TalerErrorCode.GENERIC_PARAMETER_MALFORMED:
+ return i18n.str`The amount is malformed`;
+ case TalerErrorCode.GENERIC_CURRENCY_MISMATCH:
+ return i18n.str`The currency is not supported`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
+ });
useEffect(() => {
- conversionCalculator.call();
+ conversionCalculator.run(form.isDebit ?? false, inputAmount, sellFee);
}, [form.amount, form.isDebit, notZero, higerThanMin, rate.cashout_fee]);
const calc = !calculationResult ? zeroCalc : calculationResult;
@@ -359,7 +369,10 @@ function CreateCashoutInternal({
const subject = form.subject;
// i18n.str`create cashout`,
- const cashout = actionHandler(
+ const cashout = useNotifiedOperation<
+ Awaited<ReturnType<typeof api.createCashout>>,
+ [LoggedIn, TransCalc, string, challengeIds?: string[]]
+ >(
(
ct,
session: LoggedIn,
@@ -377,50 +390,53 @@ function CreateCashoutInternal({
},
{ challengeIds },
),
- !!errors || !subject
- ? undefined
- : ([session, calc, subject, undefined as string[] | undefined] as const),
- );
- cashout.onSuccess = (success) => {
- // notifyInfo(i18n.str`Cashout created`);
- onCashout();
- };
- cashout.onFail = showError(
- i18n.str`Failed to create the cashout.`,
- (fail, session) => {
- switch (fail.case) {
- case HttpStatusCode.Accepted:
- mfa.onNewChallenge(
- i18n.str`Cashout`,
- session.username,
- fail.body,
- cashout.lambda((prev, next) =>
- !prev ? undefined : [prev[0], prev[1], prev[2], next[0]],
- ),
- );
- return undefined;
- case HttpStatusCode.NotFound:
- return i18n.str`Account not found`;
- case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
- return i18n.str`Duplicate request detected, check if the operation succeeded or try again.`;
- case TalerErrorCode.BANK_BAD_CONVERSION:
- return i18n.str`The conversion rate was applied incorrectly`;
- case TalerErrorCode.BANK_UNALLOWED_DEBIT:
- return i18n.str`The account does not have sufficient funds`;
- case HttpStatusCode.NotImplemented:
- return i18n.str`Cashout is disabled`;
- case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
- return i18n.str`Missing cashout URI in the profile`;
- case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL:
- return i18n.str`The amount is below the minimum amount permitted.`;
- case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
- return i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`;
- case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: {
- return i18n.str`The server doesn't support the current TAN channel.`;
- }
- default:
- assertUnreachable(fail);
- }
+ {
+ onSuccess: () => {
+ mfa.cancel();
+ // notifyInfo(i18n.str`Cashout created`);
+ onCashout();
+ },
+ onFail: showError(
+ i18n.str`Failed to create the cashout.`,
+ (fail, session, calc, subject) => {
+ switch (fail.case) {
+ case HttpStatusCode.Accepted:
+ mfa.onNewChallenge(
+ i18n.str`Cashout`,
+ session.username,
+ fail.body,
+ {
+ running: cashout.running,
+ cancel: cashout.cancel,
+ run: (challengeIds) =>
+ cashout.run(session, calc, subject, challengeIds),
+ },
+ );
+ return undefined;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Account not found`;
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
+ return i18n.str`Duplicate request detected, check if the operation succeeded or try again.`;
+ case TalerErrorCode.BANK_BAD_CONVERSION:
+ return i18n.str`The conversion rate was applied incorrectly`;
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return i18n.str`The account does not have sufficient funds`;
+ case HttpStatusCode.NotImplemented:
+ return i18n.str`Cashout is disabled`;
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return i18n.str`Missing cashout URI in the profile`;
+ case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL:
+ return i18n.str`The amount is below the minimum amount permitted.`;
+ case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
+ return i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`;
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: {
+ return i18n.str`The server doesn't support the current TAN channel.`;
+ }
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
},
);
@@ -758,14 +774,15 @@ function CreateCashoutInternal({
>
<i18n.Translate>Cancel</i18n.Translate>
</a>
- <Button
+ <AsyncButton
submit
name="cashout"
class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={cashout}
+ disabled={!!errors || !subject}
+ onClick={() => cashout.run(session, calc, subject!)}
>
<i18n.Translate>Cashout</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
</div>
diff --git a/packages/taler-exchange-aml-webui/src/components/CreateSession.tsx b/packages/taler-exchange-aml-webui/src/components/CreateSession.tsx
@@ -14,18 +14,18 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
- Button,
+ AsyncButton,
FormDesign,
FormUI,
InternationalizationAPI,
useForm,
- useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { OfficerNotFound } from "../hooks/officer.js";
import { usePreferences } from "../hooks/preferences.js";
-import { asPassword } from "@gnu-taler/taler-util";
+import { asPassword, Password } from "@gnu-taler/taler-util";
type FormType = {
password: string;
@@ -84,7 +84,6 @@ export function CreateSession({
}): VNode {
const { i18n } = useTranslationContext();
const [settings] = usePreferences();
- const { actionHandler, showError } = useNotificationContext();
const design = createAccountForm(i18n, settings.allowInsecurePassword);
@@ -93,10 +92,10 @@ export function CreateSession({
repeat: undefined,
});
- const create = actionHandler(
- (ct, pw) => officer.create(pw),
- status.status === "fail" ? undefined : [asPassword(status.result.password)],
- );
+ const create = useNotifiedOperation<
+ Awaited<ReturnType<OfficerNotFound["create"]>>,
+ [Password]
+ >((_ct, password: Password) => officer.create(password));
return (
<div class="flex min-h-full flex-col ">
@@ -109,13 +108,17 @@ export function CreateSession({
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] ">
<FormUI design={design} model={handler} focus />
<div class="mt-8">
- <Button
+ <AsyncButton
submit
class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={create}
+ onClick={
+ status.status === "fail"
+ ? undefined
+ : () => create.run(asPassword(status.result.password))
+ }
>
<i18n.Translate>Create</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</div>
</div>
diff --git a/packages/taler-exchange-aml-webui/src/components/UnlockSession.tsx b/packages/taler-exchange-aml-webui/src/components/UnlockSession.tsx
@@ -17,14 +17,16 @@ import {
asPassword,
assertUnreachable,
HttpStatusCode,
+ Password,
} from "@gnu-taler/taler-util";
import {
- Button,
+ AsyncButton,
FormDesign,
InputLine,
InternationalizationAPI,
useForm,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact";
@@ -51,7 +53,7 @@ const unlockAccountForm = (i18n: InternationalizationAPI): FormDesign => ({
export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode {
const { i18n } = useTranslationContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const design = unlockAccountForm(i18n);
@@ -59,19 +61,23 @@ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode {
password: undefined,
});
- const unlock = actionHandler(
- (ct, pw) => officer.tryUnlock(pw),
- status.status === "fail" ? undefined : [asPassword(status.result.password)],
- );
- unlock.onFail = showError(i18n.str`Failed to unlock the session.`, (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Forbidden:
- return i18n.str`Authorization denied for this session. Contact the administrator.`;
- default:
- assertUnreachable(fail.case);
- }
+ const unlock = useNotifiedOperation<
+ Awaited<ReturnType<OfficerLocked["tryUnlock"]>>,
+ [Password]
+ >((_ct, password: Password) => officer.tryUnlock(password), {
+ onFail: showError(i18n.str`Failed to unlock the session.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Forbidden:
+ return i18n.str`Authorization denied for this session. Contact the administrator.`;
+ default:
+ assertUnreachable(fail.case);
+ }
+ }),
});
- const forget = actionHandler(async () => officer.forget(), []);
+ const forget = useNotifiedOperation<
+ Awaited<ReturnType<OfficerLocked["forget"]>>,
+ []
+ >(async () => officer.forget());
return (
<div class="flex min-h-full flex-col ">
@@ -100,21 +106,25 @@ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode {
</div>
<div class="mt-8">
- <Button
+ <AsyncButton
submit
- onClick={unlock}
+ onClick={
+ status.status === "fail"
+ ? undefined
+ : () => unlock.run(asPassword(status.result.password))
+ }
class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Unlock</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</form>
- <Button
- onClick={forget}
+ <AsyncButton
+ onClick={() => forget.run()}
class="disabled:opacity-50 disabled:cursor-default m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
>
<i18n.Translate>Forget session</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</div>
);
diff --git a/packages/taler-exchange-aml-webui/src/pages/AccountDetails.tsx b/packages/taler-exchange-aml-webui/src/pages/AccountDetails.tsx
@@ -18,19 +18,21 @@ import {
AccountProperties,
assertUnreachable,
HttpStatusCode,
+ OfficerSession,
TalerError,
TalerExchangeApi,
TalerFormAttributes,
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
CopyButton,
ErrorLoading,
Loading,
RouteDefinition,
useExchangeApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { format } from "date-fns";
@@ -94,7 +96,43 @@ export function AccountDetails({
const session = officer.state === "ready" ? officer.session : undefined;
const { lib } = useExchangeApiContext();
const [exported, setExported] = useState<{ content: string; file: string }>();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
+
+ const downloadPdf = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.exchange.getAmlAttributesForAccountAsPdf>>,
+ [OfficerSession, string]
+ >(
+ (_ct, officerSession: OfficerSession, accountId: string) =>
+ lib.exchange.getAmlAttributesForAccountAsPdf(officerSession, accountId),
+ {
+ onSuccess: (result) => {
+ const time = format(new Date(), "yyyyMMdd_HHmmss");
+ setExported({
+ content: new Uint8Array(result).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ ),
+ file: `account_${time}_${account}.pdf`,
+ });
+ },
+ onFail: showError(i18n.str`Failed to download report.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.NoContent:
+ return i18n.str`The account has no KYC info.`;
+ case HttpStatusCode.Forbidden:
+ return i18n.str`Authorization denied for this session. Contact the administrator.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Session not found. Contact the administrator.`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`The session is disabled. Contact the administrator.`;
+ case HttpStatusCode.NotImplemented:
+ return i18n.str`The server doesn't support PDF download. Contact the administrator.`;
+ default:
+ assertUnreachable(fail.case);
+ }
+ }),
+ },
+ );
const measures = useServerMeasures();
@@ -217,42 +255,6 @@ export function AccountDetails({
: BANK_RULES.includes(r.operation_type);
});
- const time = format(new Date(), "yyyyMMdd_HHmmss");
-
- const downloadPdf = actionHandler(
- (ct, s, ac) => lib.exchange.getAmlAttributesForAccountAsPdf(s, ac),
- session ? ([session, account] as const) : undefined,
- );
-
- downloadPdf.onSuccess = (result) => {
- setExported({
- content: new Uint8Array(result).reduce(
- (data, byte) => data + String.fromCharCode(byte),
- "",
- ),
- file: `account_${time}_${account}.pdf`,
- });
- };
- downloadPdf.onFail = showError(
- i18n.str`Failed to download report.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.NoContent:
- return i18n.str`The account has no KYC info.`;
- case HttpStatusCode.Forbidden:
- return i18n.str`Authorization denied for this session. Contact the administrator.`;
- case HttpStatusCode.NotFound:
- return i18n.str`Session not found. Contact the administrator.`;
- case HttpStatusCode.Conflict:
- return i18n.str`The session is disabled. Contact the administrator.`;
- case HttpStatusCode.NotImplemented:
- return i18n.str`The server doesn't support PDF download. Contact the administrator.`;
- default:
- assertUnreachable(fail.case);
- }
- },
- );
-
return (
<div class="min-w-60">
<header class="flex flex-col justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8 gap-2">
@@ -280,9 +282,13 @@ export function AccountDetails({
<div class="flex space-x-2 mb-4">
<i18n.Translate>Export as PDF</i18n.Translate>
- <Button onClick={downloadPdf}>
+ <AsyncButton
+ onClick={
+ !session ? undefined : () => downloadPdf.run(session, account)
+ }
+ >
<img class="size-6 w-6" src={pdfIcon} />
- </Button>
+ </AsyncButton>
</div>
{!exported ? (
<div />
diff --git a/packages/taler-exchange-aml-webui/src/pages/AccountList.tsx b/packages/taler-exchange-aml-webui/src/pages/AccountList.tsx
@@ -24,7 +24,7 @@ import {
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
ErrorLoading,
FailLoading,
InputToggle,
@@ -33,6 +33,7 @@ import {
RouteDefinition,
useExchangeApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -46,6 +47,7 @@ import { useOfficer } from "../hooks/officer.js";
import { Profile } from "./Profile.js";
const utfDecoder = new TextDecoder("utf-8");
+type Mime = "text/csv" | "application/vnd.ms-excel";
export function AccountList({
routeToAccountById: caseByIdRoute,
@@ -57,13 +59,78 @@ export function AccountList({
const [opened, setOpened] = useState<boolean>();
const [highRisk, setHighRisk] = useState<boolean>();
const list = useAmlAccounts({ investigated, open: opened, highRisk });
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const officer = useOfficer();
const session = officer.state === "ready" ? officer.session : undefined;
const { lib } = useExchangeApiContext();
const [exported, setExported] = useState<{ content: string; file: string }>();
+ const fileDescription =
+ investigated === undefined
+ ? opened
+ ? highRisk
+ ? `risky_opened`
+ : `opened`
+ : highRisk
+ ? `risky`
+ : ``
+ : investigated
+ ? opened
+ ? highRisk
+ ? `investigated_risky_opened`
+ : `investigated_opened`
+ : highRisk
+ ? `investigated_risky`
+ : `investigated`
+ : opened
+ ? highRisk
+ ? `not-investigated_risky_opened`
+ : `not-investigated_opened`
+ : highRisk
+ ? `not-investigated_risky`
+ : `not-investigated`;
+
+ const download = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.exchange.getAmlAccountsAsOtherFormat>>,
+ [OfficerSession, Mime]
+ >(
+ (_ct, officerSession: OfficerSession, mime: Mime) =>
+ lib.exchange.getAmlAccountsAsOtherFormat(officerSession, mime),
+ {
+ onFail: showError(i18n.str`Failed to download`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.NoContent:
+ return i18n.str`There are no accounts in the resultset.`;
+ case HttpStatusCode.NotAcceptable:
+ return i18n.str`The format requested is not acceptable from the service provider.`;
+ case HttpStatusCode.Forbidden:
+ return i18n.str`Authorization denied for this session.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Session not found. Contact the administrator.`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`The session is disabled. Contact the administrator`;
+ }
+ }),
+ onSuccess: (result, _officerSession, mime) => {
+ const time = format(new Date(), "yyyyMMdd_HHmmss");
+ switch (mime) {
+ case "text/csv":
+ return setExported({
+ content: utfDecoder.decode(result),
+ file: `accounts_${time}_${fileDescription}.csv`,
+ });
+ case "application/vnd.ms-excel":
+ return setExported({
+ content: utfDecoder.decode(result),
+ file: `accounts_${time}_${fileDescription}.xls`,
+ });
+ default:
+ assertUnreachable(mime);
+ }
+ },
+ },
+ );
if (!list) {
return <Loading />;
@@ -140,98 +207,6 @@ export function AccountList({
? i18n.str`High risk accounts without investigation.`
: i18n.str`Accounts without investigation.`;
- const fileDescription =
- investigated === undefined
- ? opened
- ? highRisk
- ? `risky_opened`
- : `opened`
- : highRisk
- ? `risky`
- : ``
- : investigated
- ? opened
- ? highRisk
- ? `investigated_risky_opened`
- : `investigated_opened`
- : highRisk
- ? `investigated_risky`
- : `investigated`
- : opened
- ? highRisk
- ? `not-investigated_risky_opened`
- : `not-investigated_opened`
- : highRisk
- ? `not-investigated_risky`
- : `not-investigated`;
-
- const time = format(new Date(), "yyyyMMdd_HHmmss");
-
- type Mime = "text/csv" | "application/vnd.ms-excel";
- const download = actionHandler((ct, s: OfficerSession, f: Mime) =>
- lib.exchange.getAmlAccountsAsOtherFormat(s, f),
- );
- download.onFail = showError(i18n.str`Failed to download`, (fail) => {
- switch (fail.case) {
- case HttpStatusCode.NoContent:
- return i18n.str`There are no accounts in the resultset.`;
- case HttpStatusCode.NotAcceptable:
- return i18n.str`The format requested is not acceptable from the service provider.`;
- case HttpStatusCode.Forbidden:
- return i18n.str`Authorization denied for this session.`;
- case HttpStatusCode.NotFound:
- return i18n.str`Session not found. Contact the administrator.`;
- case HttpStatusCode.Conflict:
- return i18n.str`The session is disabled. Contact the administrator`;
- }
- });
- download.onSuccess = (result, s, f) => {
- switch (f) {
- case "text/csv":
- return setExported({
- content: utfDecoder.decode(result),
- file: `accounts_${time}_${fileDescription}.csv`,
- });
- case "application/vnd.ms-excel":
- return setExported({
- content: utfDecoder.decode(result),
- file: `accounts_${time}_${fileDescription}.xls`,
- });
- default:
- assertUnreachable(f);
- }
- };
-
- // const downloadXls = actionHandler(
- // (ct, s, f) => lib.exchange.getAmlAccountsAsOtherFormat(s, f),
- // session ? ([session, "application/vnd.ms-excel"] as const) : undefined,
- // );
-
- // downloadXls.onFail = showError(i18n.str`Failed to download XLS.`, (fail) => {
- // switch (fail.case) {
- // case HttpStatusCode.NoContent:
- // return i18n.str`There are no accounts in the resultset.`;
- // case HttpStatusCode.Forbidden:
- // return i18n.str`Invalid session.`;
- // case HttpStatusCode.NotFound:
- // return i18n.str`Session not found. Contact the administrator.`;
- // case HttpStatusCode.Conflict:
- // return i18n.str`The session is disabled. Contact the administrator`;
- // }
- // });
-
- // downloadCsv.onSuccess = (result) => {
- // setExported({
- // content: utfDecoder.decode(result),
- // file: `accounts_${time}_${fileDescription}.csv`,
- // });
- // };
- // downloadXls.onSuccess = (result) => {
- // setExported({
- // content: utfDecoder.decode(result),
- // file: `accounts_${time}_${fileDescription}.xls`,
- // });
- // };
return (
<div>
<div class="sm:flex sm:items-center">
@@ -245,22 +220,24 @@ export function AccountList({
{!records.length ? undefined : (
<div class="flex space-x-2 mt-4">
<i18n.Translate>Export as file</i18n.Translate>
- <Button
+ <AsyncButton
+ disabled={download.running}
onClick={
- !session ? undefined : download.withArgs(session, "text/csv")
+ !session ? undefined : () => download.run(session, "text/csv")
}
>
<img class="size-6 w-6" src={csvIcon} />
- </Button>
- <Button
+ </AsyncButton>
+ <AsyncButton
+ disabled={download.running}
onClick={
!session
? undefined
- : download.withArgs(session, "application/vnd.ms-excel")
+ : () => download.run(session, "application/vnd.ms-excel")
}
>
<img class="size-6 w-6" src={xlsIcon} />
- </Button>
+ </AsyncButton>
</div>
)}
diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Summary.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Summary.tsx
@@ -18,7 +18,8 @@ import {
AmlDecisionRequest,
assertUnreachable,
HttpStatusCode,
- opEmptySuccess,
+ OperationFail,
+ OperationOk,
opFixedSuccess,
Paytos,
TalerError,
@@ -27,9 +28,10 @@ import {
import { dummyHttpResponse } from "@gnu-taler/taler-util/http";
import {
Attention,
- Button,
+ AsyncButton,
useExchangeApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
@@ -75,7 +77,7 @@ export function Summary({
const [decision, , cleanUpDecision] = useCurrentDecisionRequest();
const measures = useServerMeasures();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const session = officer.session;
const allMeasures = computeMeasureInformation(
@@ -168,8 +170,16 @@ export function Summary({
const [submitConfirmation, setSubmitConfirmation] = useState<boolean>(false);
const requiresConfirmation = MROS_REPORT_COMPLETED;
- const submit = actionHandler(
- async (ct, req) => {
+ const submit = useNotifiedOperation<
+ | OperationOk<boolean>
+ | OperationFail<
+ | HttpStatusCode.Forbidden
+ | HttpStatusCode.NotFound
+ | HttpStatusCode.Conflict
+ >,
+ [Omit<AmlDecisionRequest, "officer_sig">]
+ >(
+ async (_ct, req: Omit<AmlDecisionRequest, "officer_sig">) => {
if (requiresConfirmation && !submitConfirmation) {
setSubmitConfirmation(true);
// FIXME: This is not the right type to use here.
@@ -179,26 +189,26 @@ export function Summary({
if (r.type === "fail") return r;
return opFixedSuccess(dummyHttpResponse, true);
},
- !request ? undefined : [request],
+ {
+ onSuccess: (completed) => {
+ if (completed) {
+ clearUp();
+ }
+ },
+ onFail: showError(i18n.str`Failed to make the decision.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Forbidden:
+ return i18n.str`Invalid credentials.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Session not found. Contact the administrator.`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`The session is disabled or a more recent decision was already submitted.`;
+ default:
+ assertUnreachable(fail.case);
+ }
+ }),
+ },
);
- submit.onSuccess = (completed) => {
- if (completed) {
- clearUp();
- }
- };
-
- submit.onFail = showError(i18n.str`Failed to make the decision.`, (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Forbidden:
- return i18n.str`Invalid credentials.`;
- case HttpStatusCode.NotFound:
- return i18n.str`Session not found. Contact the administrator.`;
- case HttpStatusCode.Conflict:
- return i18n.str`The session is disabled or a more recent decision was already submitted.`;
- default:
- assertUnreachable(fail.case);
- }
- });
if (submitConfirmation) {
return (
@@ -221,13 +231,13 @@ export function Summary({
>
<i18n.Translate>I want to check first!</i18n.Translate>
</button>
- <Button
+ <AsyncButton
submit
- onClick={submit}
+ onClick={!request ? undefined : () => submit.run(request)}
class="mt-4 disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Confirm decision</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</div>
</div>
@@ -360,13 +370,13 @@ export function Summary({
>
<i18n.Translate>Clear</i18n.Translate>
</button>
- <Button
+ <AsyncButton
submit
- onClick={submit}
+ onClick={!request ? undefined : () => submit.run(request)}
class="mt-4 disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Send decision</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</Fragment>
);
diff --git a/packages/taler-exchange-kyc-webui/src/pages/FillForm.tsx b/packages/taler-exchange-kyc-webui/src/pages/FillForm.tsx
@@ -24,7 +24,7 @@ import {
import {
AcceptTermOfServiceContext,
Attention,
- Button,
+ AsyncButton,
ErrorsSummary,
FormMetadata,
FormUI,
@@ -34,6 +34,7 @@ import {
useAsyncAsHook,
useExchangeApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
useFormMeta,
} from "@gnu-taler/web-util/browser";
@@ -114,7 +115,7 @@ function ShowForm({
onComplete: () => void;
}): VNode {
const { lib } = useExchangeApiContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const [preferences] = usePreferences();
const { i18n } = useTranslationContext();
@@ -130,28 +131,32 @@ function ShowForm({
validatedForm[TalerFormAttributes.FORM_CONTEXT] = formContext;
}
- const submit = actionHandler(
- (ct, id, f) => lib.exchange.uploadKycForm(id, f),
- !validatedForm ? undefined : ([reqId, validatedForm] as const),
- );
- submit.onSuccess = onComplete;
- submit.onFail = showError(
- i18n.str`Failed to upload the KYC information.`,
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.PayloadTooLarge:
- return i18n.str`The submission is too large. Please upload smaller files and try again.`;
- case HttpStatusCode.InternalServerError:
- return i18n.str`There was a problem processing your request. Please try again later.`;
- case HttpStatusCode.NotFound:
- return i18n.str`The account was not found`;
- case HttpStatusCode.Conflict:
- return i18n.str`Officer disabled or more recent decision was already submitted.`;
- default:
- assertUnreachable(fail);
- }
- },
- );
+ const submitArgs = !validatedForm
+ ? undefined
+ : ([reqId, validatedForm] as const);
+ const submit = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.exchange.uploadKycForm>>,
+ [KycRequirementInformationId, FormType]
+ >((ct, id, f) => lib.exchange.uploadKycForm(id, f), {
+ onSuccess: onComplete,
+ onFail: showError(
+ i18n.str`Failed to upload the KYC information.`,
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.PayloadTooLarge:
+ return i18n.str`The submission is too large. Please upload smaller files and try again.`;
+ case HttpStatusCode.InternalServerError:
+ return i18n.str`There was a problem processing your request. Please try again later.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The account was not found`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`Officer disabled or more recent decision was already submitted.`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ ),
+ });
return (
<div class="rounded-lg bg-white px-5 py-6 shadow m-4">
@@ -181,13 +186,13 @@ function ShowForm({
>
<i18n.Translate>Cancel</i18n.Translate>
</button>
- <Button
+ <AsyncButton
submit
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={submit}
+ onClick={submitArgs ? () => submit.run(...submitArgs) : undefined}
>
<i18n.Translate>Submit</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
{!status.errors ? undefined : <ErrorsSummary errors={status.errors} />}
</div>
diff --git a/packages/taler-exchange-kyc-webui/src/pages/Start.tsx b/packages/taler-exchange-kyc-webui/src/pages/Start.tsx
@@ -17,16 +17,18 @@ import {
AccessToken,
HttpStatusCode,
KycRequirementInformation,
+ KycRequirementInformationId,
TalerError,
assertUnreachable,
} from "@gnu-taler/taler-util";
import {
Attention,
- Button,
+ AsyncButton,
ErrorLoading,
Loading,
useExchangeApiContext,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -180,39 +182,39 @@ function LinkGenerator({ req }: { req: KycRequirementInformation }): VNode {
state: LinkGenerationState.WAIT,
});
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const { lib } = useExchangeApiContext();
- const start = actionHandler(
+ const start = useNotifiedOperation<
+ Awaited<ReturnType<typeof lib.exchange.startExternalKycProcess>>,
+ [KycRequirementInformationId]
+ >(
async (ct, id: string) => {
return lib.exchange.startExternalKycProcess(id);
},
- [req.id!],
- );
- start.onFail = showError(
- i18n.str`Failed to start the KYC process.`,
- (fail) => {
- setLoading({ state: LinkGenerationState.ERROR });
- switch (fail.case) {
- case HttpStatusCode.NotFound:
- return i18n.str`not found`;
- case HttpStatusCode.Conflict:
- return i18n.str`conflict`;
- case HttpStatusCode.PayloadTooLarge:
- return i18n.str`payload is too large`;
- default:
- assertUnreachable(fail.case);
- }
+ {
+ onSuccess(success) {
+ setLoading({
+ state: LinkGenerationState.DONE,
+ url: success.redirect_url,
+ });
+ },
+ onFail: showError(i18n.str`Failed to start the KYC process.`, (fail) => {
+ setLoading({ state: LinkGenerationState.ERROR });
+ switch (fail.case) {
+ case HttpStatusCode.NotFound:
+ return i18n.str`not found`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`conflict`;
+ case HttpStatusCode.PayloadTooLarge:
+ return i18n.str`payload is too large`;
+ default:
+ assertUnreachable(fail.case);
+ }
+ }),
},
);
- start.onSuccess = (success) => {
- setLoading({
- state: LinkGenerationState.DONE,
- url: success.redirect_url,
- });
- return undefined;
- };
useEffect(() => {
- start.call();
+ void start.run(req.id!);
setLoading({ state: LinkGenerationState.RUNNING });
}, []);
const redirectUrl = loading.url;
@@ -261,12 +263,12 @@ function LinkGenerator({ req }: { req: KycRequirementInformation }): VNode {
</p>
) : (
<p class="text-sm font-semibold leading-6 text-gray-900">
- <Button onClick={start}>
+ <AsyncButton onClick={() => start.run(req.id!)}>
<span class="absolute inset-x-0 -top-px bottom-0"></span>
<i18n.Translate context="KYC_REQUIREMENT_LINK_DESCRIPTION">
{req.description}
</i18n.Translate>
- </Button>
+ </AsyncButton>
</p>
)}
</div>
diff --git a/packages/taler-exchange-kyc-webui/src/pages/TriggerKyc.tsx b/packages/taler-exchange-kyc-webui/src/pages/TriggerKyc.tsx
@@ -18,6 +18,7 @@ import {
AmountJson,
Amounts,
AmountString,
+ CancellationToken,
assertUnreachable,
createNewWalletKycAccount,
eddsaGetPublic,
@@ -30,13 +31,14 @@ import {
WalletKycRequest,
} from "@gnu-taler/taler-util";
import {
- Button,
+ AsyncButton,
FormMetadata,
FormUI,
UIHandlerId,
useExchangeApiContext,
useFormMeta,
useNotificationContext,
+ useNotifiedOperation,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
@@ -53,7 +55,7 @@ type Props = {
export function TriggerKyc({ onKycStarted }: Props): VNode {
const { i18n } = useTranslationContext();
- const { actionHandler, showError } = useNotificationContext();
+ const { showError } = useNotificationContext();
const { config, lib } = useExchangeApiContext();
const theForm: FormMetadata = {
@@ -72,7 +74,6 @@ export function TriggerKyc({ onKycStarted }: Props): VNode {
currency: config.config.currency,
label: i18n.str`Amount`,
required: true,
- converterId: "Taler.Amount",
},
],
},
@@ -99,60 +100,63 @@ export function TriggerKyc({ onKycStarted }: Props): VNode {
}, [1]);
// i18n.str`trigger kyc process`,
- const send = actionHandler(
- async (ct, balance: AmountString) => {
- const account = await accountPromise;
- const limit: WalletKycRequest = {
- balance,
- reserve_pub: account.id,
- reserve_sig: encodeCrock(
- signWalletAccountSetup(account.__signingKey, balance),
- ),
- };
- const resp = await lib.exchange.notifyKycBalanceLimit(limit);
- if (resp.type === "ok") {
- return opKnownFailure(dummyHttpResponse, HttpStatusCode.NoContent);
- }
- if (resp.case === HttpStatusCode.UnavailableForLegalReasons) {
- const paytoHash = resp.body.h_payto;
- const { __signingKey } = await accountPromise;
- const merchantPub = eddsaGetPublic(__signingKey);
- const accountOwnerSig = encodeCrock(signKycAuth(__signingKey));
- const statusRes = await lib.exchange.checkKycStatus({
- accountPub: encodeCrock(merchantPub),
- accountSig: accountOwnerSig,
- paytoHash,
- });
- switch (statusRes.case) {
- case HttpStatusCode.Accepted:
- return opFixedSuccess(dummyHttpResponse, statusRes.body);
- }
- return statusRes;
+ const triggerKyc = async (_ct: CancellationToken, balance: AmountString) => {
+ const account = await accountPromise;
+ const limit: WalletKycRequest = {
+ balance,
+ reserve_pub: account.id,
+ reserve_sig: encodeCrock(
+ signWalletAccountSetup(account.__signingKey, balance),
+ ),
+ };
+ const resp = await lib.exchange.notifyKycBalanceLimit(limit);
+ if (resp.type === "ok") {
+ return opKnownFailure(dummyHttpResponse, HttpStatusCode.NoContent);
+ }
+ if (resp.case === HttpStatusCode.UnavailableForLegalReasons) {
+ const paytoHash = resp.body.h_payto;
+ const { __signingKey } = await accountPromise;
+ const merchantPub = eddsaGetPublic(__signingKey);
+ const accountOwnerSig = encodeCrock(signKycAuth(__signingKey));
+ const statusRes = await lib.exchange.checkKycStatus({
+ accountPub: encodeCrock(merchantPub),
+ accountSig: accountOwnerSig,
+ paytoHash,
+ });
+ switch (statusRes.case) {
+ case HttpStatusCode.Accepted:
+ return opFixedSuccess(dummyHttpResponse, statusRes.body);
}
- return resp;
+ return statusRes;
+ }
+ return resp;
+ };
+ const send = useNotifiedOperation<
+ Awaited<ReturnType<typeof triggerKyc>>,
+ [AmountString]
+ >(triggerKyc, {
+ onSuccess(success) {
+ onKycStarted(success.access_token);
},
+ onFail: showError(i18n.str`Failed to trigger a KYC event.`, (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.NoContent:
+ return i18n.str`No kyc configured.`;
+ case HttpStatusCode.Forbidden:
+ return i18n.str`Forbidden.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Not found.`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`Conflict.`;
+ default:
+ assertUnreachable(fail);
+ }
+ }),
+ });
+ const sendArgs =
theForm === undefined || status.status === "fail"
? undefined
- : [Amounts.stringify(status.result.amount)],
- );
-
- send.onSuccess = (success) => {
- onKycStarted(success.access_token);
- };
- send.onFail = showError(i18n.str`Failed to trigger a KYC event.`, (fail) => {
- switch (fail.case) {
- case HttpStatusCode.NoContent:
- return i18n.str`No kyc configured.`;
- case HttpStatusCode.Forbidden:
- return i18n.str`Forbidden.`;
- case HttpStatusCode.NotFound:
- return i18n.str`Not found.`;
- case HttpStatusCode.Conflict:
- return i18n.str`Conflict.`;
- default:
- assertUnreachable(fail);
- }
- });
+ : ([Amounts.stringify(status.result.amount)] as const);
return (
<div class="rounded-lg bg-white px-5 py-6 shadow m-4">
@@ -168,12 +172,12 @@ export function TriggerKyc({ onKycStarted }: Props): VNode {
>
<i18n.Translate>Cancel</i18n.Translate>
</button>
- <Button
- onClick={send}
+ <AsyncButton
+ onClick={sendArgs ? () => send.run(...sendArgs) : undefined}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Submit</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div class="grid grid-cols-1 gap-x-8 gap-y-4 ">
@@ -185,130 +189,121 @@ export function TriggerKyc({ onKycStarted }: Props): VNode {
</i18n.Translate>
</p>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000000`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000000`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger TOPS Terms of service</i18n.Translate>
- </Button>
- </div>
- <div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000010`)}
- // disabled={!submitHandler}
- class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- >
- <i18n.Translate>Trigger GLS onboarding</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000020`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000020`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.1</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000030`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000030`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.4</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000040`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000040`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.5</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000050`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000050`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.9</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000060`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000060`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.11</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000070`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000070`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.12</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000080`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000080`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.13</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000090`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000090`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.14</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000100`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000100`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.15</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000110`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000110`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Challenger test</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000120`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000120`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.9 customer</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
<div>
- <Button
- onClick={send.withArgs(`${config.config.currency}:1000130`)}
+ <AsyncButton
+ onClick={() => send.run(`${config.config.currency}:1000130`)}
// disabled={!submitHandler}
class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
<i18n.Translate>Trigger VQF 902.9 officer</i18n.Translate>
- </Button>
+ </AsyncButton>
</div>
</div>
</div>
diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx
@@ -1,155 +1,101 @@
/*
This file is part of GNU Taler
- (C) 2022-2024 Taler Systems S.A.
+ (C) 2022-2026 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
+*/
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { assertUnreachable } from "@gnu-taler/taler-util";
-import { Fragment, VNode, h } from "preact";
+import { h, JSX, VNode } from "preact";
import {
CSSProperties,
HTMLAttributes,
useEffect,
+ useRef,
useState,
} from "preact/compat";
-import { useNotificationContext } from "../context/notification.js";
-import { SafeHandler } from "../hooks/useNotifications.js";
+import { useTranslationContext } from "../context/translation.js";
import { doAutoFocus } from "./utils.js";
-type Props = Omit<
- Omit<HTMLAttributes<HTMLButtonElement>, "type">,
- "onClick"
-> & {
+export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "type"> & {
submit?: boolean;
- onClick: SafeHandler<any, any> | undefined;
focus?: boolean;
+ type?: "button" | "submit" | "reset";
};
-/**
- * we should have a button-type and a submit-type
- * submit tpye should not have focus sin the focus in the form
- * submit should only be used on forms and there should be only one
- */
+/** A plain button. Notifications and asynchronous state belong to callers. */
+export function Button({ focus, submit, type, ...rest }: ButtonProps): VNode {
+ return (
+ <button
+ {...rest}
+ ref={focus ? doAutoFocus : undefined}
+ type={type ?? (submit ? "submit" : "button")}
+ />
+ );
+}
+
+export type AsyncButtonProps = Omit<ButtonProps, "onClick"> & {
+ onClick?: (
+ event: JSX.TargetedMouseEvent<HTMLButtonElement>,
+ ) => void | Promise<void>;
+};
-/**
- * This button accept an async function and report a notification
- * on error or success.
- *
- * When the async function is running the inner text will change into
- * a "loading" animation.
- *
- * @param param0
- * @returns
- */
-export function Button({
+/** A button with local progress UI for an asynchronous click handler. */
+export function AsyncButton({
children,
- focus,
- onClick,
disabled,
- submit,
+ onClick,
...rest
-}: Props): VNode {
+}: AsyncButtonProps): VNode {
const [running, setRunning] = useState(false);
- const [failed, setFailed] = useState(false);
- const { notification: ns } = useNotificationContext();
- const notification = ns.length > 0 ? ns[0] : undefined;
- // if the button is in failed state and the user
- // change the state of the form that affect this action handler
- // the remove the failed state for faster submit
- useEffect(() => {
- if (failed) {
- setFailed(false);
- }
- }, onClick?.args ?? []);
+ const mounted = useRef(true);
+
+ useEffect(
+ () => () => {
+ mounted.current = false;
+ },
+ [],
+ );
- onClick?.addListener((e) => {
- switch (e) {
- case "start":
- return setRunning(true);
- case "fail":
- return setFailed(true);
- case "success":
- return setFailed(false);
- case "finish":
- return setRunning(false);
- default: {
- assertUnreachable(e);
- }
+ async function handleClick(
+ event: JSX.TargetedMouseEvent<HTMLButtonElement>,
+ ): Promise<void> {
+ if (!onClick || running) return;
+ setRunning(true);
+ try {
+ await onClick(event);
+ } catch (error) {
+ // AsyncButton deliberately has no notification dependency. Callers
+ // that need user-facing errors should use useNotifiedOperation.
+ console.error("Unhandled asynchronous button action", error);
+ } finally {
+ if (mounted.current) setRunning(false);
}
- });
+ }
return (
- <button
+ <Button
{...rest}
- disabled={running || !onClick || !onClick.args || disabled}
- ref={focus ? doAutoFocus : undefined}
- type={submit ? "submit" : "button"}
- data-failed={failed ? "true" : undefined}
- onClick={(e) => {
- e.preventDefault();
- if (failed) {
- setFailed(false);
- notification?.acknowledge();
- } else {
- onClick?.call();
- }
- }}
+ aria-busy={running}
+ disabled={disabled || !onClick || running}
+ onClick={handleClick}
>
- <div style={{ position: "relative" }}>
- <span style={{ visibility: running || failed ? "hidden" : undefined }}>
+ <span style={{ position: "relative" }}>
+ <span style={{ visibility: running ? "hidden" : undefined }}>
{children}
</span>
- {running ? <Wait /> : failed ? <Failed /> : undefined}
- </div>
- </button>
- );
-}
-
-function Failed(): VNode {
- return (
- <div role="status" style={{ ...centerInTheMiddle, ...tailwind_size24 }}>
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
- />
- </svg>
- <span style={tailwind_srOnly}>Failed</span>
- </div>
+ {running ? <Wait /> : undefined}
+ </span>
+ </Button>
);
}
-//////
-// tailwind helper in domCssProps since this needs to work
-// with tailwind and bulma
-//////
-
-// const tailwind_textNeutralTertiary: CSSProperties = {};
-const tailwind_animateSpin: CSSProperties = {
+const animateSpin: CSSProperties = {
animation: "spin 1s cubic-bezier(0.5, 0, 0.5, 1) infinite",
};
-const tailwind_size24: CSSProperties = {
- width: 24,
- height: 24,
-};
-const tailwind_srOnly: CSSProperties = {
+const size24: CSSProperties = { width: 24, height: 24 };
+const screenReaderOnly: CSSProperties = {
position: "absolute",
width: 1,
height: 1,
@@ -160,30 +106,30 @@ const tailwind_srOnly: CSSProperties = {
whiteSpace: "nowrap",
border: 0,
};
-const centerInTheMiddle: CSSProperties = {
+const centered: CSSProperties = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%,-50%)",
};
-const singleLineHeight: CSSProperties = {
- height: "1lh",
-};
function Wait(): VNode {
+ const { i18n } = useTranslationContext();
return (
- <div role="status" style={{ ...centerInTheMiddle, ...tailwind_size24 }}>
+ <span role="status" style={{ ...centered, ...size24 }}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
- style={{ ...singleLineHeight, ...tailwind_animateSpin }}
+ style={{ height: "1lh", ...animateSpin }}
>
<path d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
- <span style={tailwind_srOnly}>Loading</span>
- </div>
+ <span style={screenReaderOnly}>
+ <i18n.Translate>Loading</i18n.Translate>
+ </span>
+ </span>
);
}
diff --git a/packages/web-util/src/components/NotificationBanner.tsx b/packages/web-util/src/components/NotificationBanner.tsx
@@ -1,242 +1,77 @@
-import { Fragment, h, VNode } from "preact";
-import { useRef, useState } from "preact/compat";
+/*
+ This file is part of GNU Taler
+ (C) 2022-2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
import { useCommonPreferences } from "../context/common-preferences.js";
import { useNotificationContext } from "../context/notification.js";
import { useTranslationContext } from "../context/translation.js";
-import { CopyButton } from "./CopyButton.js";
import { Attention } from "./Attention.js";
-import { composeRef, saveRef } from "./utils.js";
-import { Duration } from "@gnu-taler/taler-util";
-/**
- * Toasts should be considered when displaying these types of information to the user:
- *
- * Low attention messages that do not require user action
- * Singular status updates
- * Confirmations
- * Information that does not need to be followed up
- *
- * Do not use toasts if the information contains the following:
- *
- * High attention and critical information
- * Time-sensitive information
- * Requires user action or input
- * Batch updates
- *
- * @returns
- */
+
export function ToastBanner(): VNode {
const { i18n } = useTranslationContext();
- const { notification: ns } = useNotificationContext();
+ const { notification: notifications } = useNotificationContext();
const [{ showDebugInfo }] = useCommonPreferences();
const [moreInfo, setMoreInfo] = useState(false);
- if (!ns || !ns.length) return <Fragment />;
- const notification = ns[0];
- switch (notification.message.type) {
- case "error": {
- const desc = notification.message.description;
- return (
- <Attention
- type="danger"
- title={notification.message.title}
- copy
- onClose={() => {
- notification.acknowledge();
- setMoreInfo(false);
- }}
- >
- {desc &&
- desc.length &&
- (moreInfo ? (
- desc.map((d, idx) => {
- return (
- <div key={idx} class="mt-2 text-sm text-red-700">
- {d}
- </div>
- );
- })
- ) : (
- <div class="mt-2 text-sm text-red-700">{desc[0]}</div>
- ))}
- <div class="flex justify-between">
- <div class="text-[grey]">
- {moreInfo || (desc && desc.length < 2) ? undefined : (
- <button onClick={() => setMoreInfo(true)} class="text-grey">
- <i18n.Translate>Show more info</i18n.Translate>
- </button>
- )}
- </div>
- </div>
+ if (!notifications.length) return <Fragment />;
- <pre
- class="whitespace-break-spaces text-black"
- style={{ display: showDebugInfo ? "block" : "none" }}
- >
- {JSON.stringify(
- notification.message.debug,
- function excludePrivate(key, value) {
- if (key.startsWith("__")) return "...";
- return value;
- },
- 2,
- )}
- </pre>
- </Attention>
- );
- }
- case "info":
- return (
- <Attention
- type="success"
- title={notification.message.title}
- onClose={() => {
- notification.acknowledge();
- setMoreInfo(false);
- }}
- timeout={GLOBAL_TOAST_TIMEOUT}
- />
- );
+ const notification = notifications[0];
+ if (notification.message.type === "info") {
+ return (
+ <Attention
+ type="success"
+ title={notification.message.title}
+ onClose={notification.acknowledge}
+ />
+ );
}
-}
-const GLOBAL_TOAST_TIMEOUT = Duration.fromSpec({
- seconds: 5,
-});
-
-export function ToastBannerBulma(): VNode {
- const { i18n } = useTranslationContext();
- const { notification: ns } = useNotificationContext();
- const [{ showDebugInfo }] = useCommonPreferences();
- const [moreInfo, setMoreInfo] = useState(false);
- const divHtml = useRef<HTMLDivElement>();
- if (!ns || !ns.length) return <Fragment />;
- const notification = ns[0];
- const msg = notification.message;
- switch (msg.type) {
- case "error":
- return (
- <div style={{ position: "relative" }}>
- <div
- style={{
- position: "fixed",
- zIndex: 99,
- top: 0,
- left: 0,
- right: 0,
- width: "100%",
- }}
- >
- <div class="notification">
- <div class="columns is-vcentered">
- <div ref={composeRef(saveRef(divHtml))} class="column is-12">
- <article class="message is-danger">
- <div class="message-header">
- <p>{msg.title}</p>
- <div>
- <CopyButton
- class="button"
- style={{ padding: 8 }}
- getContent={() => fromNodeToText(divHtml.current)}
- />
- <button
- class="delete "
- aria-label="close"
- style={{ margin: 8 }}
- onClick={() => notification.acknowledge()}
- />
- </div>
- </div>
- {msg.description && msg.description.length && (
- <div class="message-body">
- {moreInfo ? (
- msg.description.map((d, idx) => {
- return <div key={idx}>{d}</div>;
- })
- ) : (
- <div>{msg.description[0]}</div>
- )}
- {moreInfo ||
- msg.description.length === 1 ? undefined : (
- <a
- onClick={() => setMoreInfo(true)}
- type="button"
- style={{ justifySelf: "right", color: "gray" }}
- >
- <i18n.Translate>show more info</i18n.Translate>
- </a>
- )}
- {msg.debug && (
- <pre
- class="whitespace-break-spaces text-black"
- style={{
- display: showDebugInfo ? "block" : "none",
- }}
- >
- {JSON.stringify(
- msg.debug,
- function excludePrivate(key, value) {
- if (key.startsWith("__")) return "...";
- return value;
- },
- 2,
- )}
- </pre>
- )}
- </div>
- )}
- </article>
- </div>
- </div>
- </div>
- </div>
- </div>
- );
- case "info":
- return (
- <div style={{ position: "relative" }}>
- <div
- style={{
- position: "fixed",
- zIndex: 99,
- top: 0,
- left: 0,
- right: 0,
- width: "100%",
- }}
- >
- <div class="notification">
- <div class="columns is-vcentered">
- <div class="column is-12">
- <article class="message is-info">
- <div class="message-header">
- <p>{msg.title}</p>
- </div>
- </article>
- </div>
- </div>
- </div>
+ const descriptions = notification.message.description ?? [];
+ return (
+ <Attention
+ type="danger"
+ title={notification.message.title}
+ copy
+ onClose={() => {
+ notification.acknowledge();
+ setMoreInfo(false);
+ }}
+ >
+ {(moreInfo ? descriptions : descriptions.slice(0, 1)).map(
+ (description, index) => (
+ <div key={index} class="mt-2 text-sm text-red-700">
+ {description}
</div>
- </div>
- );
- }
-}
-
-function fromNodeToText(node: ChildNode | undefined) {
- var i, result, text, child;
- result = "";
-
- if (node)
- for (i = 0; i < node.childNodes.length; i++) {
- child = node.childNodes[i];
- text = null;
- if (child.nodeType === 1) {
- text = fromNodeToText(child);
- } else if (child.nodeType === 3) {
- text = child.nodeValue;
- }
- if (text) {
- result += "\n";
- result += text;
- }
- }
- return result;
+ ),
+ )}
+ {!moreInfo && descriptions.length > 1 ? (
+ <button onClick={() => setMoreInfo(true)} class="text-grey">
+ <i18n.Translate>Show more info</i18n.Translate>
+ </button>
+ ) : undefined}
+ <pre
+ class="whitespace-break-spaces text-black"
+ style={{ display: showDebugInfo ? "block" : "none" }}
+ >
+ {JSON.stringify(
+ notification.message.debug,
+ (key, value) => (key.startsWith("__") ? "..." : value),
+ 2,
+ )}
+ </pre>
+ </Attention>
+ );
}
diff --git a/packages/web-util/src/context/notification.ts b/packages/web-util/src/context/notification.ts
@@ -16,16 +16,10 @@
import { ComponentChildren, createContext, h, VNode } from "preact";
import { useContext } from "preact/hooks";
-import {
- newSafeHandlerBuilder,
- useNotificationHandler,
-} from "../hooks/useNotifications.js";
-import { useTranslationContext } from "./translation.js";
+import { useNotificationHandler } from "../hooks/useNotifications.js";
type Notif = ReturnType<typeof useNotificationHandler>;
-interface Type extends Notif {
- actionHandler: ReturnType<typeof newSafeHandlerBuilder>;
-}
+type Type = Notif;
function unhandled(): never {
throw Error(
"Missing NotificationProvider. The application is not properly configured.",
@@ -34,7 +28,6 @@ function unhandled(): never {
const initial: Type = {
notification: [],
- actionHandler: unhandled,
showError: unhandled,
displayInfo: unhandled,
showSuccess: unhandled,
@@ -49,39 +42,9 @@ interface Props {
// Outmost UI wrapper.
export const NotificationProvider = ({ children }: Props): VNode => {
- const { i18n } = useTranslationContext();
const { notification, ...nf } = useNotificationHandler();
- const actionHandler = newSafeHandlerBuilder({
- onError: (error) => {
- nf.displayError(
- i18n.str`Unexpected error.`,
- error,
- i18n.str`The runtime thrown an Error which was not properly handled. To report click the copy button and create an issue in https://bugs.taler.net.`,
- );
- },
- onFail: (f) => {
- nf.displayError(
- i18n.str`The operation failed`,
- f,
- i18n.str`This handler also need a better error reporting. To report click the copy button and create an issue in https://bugs.taler.net.`,
- );
- },
- // no need to show a toast for every succeed operation
- // onSuccess: (body) => {
- // displayInfo(i18n.str`operation succeeded: ${JSON.stringify(body)}`);
- // },
- listeners: [
- (e) => {
- if (e === "start") {
- nf.clear();
- }
- },
- ],
- });
-
return h(Context.Provider, {
value: {
- actionHandler,
...nf,
notification,
},
diff --git a/packages/web-util/src/hooks/index.stories.ts b/packages/web-util/src/hooks/index.stories.ts
@@ -1 +0,0 @@
-export * as a1 from "./useNotifications.stories.js";
diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts
@@ -26,3 +26,5 @@ export {
} from "./useLocalStorage.js";
export { useMemoryStorage } from "./useMemoryStorage.js";
export * from "./useNotifications.js";
+export * from "./useAsyncAction.js";
+export * from "./useNotifiedOperation.js";
diff --git a/packages/web-util/src/hooks/useAsyncAction.ts b/packages/web-util/src/hooks/useAsyncAction.ts
@@ -0,0 +1,85 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+*/
+
+import { CancellationToken } from "@gnu-taler/taler-util";
+import { useCallback, useEffect, useRef, useState } from "preact/hooks";
+
+export interface AsyncActionOptions<Result, Args extends unknown[]> {
+ onResult?: (result: Result, ...args: Args) => void | Promise<void>;
+ onError?: (error: unknown, ...args: Args) => void | Promise<void>;
+}
+
+export interface AsyncAction<Args extends unknown[]> {
+ running: boolean;
+ run: (...args: Args) => Promise<void>;
+ cancel: () => void;
+}
+
+/**
+ * Run one cancellable asynchronous operation at a time.
+ *
+ * The operation and callbacks always come from the latest render. Completion
+ * after unmount is ignored, and cancellation is not reported as a failure.
+ */
+export function useAsyncAction<Result, Args extends unknown[]>(
+ operation: (token: CancellationToken, ...args: Args) => Promise<Result>,
+ options: AsyncActionOptions<Result, Args> = {},
+): AsyncAction<Args> {
+ const [running, setRunning] = useState(false);
+ const runningRef = useRef(false);
+ const mounted = useRef(true);
+ const source = useRef<CancellationToken.Source>();
+ const operationRef = useRef(operation);
+ const optionsRef = useRef(options);
+ operationRef.current = operation;
+ optionsRef.current = options;
+
+ const cancel = useCallback(() => {
+ source.current?.cancel();
+ }, []);
+
+ useEffect(
+ () => () => {
+ mounted.current = false;
+ source.current?.cancel();
+ source.current?.dispose();
+ },
+ [],
+ );
+
+ const run = useCallback(async (...args: Args): Promise<void> => {
+ if (runningRef.current) return;
+ runningRef.current = true;
+ setRunning(true);
+ const currentSource = CancellationToken.create();
+ source.current = currentSource;
+ try {
+ const result = await currentSource.token.racePromise(
+ operationRef.current(currentSource.token, ...args),
+ );
+ if (mounted.current) {
+ await optionsRef.current.onResult?.(result, ...args);
+ }
+ } catch (error: unknown) {
+ if (
+ mounted.current &&
+ !(error instanceof CancellationToken.CancellationError)
+ ) {
+ await optionsRef.current.onError?.(error, ...args);
+ }
+ } finally {
+ currentSource.dispose();
+ if (source.current === currentSource) source.current = undefined;
+ runningRef.current = false;
+ if (mounted.current) setRunning(false);
+ }
+ }, []);
+
+ return { running, run, cancel };
+}
diff --git a/packages/web-util/src/hooks/useNotifications.stories.tsx b/packages/web-util/src/hooks/useNotifications.stories.tsx
@@ -1,524 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- *
- * @author Sebastian Javier Marchano (sebasjm)
- */
-
-import {
- AbsoluteTime,
- opEmptySuccess,
- opFixedSuccess,
- opKnownFailure,
- TranslatedString,
-} from "@gnu-taler/taler-util";
-import { Fragment, h, VNode } from "preact";
-import { useEffect, useState } from "preact/hooks";
-import { Attention } from "../components/Attention.js";
-import { Button } from "../components/Button.js";
-import {
- NotificationProvider,
- useNotificationContext,
-} from "../context/notification.js";
-import { delayMs } from "./useAsync.js";
-import * as tests from "../tests/hook.js";
-import {
- newSafeHandlerBuilder,
- Notification,
- useNotificationHandler,
-} from "./useNotifications.js";
-import { HttpResponse } from "../../../taler-util/src/http-common.js";
-
-export default {
- title: "Use Notifications NG",
-};
-
-export const autoTriggered = tests.createExample(() => {
- const safe = newSafeHandlerBuilder({});
- const [count, setCount] = useState(0);
- function inc() {
- setCount((n) => n + 1);
- }
- const action = safe(async (ct) => {
- await delayMs(1500, ct);
- inc();
- return opEmptySuccess({} as any);
- }, []);
- useEffect(() => {
- const id = setInterval(() => {
- action.call();
- }, 3_000);
- return () => {
- clearInterval(id);
- };
- });
- return (
- <div>
- <div>
- This button is going to be cliked programatically every 3 seconds,
- unless is already working. While the action is active it can't be
- trigered concurrently. Cancel should prevent the action to complete.
- </div>
- <div>click {count} times</div>
- <div class="grid gap-2 w-40">
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={action}
- >
- button
- </Button>
- <button
- class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
- onClick={action.call}
- >
- call directly
- </button>
- <button
- class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
- onClick={action.cancel}
- >
- cancel
- </button>
- </div>
- </div>
- );
-}, {});
-
-export const withCancel = tests.createExample(() => {
- const safe = newSafeHandlerBuilder({});
- const [count, setCount] = useState(0);
- function inc() {
- setCount((n) => n + 1);
- }
- const action = safe(async (ct) => {
- await delayMs(5_000, ct);
- inc();
- return opEmptySuccess({} as any);
- }, []);
- return (
- <div>
- <div>This button will take 5 secs to complete but can be cancelled</div>
- <div>click {count} times</div>
- <div class="grid gap-2 w-40">
- <button
- class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
- onClick={action.cancel}
- >
- cancel
- </button>
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={action}
- >
- button
- </Button>
- </div>
- </div>
- );
-}, {});
-
-export const sharedButton = tests.createExample(() => {
- const safe = newSafeHandlerBuilder({});
- const [count, setCount] = useState(0);
- function inc(d: number) {
- setCount((n) => n + d);
- }
-
- const action = safe(async (ct, size: number) => {
- await delayMs(1500, ct);
- inc(size);
- return opEmptySuccess({} as any);
- });
-
- return (
- <div>
- <div>These buttons tiggers the same action but with different args. </div>
- <p>It should block both buttons while working.</p>
- <p>The "cancel" button it should cancel any of the button.</p>
- <div>click {count} times</div>
- <div class="grid gap-2 w-40">
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={action.withArgs(1)}
- >
- button +1
- </Button>
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={action.withArgs(2)}
- >
- button +2
- </Button>
- <button
- class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
- onClick={action.cancel}
- >
- cancel
- </button>
- </div>
- </div>
- );
-}, {});
-
-export const conditionally = tests.createExample(() => {
- const { notification, showSuccess, clear } = useNotificationHandler();
- const safe = newSafeHandlerBuilder({});
- const [count, setCount] = useState(0);
- const [name, setName] = useState("");
- function inc() {
- setCount((n) => n + 1);
- }
- const action = safe(
- async (ct, s: string) => {
- await delayMs(500);
- inc();
- return opFixedSuccess({} as any, s);
- },
- name.length > 4 ? [name] : undefined,
- );
- action.addListener((e) => (e === "start" ? clear() : undefined));
- action.onSuccess = showSuccess(
- (d) => `the name ${d} is a good name` as TranslatedString,
- );
-
- return (
- <div>
- <div>
- This actions is disabled until the conditions on the forms are met. It
- should show succeed on click
- </div>
- <input
- class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 "
- onChange={(e) => {
- setName(e.currentTarget.value);
- }}
- placeholder={"The name should be greater than 4 characters"}
- />
- <div>click {count} times</div>
- <div class="grid gap-2 w-40">
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={action}
- >
- button
- </Button>
- </div>
- <ShowMessage n={notification} />
- </div>
- );
-}, {});
-
-export const messages = tests.createExample(() => {
- const { notification, showError, showSuccess, clear } =
- useNotificationHandler();
- const safe = newSafeHandlerBuilder({});
- const [name, setName] = useState("");
- const [count, setCount] = useState(0);
- function inc() {
- setCount((n) => n + 1);
- return count + 1;
- }
- const action = safe(
- async function (ct, name: string) {
- await delayMs(500);
- if (!name.length) return opKnownFailure({} as any, "no-name");
- if (name.length <= 4)
- return opKnownFailure({} as any, "short-name");
- const id = inc();
-
- return opFixedSuccess({} as any, id);
- },
- [name],
- );
- action.addListener((e) => (e === "start" ? clear() : undefined));
- action.onFail = showError(
- "the operation failed" as TranslatedString,
- (fail) => {
- switch (fail.case) {
- case "no-name":
- return "please enter a name" as TranslatedString;
- case "short-name":
- return "the name is not long enough" as TranslatedString;
- }
- },
- );
- action.onSuccess = showSuccess((s) => {
- return `person updated, operation id: ${s}` as TranslatedString;
- });
- return (
- <div>
- <div>Fail if the input is invalid, shows success otherwise</div>
- <input
- class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 "
- onChange={(e) => {
- setName(e.currentTarget.value);
- }}
- placeholder={"The name should be greater than 4 characters"}
- />
- <div>click {count} times</div>
- <div class="grid gap-2 w-40">
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={action}
- >
- button
- </Button>
- </div>
- <ShowMessage n={notification} />
- </div>
- );
-}, {});
-
-export const recreatedArgumentsKeepErrorVisible = tests.createExample(() => {
- return (
- <NotificationProvider>
- <RecreatedArgumentsKeepErrorVisible />
- </NotificationProvider>
- );
-}, {});
-
-function RecreatedArgumentsKeepErrorVisible(): VNode {
- const { actionHandler, notification, showError } = useNotificationContext();
- const action = actionHandler(
- async function (_ct, _input: { value: string }) {
- return opKnownFailure({} as any, "failure");
- },
- // Deliberately recreate the argument object on every render. This mirrors
- // the password wrapper used by the AML session unlock form.
- [{ value: "same value" }],
- );
- action.onFail = showError(
- "the operation failed" as TranslatedString,
- () => "the error should remain visible" as TranslatedString,
- );
-
- return (
- <div>
- <p>
- Click the button. The error must remain visible after the failed action
- rerenders the component.
- </p>
- <Button onClick={action}>fail</Button>
- {notification.length ? (
- <Attention
- type="danger"
- title={notification[0].message.title}
- onClose={notification[0].acknowledge}
- >
- {notification[0].message.type === "error"
- ? notification[0].message.description
- : undefined}
- </Attention>
- ) : undefined}
- </div>
- );
-}
-
-export const confirm = tests.createExample(() => {
- const safe = newSafeHandlerBuilder({});
- const [name, setName] = useState("");
- const [showConfirmDialog, setShowConfirmDialog] = useState(false);
- const { notification, showError, showSuccess, clear } =
- useNotificationHandler();
- const [count, setCount] = useState(0);
- function inc() {
- setCount((n) => n + 1);
- return count + 1;
- }
- const action = safe(
- async function (ct, name: string, confirm?: boolean) {
- await delayMs(500);
- if (!name.length) return opKnownFailure({} as any, "no-name");
- if (name.length <= 4)
- return opKnownFailure({} as any, "short-name");
- if (!confirm) {
- return opKnownFailure({} as any, "confirm");
- }
- const id = inc();
-
- return opFixedSuccess({} as any, name + id);
- },
- [name],
- );
-
- action.addListener((e) => (e === "start" ? clear() : undefined));
- action.onFail = showError(
- "the operation failed" as TranslatedString,
- (fail) => {
- switch (fail.case) {
- case "no-name":
- return "please enter a name" as TranslatedString;
- case "short-name":
- return "the name is not long enough" as TranslatedString;
- case "confirm":
- setShowConfirmDialog(true);
- return undefined;
- }
- },
- );
-
- action.onSuccess = showSuccess((s) => {
- setShowConfirmDialog(false);
- return `person updated, operation id: ${s}` as TranslatedString;
- });
-
- const confirm = action.lambda(
- (prev) => (!prev ? undefined : [prev[0], true]),
- [],
- );
-
- return (
- <div>
- <div>It will ask for a confirmaton if didn't fail and before succed</div>
- <p>When the value is correct it will ask for confirmation</p>
- <p>After confirmation the operation should succeed</p>
- <p>On fail it should show error dialog</p>
-
- <input
- class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 "
- onChange={(e) => {
- setName(e.currentTarget.value);
- }}
- placeholder={"The name should be greater than 4 characters"}
- />
- <div>click {count} times</div>
- <div class="grid gap-2 w-40">
- {!showConfirmDialog ? (
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={action}
- >
- agree
- </Button>
- ) : (
- <Fragment>
- <button
- class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
- onClick={() => setShowConfirmDialog(false)}
- >
- cancel
- </button>
-
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
- onClick={confirm}
- >
- confirm
- </Button>
- </Fragment>
- )}
- </div>
- <ShowMessage n={notification} />
- </div>
- );
-}, {});
-
-export const unhandledError = tests.createExample(() => {
- const { notification, displayError, displayInfo, clear } =
- useNotificationHandler();
- const safeHandler = newSafeHandlerBuilder({
- onError: (error) => {
- displayError(`unpexpected error` as TranslatedString, error);
- },
- onFail: (error) => {
- displayError(`the operation failed` as TranslatedString, error);
- },
- onSuccess: (body) => {
- displayInfo(
- `operation succeeded: ${JSON.stringify(body)}` as TranslatedString,
- );
- },
- });
- const [count, setCount] = useState(0);
- function inc() {
- setCount((n) => n + 1);
- return count + 1;
- }
- const action = safeHandler(async function (ct, name: string) {
- await delayMs(1500, ct);
- if (!name.length) throw Error("missing name");
- if (name.length <= 4)
- return opKnownFailure({} as any, "short-name");
- const id = inc();
-
- return opFixedSuccess({} as any, name + id);
- });
-
- action.addListener((e) => (e === "start" ? clear() : undefined));
- const ok = action.withArgs("taler");
- const fail = action.withArgs("qwe");
- const error = action.withArgs("");
-
- return (
- <div>
- <div>
- This buttons doesnt have set a particular handler on success and on
- error but it should work anyway.
- <p>"ok" button should show a info notification when clicked.</p>
- <p>"cancel" should not show any error</p>
- <p>"fail" should show a fail operation based on a validation</p>
- <p>"error" should show a unexpected error</p>
- </div>
-
- <div>click {count} times</div>
- <div class="grid gap-2 w-40">
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={ok}
- >
- ok
- </Button>
- <button
- class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
- onClick={ok.cancel}
- >
- cancel
- </button>
-
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={fail}
- >
- fail
- </Button>
- <Button
- class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- onClick={error}
- >
- error
- </Button>
- </div>
- <ShowMessage n={notification} />
- </div>
- );
-}, {});
-
-function ShowMessage({ n: ns }: { n?: Notification[] }): VNode {
- if (!ns || !ns.length) return <Fragment />;
- const n = ns[0];
- return (
- <Attention
- type={n.message.type}
- title={n.message.title}
- onClose={() => {
- n.acknowledge();
- }}
- >
- {n.message.type === "error" ? n.message.description : undefined}
- </Attention>
- );
-}
diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts
@@ -1,134 +1,105 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+*/
+
import {
AbsoluteTime,
- assertUnreachable,
- CancellationToken,
- OperationAlternative,
- OperationFail,
- OperationOk,
- OperationResult,
TalerError,
TalerErrorCode,
TranslatedString,
} from "@gnu-taler/taler-util";
import { useState } from "preact/hooks";
-import {
- InternationalizationAPI,
- useTranslationContext,
-} from "../context/translation.js";
+import { InternationalizationAPI } from "../context/translation.js";
export type NotificationMessage = ErrorNotification | InfoNotification;
export interface ErrorNotification {
type: "error";
title: TranslatedString;
- ack?: boolean;
- timeout?: boolean;
description?: TranslatedString[];
- debug?: any;
- actions?: {};
+ debug?: unknown;
when: AbsoluteTime;
}
+
export interface InfoNotification {
type: "info";
title: TranslatedString;
- ack?: boolean;
- timeout?: boolean;
when: AbsoluteTime;
}
-export type Notification = {
+export interface Notification {
message: NotificationMessage;
acknowledge: () => void;
-};
-
-function hashCode(str: string): string {
- if (str.length === 0) return "0";
- let hash = 0;
- let chr;
- for (let i = 0; i < str.length; i++) {
- chr = str.charCodeAt(i);
- hash = (hash << 5) - hash + chr;
- hash |= 0; // Convert to 32bit integer
- }
- return hash.toString(16);
}
-function hash(msg: NotificationMessage): string {
- let str = (msg.type + ":" + msg.title) as string;
- if (msg.type === "error") {
- if (msg.description) {
- str += ":" + msg.description;
- }
- if (msg.debug) {
- str += ":" + msg.debug;
- }
- }
- return hashCode(str);
-}
+export type ReplaceReturnType<T, NewReturn> = T extends (...args: any[]) => any
+ ? (...args: Parameters<T>) => NewReturn
+ : never;
+type Params<T> = T extends (...args: infer P) => any ? P : never;
+type FunctionThatReturnsVoid<T> = (...args: Params<T>) => void;
export function useNotificationHandler() {
const [notification, setNotification] = useState<Notification[]>([]);
- function pushNotification(n: Notification) {
- setNotification((ns) => [n, ...ns]);
- }
- function ackNotification(when: AbsoluteTime) {
- return (): void =>
- setNotification((ns) =>
- ns.filter((n) => n.message.when.t_ms !== when.t_ms),
- );
+ function pushNotification(message: NotificationMessage): void {
+ setNotification((current) => {
+ const item: Notification = {
+ message,
+ acknowledge: () => {
+ setNotification((latest) => latest.filter((n) => n !== item));
+ },
+ };
+ return [item, ...current];
+ });
}
+
function displayError(
title: TranslatedString,
debug: Error | TalerError | unknown | undefined,
...description: TranslatedString[]
- ) {
- const when = AbsoluteTime.now();
+ ): void {
pushNotification({
- message: {
- title,
- type: "error",
- when,
- description,
- debug,
- },
- acknowledge: ackNotification(when),
+ title,
+ type: "error",
+ when: AbsoluteTime.now(),
+ description,
+ debug,
});
}
- function displayInfo(title: TranslatedString) {
- const when = AbsoluteTime.now();
- pushNotification({
- message: {
- title,
- type: "info",
- when,
- },
- acknowledge: ackNotification(when),
- });
+
+ function displayInfo(title: TranslatedString): void {
+ pushNotification({ title, type: "info", when: AbsoluteTime.now() });
}
function showError<T extends FunctionThatReturnsVoid<T>>(
title: TranslatedString,
- h: ReplaceReturnType<T, TranslatedString | TranslatedString[] | undefined>,
+ handler: ReplaceReturnType<
+ T,
+ TranslatedString | TranslatedString[] | undefined
+ >,
): T {
return ((...args: Parameters<T>): void => {
- const n = h(...args);
- if (n === undefined) return;
- if (Array.isArray(n)) {
- displayError(title, args, ...n);
- } else {
- displayError(title, args, n);
- }
+ const description = handler(...args);
+ if (description === undefined) return;
+ displayError(
+ title,
+ args,
+ ...(Array.isArray(description) ? description : [description]),
+ );
}) as T;
}
function showSuccess<T extends FunctionThatReturnsVoid<T>>(
- h: ReplaceReturnType<T, TranslatedString | undefined>,
+ handler: ReplaceReturnType<T, TranslatedString | undefined>,
): T {
return ((...args: Parameters<T>): void => {
- const n = h(...args);
- if (n === undefined) return;
- displayInfo(n);
+ const title = handler(...args);
+ if (title !== undefined) displayInfo(title);
}) as T;
}
@@ -142,376 +113,6 @@ export function useNotificationHandler() {
};
}
-export type ReplaceReturnType<T, TNewReturn> = T extends (...a: any) => any
- ? (...a: Parameters<T>) => TNewReturn
- : never;
-export type Params<T> = T extends (...a: infer P) => any ? P : never;
-export type FunctionThatReturnsVoid<T> = (...a: Params<T>) => void;
-
-/**
- * A function that may fail and return a message to be shown
- * as a notification
- */
-export type FunctionThatMayFail<T extends any[]> = (
- ...args: T
-) => Promise<NotificationMessage | undefined>;
-
-/**
- * Initialize a notification handler.
- * @returns a tuple of notification and setter
- * 1) notification that may be set by a function when it fails.
- * 2) a error handling function that converts a function that returns a message
- * into a function that will set the notification.
- *
- */
-// export function useLocalNotificationBetter(): [
-// Notification | undefined,
-// <Args extends any[], R extends OperationResult<any, any>>(
-// opName: TranslatedString,
-// doAction: (...args: Args) => Promise<R>,
-// args?: Args,
-// ) => SafeHandler<Args, R>,
-// ] {
-// const [value, save] = useState<NotificationMessage>();
-// const notif = !value
-// ? undefined
-// : {
-// message: value,
-// acknowledge: () => {
-// save(undefined);
-// },
-// };
-
-// // FIXME: we should move this outside of logic
-// const { i18n } = useTranslationContext();
-
-// function safeFunctionHandler<
-// Args extends any[],
-// R extends OperationResult<any, any>,
-// >(
-// opName: TranslatedString,
-// doAction: (...args: Args) => Promise<R>,
-// args?: Args,
-// ): SafeHandler<Args, R> {
-// function buildSafeHandler(
-// a: Args | undefined,
-// doAction: (...args: Args) => Promise<R>,
-// ): SafeHandler<Args, R> {
-// const s = CancellationToken.create();
-// let running = false;
-// const thiz: SafeHandler<Args, R> = {
-// args: a,
-// cancel: () => {
-// if (running) {
-// s.cancel();
-// }
-// },
-// withArgs: (...newArgs) => {
-// const r = buildSafeHandler(newArgs, doAction);
-// r.onSuccess = thiz.onSuccess;
-// r.onFail = thiz.onFail;
-// return r;
-// },
-// lambda: (converter, init) => {
-// type D = Parameters<typeof converter>;
-// type SH = SafeHandler<D, R>;
-
-// const r = buildSafeHandler(
-// init ? converter(...init) : undefined,
-// doAction,
-// );
-// // @ts-expect-error
-// r.withArgs = (...args: D) => {
-// const d = converter(...args);
-// if (!d) return thiz;
-// const e = thiz.withArgs(...d);
-// return e;
-// };
-// /**
-// * FIXME: there is a problem with this
-// *
-// * adding onSuccess function after creating the lambda makes the withArgs
-// * build handlers without onSuccess. consider this
-// *
-// * const h = safeHandler(handler).lambda((param) -> .. )
-// * h.onSuccess = () => i18n.str`ok`
-// * const button = h.withArgs(p);
-// *
-// * button.call()
-// *
-// * the onSuccess function is undefined when button is clicked.
-// * But not if the lambda is created after the onSuccess assignment
-// */
-// r.onSuccess = thiz.onSuccess;
-// r.onFail = thiz.onFail;
-// return r as any as SH;
-// },
-// call: async (): Promise<void> => {
-// if (!thiz.args || running) return;
-// running = true;
-// try {
-// thiz.onStart.forEach((listener) => {
-// listener();
-// });
-
-// const resp = await s.token.racePromise(doAction(...thiz.args));
-// switch (resp.type) {
-// case "ok": {
-// const msg = thiz.onSuccess(resp.body, ...thiz.args);
-// if (msg) {
-// save(successWithTitle(msg));
-// }
-// return;
-// }
-// case "fail": {
-// const error = thiz.onFail(resp as any, ...thiz.args);
-// if (error) {
-// save(failWithTitle(i18n, opName, resp, error, thiz.args));
-// }
-// return;
-// }
-// default: {
-// assertUnreachable(resp);
-// }
-// }
-// } catch (error: unknown) {
-// if (error instanceof CancellationToken.CancellationError) {
-// return;
-// }
-// // This functions should not throw, this is a problem.
-// logBugForDevelopers(error);
-// onUnexpected(
-// i18n,
-// i18n.str`Unexpected error trying to ${opName}`,
-// save,
-// )(error, thiz.args);
-// return;
-// } finally {
-// thiz.onComplete.forEach((listener) => {
-// listener();
-// });
-// running = false;
-// }
-// },
-// onFail: (fail, ...rest) =>
-// i18n.str`Unhandled failure trying to ${opName}. Code ${fail.case}`,
-// onSuccess: () => undefined,
-// onStart: [],
-// onComplete: [],
-// addCompleteListener: (h) => {
-// thiz.onComplete.push(h);
-// return thiz;
-// },
-// addStartListener: (h) => {
-// thiz.onStart.push(h);
-// return thiz;
-// },
-// };
-// return thiz;
-// }
-// return buildSafeHandler(args, doAction);
-// }
-
-// return [notif, safeFunctionHandler];
-// }
-
-export interface SafeHandler<Args extends any[], OpType> {
- /**
- * Be careful of not settings the args with always-changing values
- * like `[]` or `{}` (which returns new instance every time)
- *
- * The list of arguments is subject to be used as an useEffect dependency array
- * for re-render purpose
- */
- readonly args: Args | undefined;
- readonly listeners: ((e: "start" | "success" | "fail" | "finish") => void)[];
-
- call(): Promise<void>;
- cancel(): void;
-
- /**
- * creates another handler with new arguments
- */
- withArgs(...args: Args): SafeHandler<Args, Error>;
-
- /**
- * Derive a new handler with different calling interface.
- *
- * The converter will be called with previoulsy set arguments plus the
- * new arguments which should return the merged result.
- *
- */
- lambda<OtherArgs extends any[]>(
- convert: (
- prevArgs: Args | undefined,
- nextArgs: OtherArgs,
- ) => Args | undefined,
- init?: OtherArgs,
- ): SafeHandler<OtherArgs, OpType>;
-
- onSuccess: OnOperationSuccesReturnType_NG<OpType, Args>;
- onFail: OnOperationFailReturnType_NG<OpType, Args>;
-
- addListener: (
- h: (event: "start" | "success" | "fail" | "finish") => void,
- ) => SafeHandler<Args, Error>;
-}
-
-interface InnerSafeHandler<Args extends any[], R> extends SafeHandler<Args, R> {
- cts: {
- source: CancellationToken.Source;
- running: boolean;
- };
-}
-
-function noop(): undefined {}
-
-export function simpleSafeHandler(fn: () => void) {
- return newSafeHandlerBuilder()(async () => {
- fn();
- return {
- type: "ok",
- body: undefined,
- case: "ok",
- response: {} as any,
- };
- }, []);
-}
-
-export function newSafeHandlerBuilder<
- A extends any[],
- B extends OperationResult<any, any>,
->(
- opts: {
- onSuccess?: (result: unknown, ...args: any[]) => void;
- onFail?: (fail: unknown, ...args: any[]) => void;
- onError?: (e: unknown, ...args: any[]) => void;
- listeners?: SafeHandler<unknown[], unknown>["listeners"];
- } = {},
-) {
- return function newSafeHandler<Args extends A, R extends B>(
- doAction: (t: CancellationToken, ...args: Args) => Promise<R>,
- a?: Args,
- ): SafeHandler<Args, R> {
- const thiz: InnerSafeHandler<Args, R> = {
- args: a,
- cts: {
- source: CancellationToken.create(),
- running: false,
- },
- cancel: () => {
- thiz.cts.source.cancel();
- thiz.cts.source = CancellationToken.create();
- },
- withArgs: (...newArgs) => {
- const r = newSafeHandler(doAction, newArgs);
- // @ts-expect-error
- r.listeners = thiz.listeners;
- // @ts-expect-error
- r.cts = thiz.cts;
- r.cancel = thiz.cancel;
- r.onSuccess = thiz.onSuccess;
- r.onFail = thiz.onFail;
- return r;
- },
- lambda: (converter, init) => {
- type D = Parameters<typeof converter>[1];
- type SH = SafeHandler<D, R>;
-
- const r = newSafeHandler(
- doAction,
- init !== undefined ? converter(thiz.args, init) : undefined,
- );
- // @ts-expect-error
- r.withArgs = (...args: D) => {
- const d = converter(thiz.args, args);
- if (d === undefined) return thiz;
- const e = thiz.withArgs(...d);
- // @ts-expect-error
- e.listeners = r.listeners;
- // @ts-expect-error
- e.cts = r.cts;
- e.cancel = r.cancel;
- e.onSuccess = r.onSuccess;
- e.onFail = r.onFail;
- return e;
- };
- // @ts-expect-error
- r.listeners = thiz.listeners;
- // @ts-expect-error
- r.cts = thiz.cts;
- r.cancel = thiz.cancel;
- r.onSuccess = thiz.onSuccess;
- r.onFail = thiz.onFail;
- return r as any as SH;
- },
- call: async (): Promise<void> => {
- if (!thiz.args || thiz.cts.running) return;
- thiz.cts.running = true;
- try {
- thiz.listeners.forEach((listener) => {
- listener("start");
- });
- const resp = await thiz.cts.source.token.racePromise(
- doAction(thiz.cts.source.token, ...thiz.args),
- );
- switch (resp.type) {
- case "ok": {
- thiz.listeners.forEach((listener) => {
- listener("success");
- });
- thiz.onSuccess(resp.body, ...thiz.args);
- return;
- }
- case "fail": {
- thiz.listeners.forEach((listener) => {
- listener("fail");
- });
- thiz.onFail(resp as any, ...thiz.args);
- return;
- }
- default: {
- assertUnreachable(resp);
- }
- }
- } catch (error: unknown) {
- thiz.listeners.forEach((listener) => {
- listener("fail");
- });
- if (error instanceof CancellationToken.CancellationError) {
- return;
- }
- logBugForDevelopers(error);
- (opts.onError ?? noop)(error, ...thiz.args);
- return;
- } finally {
- thiz.listeners.forEach((listener) => {
- listener("finish");
- });
- thiz.cts.running = false;
- }
- },
- onFail: opts.onFail ?? noop,
- onSuccess: opts.onSuccess ?? noop,
- // we save a copy because we don't want further
- // references to affect the original handler
- listeners: opts.listeners?.slice() ?? [],
- addListener: (h) => {
- thiz.listeners.push(h);
- return thiz;
- },
- };
- return thiz;
- };
-}
-
-function logBugForDevelopers(error: unknown) {
- console.error(
- `Internal error, this is mostly a bug in the application. Please report: `,
- error,
- );
-}
-
function describeErrorResponse(
i18n: InternationalizationAPI,
errorResponse: { code?: number; hint?: string },
@@ -525,8 +126,8 @@ function describeErrorResponse(
}
}
-function notUndefined<T>(t: T | undefined): t is T {
- return !!t;
+function notUndefined<T>(value: T | undefined): value is T {
+ return value !== undefined;
}
export function translateTalerError(
@@ -539,16 +140,11 @@ export function translateTalerError(
) {
return [
i18n.str`The request reached a timeout, check your connection.`,
- // Not every timeout is about an HTTP request.
cause.errorDetail.requestUrl
- ? i18n.str`The ${cause.errorDetail.requestMethod} request to ${
- cause.errorDetail.requestUrl
- } failed after ${(cause.errorDetail.timeoutMs ?? 0) / 1000} seconds.`
+ ? i18n.str`The ${cause.errorDetail.requestMethod} request to ${cause.errorDetail.requestUrl} failed after ${(cause.errorDetail.timeoutMs ?? 0) / 1000} seconds.`
: undefined,
cause.errorDetail.when
- ? i18n.str`The last request time is ${AbsoluteTime.stringify(
- cause.errorDetail.when,
- )}`
+ ? i18n.str`The last request time is ${AbsoluteTime.stringify(cause.errorDetail.when)}`
: undefined,
].filter(notUndefined);
}
@@ -557,9 +153,7 @@ export function translateTalerError(
i18n.str`The request was cancelled.`,
i18n.str`The ${cause.errorDetail.requestMethod} request ${cause.errorDetail.requestUrl} failed with code ${cause.errorDetail.httpStatusCode}.`,
cause.errorDetail.when
- ? i18n.str`The request was made at ${AbsoluteTime.stringify(
- cause.errorDetail.when,
- )}`
+ ? i18n.str`The request was made at ${AbsoluteTime.stringify(cause.errorDetail.when)}`
: undefined,
].filter(notUndefined);
}
@@ -568,9 +162,7 @@ export function translateTalerError(
i18n.str`Too many requests were made to the server and this action was throttled.`,
i18n.str`The request "${cause.errorDetail.requestMethod} ${cause.errorDetail.requestUrl}" failed with code ${cause.errorDetail.httpStatusCode}`,
cause.errorDetail.when
- ? i18n.str`The last request time is ${AbsoluteTime.stringify(
- cause.errorDetail.when,
- )}`
+ ? i18n.str`The last request time is ${AbsoluteTime.stringify(cause.errorDetail.when)}`
: undefined,
].filter(notUndefined);
}
@@ -579,9 +171,7 @@ export function translateTalerError(
i18n.str`The server's response was malformed.`,
i18n.str`The response to "${cause.errorDetail.requestMethod} ${cause.errorDetail.requestUrl}" failed with code ${cause.errorDetail.httpStatusCode}`,
cause.errorDetail.when
- ? i18n.str`The request was made at ${AbsoluteTime.stringify(
- cause.errorDetail.when,
- )}`
+ ? i18n.str`The request was made at ${AbsoluteTime.stringify(cause.errorDetail.when)}`
: undefined,
cause.errorDetail.contentType
? i18n.str`The content type is ${cause.errorDetail.contentType}`
@@ -599,9 +189,7 @@ export function translateTalerError(
i18n.str`Due to a network problem the request could not be finished.`,
i18n.str`The ${cause.errorDetail.requestMethod} request to ${cause.errorDetail.requestUrl} failed.`,
cause.errorDetail.when
- ? i18n.str`The request was made at ${AbsoluteTime.stringify(
- cause.errorDetail.when,
- )}`
+ ? i18n.str`The request was made at ${AbsoluteTime.stringify(cause.errorDetail.when)}`
: undefined,
].filter(notUndefined);
}
@@ -614,9 +202,7 @@ export function translateTalerError(
i18n.str`The server's response was unexpected. This means the client and the server are not in sync about the protocol.`,
i18n.str`The ${cause.errorDetail.requestMethod} request to ${cause.errorDetail.requestUrl} failed with code ${cause.errorDetail.httpStatusCode}`,
cause.errorDetail.when
- ? i18n.str`The request was made at ${AbsoluteTime.stringify(
- cause.errorDetail.when,
- )}`
+ ? i18n.str`The request was made at ${AbsoluteTime.stringify(cause.errorDetail.when)}`
: undefined,
describeErrorResponse(i18n, cause.errorDetail.errorResponse),
hint ? i18n.str`And the server says: "${hint}"` : undefined,
@@ -624,145 +210,3 @@ export function translateTalerError(
}
return [i18n.str`Unexpected error`, cause.message as TranslatedString];
}
-
-function onUnexpected(
- i18n: InternationalizationAPI,
- title: TranslatedString,
- save: (m: NotificationMessage) => void,
-): (cause: unknown, args: any[]) => void {
- return (error, args) => {
- if (error instanceof TalerError) {
- save({
- title,
- type: "error",
- description: translateTalerError(error, i18n),
- debug: {
- error,
- stack: error instanceof Error ? error.stack : undefined,
- args: sanitizeFunctionArguments(args),
- when: AbsoluteTime.now(),
- },
- when: AbsoluteTime.now(),
- });
- } else {
- const description = (
- error instanceof Error ? error.message : String(error)
- ) as TranslatedString;
-
- save({
- title,
- type: "error",
- description: [
- i18n.str`Unexpected error, this is likely a bug. Please report it.`,
- ],
- debug: {
- error: String(error),
- stack: error instanceof Error ? error.stack : undefined,
- args: sanitizeFunctionArguments(args),
- when: AbsoluteTime.now(),
- },
- when: AbsoluteTime.now(),
- });
- }
- };
-}
-
-function sanitizeFunctionArguments(args: any[]): string {
- return args
- .map((d) =>
- typeof d === "string" && d.startsWith("secret-token:")
- ? "secret-token:...redacted..."
- : typeof d === "object"
- ? JSON.stringify(d, undefined, 2)
- : d,
- )
- .join(", ");
-}
-
-/**
- * A function converted into a safe handler.
- *
- *
- */
-// export interface SafeHandler<Args extends any[], Errors> {
-// readonly args: Args | undefined;
-// readonly onStart: (() => void)[];
-// readonly onComplete: (() => void)[];
-// /**
-// * call the action with the arguments
-// */
-// call(): Promise<void>;
-// cancel(): void;
-// /**
-// * creates another handler for the same actions but different arguments
-// * @param e
-// */
-// lambda<OtherArgs extends any[]>(
-// e: (...d: OtherArgs) => Args | undefined,
-// init?: OtherArgs,
-// ): SafeHandler<OtherArgs, Errors>;
-// /**
-// * creates another handler with new arguments
-// * @param args
-// */
-// withArgs(...args: Args): SafeHandler<Args, Error>;
-
-// onSuccess: OnOperationSuccesReturnType<Errors, Args>;
-// onFail: OnOperationFailReturnType<Errors, Args>;
-// addStartListener: (h: () => void) => SafeHandler<Args, Error>;
-// addCompleteListener: (h: () => void) => SafeHandler<Args, Error>;
-// }
-
-function successWithTitle(title: TranslatedString): NotificationMessage {
- return {
- title,
- type: "info",
- when: AbsoluteTime.now(),
- };
-}
-
-function failWithTitle(
- i18n: InternationalizationAPI,
- opName: TranslatedString,
- fail: OperationFail<any>,
- description: TranslatedString,
- args: any[],
-): NotificationMessage {
- return {
- title: i18n.str`Unable to ${opName}.`,
- type: "error",
- description: [description],
- debug: {
- detail: fail.detail,
- case: fail.case,
- when: AbsoluteTime.now(),
- },
- when: AbsoluteTime.now(),
- };
-}
-
-export type OnOperationSuccesReturnType<T, K extends any[]> = (
- result: T extends OperationOk<infer B> ? B : never,
- ...args: K
-) => TranslatedString | undefined | void;
-
-export type OnOperationFailReturnType<T, K extends any[]> = (
- d:
- | (T extends OperationFail<any> ? T : never)
- | (T extends OperationAlternative<any, any> ? T : never),
- ...args: K
-) => TranslatedString | undefined;
-
-export type OnOperationUnexpectedFailReturnType = (e: unknown) => void;
-
-export type OnOperationSuccesReturnType_NG<T, K extends any[]> = (
- result: T extends OperationOk<infer B> ? B : never,
- ...args: K
-) => void;
-
-export type OnOperationFailReturnType_NG<T, K extends any[]> = (
- d:
- | (T extends OperationFail<any> ? T : never)
- | (T extends OperationAlternative<any, any> ? T : never),
- ...args: K
-) => void;
diff --git a/packages/web-util/src/hooks/useNotifiedOperation.ts b/packages/web-util/src/hooks/useNotifiedOperation.ts
@@ -0,0 +1,86 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+*/
+
+import {
+ assertUnreachable,
+ CancellationToken,
+ OperationAlternative,
+ OperationFail,
+ OperationOk,
+ OperationResult,
+} from "@gnu-taler/taler-util";
+import { useNotificationContext } from "../context/notification.js";
+import { useTranslationContext } from "../context/translation.js";
+import { AsyncAction, useAsyncAction } from "./useAsyncAction.js";
+
+export type OperationSuccess<Result> =
+ Result extends OperationOk<infer Body> ? Body : never;
+export type OperationFailure<Result> =
+ | (Result extends OperationFail<unknown> ? Result : never)
+ | (Result extends OperationAlternative<unknown, unknown> ? Result : never);
+
+export interface NotifiedOperationOptions<
+ Result extends OperationResult<unknown, unknown>,
+ Args extends unknown[],
+> {
+ onSuccess?: (
+ result: OperationSuccess<Result>,
+ ...args: Args
+ ) => void | Promise<void>;
+ onFail?: (
+ failure: OperationFailure<Result>,
+ ...args: Args
+ ) => void | Promise<void>;
+}
+
+/** Connect an OperationResult-returning action to the notification system. */
+export function useNotifiedOperation<
+ Result extends OperationResult<unknown, unknown>,
+ Args extends unknown[],
+>(
+ operation: (token: CancellationToken, ...args: Args) => Promise<Result>,
+ options: NotifiedOperationOptions<Result, Args> = {},
+): AsyncAction<Args> {
+ const { displayError } = useNotificationContext();
+ const { i18n } = useTranslationContext();
+
+ return useAsyncAction(operation, {
+ onResult: async (result, ...args) => {
+ switch (result.type) {
+ case "ok":
+ await options.onSuccess?.(
+ result.body as OperationSuccess<Result>,
+ ...args,
+ );
+ return;
+ case "fail":
+ if (options.onFail) {
+ await options.onFail(result as OperationFailure<Result>, ...args);
+ } else {
+ displayError(
+ i18n.str`The operation failed.`,
+ result,
+ i18n.str`The server rejected the operation without a specific error message.`,
+ );
+ }
+ return;
+ default:
+ assertUnreachable(result);
+ }
+ },
+ onError: (error, ..._args) => {
+ console.error("Unexpected error while running an operation", error);
+ displayError(
+ i18n.str`Unexpected error.`,
+ error,
+ i18n.str`The runtime threw an Error which was not properly handled. Please report this at https://bugs.taler.net.`,
+ );
+ },
+ });
+}