taler-typescript-core

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

commit 01ae524ead2264774d877b135a581681651a74f5
parent d5d3fe085550aec1f8f62bb4e3c636be85370ae2
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Mon,  1 Jun 2026 11:13:42 -0300

several fixes and improvements

better error reporting (user can copy the content)
normalize safeHandler and MFA
simplify button
only one notificaton handler
prevent printing sensitive info

Diffstat:
Mpackages/aml-backoffice-ui/src/App.tsx | 81+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx | 64+++++++++++++++++++++++++++++++++++++++-------------------------
Mpackages/aml-backoffice-ui/src/components/CreateSession.tsx | 23++++++++++-------------
Mpackages/aml-backoffice-ui/src/components/MeasureList.tsx | 79++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mpackages/aml-backoffice-ui/src/components/MeasuresTable.tsx | 4----
Mpackages/aml-backoffice-ui/src/components/UnlockSession.tsx | 45++++++++++++++++++++-------------------------
Mpackages/aml-backoffice-ui/src/hooks/officer.ts | 17+++++++++--------
Mpackages/aml-backoffice-ui/src/hooks/preferences.ts | 6------
Mpackages/aml-backoffice-ui/src/pages/AccountDetails.tsx | 76++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/aml-backoffice-ui/src/pages/AccountList.tsx | 213+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/aml-backoffice-ui/src/pages/Dashboard.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/Search.tsx | 81+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx | 55++++++++++++++++++++++++++-----------------------------
Mpackages/aml-backoffice-ui/src/pages/Transfers.tsx | 81+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Summary.tsx | 28+++++++++++++---------------
15 files changed, 445 insertions(+), 410 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/App.tsx b/packages/aml-backoffice-ui/src/App.tsx @@ -23,6 +23,7 @@ import { BrowserHashNavigationProvider, ExchangeApiProvider, Loading, + NotificationProvider, TranslationProvider, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; @@ -56,48 +57,50 @@ export function App(): VNode { return ( <UiSettingsProvider value={settings}> <TranslationProvider source={strings}> - <ExchangeApiProvider - baseUrl={new URL("/", baseUrl)} - frameOnError={ExchangeAmlFrame} - evictors={{ - exchange: evictExchangeSwrCache, - }} - preventCompression={preventCompression} - > - <SWRConfig - value={{ - provider: WITH_LOCAL_STORAGE_CACHE - ? localStorageProvider - : undefined, - // normally, do not revalidate - revalidateOnFocus: false, - revalidateOnReconnect: false, - revalidateIfStale: false, - revalidateOnMount: undefined, - focusThrottleInterval: undefined, + <NotificationProvider> + <ExchangeApiProvider + baseUrl={new URL("/", baseUrl)} + frameOnError={ExchangeAmlFrame} + evictors={{ + exchange: evictExchangeSwrCache, + }} + preventCompression={preventCompression} + > + <SWRConfig + value={{ + provider: WITH_LOCAL_STORAGE_CACHE + ? localStorageProvider + : undefined, + // normally, do not revalidate + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + revalidateOnMount: undefined, + focusThrottleInterval: undefined, - // normally, do not refresh - refreshInterval: undefined, - dedupingInterval: 2000, - refreshWhenHidden: false, - refreshWhenOffline: false, + // normally, do not refresh + refreshInterval: undefined, + dedupingInterval: 2000, + refreshWhenHidden: false, + refreshWhenOffline: false, - // ignore errors - shouldRetryOnError: false, - errorRetryCount: 0, - errorRetryInterval: undefined, + // ignore errors + shouldRetryOnError: false, + errorRetryCount: 0, + errorRetryInterval: undefined, - // do not go to loading again if already has data - keepPreviousData: true, - }} - > - <BrowserHashNavigationProvider> - <UiFormsProvider> - <Routing /> - </UiFormsProvider> - </BrowserHashNavigationProvider> - </SWRConfig> - </ExchangeApiProvider> + // do not go to loading again if already has data + keepPreviousData: true, + }} + > + <BrowserHashNavigationProvider> + <UiFormsProvider> + <Routing /> + </UiFormsProvider> + </BrowserHashNavigationProvider> + </SWRConfig> + </ExchangeApiProvider> + </NotificationProvider> </TranslationProvider> </UiSettingsProvider> ); diff --git a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx @@ -13,17 +13,15 @@ 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 { TranslatedString } from "@gnu-taler/taler-util"; import { Footer, Header, ToastBanner, - notifyError, - notifyException, + useCommonPreferences, + useRenderErrorReport, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, VNode, h } from "preact"; -import { useEffect, useErrorBoundary } from "preact/hooks"; import { useUiSettingsContext } from "./context/ui-settings.js"; import { OfficerState } from "./hooks/officer.js"; import { @@ -37,25 +35,6 @@ const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; const TALER_SCREEN_ID = 101; -function useErrorReport() { - const { i18n } = useTranslationContext(); - const [error] = useErrorBoundary(); - - useEffect(() => { - if (error) { - if (error instanceof Error) { - notifyException(i18n.str`Internal error, please report.`, error); - } else { - notifyError( - i18n.str`Internal error, please report.`, - String(error) as TranslatedString, - ); - } - console.log(error); - } - }, [error]); -} - export function ExchangeAmlFrame({ children, officer, @@ -67,7 +46,11 @@ export function ExchangeAmlFrame({ }): VNode { const { i18n } = useTranslationContext(); - useErrorReport(); + const [{ showDebugInfo }, update] = useCommonPreferences(); + useRenderErrorReport({ + hash: __GIT_HASH__, + version: __VERSION__, + }); const [preferences, updatePreferences] = usePreferences(); const settings = useUiSettingsContext(); @@ -127,6 +110,37 @@ export function ExchangeAmlFrame({ </li> ); })} + <li class="mt-2 pl-2"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Show debug information</i18n.Translate> + </span> + </span> + <button + type="button" + name={`debug switch`} + data-enabled={showDebugInfo} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + update("showDebugInfo", !showDebugInfo); + }} + > + <span + aria-hidden="true" + data-enabled={showDebugInfo} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </li> </ul> </li> </Header> @@ -134,7 +148,7 @@ export function ExchangeAmlFrame({ <div class="fixed z-20 w-full"> <div class="mx-auto w-4/5"> - <ToastBanner debug={preferences.showDebugInfo} /> + <ToastBanner /> </div> </div> diff --git a/packages/aml-backoffice-ui/src/components/CreateSession.tsx b/packages/aml-backoffice-ui/src/components/CreateSession.tsx @@ -14,18 +14,18 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { - ButtonBetter, + Button, FormDesign, FormUI, InternationalizationAPI, - LocalNotificationBanner, useForm, - useLocalNotificationBetter, + useNotificationContext, 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"; type FormType = { password: string; @@ -84,7 +84,7 @@ export function CreateSession({ }): VNode { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const design = createAccountForm(i18n, settings.allowInsecurePassword); @@ -93,16 +93,13 @@ export function CreateSession({ repeat: undefined, }); - const create = safeFunctionHandler( - i18n.str`create session`, - officer.create, - status.status === "fail" ? undefined : [status.result.password], + const create = actionHandler( + (ct, pw) => officer.create(pw), + status.status === "fail" ? undefined : [asPassword(status.result.password)], ); return ( <div class="flex min-h-full flex-col "> - <LocalNotificationBanner notification={notification} /> - <div class="sm:mx-auto sm:w-full sm:max-w-md"> <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900"> <i18n.Translate>Create session</i18n.Translate> @@ -110,15 +107,15 @@ export function CreateSession({ </div> <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> - <FormUI design={design} model={handler} /> + <FormUI design={design} model={handler} focus /> <div class="mt-8"> - <ButtonBetter + <Button 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} > <i18n.Translate>Create</i18n.Translate> - </ButtonBetter> + </Button> </div> </div> </div> diff --git a/packages/aml-backoffice-ui/src/components/MeasureList.tsx b/packages/aml-backoffice-ui/src/components/MeasureList.tsx @@ -21,6 +21,7 @@ import { import { Attention, ErrorLoading, + FailLoading, Loading, RouteDefinition, useTranslationContext, @@ -43,50 +44,46 @@ export function MeasureList({ routeToNew }: { routeToNew: RouteDefinition }) { return <Loading />; } if (measures instanceof TalerError) { - return <ErrorLoading error={measures} />; + return <ErrorLoading title={i18n.str`Failed to load server measures.`} error={measures} />; } if (measures.type === "fail") { - switch (measures.case) { - case HttpStatusCode.Forbidden: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - This session signature is invalid, contact administrator or - create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - case HttpStatusCode.NotFound: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - The designated AML session is not known, contact administrator - or create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - case HttpStatusCode.Conflict: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - The designated AML session is not enabled, contact administrator - or create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - default: - assertUnreachable(measures); - } + return ( + <Fragment> + <Profile /> + <FailLoading + operation={measures} + title={i18n.str`Failed to load the measures`} + translate={(d) => { + switch (d.case) { + case HttpStatusCode.Forbidden: + return ( + <i18n.Translate> + This session signature is invalid, contact administrator or + create a new one. + </i18n.Translate> + ); + case HttpStatusCode.NotFound: + return ( + <i18n.Translate> + The designated AML session is not known, contact + administrator or create a new one. + </i18n.Translate> + ); + case HttpStatusCode.Conflict: + return ( + <i18n.Translate> + The designated AML session is not enabled, contact + administrator or create a new one. + </i18n.Translate> + ); + default: + assertUnreachable(d.case); + } + }} + /> + </Fragment> + ); } const ms = computeMeasureInformation(measures.body); diff --git a/packages/aml-backoffice-ui/src/components/MeasuresTable.tsx b/packages/aml-backoffice-ui/src/components/MeasuresTable.tsx @@ -13,10 +13,6 @@ 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 { - AmlProgramRequirement, - KycCheckInformation, -} from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { diff --git a/packages/aml-backoffice-ui/src/components/UnlockSession.tsx b/packages/aml-backoffice-ui/src/components/UnlockSession.tsx @@ -14,18 +14,21 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { - ButtonBetter, + asPassword, + assertUnreachable, + HttpStatusCode, +} from "@gnu-taler/taler-util"; +import { + Button, FormDesign, InputLine, InternationalizationAPI, - LocalNotificationBanner, useForm, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; +import { h, VNode } from "preact"; import { OfficerLocked } from "../hooks/officer.js"; -import { assertUnreachable, HttpStatusCode } from "@gnu-taler/taler-util"; type FormType = { password: string; @@ -48,7 +51,7 @@ const unlockAccountForm = (i18n: InternationalizationAPI): FormDesign => ({ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode { const { i18n } = useTranslationContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const design = unlockAccountForm(i18n); @@ -56,30 +59,22 @@ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode { password: undefined, }); - const unlock = safeFunctionHandler( - i18n.str`unlock session`, - officer.tryUnlock, - status.status === "fail" ? undefined : [status.result.password], + const unlock = actionHandler( + (ct, pw) => officer.tryUnlock(pw), + status.status === "fail" ? undefined : [asPassword(status.result.password)], ); - unlock.onFail = (fail) => { + unlock.onFail = showError(i18n.str`Failed to unlock the session.`, (fail) => { switch (fail.case) { case HttpStatusCode.Forbidden: - return i18n.str`Wrong password.`; - + return i18n.str`Authorization denied for this session. Contact the administrator.`; default: assertUnreachable(fail.case); } - }; - const forget = safeFunctionHandler( - i18n.str`forget session`, - async () => officer.forget(), - [], - ); + }); + const forget = actionHandler(async () => officer.forget(), []); return ( <div class="flex min-h-full flex-col "> - <LocalNotificationBanner notification={notification} /> - <div class="sm:mx-auto sm:w-full sm:max-w-md"> <h1 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900"> <i18n.Translate>Session locked</i18n.Translate> @@ -105,21 +100,21 @@ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode { </div> <div class="mt-8"> - <ButtonBetter + <Button submit 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> - </ButtonBetter> + </Button> </div> </form> - <ButtonBetter + <Button 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 session</i18n.Translate> - </ButtonBetter> + </Button> </div> </div> ); diff --git a/packages/aml-backoffice-ui/src/hooks/officer.ts b/packages/aml-backoffice-ui/src/hooks/officer.ts @@ -24,6 +24,7 @@ import { OfficerSession, OperationFail, OperationOk, + Password, buildCodecForObject, codecForAbsoluteTime, codecForString, @@ -87,13 +88,13 @@ export type OfficerState = OfficerNotReady | OfficerReady; export type OfficerNotReady = OfficerNotFound | OfficerLocked; export interface OfficerNotFound { state: "not-found"; - create: (password: string) => Promise<OperationOk<OfficerId>>; + create: (password: Password) => Promise<OperationOk<OfficerId>>; } export interface OfficerLocked { state: "locked"; forget: () => OperationOk<void>; tryUnlock: ( - password: string, + password: Password, ) => Promise<OperationOk<void> | OperationFail<HttpStatusCode.Forbidden>>; } export interface OfficerReady { @@ -123,7 +124,7 @@ export function useOfficer(): OfficerState { return { id: sessionStorage.value.id as OfficerId, - signingKey: decodeCrock(sessionStorage.value.strKey) as EddsaPrivP, + __signingKey: decodeCrock(sessionStorage.value.strKey) as EddsaPrivP, unlocked: sessionStorage.value.unlocked, }; }, [sessionStorage.value?.id, sessionStorage.value?.strKey]); @@ -138,11 +139,11 @@ export function useOfficer(): OfficerState { if (currentSession === undefined) { return { state: "not-found", - create: async (pwd: string) => { + create: async (pwd: Password) => { const resp = await api.getSeed(); const extraEntropy = resp.type === "ok" ? resp.body : new Uint8Array(); - const { id, safe, signingKey } = await createNewOfficerAccount( + const { id, safe, __signingKey } = await createNewOfficerAccount( pwd, extraEntropy, ); @@ -153,7 +154,7 @@ export function useOfficer(): OfficerState { }); // accountStorage.update({ id, signingKey }); - const strKey = encodeCrock(signingKey); + const strKey = encodeCrock(__signingKey); sessionStorage.update({ id, strKey, unlocked: AbsoluteTime.now() }); // FIXME: This is really not the right type to use here. @@ -176,13 +177,13 @@ export function useOfficer(): OfficerState { officerStorage.reset(); return opFixedSuccess(dummyHttpResponse, undefined); }, - tryUnlock: async (pwd: string) => { + tryUnlock: async (pwd: Password) => { try { const ac = await unlockOfficerAccount(currentSession, pwd); // accountStorage.update(ac); sessionStorage.update({ id: ac.id, - strKey: encodeCrock(ac.signingKey), + strKey: encodeCrock(ac.__signingKey), unlocked: AbsoluteTime.now(), }); return opFixedSuccess(dummyHttpResponse, undefined); diff --git a/packages/aml-backoffice-ui/src/hooks/preferences.ts b/packages/aml-backoffice-ui/src/hooks/preferences.ts @@ -28,7 +28,6 @@ import { } from "@gnu-taler/web-util/browser"; interface Preferences { - showDebugInfo: boolean; allowInsecurePassword: boolean; keepSessionAfterReload: boolean; testingDialect: boolean; @@ -41,7 +40,6 @@ export const codecForPreferences = (): Codec<Preferences> => "allowInsecurePassword", codecOptionalDefault(codecForBoolean(), false), ) - .property("showDebugInfo", codecOptionalDefault(codecForBoolean(), false)) .property("testingDialect", codecOptionalDefault(codecForBoolean(), false)) .property( "keepSessionAfterReload", @@ -55,7 +53,6 @@ export const codecForPreferences = (): Codec<Preferences> => const defaultPreferences: Preferences = { allowInsecurePassword: false, - showDebugInfo: false, testingDialect: false, keepSessionAfterReload: false, preventCompression: false, @@ -88,7 +85,6 @@ export function usePreferences(): [ export function getAllBooleanPreferences(): Array<keyof Preferences> { return [ - "showDebugInfo", "allowInsecurePassword", "keepSessionAfterReload", "testingDialect", @@ -103,8 +99,6 @@ export function getLabelForPreferences( i18n: ReturnType<typeof useTranslationContext>["i18n"], ): TranslatedString { switch (k) { - case "showDebugInfo": - return i18n.str`Show debug info`; case "testingDialect": return i18n.str`Use testing dialect`; case "allowInsecurePassword": diff --git a/packages/aml-backoffice-ui/src/pages/AccountDetails.tsx b/packages/aml-backoffice-ui/src/pages/AccountDetails.tsx @@ -24,14 +24,13 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, + Button, CopyButton, ErrorLoading, Loading, - LocalNotificationBanner, RouteDefinition, useExchangeApiContext, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; @@ -95,7 +94,7 @@ export function AccountDetails({ const session = officer.state === "ready" ? officer.session : undefined; const { lib } = useExchangeApiContext(); const [exported, setExported] = useState<{ content: string; file: string }>(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const measures = useServerMeasures(); @@ -103,7 +102,12 @@ export function AccountDetails({ return <Loading />; } if (details instanceof TalerError) { - return <ErrorLoading error={details} />; + return ( + <ErrorLoading + title={i18n.str`Failed to load account information.`} + error={details} + /> + ); } if (details.type === "fail") { switch (details.case) { @@ -116,7 +120,12 @@ export function AccountDetails({ } } if (history instanceof TalerError) { - return <ErrorLoading error={history} />; + return ( + <ErrorLoading + title={i18n.str`Failed to load decision history.`} + error={history} + /> + ); } if (history.type === "fail") { switch (history.case) { @@ -129,7 +138,12 @@ export function AccountDetails({ } } if (legistimizations instanceof TalerError) { - return <ErrorLoading error={legistimizations} />; + return ( + <ErrorLoading + title={i18n.str`Failed to load current legitimizations.`} + error={legistimizations} + /> + ); } const collectionEvents = details.body.details; @@ -197,11 +211,11 @@ export function AccountDetails({ const time = format(new Date(), "yyyyMMdd_HHmmss"); - const downloadPdf = safeFunctionHandler( - i18n.str`download pdf`, - lib.exchange.getAmlAttributesForAccountAsPdf.bind(lib.exchange), - session ? [session, account] : undefined, + 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( @@ -211,26 +225,28 @@ export function AccountDetails({ file: `account_${time}_${account}.pdf`, }); }; - downloadPdf.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.NoContent: - return i18n.str`The account has no KYC info.`; - 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.`; - case HttpStatusCode.NotImplemented: - return i18n.str`The server doesn't support PDF download. Contact the administrator.`; - default: - assertUnreachable(fail.case); - } - }; + 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"> - <LocalNotificationBanner notification={notification} /> <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"> <h1 class="text-base font-semibold leading-7 text-black"> <i18n.Translate>Case history for selected account</i18n.Translate> @@ -256,9 +272,9 @@ export function AccountDetails({ <div class="flex space-x-2 mb-4"> <i18n.Translate>Export as PDF</i18n.Translate> - <ButtonBetter onClick={downloadPdf}> + <Button onClick={downloadPdf}> <img class="size-6 w-6" src={pdfIcon} /> - </ButtonBetter> + </Button> </div> {!exported ? ( <div /> diff --git a/packages/aml-backoffice-ui/src/pages/AccountList.tsx b/packages/aml-backoffice-ui/src/pages/AccountList.tsx @@ -16,24 +16,23 @@ import { AbsoluteTime, CustomerAccountSummary, - Duration, HttpStatusCode, + OfficerSession, Paytos, TalerError, assertUnreachable, - opFixedSuccess, } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, + Button, ErrorLoading, + FailLoading, InputToggle, Loading, - LocalNotificationBanner, Pagination, RouteDefinition, useExchangeApiContext, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -58,7 +57,8 @@ export function AccountList({ const [opened, setOpened] = useState<boolean>(); const [highRisk, setHighRisk] = useState<boolean>(); const list = useAmlAccounts({ investigated, open: opened, highRisk }); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); + const officer = useOfficer(); const session = officer.state === "ready" ? officer.session : undefined; const { lib } = useExchangeApiContext(); @@ -67,50 +67,48 @@ export function AccountList({ return <Loading />; } if (list instanceof TalerError) { - return <ErrorLoading error={list} />; + return ( + <ErrorLoading title={i18n.str`Failed to load accounts`} error={list} /> + ); } if (list.type === "fail") { - switch (list.case) { - case HttpStatusCode.Forbidden: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - This session signature is invalid, contact administrator or - create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - case HttpStatusCode.NotFound: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - The designated AML session is not known, contact administrator - or create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - case HttpStatusCode.Conflict: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - The designated AML session is not enabled, contact administrator - or create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - default: - assertUnreachable(list); - } + return ( + <Fragment> + <Profile /> + <FailLoading + operation={list} + title={i18n.str`Failed to load the accounts`} + translate={(d) => { + switch (d.case) { + case HttpStatusCode.Forbidden: + return ( + <i18n.Translate> + This session signature is invalid, contact administrator or + create a new one. + </i18n.Translate> + ); + case HttpStatusCode.NotFound: + return ( + <i18n.Translate> + The designated AML session is not known, contact + administrator or create a new one. + </i18n.Translate> + ); + case HttpStatusCode.Conflict: + return ( + <i18n.Translate> + The designated AML session is not enabled, contact + administrator or create a new one. + </i18n.Translate> + ); + default: + assertUnreachable(d.case); + } + }} + /> + </Fragment> + ); } const records = list.body; @@ -168,56 +166,74 @@ export function AccountList({ : `not-investigated`; const time = format(new Date(), "yyyyMMdd_HHmmss"); - const downloadCsv = safeFunctionHandler( - i18n.str`download csv`, - lib.exchange.getAmlAccountsAsOtherFormat.bind(lib.exchange), - session ? [session, "text/csv"] : undefined, + + type Mime = "text/csv" | "application/vnd.ms-excel"; + const download = actionHandler((ct, s: OfficerSession, f: Mime) => + lib.exchange.getAmlAccountsAsOtherFormat(s, f), ); - downloadCsv.onFail = (fail) => { + download.onFail = showError(i18n.str`Failed to download`, (fail) => { switch (fail.case) { case HttpStatusCode.NoContent: - return i18n.str`Ther are no accounts in the resultset.`; + 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`Invalid session.`; + 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`; } - }; - const downloadXls = safeFunctionHandler( - i18n.str`download xls`, - lib.exchange.getAmlAccountsAsOtherFormat.bind(lib.exchange), - session ? [session, "application/vnd.ms-excel"] : undefined, - ); - downloadXls.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.NoContent: - return i18n.str`Ther 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`; + }); + 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); } }; - 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`, - }); - }; + // 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> - <LocalNotificationBanner notification={notification} /> <div class="sm:flex sm:items-center"> <div class="px-2 sm:flex-auto"> <h1 class="text-base font-semibold leading-6 text-gray-900"> @@ -226,15 +242,28 @@ export function AccountList({ <p class="mt-2 text-sm text-gray-700 w-80"> <i18n.Translate>{description}</i18n.Translate> </p> - <div class="flex space-x-2 mt-4"> - <i18n.Translate>Export as file</i18n.Translate> - <ButtonBetter onClick={downloadCsv}> - <img class="size-6 w-6" src={csvIcon} /> - </ButtonBetter> - <ButtonBetter onClick={downloadXls}> - <img class="size-6 w-6" src={xlsIcon} /> - </ButtonBetter> - </div> + {!records.length ? undefined : ( + <div class="flex space-x-2 mt-4"> + <i18n.Translate>Export as file</i18n.Translate> + <Button + onClick={ + !session ? undefined : download.withArgs(session, "text/csv") + } + > + <img class="size-6 w-6" src={csvIcon} /> + </Button> + <Button + onClick={ + !session + ? undefined + : download.withArgs(session, "application/vnd.ms-excel") + } + > + <img class="size-6 w-6" src={xlsIcon} /> + </Button> + </div> + )} + {!exported ? ( <div class="h-5 mb-5" /> ) : ( diff --git a/packages/aml-backoffice-ui/src/pages/Dashboard.tsx b/packages/aml-backoffice-ui/src/pages/Dashboard.tsx @@ -83,7 +83,7 @@ function EventMetrics(): VNode { return <Loading />; } if (resp instanceof TalerError) { - return <ErrorLoading error={resp} />; + return <ErrorLoading title={i18n.str`Failed to load TOPS statistics.`} error={resp} />; } return ( diff --git a/packages/aml-backoffice-ui/src/pages/Search.tsx b/packages/aml-backoffice-ui/src/pages/Search.tsx @@ -37,6 +37,7 @@ import { Attention, encodeCrockForURI, ErrorLoading, + FailLoading, FormDesign, FormUI, InternationalizationAPI, @@ -148,49 +149,47 @@ function ShowResult({ return <Loading />; } if (history instanceof TalerError) { - return <ErrorLoading error={history} />; + return ( + <ErrorLoading + title={i18n.str`Failed to load account decisions.`} + error={history} + /> + ); } if (history.type === "fail") { - switch (history.case) { - case HttpStatusCode.Forbidden: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - This session signature is invalid, contact administrator or - create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - case HttpStatusCode.NotFound: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - The designated AML session is not known, contact administrator - or create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - case HttpStatusCode.Conflict: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - The designated AML session is not enabled, contact administrator - or create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - default: - assertUnreachable(history); - } + return ( + <FailLoading + operation={history} + title={i18n.str`Failed to load the account history.`} + translate={(d) => { + switch (d.case) { + case HttpStatusCode.Forbidden: + return ( + <i18n.Translate> + This session signature is invalid, contact administrator or + create a new one. + </i18n.Translate> + ); + case HttpStatusCode.NotFound: + return ( + <i18n.Translate> + The designated AML session is not known, contact + administrator or create a new one. + </i18n.Translate> + ); + case HttpStatusCode.Conflict: + return ( + <i18n.Translate> + The designated AML session is not enabled, contact + administrator or create a new one. + </i18n.Translate> + ); + default: + assertUnreachable(d.case); + } + }} + /> + ); } if (history.body.length) { diff --git a/packages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx b/packages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx @@ -23,6 +23,7 @@ import { import { Attention, ErrorLoading, + FailLoading, FormMetadata, FormUI, Loading, @@ -56,49 +57,45 @@ export function ShowCollectedInfo({ return <Loading />; } if (details instanceof TalerError) { - return <ErrorLoading error={details} />; + return ( + <ErrorLoading + title={i18n.str`Failed to load account information.`} + error={details} + /> + ); } if (details.type === "fail") { - switch (details.case) { - case HttpStatusCode.Forbidden: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> + return <FailLoading + operation={details} + title={i18n.str`Failed to load the account information.`} + translate={(d) => { + switch (d.case) { + case HttpStatusCode.Forbidden: + return ( <i18n.Translate> This session signature is invalid, contact administrator or create a new one. </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - case HttpStatusCode.NotFound: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> + ); + case HttpStatusCode.NotFound: + return ( <i18n.Translate> The designated AML session is not known, contact administrator or create a new one. </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - case HttpStatusCode.Conflict: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> + ); + case HttpStatusCode.Conflict: + return ( <i18n.Translate> The designated AML session is not enabled, contact administrator or create a new one. </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - default: - assertUnreachable(details); - } + ); + default: + assertUnreachable(d.case); + } + }} + />; } const { details: history } = details.body; diff --git a/packages/aml-backoffice-ui/src/pages/Transfers.tsx b/packages/aml-backoffice-ui/src/pages/Transfers.tsx @@ -26,6 +26,7 @@ import { import { Attention, ErrorLoading, + FailLoading, FormDesign, FormUI, Loading, @@ -108,49 +109,47 @@ export function Transfers({ return <Loading />; } if (resp instanceof Error) { - return <ErrorLoading error={resp} />; + return ( + <ErrorLoading + title={i18n.str`Failed to load transfer list.`} + error={resp} + /> + ); } if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Forbidden: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - This session signature is invalid, contact administrator or - create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - case HttpStatusCode.NotFound: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - The designated AML session is not known, contact administrator - or create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - case HttpStatusCode.Conflict: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - The designated AML session is not enabled, contact administrator - or create a new one. - </i18n.Translate> - </Attention> - <Profile /> - </Fragment> - ); - default: - assertUnreachable(resp); - } + return ( + <FailLoading + operation={resp} + title={i18n.str`Failed to load the transfer list.`} + translate={(d) => { + switch (d.case) { + case HttpStatusCode.Forbidden: + return ( + <i18n.Translate> + This session signature is invalid, contact administrator or + create a new one. + </i18n.Translate> + ); + case HttpStatusCode.NotFound: + return ( + <i18n.Translate> + The designated AML session is not known, contact administrator + or create a new one. + </i18n.Translate> + ); + case HttpStatusCode.Conflict: + return ( + <i18n.Translate> + The designated AML session is not enabled, contact + administrator or create a new one. + </i18n.Translate> + ); + default: + assertUnreachable(d.case); + } + }} + /> + ); } const transactions = resp.body; diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx @@ -26,11 +26,10 @@ import { import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { Attention, - ButtonBetter, - LocalNotificationBanner, + Button, useExchangeApiContext, - useLocalNotificationBetter, - useTranslationContext, + useNotificationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -75,7 +74,7 @@ export function Summary({ const [decision, , cleanUpDecision] = useCurrentDecisionRequest(); const measures = useServerMeasures(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); const session = officer.session; const allMeasures = computeMeasureInformation( @@ -167,9 +166,9 @@ export function Summary({ const [submitConfirmation, setSubmitConfirmation] = useState<boolean>(false); const requiresConfirmation = MROS_REPORT_COMPLETED; - const submit = safeFunctionHandler( - i18n.str`make aml decision`, - async (req) => { + + const submit = actionHandler( + async (ct, req) => { if (requiresConfirmation && !submitConfirmation) { setSubmitConfirmation(true); // FIXME: This is not the right type to use here. @@ -181,7 +180,7 @@ export function Summary({ ); submit.onSuccess = clearUp; - submit.onFail = (fail) => { + submit.onFail = showError(i18n.str`Failed to make the decision.`, (fail) => { switch (fail.case) { case HttpStatusCode.Forbidden: return i18n.str`Invalid credentials.`; @@ -192,7 +191,7 @@ export function Summary({ default: assertUnreachable(fail.case); } - }; + }); if (submitConfirmation) { return ( @@ -215,13 +214,13 @@ export function Summary({ > <i18n.Translate>I want to check first!</i18n.Translate> </button> - <ButtonBetter + <Button submit 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> - </ButtonBetter> + </Button> </div> </div> </div> @@ -231,7 +230,6 @@ export function Summary({ return ( <Fragment> - <LocalNotificationBanner notification={notification} /> {INVALID_RULES ? ( <Fragment> {!decision.deadline && ( @@ -355,13 +353,13 @@ export function Summary({ > <i18n.Translate>Clear</i18n.Translate> </button> - <ButtonBetter + <Button submit 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> - </ButtonBetter> + </Button> </div> </Fragment> );