diff options
Diffstat (limited to 'packages/aml-backoffice-ui')
22 files changed, 665 insertions, 274 deletions
diff --git a/packages/aml-backoffice-ui/src/account.ts b/packages/aml-backoffice-ui/src/account.ts index bd3c2003e..1c8cd7f53 100644 --- a/packages/aml-backoffice-ui/src/account.ts +++ b/packages/aml-backoffice-ui/src/account.ts @@ -1,14 +1,20 @@ import { - bytesToString, + PaytoUri, + TalerSignaturePurpose, + bufferForUint32, + buildSigPS, createEddsaKeyPair, decodeCrock, decryptWithDerivedKey, eddsaGetPublic, + eddsaSign, encodeCrock, encryptWithDerivedKey, getRandomBytesF, stringToBytes, + stringifyPaytoUri, } from "@gnu-taler/taler-util"; +import { AmlExchangeBackend } from "./types.js"; export interface Account { accountId: AccountId; @@ -45,6 +51,26 @@ export async function unlockAccount( return { accountId, signingKey }; } +export function buildQuerySignature(key: SigningKey): string { + const sigBlob = buildSigPS( + TalerSignaturePurpose.TALER_SIGNATURE_AML_QUERY, + ).build(); + + return encodeCrock(eddsaSign(sigBlob, key)); +} +export function buildDecisionSignature( + key: SigningKey, + payto: PaytoUri, + state: AmlExchangeBackend.AmlState, +): string { + const sigBlob = buildSigPS(TalerSignaturePurpose.TALER_SIGNATURE_AML_DECISION) + .put(decodeCrock(stringifyPaytoUri(payto))) + .put(bufferForUint32(state)) + .build(); + + return encodeCrock(eddsaSign(sigBlob, key)); +} + declare const opaque_Account: unique symbol; export type LockedAccount = string & { [opaque_Account]: true }; diff --git a/packages/aml-backoffice-ui/src/forms/902_11e.ts b/packages/aml-backoffice-ui/src/forms/902_11e.ts index 24df6a44c..a91e7a866 100644 --- a/packages/aml-backoffice-ui/src/forms/902_11e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_11e.ts @@ -1,12 +1,7 @@ -import { - AbsoluteTime, - AmountJson, - TranslatedString, -} from "@gnu-taler/taler-util"; +import { TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { FlexibleForm } from "./index.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; -import { AmlState } from "../types.js"; +import { FlexibleForm } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; export const v1 = (current: State): FlexibleForm<Form902_11.Form> => ({ diff --git a/packages/aml-backoffice-ui/src/forms/902_12e.ts b/packages/aml-backoffice-ui/src/forms/902_12e.ts index c80539511..ea95b494b 100644 --- a/packages/aml-backoffice-ui/src/forms/902_12e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_12e.ts @@ -1,12 +1,7 @@ -import { - AbsoluteTime, - AmountJson, - TranslatedString, -} from "@gnu-taler/taler-util"; +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { FlexibleForm } from "./index.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; -import { AmlState } from "../types.js"; +import { FlexibleForm } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; export const v1 = (current: State): FlexibleForm<Form902_12.Form> => ({ diff --git a/packages/aml-backoffice-ui/src/forms/902_13e.ts b/packages/aml-backoffice-ui/src/forms/902_13e.ts index 63870f00a..666cf35d4 100644 --- a/packages/aml-backoffice-ui/src/forms/902_13e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_13e.ts @@ -1,12 +1,7 @@ -import { - AbsoluteTime, - AmountJson, - TranslatedString, -} from "@gnu-taler/taler-util"; +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { FlexibleForm } from "./index.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; -import { AmlState } from "../types.js"; +import { FlexibleForm } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; export const v1 = (current: State): FlexibleForm<Form902_13.Form> => ({ diff --git a/packages/aml-backoffice-ui/src/forms/902_15e.ts b/packages/aml-backoffice-ui/src/forms/902_15e.ts index 19a16d3f2..502cee8e5 100644 --- a/packages/aml-backoffice-ui/src/forms/902_15e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_15e.ts @@ -1,12 +1,7 @@ -import { - AbsoluteTime, - AmountJson, - TranslatedString, -} from "@gnu-taler/taler-util"; +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { FlexibleForm } from "./index.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; -import { AmlState } from "../types.js"; +import { FlexibleForm } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; export const v1 = (current: State): FlexibleForm<Form902_15.Form> => ({ diff --git a/packages/aml-backoffice-ui/src/forms/902_1e.ts b/packages/aml-backoffice-ui/src/forms/902_1e.ts index 654085443..167d1ac19 100644 --- a/packages/aml-backoffice-ui/src/forms/902_1e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_1e.ts @@ -1,14 +1,7 @@ -import { - AbsoluteTime, - AmountJson, - Amounts, - TranslatedString, -} from "@gnu-taler/taler-util"; -import { FlexibleForm, languageList } from "./index.js"; +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; -import { AmlState } from "../types.js"; -import { amlStateConverter } from "../pages/CaseDetails.js"; +import { FlexibleForm, languageList } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; export const v1 = (current: State): FlexibleForm<Form902_1.Form> => ({ diff --git a/packages/aml-backoffice-ui/src/forms/902_4e.ts b/packages/aml-backoffice-ui/src/forms/902_4e.ts index f77a2f63a..cecd74390 100644 --- a/packages/aml-backoffice-ui/src/forms/902_4e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_4e.ts @@ -1,17 +1,10 @@ -import { - AbsoluteTime, - AmountJson, - Amounts, - TranslatedString, -} from "@gnu-taler/taler-util"; -import { FormState } from "../handlers/FormProvider.js"; -import { FlexibleForm } from "./index.js"; +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { ArrowRightIcon } from "@heroicons/react/24/outline"; -import { h as create } from "preact"; import { ChevronRightIcon } from "@heroicons/react/24/solid"; +import { h as create } from "preact"; +import { FormState } from "../handlers/FormProvider.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; -import { AmlState } from "../types.js"; -import { amlStateConverter } from "../pages/CaseDetails.js"; +import { FlexibleForm } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({ diff --git a/packages/aml-backoffice-ui/src/forms/902_5e.ts b/packages/aml-backoffice-ui/src/forms/902_5e.ts index bd27b7a7f..501a3b23c 100644 --- a/packages/aml-backoffice-ui/src/forms/902_5e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_5e.ts @@ -1,12 +1,7 @@ -import { - AbsoluteTime, - AmountJson, - TranslatedString, -} from "@gnu-taler/taler-util"; +import { TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { FlexibleForm, currencyList } from "./index.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; -import { AmlState } from "../types.js"; +import { FlexibleForm, currencyList } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; export const v1 = (current: State): FlexibleForm<Form902_5.Form> => ({ diff --git a/packages/aml-backoffice-ui/src/forms/902_9e.ts b/packages/aml-backoffice-ui/src/forms/902_9e.ts index e79597bfb..04f0a1572 100644 --- a/packages/aml-backoffice-ui/src/forms/902_9e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_9e.ts @@ -1,12 +1,7 @@ -import { - AbsoluteTime, - AmountJson, - TranslatedString, -} from "@gnu-taler/taler-util"; +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { FlexibleForm } from "./index.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; -import { AmlState } from "../types.js"; +import { FlexibleForm } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; export const v1 = (current: State): FlexibleForm<Form902_9.Form> => ({ diff --git a/packages/aml-backoffice-ui/src/forms/simplest.ts b/packages/aml-backoffice-ui/src/forms/simplest.ts index 7eda03fef..023c1765f 100644 --- a/packages/aml-backoffice-ui/src/forms/simplest.ts +++ b/packages/aml-backoffice-ui/src/forms/simplest.ts @@ -5,11 +5,11 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { FlexibleForm } from "./index.js"; -import { AmlState } from "../types.js"; -import { amlStateConverter } from "../pages/CaseDetails.js"; +import { DoubleColumnFormSection } from "../handlers/forms.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; -import { DoubleColumnFormSection, UIFormField } from "../handlers/forms.js"; +import { amlStateConverter } from "../pages/CaseDetails.js"; +import { AmlExchangeBackend } from "../types.js"; +import { FlexibleForm } from "./index.js"; export const v1 = (current: State): FlexibleForm<Simplest.Form> => ({ versionId: "2023-05-25", @@ -36,7 +36,7 @@ export const v1 = (current: State): FlexibleForm<Simplest.Form> => ({ disabled: true, }, threshold: { - disabled: v.state === AmlState.frozen, + disabled: v.state === AmlExchangeBackend.AmlState.frozen, }, }; }, @@ -46,7 +46,7 @@ export namespace Simplest { export interface WithResolution { when: AbsoluteTime; threshold: AmountJson; - state: AmlState; + state: AmlExchangeBackend.AmlState; } export interface Form extends WithResolution { comment: string; @@ -77,15 +77,15 @@ export function resolutionSection(current: State): DoubleColumnFormSection { converter: amlStateConverter, choices: [ { - value: AmlState.frozen, + value: AmlExchangeBackend.AmlState.frozen, label: "Frozen" as TranslatedString, }, { - value: AmlState.pending, + value: AmlExchangeBackend.AmlState.pending, label: "Pending" as TranslatedString, }, { - value: AmlState.normal, + value: AmlExchangeBackend.AmlState.normal, label: "Normal" as TranslatedString, }, ], diff --git a/packages/aml-backoffice-ui/src/hooks/useBackend.ts b/packages/aml-backoffice-ui/src/hooks/useBackend.ts new file mode 100644 index 000000000..e68efb2e3 --- /dev/null +++ b/packages/aml-backoffice-ui/src/hooks/useBackend.ts @@ -0,0 +1,81 @@ +import { + HttpResponseOk, + RequestOptions, + useApiContext, +} from "@gnu-taler/web-util/browser"; +import { useCallback } from "preact/hooks"; +import { uiSettings } from "../settings.js"; +import { canonicalizeBaseUrl } from "@gnu-taler/taler-util"; +import { useOfficer } from "./useOfficer.js"; +import { buildQuerySignature } from "../account.js"; + +interface useBackendType { + request: <T>( + path: string, + options?: RequestOptions, + ) => Promise<HttpResponseOk<T>>; + fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; + paginatedFetcher: <T>( + args: [string, number, number, string], + ) => Promise<HttpResponseOk<T>>; +} +export function usePublicBackend(): useBackendType { + const { request: requestHandler } = useApiContext(); + + const baseUrl = getInitialBackendBaseURL(); + + const request = useCallback( + function requestImpl<T>( + path: string, + options: RequestOptions = {}, + ): Promise<HttpResponseOk<T>> { + return requestHandler<T>(baseUrl, path, options); + }, + [baseUrl], + ); + + const fetcher = useCallback( + function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> { + return requestHandler<T>(baseUrl, endpoint); + }, + [baseUrl], + ); + const paginatedFetcher = useCallback( + function fetcherImpl<T>([endpoint, page, size, talerAmlOfficerSignature]: [ + string, + number, + number, + string, + ]): Promise<HttpResponseOk<T>> { + return requestHandler<T>(baseUrl, endpoint, { + params: { page: page || 1, size }, + talerAmlOfficerSignature, + }); + }, + [baseUrl], + ); + return { + request, + fetcher, + paginatedFetcher, + }; +} + +export function getInitialBackendBaseURL(): string { + const overrideUrl = + typeof localStorage !== "undefined" + ? localStorage.getItem("exchange-aml-base-url") + : undefined; + if (!overrideUrl) { + //normal path + if (!uiSettings.backendBaseURL) { + console.error( + "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'", + ); + return canonicalizeBaseUrl(window.origin); + } + return canonicalizeBaseUrl(uiSettings.backendBaseURL); + } + // testing/development path + return canonicalizeBaseUrl(overrideUrl); +} diff --git a/packages/aml-backoffice-ui/src/hooks/useCases.ts b/packages/aml-backoffice-ui/src/hooks/useCases.ts new file mode 100644 index 000000000..04b7c383b --- /dev/null +++ b/packages/aml-backoffice-ui/src/hooks/useCases.ts @@ -0,0 +1,94 @@ +import { useEffect, useState } from "preact/hooks"; + +import { AmlExchangeBackend } from "../types.js"; +import { + HttpResponse, + HttpResponseOk, + HttpResponsePaginated, + RequestError, +} from "@gnu-taler/web-util/browser"; +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook } from "swr"; +import { usePublicBackend } from "./useBackend.js"; +import { AccountId, buildQuerySignature } from "../account.js"; +import { useOfficer } from "./useOfficer.js"; +const useSWR = _useSWR as unknown as SWRHook; + +const PAGE_SIZE = 10; +const MAX_RESULT_SIZE = PAGE_SIZE * 2 - 1; +/** + * FIXME: mutate result when balance change (transaction ) + * @param account + * @param args + * @returns + */ +export function useCases( + account: AccountId, + state: AmlExchangeBackend.AmlState, + signature: string | undefined, +): HttpResponsePaginated< + AmlExchangeBackend.AmlRecords, + AmlExchangeBackend.AmlError +> { + const { paginatedFetcher } = usePublicBackend(); + + const [page, setPage] = useState(1); + + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk<AmlExchangeBackend.AmlRecords>, + RequestError<AmlExchangeBackend.AmlError> + >( + [ + `aml/${account}/decisions/${AmlExchangeBackend.AmlState[state]}`, + page, + PAGE_SIZE, + signature, + ], + paginatedFetcher, + ); + + const [lastAfter, setLastAfter] = useState< + HttpResponse<AmlExchangeBackend.AmlRecords, AmlExchangeBackend.AmlError> + >({ loading: true }); + + useEffect(() => { + if (afterData) setLastAfter(afterData); + }, [afterData]); + + if (afterError) { + return afterError.cause; + } + + // if the query returns less that we ask, then we have reach the end or beginning + const isReachingEnd = + afterData && afterData.data && afterData.data.records.length < PAGE_SIZE; + const isReachingStart = false; + + const pagination = { + isReachingEnd, + isReachingStart, + loadMore: () => { + if (!afterData || isReachingEnd) return; + if (afterData.data && afterData.data.records.length < MAX_RESULT_SIZE) { + setPage(page + 1); + } + }, + loadMorePrev: () => { + null; + }, + }; + + const records = !afterData + ? [] + : ((afterData ?? lastAfter).data ?? { records: [] }).records; + console.log("afterdata", afterData, lastAfter, records) + if (loadingAfter) return { loading: true, data: { records } }; + if (afterData) { + return { ok: true, data: { records }, ...pagination }; + } + return { loading: true }; +} diff --git a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx index 713c0d7c1..c3fb7dafe 100644 --- a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx +++ b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx @@ -11,8 +11,8 @@ import { v1 as form_902_9e_v1 } from "../forms/902_9e.js"; import { v1 as simplest } from "../forms/simplest.js"; import { DocumentDuplicateIcon } from "@heroicons/react/24/solid"; import { AbsoluteTime } from "@gnu-taler/taler-util"; -import { AmlState } from "../types.js"; import { AmountJson, Amounts } from "@gnu-taler/taler-util"; +import { AmlExchangeBackend } from "../types.js"; export function AntiMoneyLaunderingForm({ number }: { number?: string }) { const selectedForm = Number.parseInt(number ?? "0", 10); @@ -28,7 +28,7 @@ export function AntiMoneyLaunderingForm({ number }: { number?: string }) { <NiceForm initial={storedValue} form={showingFrom({ - state: AmlState.pending, + state: AmlExchangeBackend.AmlState.pending, threshold: Amounts.parseOrThrow("USD:10"), })} onUpdate={() => {}} @@ -37,7 +37,7 @@ export function AntiMoneyLaunderingForm({ number }: { number?: string }) { } export interface State { - state: AmlState; + state: AmlExchangeBackend.AmlState; threshold: AmountJson; } diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx index e5fb8eaba..d02d8b395 100644 --- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx +++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -1,11 +1,5 @@ import { Fragment, VNode, h } from "preact"; import { - AmlDecisionDetail, - AmlDecisionDetails, - AmlState, - KycDetail, -} from "../types.js"; -import { AbsoluteTime, AmountJson, Amounts, @@ -18,8 +12,9 @@ import { NiceForm } from "../NiceForm.js"; import { FlexibleForm } from "../forms/index.js"; import { UIFormField } from "../handlers/forms.js"; import { Pages } from "../pages.js"; +import { AmlExchangeBackend } from "../types.js"; -const response: AmlDecisionDetails = { +const response: AmlExchangeBackend.AmlDecisionDetails = { aml_history: [ { justification: "Lack of documentation", @@ -81,7 +76,7 @@ type AmlFormEvent = { type: "aml-form"; when: AbsoluteTime; title: TranslatedString; - state: AmlState; + state: AmlExchangeBackend.AmlState; threshold: AmountJson; }; type KycCollectionEvent = { @@ -105,8 +100,8 @@ function selectSooner(a: WithTime, b: WithTime) { } function getEventsFromAmlHistory( - aml: AmlDecisionDetail[], - kyc: KycDetail[], + aml: AmlExchangeBackend.AmlDecisionDetail[], + kyc: AmlExchangeBackend.KycDetail[], ): AmlEvent[] { const ae: AmlEvent[] = aml.map((a) => { return { @@ -187,7 +182,7 @@ export function CaseDetails({ account }: { account?: string }) { switch (e.type) { case "aml-form": { switch (e.state) { - case AmlState.normal: { + case AmlExchangeBackend.AmlState.normal: { return ( <div> <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"> @@ -200,7 +195,7 @@ export function CaseDetails({ account }: { account?: string }) { </div> ); } - case AmlState.pending: { + case AmlExchangeBackend.AmlState.pending: { return ( <div> <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20"> @@ -213,7 +208,7 @@ export function CaseDetails({ account }: { account?: string }) { </div> ); } - case AmlState.frozen: { + case AmlExchangeBackend.AmlState.frozen: { return ( <div> <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20"> @@ -304,15 +299,15 @@ function ShowConsolidated({ choices: [ { label: "Frozen" as TranslatedString, - value: AmlState.frozen, + value: AmlExchangeBackend.AmlState.frozen, }, { label: "Pending" as TranslatedString, - value: AmlState.pending, + value: AmlExchangeBackend.AmlState.pending, }, { label: "Normal" as TranslatedString, - value: AmlState.normal, + value: AmlExchangeBackend.AmlState.normal, }, ], }, @@ -361,7 +356,7 @@ function ShowConsolidated({ interface Consolidated { aml: { - state?: AmlState; + state?: AmlExchangeBackend.AmlState; threshold?: AmountJson; since: AbsoluteTime; }; @@ -421,26 +416,26 @@ export const amlStateConverter = { fromStringUI: parseAmlState, }; -function stringifyAmlState(s: AmlState | undefined): string { +function stringifyAmlState(s: AmlExchangeBackend.AmlState | undefined): string { if (s === undefined) return ""; switch (s) { - case AmlState.normal: + case AmlExchangeBackend.AmlState.normal: return "normal"; - case AmlState.pending: + case AmlExchangeBackend.AmlState.pending: return "pending"; - case AmlState.frozen: + case AmlExchangeBackend.AmlState.frozen: return "frozen"; } } -function parseAmlState(s: string | undefined): AmlState { +function parseAmlState(s: string | undefined): AmlExchangeBackend.AmlState { switch (s) { case "normal": - return AmlState.normal; + return AmlExchangeBackend.AmlState.normal; case "pending": - return AmlState.pending; + return AmlExchangeBackend.AmlState.pending; case "frozen": - return AmlState.frozen; + return AmlExchangeBackend.AmlState.frozen; default: throw Error(`unknown AML state: ${s}`); } diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx index 28b9d2a88..d96e7a90c 100644 --- a/packages/aml-backoffice-ui/src/pages/Cases.tsx +++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -1,15 +1,18 @@ -import { VNode, h } from "preact"; -import { Pages } from "../pages.js"; -import { AmlRecords, AmlState } from "../types.js"; -import { InputChoiceHorizontal } from "../handlers/InputChoiceHorizontal.js"; -import { createNewForm } from "../handlers/forms.js"; import { TranslatedString } from "@gnu-taler/taler-util"; -import { amlStateConverter as amlStateConverter } from "./CaseDetails.js"; +import { VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; +import { createNewForm } from "../handlers/forms.js"; +import { useCases } from "../hooks/useCases.js"; import { useOfficer } from "../hooks/useOfficer.js"; +import { Pages } from "../pages.js"; +import { AmlExchangeBackend } from "../types.js"; +import { amlStateConverter } from "./CaseDetails.js"; +import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; +import { buildQuerySignature } from "../account.js"; +import { handleNotOkResult } from "../utils/errors.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; -const response: AmlRecords = { +const response: AmlExchangeBackend.AmlRecords = { records: [ { current_state: 0, @@ -56,7 +59,7 @@ const response: AmlRecords = { function doFilter( list: typeof response.records, - filter: AmlState | undefined, + filter: AmlExchangeBackend.AmlState | undefined, ): typeof response.records { if (filter === undefined) return list; return list.filter((r) => r.current_state === filter); @@ -64,14 +67,27 @@ function doFilter( export function Cases() { const officer = useOfficer(); + const { i18n } = useTranslationContext(); if (officer.state !== "ready") { return <HandleAccountNotReady officer={officer} />; } const form = createNewForm<{ - state: AmlState; + state: AmlExchangeBackend.AmlState; }>(); - const initial = { state: AmlState.pending }; - const [list, setList] = useState(doFilter(response.records, initial.state)); + + const signature = + officer.state === "ready" + ? buildQuerySignature(officer.account.signingKey) + : undefined; + + const initial = AmlExchangeBackend.AmlState.pending; + const [stateFilter, setStateFilter] = useState(initial); + const list = useCases(officer.account.accountId, stateFilter, signature); + + if (!list.ok && !list.loading) { + return handleNotOkResult(i18n)(list); + } + return ( <div> <div class="px-4 sm:px-6 lg:px-8"> @@ -85,9 +101,9 @@ export function Cases() { </p> </div> <form.Provider - initialValue={initial} + initialValue={{ state: stateFilter }} onUpdate={(v) => { - setList(doFilter(response.records, v.state)); + setStateFilter(v.state ?? initial); }} onSubmit={(v) => {}} > @@ -98,15 +114,15 @@ export function Cases() { choices={[ { label: "Pending" as TranslatedString, - value: AmlState.pending, + value: AmlExchangeBackend.AmlState.pending, }, { label: "Frozen" as TranslatedString, - value: AmlState.frozen, + value: AmlExchangeBackend.AmlState.frozen, }, { label: "Normal" as TranslatedString, - value: AmlState.normal, + value: AmlExchangeBackend.AmlState.normal, }, ]} /> @@ -114,82 +130,86 @@ export function Cases() { </div> <div class="mt-8 flow-root"> <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> - <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> - <Pagination /> - <table class="min-w-full divide-y divide-gray-300"> - <thead> - <tr> - <th - scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - > - Account Id - </th> - <th - scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - > - Status - </th> - <th - scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - > - Threshold - </th> - </tr> - </thead> - <tbody class="divide-y divide-gray-200 bg-white"> - {list.map((r) => { - return ( - <tr class="hover:bg-gray-100 "> - <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 "> - <div class="text-gray-900"> - <a - href={Pages.details.url({ account: r.h_payto })} - class="text-indigo-600 hover:text-indigo-900" - > - {r.h_payto} - </a> - </div> - </td> - <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500"> - {((state: AmlState): VNode => { - switch (state) { - case AmlState.normal: { - return ( - <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"> - Normal - </span> - ); - } - case AmlState.pending: { - return ( - <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20"> - Pending - </span> - ); - } - case AmlState.frozen: { - return ( - <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20"> - Frozen - </span> - ); + {!list.data.records.length ? ( + <div>empty result </div> + ) : ( + <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + <Pagination /> + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + Account Id + </th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + Status + </th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + Threshold + </th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200 bg-white"> + {list.data.records.map((r) => { + return ( + <tr class="hover:bg-gray-100 "> + <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 "> + <div class="text-gray-900"> + <a + href={Pages.details.url({ account: r.h_payto })} + class="text-indigo-600 hover:text-indigo-900" + > + {r.h_payto} + </a> + </div> + </td> + <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500"> + {((state: AmlExchangeBackend.AmlState): VNode => { + switch (state) { + case AmlExchangeBackend.AmlState.normal: { + return ( + <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"> + Normal + </span> + ); + } + case AmlExchangeBackend.AmlState.pending: { + return ( + <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20"> + Pending + </span> + ); + } + case AmlExchangeBackend.AmlState.frozen: { + return ( + <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20"> + Frozen + </span> + ); + } } - } - })(r.current_state)} - </td> - <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900"> - {r.threshold} - </td> - </tr> - ); - })} - </tbody> - </table> - <Pagination /> - </div> + })(r.current_state)} + </td> + <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900"> + {r.threshold} + </td> + </tr> + ); + })} + </tbody> + </table> + <Pagination /> + </div> + )} </div> </div> </div> diff --git a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx index fdb255701..bbd04daee 100644 --- a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx +++ b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx @@ -2,8 +2,8 @@ import { VNode, h } from "preact"; import { allForms } from "./AntiMoneyLaunderingForm.js"; import { Pages } from "../pages.js"; import { NiceForm } from "../NiceForm.js"; -import { AmlState } from "../types.js"; import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util"; +import { AmlExchangeBackend } from "../types.js"; export function NewFormEntry({ account, @@ -27,7 +27,7 @@ export function NewFormEntry({ const initial = { fullName: "loggedIn_user_fullname", when: AbsoluteTime.now(), - state: AmlState.pending, + state: AmlExchangeBackend.AmlState.pending, threshold: Amounts.parseOrThrow("USD:10"), }; return ( diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx index 2ebac0718..39f8addd3 100644 --- a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx +++ b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx @@ -30,6 +30,9 @@ export function UnlockAccount({ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12"> <Form.Provider + initialValue={{ + password: "welcometo.5146", + }} onSubmit={async (v) => { try { await onAccountUnlocked(v.password!); diff --git a/packages/aml-backoffice-ui/src/settings.ts b/packages/aml-backoffice-ui/src/settings.ts new file mode 100644 index 000000000..2897874a2 --- /dev/null +++ b/packages/aml-backoffice-ui/src/settings.ts @@ -0,0 +1,35 @@ +/* + 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/> + */ + +export interface UiSettings { + backendBaseURL: string; + allowRegistrations: boolean; + uiName: string; +} + +/** + * Global settings for the UI. + */ +const defaultSettings: UiSettings = { + backendBaseURL: "https://exchange.demo.taler.net/", + allowRegistrations: true, + uiName: "Taler Bank", +}; + +export const uiSettings: UiSettings = + "talerExchangeAmlSettings" in globalThis + ? (globalThis as any).talerExchangeAmlSettings + : defaultSettings; diff --git a/packages/aml-backoffice-ui/src/types.ts b/packages/aml-backoffice-ui/src/types.ts index 1197b6b35..104d938b3 100644 --- a/packages/aml-backoffice-ui/src/types.ts +++ b/packages/aml-backoffice-ui/src/types.ts @@ -1,81 +1,88 @@ -export interface AmlDecisionDetails { - // Array of AML decisions made for this account. Possibly - // contains only the most recent decision if "history" was - // not set to 'true'. - aml_history: AmlDecisionDetail[]; - - // Array of KYC attributes obtained for this account. - kyc_attributes: KycDetail[]; -} - -type AmlOfficerPublicKeyP = string; - -export interface AmlDecisionDetail { - // What was the justification given? - justification: string; - - // What is the new AML state. - new_state: Integer; - - // When was this decision made? - decision_time: Timestamp; - - // What is the new AML decision threshold (in monthly transaction volume)? - new_threshold: Amount; - - // Who made the decision? - decider_pub: AmlOfficerPublicKeyP; -} -export interface KycDetail { - // Name of the configuration section that specifies the provider - // which was used to collect the KYC details - provider_section: string; - - // The collected KYC data. NULL if the attribute data could not - // be decrypted (internal error of the exchange, likely the - // attribute key was changed). - attributes?: Object; - - // Time when the KYC data was collected - collection_time: Timestamp; - - // Time when the validity of the KYC data will expire - expiration_time: Timestamp; -} - -interface Timestamp { - // Seconds since epoch, or the special - // value "never" to represent an event that will - // never happen. - t_s: number | "never"; -} - -type PaytoHash = string; -type Integer = number; -type Amount = string; - -export interface AmlRecords { - // Array of AML records matching the query. - records: AmlRecord[]; -} - -interface AmlRecord { - // Which payto-address is this record about. - // Identifies a GNU Taler wallet or an affected bank account. - h_payto: PaytoHash; - - // What is the current AML state. - current_state: AmlState; - - // Monthly transaction threshold before a review will be triggered - threshold: Amount; - - // RowID of the record. - rowid: Integer; -} - -export enum AmlState { - normal = 0, - pending = 1, - frozen = 2, +export namespace AmlExchangeBackend { + // FIXME: placeholder + export interface AmlError { + code: number; + hint: string; + } + export interface AmlDecisionDetails { + // Array of AML decisions made for this account. Possibly + // contains only the most recent decision if "history" was + // not set to 'true'. + aml_history: AmlDecisionDetail[]; + + // Array of KYC attributes obtained for this account. + kyc_attributes: KycDetail[]; + } + + type AmlOfficerPublicKeyP = string; + + export interface AmlDecisionDetail { + // What was the justification given? + justification: string; + + // What is the new AML state. + new_state: Integer; + + // When was this decision made? + decision_time: Timestamp; + + // What is the new AML decision threshold (in monthly transaction volume)? + new_threshold: Amount; + + // Who made the decision? + decider_pub: AmlOfficerPublicKeyP; + } + export interface KycDetail { + // Name of the configuration section that specifies the provider + // which was used to collect the KYC details + provider_section: string; + + // The collected KYC data. NULL if the attribute data could not + // be decrypted (internal error of the exchange, likely the + // attribute key was changed). + attributes?: Object; + + // Time when the KYC data was collected + collection_time: Timestamp; + + // Time when the validity of the KYC data will expire + expiration_time: Timestamp; + } + + interface Timestamp { + // Seconds since epoch, or the special + // value "never" to represent an event that will + // never happen. + t_s: number | "never"; + } + + type PaytoHash = string; + type Integer = number; + type Amount = string; + + export interface AmlRecords { + // Array of AML records matching the query. + records: AmlRecord[]; + } + + interface AmlRecord { + // Which payto-address is this record about. + // Identifies a GNU Taler wallet or an affected bank account. + h_payto: PaytoHash; + + // What is the current AML state. + current_state: AmlState; + + // Monthly transaction threshold before a review will be triggered + threshold: Amount; + + // RowID of the record. + rowid: Integer; + } + + export enum AmlState { + normal = 0, + pending = 1, + frozen = 2, + } } diff --git a/packages/aml-backoffice-ui/src/utils/Loading.tsx b/packages/aml-backoffice-ui/src/utils/Loading.tsx new file mode 100644 index 000000000..7cbdad681 --- /dev/null +++ b/packages/aml-backoffice-ui/src/utils/Loading.tsx @@ -0,0 +1,43 @@ +/* + 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/> + */ + +import { h, VNode } from "preact"; + +export function Loading(): VNode { + return ( + <div + class="columns is-centered is-vcentered" + style={{ + height: "calc(100% - 3rem)", + position: "absolute", + width: "100%", + }} + > + <Spinner /> + </div> + ); +} + +export function Spinner(): VNode { + return ( + <div class="lds-ring"> + <div /> + <div /> + <div /> + <div /> + </div> + ); +} diff --git a/packages/aml-backoffice-ui/src/utils/QR.tsx b/packages/aml-backoffice-ui/src/utils/QR.tsx new file mode 100644 index 000000000..1dc1712b7 --- /dev/null +++ b/packages/aml-backoffice-ui/src/utils/QR.tsx @@ -0,0 +1,54 @@ +/* + 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/> + */ + +import { h, VNode } from "preact"; +import { useEffect, useRef } from "preact/hooks"; +// import qrcode from "qrcode-generator"; + +export function QR({ text }: { text: string }): VNode { + const divRef = useRef<HTMLDivElement>(null); + useEffect(() => { + // const qr = qrcode(0, "L"); + // qr.addData(text); + // qr.make(); + // if (divRef.current) + // divRef.current.innerHTML = qr.createSvgTag({ + // scalable: true, + // }); + }); + + return ( + <div + style={{ + width: "100%", + display: "flex", + flexDirection: "column", + alignItems: "left", + }} + > + <div + style={{ + width: "50%", + minWidth: 200, + maxWidth: 300, + marginRight: "auto", + marginLeft: "auto", + }} + ref={divRef} + /> + </div> + ); +} diff --git a/packages/aml-backoffice-ui/src/utils/errors.tsx b/packages/aml-backoffice-ui/src/utils/errors.tsx new file mode 100644 index 000000000..b67d61a5f --- /dev/null +++ b/packages/aml-backoffice-ui/src/utils/errors.tsx @@ -0,0 +1,77 @@ +import { + ErrorType, + HttpResponse, + HttpResponsePaginated, + notifyError, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { Loading } from "./Loading.js"; +import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util"; +import { AmlExchangeBackend } from "../types.js"; + +export function handleNotOkResult<Error extends AmlExchangeBackend.AmlError>( + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): <T>( + result: HttpResponsePaginated<T, Error> | HttpResponse<T, Error>, +) => VNode { + return function handleNotOkResult2<T>( + result: HttpResponsePaginated<T, Error> | HttpResponse<T, Error>, + ): VNode { + if (result.loading) return <Loading />; + if (!result.ok) { + switch (result.type) { + case ErrorType.TIMEOUT: { + notifyError(i18n.str`Request timeout, try again later.`, undefined); + break; + } + case ErrorType.CLIENT: { + if (result.status === HttpStatusCode.Unauthorized) { + notifyError(i18n.str`Wrong credentials`, undefined); + return <div> not authorized</div>; + } + const errorData = result.payload; + notifyError( + i18n.str`Could not load due to a client error`, + errorData.hint as TranslatedString, + JSON.stringify(result), + ); + break; + } + case ErrorType.SERVER: { + notifyError( + i18n.str`Server returned with error`, + result.payload.hint as TranslatedString, + JSON.stringify(result.payload), + ); + break; + } + case ErrorType.UNREADABLE: { + notifyError( + i18n.str`Unexpected error.`, + `Response from ${result.info?.url} is unreadable, http status: ${result.status}` as TranslatedString, + JSON.stringify(result), + ); + break; + } + case ErrorType.UNEXPECTED: { + notifyError( + i18n.str`Unexpected error.`, + `Diagnostic from ${result.info?.url} is "${result.message}"` as TranslatedString, + JSON.stringify(result), + ); + break; + } + default: { + assertUnreachable(result); + } + } + + return <div>error</div>; + } + return <div />; + }; +} +export function assertUnreachable(x: never): never { + throw new Error("Didn't expect to get here"); +} |