commit 4bea2e24c5a587631c4af835c0539ac2d00c52b0 parent 9739e6aa986284f5bd7147624aa5e14bbd21eab8 Author: Sebastian <sebasjm@gmail.com> Date: Tue, 21 Oct 2025 12:03:35 -0300 implemented in aml Diffstat:
27 files changed, 426 insertions(+), 452 deletions(-)
diff --git a/packages/aml-backoffice-ui/src/Routing.tsx b/packages/aml-backoffice-ui/src/Routing.tsx @@ -28,13 +28,18 @@ import { useEffect } from "preact/hooks"; import { HandleAccountNotReady } from "./components/HandleAccountNotReady.js"; import { ExchangeAmlFrame } from "./ExchangeAmlFrame.js"; import { useCurrentDecisionRequest } from "./hooks/decision-request.js"; -import { useExpireSessionAfter1hr, useOfficer } from "./hooks/officer.js"; +import { + OfficerReady, + useExpireSessionAfter1hr, + useOfficer, +} from "./hooks/officer.js"; import { AccountDetails } from "./pages/AccountDetails.js"; import { - AccountList, HomeIcon, + AccountList, + HomeIcon, PeopleIcon, SearchIcon, - TransfersIcon + TransfersIcon, } from "./pages/AccountList.js"; import { Dashboard } from "./pages/Dashboard.js"; import { DecisionWizard, WizardSteps } from "./pages/DecisionWizard.js"; @@ -51,7 +56,7 @@ export function Routing(): VNode { if (session.state === "ready") { return ( <ExchangeAmlFrame officer={session} NavigationBar={Navigation}> - <PrivateRouting /> + <PrivateRouting officer={session} /> </ExchangeAmlFrame> ); } @@ -143,7 +148,7 @@ const privatePages = { ), }; -function PrivateRouting(): VNode { +function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { const { navigateTo } = useNavigationContext(); const location = useCurrentLocation(privatePages); const [, , startNewRequest] = useCurrentDecisionRequest(); @@ -152,7 +157,7 @@ function PrivateRouting(): VNode { navigateTo(privatePages.dashboard.url({})); } }, [location]); - useExpireSessionAfter1hr() + useExpireSessionAfter1hr(); switch (location.name) { case undefined: { @@ -164,6 +169,7 @@ function PrivateRouting(): VNode { case "decide": { return ( <DecisionWizard + officer={officer} account={location.values.cid} formId={ location.params.formId ? location.params.formId[0] : undefined @@ -192,6 +198,7 @@ function PrivateRouting(): VNode { case "decideWithStep": { return ( <DecisionWizard + officer={officer} account={location.values.cid} formId={ location.params.formId ? location.params.formId[0] : undefined @@ -221,6 +228,7 @@ function PrivateRouting(): VNode { case "decideNew": { return ( <DecisionWizard + officer={officer} account={location.values.cid} newPayto={decodeCrockFromURI(location.values.payto)} formId={ @@ -252,6 +260,7 @@ function PrivateRouting(): VNode { return ( <DecisionWizard account={location.values.cid} + officer={officer} newPayto={decodeCrockFromURI(location.values.payto)} formId={ location.params.formId ? location.params.formId[0] : undefined diff --git a/packages/aml-backoffice-ui/src/components/CreateAccount.tsx b/packages/aml-backoffice-ui/src/components/CreateAccount.tsx @@ -14,18 +14,19 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { - Button, + ButtonBetter, FormDesign, FormUI, InternationalizationAPI, LocalNotificationBanner, useForm, - useLocalNotificationHandler, - useTranslationContext, + useLocalNotificationBetter, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { useOfficer } from "../hooks/officer.js"; +import { OfficerNotFound } from "../hooks/officer.js"; import { usePreferences } from "../hooks/preferences.js"; +import { HttpStatusCode, opKnownFailure } from "@gnu-taler/taler-util"; type FormType = { password: string; @@ -66,42 +67,38 @@ const createAccountForm = ( type: "secret", label: i18n.str`Repeat password`, required: true, - validator(value) { + validator(value, state) { return !value ? i18n.str`Required` - : // : state.password !== value - // ? i18n.str`doesn't match` - undefined; + : state.password !== value + ? i18n.str`doesn't match` + : undefined; }, }, ], }); -export function CreateAccount(): VNode { +export function CreateAccount({ + officer, +}: { + officer: OfficerNotFound; +}): VNode { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); - const officer = useOfficer(); - - const [notification, withErrorHandler] = useLocalNotificationHandler(); + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); const design = createAccountForm(i18n, settings.allowInsecurePassword); - const { model: handler, status } = useForm<FormType>( - design, - { - password: undefined, - repeat: undefined, - }, - // createFormValidator(i18n, settings.allowInsecurePassword), + const { model: handler, status } = useForm<FormType>(design, { + password: undefined, + repeat: undefined, + }); + + const create = safeFunctionHandler( + officer.create, + status.status === "fail" ? undefined : [status.result.password], ); - const createAccountHandler = - status.status === "fail" || officer.state !== "not-found" - ? undefined - : withErrorHandler( - async () => officer.create(status.result.password), - () => {}, - ); return ( <div class="flex min-h-full flex-col "> <LocalNotificationBanner notification={notification} /> @@ -115,14 +112,13 @@ export function CreateAccount(): VNode { <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> <FormUI design={design} model={handler} /> <div class="mt-8"> - <Button + <ButtonBetter type="submit" - disabled={!createAccountHandler} 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" - handler={createAccountHandler} + onClick={create} > <i18n.Translate>Create</i18n.Translate> - </Button> + </ButtonBetter> </div> </div> </div> diff --git a/packages/aml-backoffice-ui/src/components/HandleAccountNotReady.tsx b/packages/aml-backoffice-ui/src/components/HandleAccountNotReady.tsx @@ -25,11 +25,11 @@ export function HandleAccountNotReady({ officer: OfficerNotReady; }): VNode { if (officer.state === "not-found") { - return <CreateAccount />; + return <CreateAccount officer={officer}/>; } if (officer.state === "locked") { - return <UnlockAccount />; + return <UnlockAccount officer={officer}/>; } assertUnreachable(officer); } diff --git a/packages/aml-backoffice-ui/src/components/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/components/UnlockAccount.tsx @@ -14,17 +14,17 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { - Button, + ButtonBetter, FormDesign, InputLine, InternationalizationAPI, LocalNotificationBanner, useForm, - useLocalNotificationHandler, - useTranslationContext, + useLocalNotificationBetter, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { useOfficer } from "../hooks/officer.js"; +import { OfficerLocked } from "../hooks/officer.js"; type FormType = { password: string; @@ -44,11 +44,10 @@ const unlockAccountForm = (i18n: InternationalizationAPI): FormDesign => ({ ], }); -export function UnlockAccount(): VNode { +export function UnlockAccount({ officer }: { officer: OfficerLocked }): VNode { const { i18n } = useTranslationContext(); - const officer = useOfficer(); - const [notification, withErrorHandler] = useLocalNotificationHandler(); + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); const design = unlockAccountForm(i18n); @@ -56,21 +55,14 @@ export function UnlockAccount(): VNode { password: undefined, }); - const unlockHandler = - status.status === "fail" || officer.state !== "locked" - ? undefined - : withErrorHandler( - async () => officer.tryUnlock(status.result.password), - () => {}, - ); - - const forgetHandler = - officer.state === "not-found" - ? undefined - : withErrorHandler( - async () => officer.forget(), - () => {}, - ); + const unlock = safeFunctionHandler( + officer.tryUnlock, + status.status === "fail" ? undefined : [status.result.password], + ); + const forget = safeFunctionHandler( + async () => officer.forget(), + [], + ); return ( <div class="flex min-h-full flex-col "> @@ -101,24 +93,22 @@ export function UnlockAccount(): VNode { </div> <div class="mt-8"> - <Button + <ButtonBetter type="submit" - handler={unlockHandler} - disabled={!unlockHandler} + onClick={unlock} 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> + </ButtonBetter> </div> </div> - <Button + <ButtonBetter type="button" - handler={forgetHandler} - disabled={!forgetHandler} + onClick={forget} 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 account</i18n.Translate> - </Button> + </ButtonBetter> </div> </div> ); diff --git a/packages/aml-backoffice-ui/src/hooks/officer.ts b/packages/aml-backoffice-ui/src/hooks/officer.ts @@ -72,16 +72,16 @@ export const codecForOfficer = (): Codec<Officer> => export type OfficerState = OfficerNotReady | OfficerReady; export type OfficerNotReady = OfficerNotFound | OfficerLocked; -interface OfficerNotFound { +export interface OfficerNotFound { state: "not-found"; create: (password: string) => Promise<OperationOk<OfficerId>>; } -interface OfficerLocked { +export interface OfficerLocked { state: "locked"; forget: () => OperationOk<void>; tryUnlock: (password: string) => Promise<OperationOk<void>>; } -interface OfficerReady { +export interface OfficerReady { state: "ready"; account: OfficerAccount; forget: () => OperationOk<void>; diff --git a/packages/aml-backoffice-ui/src/pages/DecisionWizard.tsx b/packages/aml-backoffice-ui/src/pages/DecisionWizard.tsx @@ -35,6 +35,7 @@ import { Measures } from "./decision/Measures.js"; import { Properties } from "./decision/Properties.js"; import { Rules } from "./decision/Rules.js"; import { Summary } from "./decision/Summary.js"; +import { OfficerReady } from "../hooks/officer.js"; const TALER_SCREEN_ID = 113; @@ -111,11 +112,13 @@ export function DecisionWizard({ step, formId, onMove, + officer, }: { account: string; newPayto?: string; formId: string | undefined; step?: WizardSteps; + officer: OfficerReady, onMove: (n: WizardSteps | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); @@ -136,7 +139,7 @@ export function DecisionWizard({ return <Attributes formId={formId} />; case "summary": return ( - <Summary account={account} onMove={onMove} newPayto={newPayto} /> + <Summary account={account} onMove={onMove} newPayto={newPayto} officer={officer}/> ); } assertUnreachable(stepOrDefault); diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx @@ -18,11 +18,8 @@ import { AmlDecisionRequest, assertUnreachable, HttpStatusCode, - MeasureInformation, opEmptySuccess, - opFixedSuccess, parsePaytoUri, - PaytoString, stringifyPaytoUri, TalerError, TOPS_AmlEventsName, @@ -30,21 +27,23 @@ import { import { Attention, Button, + ButtonBetter, LocalNotificationBanner, useExchangeApiContext, - useLocalNotificationHandler, + useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { - CurrentMeasureTable, -} from "../../components/MeasuresTable.js"; +import { CurrentMeasureTable } from "../../components/MeasuresTable.js"; import { ShowDecisionLimitInfo } from "../../components/ShowDecisionLimitInfo.js"; import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; -import { useOfficer } from "../../hooks/officer.js"; +import { OfficerReady } from "../../hooks/officer.js"; import { useServerMeasures } from "../../hooks/server-info.js"; -import { computeMeasureInformation, UiMeasureInformation } from "../../utils/computeAvailableMesaures.js"; +import { + computeMeasureInformation, + UiMeasureInformation, +} from "../../utils/computeAvailableMesaures.js"; import { isAttributesCompleted, isEventsCompleted, @@ -66,17 +65,20 @@ export function Summary({ account, onMove, newPayto, + officer, }: { account?: string; newPayto?: string; + officer: OfficerReady; onMove: (n: WizardSteps | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); const [decision, , cleanUpDecision] = useCurrentDecisionRequest(); const measures = useServerMeasures(); - const [notification, withErrorHandler] = useLocalNotificationHandler(); - const officer = useOfficer(); - const session = officer.state === "ready" ? officer.account : undefined; + + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + + const session = officer.account; const allMeasures = computeMeasureInformation( !measures || measures instanceof TalerError || measures.type === "fail" ? undefined @@ -132,76 +134,56 @@ export function Summary({ fullPayto.params["receiver-name"] = decision.accountName; } - const submitHandler = - CANT_SUBMIT || !session - ? undefined - : withErrorHandler( - () => { - const request: Omit<AmlDecisionRequest, "officer_sig"> = { - h_payto: account, - decision_time: AbsoluteTime.toProtocolTimestamp( - AbsoluteTime.now(), - ), - justification: decision.justification!, - payto_uri: !fullPayto ? undefined : stringifyPaytoUri(fullPayto), - keep_investigating: decision.keep_investigating ?? false, - new_rules: { - expiration_time: AbsoluteTime.toProtocolTimestamp( - decision.deadline!, - ), - rules: decision.rules!, - successor_measure: decision.onExpire_measure, - custom_measures: decision.custom_measures ?? {}, - }, - attributes_expiration: decision.attributes?.expiration - ? AbsoluteTime.toProtocolTimestamp( - decision.attributes.expiration, - ) - : undefined, - events: decision.triggering_events, - attributes: decision.attributes?.data, - properties: decision.properties!, - new_measures: - !decision.new_measures || !decision.new_measures.length - ? undefined - : decision.new_measures.join(" "), - }; - return lib.exchange.makeAmlDesicion(session, request); - }, - (suc) => { - clearUp(); - }, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Forbidden: - if (session) { - return i18n.str`Wrong credentials for "${session}"`; - } else { - return i18n.str`Wrong credentials.`; - } - 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.case); - } - }, - ); + const request: Omit<AmlDecisionRequest, "officer_sig"> = { + h_payto: account!, + decision_time: AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now()), + justification: decision.justification!, + payto_uri: !fullPayto ? undefined : stringifyPaytoUri(fullPayto), + keep_investigating: decision.keep_investigating ?? false, + new_rules: { + expiration_time: AbsoluteTime.toProtocolTimestamp(decision.deadline!), + rules: decision.rules!, + successor_measure: decision.onExpire_measure, + custom_measures: decision.custom_measures ?? {}, + }, + attributes_expiration: decision.attributes?.expiration + ? AbsoluteTime.toProtocolTimestamp(decision.attributes.expiration) + : undefined, + events: decision.triggering_events, + attributes: decision.attributes?.data, + properties: decision.properties!, + new_measures: + !decision.new_measures || !decision.new_measures.length + ? undefined + : decision.new_measures.join(" "), + }; + const [submitConfirmation, setSubmitConfirmation] = useState<boolean>(false); const requiresConfirmation = MROS_REPORT_COMPLETED; - if (submitHandler && requiresConfirmation) { - const originalHandler = submitHandler.onClick!; - submitHandler.onClick = async function d() { - if (!submitConfirmation) { + const submit = safeFunctionHandler( + async () => { + if (requiresConfirmation && !submitConfirmation) { setSubmitConfirmation(true); - return; + return opEmptySuccess(); } - return originalHandler(); - }; - } + return lib.exchange.makeAmlDesicion(session, request); + }, + CANT_SUBMIT ? undefined : [], + ); + submit.onSuccess = clearUp; - const [submitConfirmation, setSubmitConfirmation] = useState<boolean>(false); + submit.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Forbidden: + return i18n.str`Wrong credentials for "${session}"`; + 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.case); + } + }; if (submitConfirmation) { return ( @@ -224,14 +206,13 @@ export function Summary({ > <i18n.Translate>I want to check first!</i18n.Translate> </button> - <Button + <ButtonBetter type="submit" - handler={submitHandler} - disabled={!submitHandler} + onClick={submit} 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> + </ButtonBetter> </div> </div> </div> @@ -365,37 +346,14 @@ export function Summary({ > <i18n.Translate>Clear</i18n.Translate> </button> - <Button + <ButtonBetter type="submit" - handler={submitHandler} - disabled={!submitHandler} + onClick={submit} 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> + </ButtonBetter> </div> </Fragment> ); } -/** - * FIXME: this should be removed when the server allows null programs - * or we define a no-operation program - * - * https://bugs.gnunet.org/view.php?id=9810 - * https://bugs.gnunet.org/view.php?id=9874 - * - * @param measures - * @returns -// */ -// function workaround_defaultProgramName( -// measures: Record<string, MeasureInformation>, -// ) { -// const ms = Object.keys(measures); -// for (const name of ms) { -// const m = measures[name]; -// if (!m.prog_name) { -// measures[name].prog_name = "preserve-investigate"; -// } -// } -// return measures; -// } diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx @@ -16,14 +16,14 @@ import { LocalNotificationBanner, - makeSafeCall, + safeFunctionHandler, urlPattern, useBankCoreApiContext, useChallengeHandler, useCurrentLocation, useLocalNotificationBetter, useNavigationContext, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -34,7 +34,7 @@ import { TalerErrorCode, TokenRequest, assertUnreachable, - createRFC8959AccessTokenEncoded + createRFC8959AccessTokenEncoded, } from "@gnu-taler/taler-util"; import { useEffect } from "preact/hooks"; import { @@ -118,7 +118,9 @@ function PublicRounting({ const { navigateTo } = useNavigationContext(); const { config, lib } = useBankCoreApiContext(); - const [notification, notifyOnError] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = + useLocalNotificationBetter(); + const mfa = useChallengeHandler(); useEffect(() => { @@ -133,44 +135,47 @@ function PublicRounting({ refreshable: true, } as TokenRequest; - const [doAutomaticLogin, repeatLogin, lastCallingArgs] = mfa.withMfaHandler( - ({ ids: challengeIds, onChallengeRequired }) => - makeSafeCall( - i18n, - (username: string, password: string) => - lib.bank.createAccessToken( - username, - { type: "basic", password }, - tokenRequest, - { challengeIds }, - ), - (success, username) => { - onLoggedUser( - username, - createRFC8959AccessTokenEncoded(success.body.access_token), - AbsoluteTime.fromProtocolTimestamp(success.body.expiration), - ); - }, - (fail, username) => { - switch (fail.case) { - case HttpStatusCode.Accepted: { - onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; - } - case HttpStatusCode.Unauthorized: - return i18n.str`Wrong credentials for "${username}"`; - case TalerErrorCode.GENERIC_FORBIDDEN: - return i18n.str`You have no permission to this account.`; - case TalerErrorCode.BANK_ACCOUNT_LOCKED: - return i18n.str`This account is locked. If you have a active session you can change the password or contact the administrator.`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - } - }, + const login = safeFunctionHandler( + (username: string, password: string, challengeIds: string[]) => + lib.bank.createAccessToken( + username, + { type: "basic", password }, + tokenRequest, + { challengeIds }, ), ); - if (mfa.pendingChallenge && repeatLogin) { + login.onSuccess = (success, username) => + onLoggedUser( + username, + createRFC8959AccessTokenEncoded(success.body.access_token), + AbsoluteTime.fromProtocolTimestamp(success.body.expiration), + ); + + login.onUnexpectedFailure = defaultUnexpectedFailureMessages; + + login.onFail = saveNotification((fail, username) => { + switch (fail.case) { + case HttpStatusCode.Accepted: { + mfa.onChallengeRequired(fail.body); + return i18n.str`A second factor authentication is required.`; + } + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials for "${username}"`; + case TalerErrorCode.GENERIC_FORBIDDEN: + return i18n.str`You have no permission to this account.`; + case TalerErrorCode.BANK_ACCOUNT_LOCKED: + return i18n.str`This account is locked. If you have a active session you can change the password or contact the administrator.`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + } + }); + + const repeatLogin = login.lambda((ids: string[]) => { + return [login.args![0], login.args![1], ids]; + }); + + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} diff --git a/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx b/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx @@ -136,7 +136,8 @@ function Form({ lib: { bank }, config, } = useBankCoreApiContext(); - const [notification, notify, handleError] = useLocalNotification(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); +; const [section, setSection] = useState< "detail" | "cashout" | "cashin" | "users" | "test" | "delete" @@ -777,7 +778,8 @@ export function createFormValidator( function TestConversionClass({ classId }: { classId: number }): VNode { const { i18n } = useTranslationContext(); - const [notification, notify, handleError] = useLocalNotification(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); +; const result = useConversionInfo(); const info = diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx @@ -82,7 +82,8 @@ export function LoginForm({ const { lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, notifyOnError] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); const { config } = useBankCoreApiContext(); diff --git a/packages/bank-ui/src/pages/NewConversionRateClass.tsx b/packages/bank-ui/src/pages/NewConversionRateClass.tsx @@ -1,19 +1,21 @@ import { + assertUnreachable, + HttpStatusCode, + TalerCorebankApi, + TalerErrorCode, +} from "@gnu-taler/taler-util"; +import { LocalNotificationBanner, notifyInfo, RouteDefinition, useBankCoreApiContext, - useLocalNotification, + useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; import { useSessionState } from "../hooks/session.js"; import { ConversionRateClassForm } from "./admin/ConversionRateClassForm.js"; -import { useState } from "preact/hooks"; -import { TalerCorebankApi } from "@gnu-taler/taler-util"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; -import { assertUnreachable } from "@gnu-taler/taler-util"; -import { TalerErrorCode } from "@gnu-taler/taler-util"; interface Props { routeCancel: RouteDefinition; @@ -31,8 +33,9 @@ export function NewConversionRateClass({ lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, notify, handleError] = useLocalNotification(); - + const [notification, saveNotification, defaultUnexpectedFailureMessages] = + useLocalNotificationBetter(); + const [submitData, setSubmitData] = useState< TalerCorebankApi.ConversionRateClassInput | undefined >(); diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx @@ -69,7 +69,8 @@ export function NeedConfirmationView({ }: State.NeedConfirmation) { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); - const [notification, notifyOnError] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); + const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const mfa = useChallengeHandler(); @@ -518,7 +519,8 @@ export function ReadyView({ }: State.Ready): VNode { const { i18n } = useTranslationContext(); const walletInegrationApi = useTalerWalletIntegrationAPI(); - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); + const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const { diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -107,7 +107,8 @@ export function PaytoWireTransferForm({ const parsedAmount = Amounts.parse( `${limitWithFee.currency}:${trimmedAmountStr}`, ); - const [notification, notifyOnError] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); const paytoType = diff --git a/packages/bank-ui/src/pages/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx @@ -76,7 +76,8 @@ function RegistrationForm({ // const [phone, setPhone] = useState<string | undefined>(); // const [email, setEmail] = useState<string | undefined>(); const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); - const [notification, , handleError] = useLocalNotification(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); +; const settings = useSettingsContext(); const [pref] = usePreferences(); @@ -93,16 +94,6 @@ function RegistrationForm({ : !USERNAME_REGEX.test(username) ? i18n.str`Use letters, numbers or any of these characters: - . _ ~` : undefined, - // phone: !phone - // ? undefined - // : !PHONE_REGEX.test(phone) - // ? i18n.str`Use letters and numbers only, and start with a lowercase letter` - // : undefined, - // email: !email - // ? undefined - // : !EMAIL_REGEX.test(email) - // ? i18n.str`Use letters and numbers only, and start with a lowercase letter` - // : undefined, password: !password ? i18n.str`Missing password` : pref.allowsSimplePassword && password.length < 8 diff --git a/packages/bank-ui/src/pages/SolveMFA.tsx b/packages/bank-ui/src/pages/SolveMFA.tsx @@ -51,7 +51,8 @@ function SolveChallenge({ const { lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, notifyOnError] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = + useLocalNotificationBetter(); const [showExpired, setExpired] = useState( expiration !== undefined && AbsoluteTime.isExpired(expiration), @@ -73,15 +74,13 @@ function SolveChallenge({ }; }, []); - const doVerification = notifyOnError( - safeFunctionHandler( - (tan: string) => - api.confirmChallenge(username, challenge.challenge_id, { tan }), - !errors ? [tanCode!] : undefined, - ), + const doVerification = safeFunctionHandler( + (tan: string) => + api.confirmChallenge(username, challenge.challenge_id, { tan }), + !errors ? [tanCode!] : undefined, ); - - doVerification.onFail = (resp) => { + doVerification.onUnexpectedFailure = defaultUnexpectedFailureMessages; + doVerification.onFail = saveNotification((resp) => { switch (resp.case) { case TalerErrorCode.BANK_TRANSACTION_NOT_FOUND: return i18n.str`Unknown challenge.`; @@ -94,7 +93,7 @@ function SolveChallenge({ case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: return i18n.str`Expired challenge.`; } - }; + }); doVerification.onSuccess = onSolved; return ( @@ -234,7 +233,9 @@ export function SolveMFAChallenges({ ch: Challenge; expiration: AbsoluteTime; }>(); - const [notification, notifyOnError] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = + useLocalNotificationBetter(); + const { lib: { bank: api }, } = useBankCoreApiContext(); @@ -269,11 +270,10 @@ export function SolveMFAChallenges({ ? currentSolved.length === currentChallenge.challenges.length : currentSolved.length > 0; - const sendMessage = notifyOnError( - safeFunctionHandler((ch: Challenge) => - api.sendChallenge(username, ch.challenge_id), - ), + const sendMessage = safeFunctionHandler((ch: Challenge) => + api.sendChallenge(username, ch.challenge_id), ); + sendMessage.onUnexpectedFailure = defaultUnexpectedFailureMessages; sendMessage.onSuccess = (success, ch) => { if (success.body.earliest_retransmission) { setRetransmission({ @@ -291,7 +291,7 @@ export function SolveMFAChallenges({ }); }; - sendMessage.onFail = (fail) => { + sendMessage.onFail = saveNotification((fail) => { switch (fail.case) { case HttpStatusCode.Unauthorized: return i18n.str`Failed to send the verification code.`; @@ -304,10 +304,19 @@ export function SolveMFAChallenges({ case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: return i18n.str`Code transmission failed.`; } - }; + }); - const doComplete = notifyOnError(onCompleted.withArgs(solved)); + const doComplete = onCompleted.withArgs(solved); + const selectChallenge = safeFunctionHandler(async (ch: Challenge) => { + setSelected({ + ch, + expiration: AbsoluteTime.never(), + }); + return opEmptySuccess(); + }); + selectChallenge.onUnexpectedFailure = defaultUnexpectedFailureMessages; + return ( <Fragment> <LocalNotificationBanner notification={notification} /> @@ -372,18 +381,9 @@ export function SolveMFAChallenges({ hasSolvedEnough || solved.indexOf(challenge.challenge_id) !== -1; - const doSelect = notifyOnError( - safeFunctionHandler( - async () => { - setSelected({ - ch: challenge, - expiration: AbsoluteTime.never(), - }); - return opEmptySuccess(); - }, - noNeedToComplete ? undefined : [], - ), - ); + const doSelect = noNeedToComplete + ? selectChallenge + : selectChallenge.withArgs(challenge); const doSend = alreadySent || noNeedToComplete diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx @@ -126,7 +126,8 @@ function OldWithdrawalForm({ const [amountStr, setAmountStr] = useState<string | undefined>( `${settings.defaultSuggestedAmount ?? 1}`, ); - const [notification, notify, handleError] = useLocalNotification(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); +; const trimmedAmountStr = amountStr?.trim(); diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -85,7 +85,8 @@ export function ShowAccountDetails({ const [submitAccount, setSubmitAccount] = useState< TalerCorebankApi.AccountReconfiguration | undefined >(); - const [notification, notifyOnError] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); const result = useAccountDetails(account); diff --git a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -13,14 +13,14 @@ 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 { HttpStatusCode, TalerErrorCode } from "@gnu-taler/taler-util"; +import { AccessToken, TalerCorebankApi } from "@gnu-taler/taler-util"; import { ButtonBetter, LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, - makeSafeCall, notifyInfo, + safeFunctionHandler, useBankCoreApiContext, useChallengeHandler, useLocalNotificationBetter, @@ -33,6 +33,8 @@ import { undefinedIfEmpty } from "../../utils.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; import { SolveMFAChallenges } from "../SolveMFA.js"; +import { TalerErrorCode } from "@gnu-taler/taler-util"; +import { HttpStatusCode } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 119; @@ -89,63 +91,71 @@ export function UpdateAccountPassword({ ? i18n.str`Repeated password doesn't match` : undefined, }); - const [notification, notifyOnError] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = + useLocalNotificationBetter(); + const mfa = useChallengeHandler(); - const request = !password - ? undefined - : { - old_password: current, - new_password: password, - }; + const update = safeFunctionHandler( + ( + token: AccessToken, + request: TalerCorebankApi.AccountPasswordChange, + challengeIds: string[], + ) => + api.updatePassword({ username: accountName, token }, request, { + challengeIds, + }), + !password || !token + ? undefined + : [ + token, + { + old_password: current, + new_password: password, + }, + [], + ], + ); - const [doUpdatePassword, repeatUpdatePassword] = - !token || !request || !!errors - ? [undefined, undefined] - : mfa.withMfaHandler(({ ids: challengeIds, onChallengeRequired }) => - makeSafeCall( - i18n, - () => - api.updatePassword({ username: accountName, token }, request, { - challengeIds, - }), - (success) => { - notifyInfo(i18n.str`Password changed`); - onUpdateSuccess(); - }, - (fail) => { - 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: { - onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; - } - 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.`; - } - }, - ), - ); + update.onUnexpectedFailure = defaultUnexpectedFailureMessages; + update.onSuccess = (success) => { + notifyInfo(i18n.str`Password changed`); + onUpdateSuccess(); + }; + update.onFail = saveNotification((fail) => { + 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.onChallengeRequired(fail.body); + return i18n.str`A second factor authentication is required.`; + } + 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.`; + } + }); + const repeatUpdate = update.lambda((ids: string[]) => { + return [update.args![0], update.args![1], ids]; + }); - if (mfa.pendingChallenge && repeatUpdatePassword) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} description={i18n.str`Update account password.`} username={accountName} onCancel={mfa.doCancelChallenge} - onCompleted={repeatUpdatePassword} + onCompleted={repeatUpdate} /> ); } @@ -292,9 +302,7 @@ export function UpdateAccountPassword({ type="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={ - !doUpdatePassword ? undefined : notifyOnError(doUpdatePassword) - } + onClick={update} > <i18n.Translate>Change</i18n.Translate> </ButtonBetter> diff --git a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx @@ -55,7 +55,8 @@ export function CreateNewAccount({ const [submitAccount, setSubmitAccount] = useState< TalerCorebankApi.RegisterAccountRequest | undefined >(); - const [notification, notify, handleError] = useLocalNotification(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); +; async function doCreate() { if (!submitAccount || !token) return; diff --git a/packages/bank-ui/src/pages/admin/DownloadStats.tsx b/packages/bank-ui/src/pages/admin/DownloadStats.tsx @@ -77,7 +77,8 @@ export function DownloadStats({ routeCancel }: Props): VNode { const [lastStep, setLastStep] = useState<{ step: number; total: number }>(); const [downloaded, setDownloaded] = useState<string>(); const referenceDates = [new Date()]; - const [notification, , handleError] = useLocalNotification(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); +; if (!creds) { return <div>only admin can download stats</div>; diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx @@ -69,7 +69,8 @@ export function RemoveAccount({ const { lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, notifyOnError] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); if (!result) { diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx @@ -128,7 +128,8 @@ function useComponentState({ lib: { conversion }, } = useBankCoreApiContext(); - const [notification, notify, handleError] = useLocalNotification(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); +; const initalState: FormValues<FormType> = { amount: "100", diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -223,7 +223,8 @@ function CreateCashoutInternal({ estimateByDebit: calculateFromDebit, } = useCashoutEstimatorByUser(accountData.name); const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); - const [notification, notifyOnError] = useLocalNotificationBetter(); + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); const { i18n } = useTranslationContext(); const { diff --git a/packages/taler-util/src/i18n.ts b/packages/taler-util/src/i18n.ts @@ -30,6 +30,7 @@ export function internalSetStrings(langStrings: any): void { declare const __translated: unique symbol; export type TranslatedString = string & { [__translated]: true }; +export type ToTranslateString = string & { [__translated]: true }; /** * Convert template strings to a msgid diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx @@ -70,7 +70,7 @@ export function Button({ type PropsBetter = Omit<HTMLAttributes<HTMLButtonElement>, "onClick"> & { - onClick: SafeHandlerTemplate<any, void, any> | undefined + onClick: SafeHandlerTemplate<any, any> | undefined } /** * FIXME: removed deprecated and change for this one @@ -87,7 +87,7 @@ export function ButtonBetter({ return ( <button {...rest} - disabled={disabled || running || !onClick} + disabled={disabled || running || !onClick || !onClick.args} onClick={(e) => { e.preventDefault(); if (!onClick || !onClick.args) { diff --git a/packages/web-util/src/hooks/useChallenge.ts b/packages/web-util/src/hooks/useChallenge.ts @@ -74,20 +74,19 @@ type CallbackFactory<T extends any[], R> = ( * @returns */ export function useChallengeHandler<T>(): MfaState<T> { - const [current, setCurrent] = useState<{challenge: ChallengeResponse, action: SafeHandlerTemplate<any, void, T>}>(); + const [current, setCurrent] = useState<ChallengeResponse>(); function reset() { setCurrent(undefined); } - function onChallengeRequired(c: ChallengeResponse, action: SafeHandlerTemplate<any, void, T>) { - setCurrent({challenge: c, action}) + function onChallengeRequired(challenge: ChallengeResponse) { + setCurrent(challenge) } return { doCancelChallenge: reset, onChallengeRequired, - pendingChallenge: current?.challenge, - repeatAction, + pendingChallenge: current, }; } diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts @@ -207,12 +207,11 @@ type ReplaceReturnType<T extends (...a: any) => any, TNewReturn> = ( */ export function useLocalNotificationBetter(): [ Notification | undefined, - <T extends OnOperationFailReturnType<any, any>>( - f: ReplaceReturnType<T, TranslatedString>, - ) => T, - () => (cause: unknown) => void, + <Args extends any[], R extends OperationResult<any, any>>( + doAction: (...args: Args) => Promise<R>, + args?: Args, + ) => SafeHandlerTemplate<Args, R>, ] { - const { i18n } = useTranslationContext(); const [value, save] = useState<NotificationMessage>(); const notif = !value ? undefined @@ -223,49 +222,107 @@ export function useLocalNotificationBetter(): [ }, }; - function wrap<T extends OnOperationFailReturnType<any, any>>( - fn: ReplaceReturnType<T, TranslatedString>, - ): T { - const asd = (...args: Parameters<T>) => { - const errors = fn(...args); - if (errors) { - save(failWithTitle(args[0], errors)); - } - return errors; + // FIXME: we should move this outside of logic + const { i18n } = useTranslationContext(); + + function safeFunctionHandler< + Args extends any[], + R extends OperationResult<any, any>, + >( + doAction: (...args: Args) => Promise<R>, + args?: Args, + ): SafeHandlerTemplate<Args, R> { + const handler: SafeHandlerTemplate<Args, R> = { + args, + withArgs(...newArgs) { + return { + ...handler, + args: newArgs, + }; + }, + lambda(converter) { + type D = Parameters<typeof converter>; + type R = SafeHandlerTemplate<D, Error>; + const r = { + ...handler, + args: undefined, + withArgs(...args: D) { + const d = converter(...args); + const e = handler.withArgs(...d); + return e; + }, + }; + return r as any as R; + }, + call: async (): Promise<void> => { + if (!handler.args) return; + try { + const resp = await doAction(...handler.args); + switch (resp.type) { + case "ok": { + const msg = handler.onSuccess(resp as any, ...handler.args); + if (msg) { + save(successWithTitle(msg)); + } + return; + } + case "fail": { + const error = handler.onFail(resp as any, ...handler.args); + if (error) { + save(failWithTitle(resp, error)); + } + return; + } + default: { + assertUnreachable(resp); + } + } + } catch (error: unknown) { + // This functions should not throw, this is a problem. + console.error(`Error: `, error); + onUnexpected(i18n, save)(error); + return; + } + }, + onFail: (fail, ...rest) => i18n.str`Unhandled failure, please report. Code ${(fail.case)}`, + onSuccess: () => undefined, }; - return asd as any; + return handler; } - function onUnexpected(): (cause: unknown) => void { - return (error) => { - if (error instanceof TalerError) { - save({ - title: translateTalerError(error, i18n), - type: "error", - description: - error && error.errorDetail.hint - ? (error.errorDetail.hint as TranslatedString) - : undefined, - debug: error, - when: AbsoluteTime.now(), - }); - } else { - const description = ( - error instanceof Error ? error.message : String(error) - ) as TranslatedString; + return [notif, safeFunctionHandler]; +} - save({ - title: i18n.str`Operation failed`, - type: "error", - description, - debug: error, - when: AbsoluteTime.now(), - }); - } - }; - } +function onUnexpected( + i18n: InternationalizationAPI, + save: (m: NotificationMessage) => void, +): (cause: unknown) => void { + return (error) => { + if (error instanceof TalerError) { + save({ + title: translateTalerError(error, i18n), + type: "error", + description: + error && error.errorDetail.hint + ? (error.errorDetail.hint as TranslatedString) + : undefined, + debug: error, + when: AbsoluteTime.now(), + }); + } else { + const description = ( + error instanceof Error ? error.message : String(error) + ) as TranslatedString; - return [notif, wrap, onUnexpected]; + save({ + title: i18n.str`Operation failed`, + type: "error", + description, + debug: error, + when: AbsoluteTime.now(), + }); + } + }; } /** @@ -294,10 +351,17 @@ export interface SafeHandlerTemplate<Args extends any[], Errors> { onSuccess: OnOperationSuccesReturnType<Errors, Args>; onFail: OnOperationFailReturnType<Errors, Args>; - onUnexpectedFailure: OnOperationUnexpectedFailReturnType; } -export function failWithTitle( +function successWithTitle(title: TranslatedString): NotificationMessage { + return { + title, + type: "info", + when: AbsoluteTime.now(), + }; +} + +function failWithTitle( fail: OperationFail<any>, title: TranslatedString, ): NotificationMessage { @@ -310,83 +374,17 @@ export function failWithTitle( when: AbsoluteTime.now(), }; } -/** - * Convert an function that return an operation into a function that return - * a notification if it fail. - * - * @returns - */ -export function safeFunctionHandler< - Args extends any[], - R extends OperationResult<any, any>, ->( - doAction: (...args: Args) => Promise<R>, - args?: Args, -): SafeHandlerTemplate<Args, R> { - const handler: SafeHandlerTemplate<Args, R> = { - args, - withArgs(...newArgs) { - return { - ...handler, - args: newArgs, - }; - }, - lambda(converter) { - type D = Parameters<typeof converter>; - type R = SafeHandlerTemplate<D, Error>; - const r = { - ...handler, - args: undefined, - withArgs(...args: D) { - const d = converter(...args); - const e = handler.withArgs(...d); - return e; - }, - }; - return r as any as R; - }, - call: async (): Promise<void> => { - if (!handler.args) return; - try { - const resp = await doAction(...handler.args); - switch (resp.type) { - case "ok": { - handler.onSuccess(resp as any, ...handler.args); - return; - } - case "fail": { - handler.onFail(resp as any, ...handler.args); - return; - } - default: { - assertUnreachable(resp); - } - } - } catch (error: unknown) { - // This functions should not throw, this is a problem. - console.error(`Error: `, error); - handler.onUnexpectedFailure(error); - return; - } - }, - onFail: () => undefined, - onSuccess: () => {}, - onUnexpectedFailure: () => - "<unhandled unexpected failure>" as TranslatedString, - }; - return handler; -} export type OnOperationSuccesReturnType<T, K extends any[]> = ( result: T extends OperationOk<any> ? T : never, ...args: K -) => void; +) => 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 -) => void; +) => TranslatedString | undefined; export type OnOperationUnexpectedFailReturnType = (e: unknown) => void;