diff options
author | Sebastian <sebasjm@gmail.com> | 2024-04-29 17:23:04 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2024-04-29 17:23:04 -0300 |
commit | 22709ff4e2918a8d0e528539d11d761381920b45 (patch) | |
tree | 7e01f9115ed44e5e3875e3473eb0d31314380d5a /packages | |
parent | eeabe64b3f0ac02818561ea6fca364d619f061b7 (diff) |
use exchange api type and start using ui_fields
Diffstat (limited to 'packages')
21 files changed, 507 insertions, 742 deletions
diff --git a/packages/aml-backoffice-ui/src/forms/declaration.ts b/packages/aml-backoffice-ui/src/forms/declaration.ts index fb7b8f334..c467f537b 100644 --- a/packages/aml-backoffice-ui/src/forms/declaration.ts +++ b/packages/aml-backoffice-ui/src/forms/declaration.ts @@ -14,12 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import type { AmountJson, TranslatedString } from "@gnu-taler/taler-util"; +import type { AmountJson, TalerExchangeApi, TranslatedString } from "@gnu-taler/taler-util"; import type { FlexibleForm, InternationalizationAPI, } from "@gnu-taler/web-util/browser"; -import { AmlExchangeBackend } from "../utils/types.js"; /** * import entry point without hard reference. @@ -32,7 +31,7 @@ import { AmlExchangeBackend } from "../utils/types.js"; */ export interface BaseForm { - state: AmlExchangeBackend.AmlState; + state: TalerExchangeApi.AmlState; threshold: AmountJson; } diff --git a/packages/aml-backoffice-ui/src/forms/simplest.ts b/packages/aml-backoffice-ui/src/forms/simplest.ts index bd512546d..6455b6f41 100644 --- a/packages/aml-backoffice-ui/src/forms/simplest.ts +++ b/packages/aml-backoffice-ui/src/forms/simplest.ts @@ -13,13 +13,13 @@ 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 type { - TranslatedString +import { + TalerExchangeApi, + type TranslatedString } from "@gnu-taler/taler-util"; import type { DoubleColumnFormSection, FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser"; import { amlStateConverter } from "../utils/converter.js"; -import { AmlExchangeBackend } from "../utils/types.js"; import { BaseForm } from "./declaration.js"; export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Simplest.Form> => ({ @@ -44,10 +44,9 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib return { comment: { help: ((v.comment?.length ?? 0) > 100 ? "keep it short" : "") as TranslatedString, - }, threshold: { - disabled: v.state === AmlExchangeBackend.AmlState.frozen, + disabled: v.state === TalerExchangeApi.AmlState.frozen, }, }; }, @@ -71,18 +70,17 @@ export function resolutionSection(current: BaseForm, i18n: InternationalizationA props: { name: "state", label: i18n.str`New state`, - converter: amlStateConverter, choices: [ { - value: AmlExchangeBackend.AmlState.frozen, + value: TalerExchangeApi.AmlState.frozen, label: i18n.str`Frozen`, }, { - value: AmlExchangeBackend.AmlState.pending, + value: TalerExchangeApi.AmlState.pending, label: i18n.str`Pending`, }, { - value: AmlExchangeBackend.AmlState.normal, + value: TalerExchangeApi.AmlState.normal, label: i18n.str`Normal`, }, ], diff --git a/packages/aml-backoffice-ui/src/hooks/form.ts b/packages/aml-backoffice-ui/src/hooks/form.ts index e3d97db8c..e14e29819 100644 --- a/packages/aml-backoffice-ui/src/hooks/form.ts +++ b/packages/aml-backoffice-ui/src/hooks/form.ts @@ -14,29 +14,32 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountJson, TranslatedString } from "@gnu-taler/taler-util"; +import { + AmountJson, + TalerExchangeApi, + TranslatedString, +} from "@gnu-taler/taler-util"; import { useState } from "preact/hooks"; +import { UIField } from "@gnu-taler/web-util/browser"; -export type UIField = { - value: string | undefined; - onUpdate: (s: string) => void; - error: TranslatedString | undefined; -}; +// export type UIField = { +// value: string | undefined; +// onUpdate: (s: string) => void; +// error: TranslatedString | undefined; +// }; type FormHandler<T> = { [k in keyof T]?: T[k] extends string ? UIField : T[k] extends AmountJson ? UIField - : FormHandler<T[k]>; + : T[k] extends TalerExchangeApi.AmlState + ? UIField + : FormHandler<T[k]>; }; export type FormValues<T> = { - [k in keyof T]: T[k] extends string - ? string | undefined - : T[k] extends AmountJson - ? string | undefined - : FormValues<T[k]>; + [k in keyof T]: T[k] extends string ? string | undefined : FormValues<T[k]>; }; export type RecursivePartial<T> = { @@ -44,7 +47,9 @@ export type RecursivePartial<T> = { ? string : T[k] extends AmountJson ? AmountJson - : RecursivePartial<T[k]>; + : T[k] extends TalerExchangeApi.AmlState + ? TalerExchangeApi.AmlState + : RecursivePartial<T[k]>; }; export type FormErrors<T> = { @@ -52,7 +57,9 @@ export type FormErrors<T> = { ? TranslatedString : T[k] extends AmountJson ? TranslatedString - : FormErrors<T[k]>; + : T[k] extends TalerExchangeApi.AmlState + ? TranslatedString + : FormErrors<T[k]>; }; export type FormStatus<T> = @@ -76,10 +83,15 @@ function constructFormHandler<T>( const handler = keys.reduce((prev, fieldName) => { const currentValue: unknown = form[fieldName]; - const currentError: unknown = errors ? errors[fieldName] : undefined; + const currentError: unknown = + errors !== undefined ? errors[fieldName] : undefined; function updater(newValue: unknown) { updateForm({ ...form, [fieldName]: newValue }); } + /** + * There is no clear way to know if this object is a custom field + * or a group of fields + */ if (typeof currentValue === "object") { // @ts-expect-error FIXME better typing const group = constructFormHandler(currentValue, updater, currentError); @@ -87,12 +99,14 @@ function constructFormHandler<T>( prev[fieldName] = group; return prev; } + const field: UIField = { // @ts-expect-error FIXME better typing error: currentError, // @ts-expect-error FIXME better typing value: currentValue, - onUpdate: updater, + onChange: updater, + state: {}, }; // @ts-expect-error FIXME better typing prev[fieldName] = field; diff --git a/packages/aml-backoffice-ui/src/hooks/officer.ts b/packages/aml-backoffice-ui/src/hooks/officer.ts index dabe866d3..1bb73b8fc 100644 --- a/packages/aml-backoffice-ui/src/hooks/officer.ts +++ b/packages/aml-backoffice-ui/src/hooks/officer.ts @@ -66,14 +66,14 @@ interface OfficerNotFound { } interface OfficerLocked { state: "locked"; - forget: () => void; - tryUnlock: (password: string) => Promise<void>; + forget: () => OperationOk<void>; + tryUnlock: (password: string) => Promise<OperationOk<void>>; } interface OfficerReady { state: "ready"; account: OfficerAccount; - forget: () => void; - lock: () => void; + forget: () => OperationOk<void>; + lock: () => OperationOk<void>; } const OFFICER_KEY = buildStorageKey("officer", codecForOfficer()); @@ -133,6 +133,7 @@ export function useOfficer(): OfficerState { state: "locked", forget: () => { officerStorage.reset(); + return opFixedSuccess(undefined) }, tryUnlock: async (pwd: string) => { const ac = await unlockOfficerAccount(officer.account, pwd); @@ -141,6 +142,7 @@ export function useOfficer(): OfficerState { id: ac.id, strKey: encodeCrock(ac.signingKey), }); + return opFixedSuccess(undefined) }, }; } @@ -150,10 +152,12 @@ export function useOfficer(): OfficerState { account, lock: () => { accountStorage.reset(); + return opFixedSuccess(undefined) }, forget: () => { officerStorage.reset(); accountStorage.reset(); + return opFixedSuccess(undefined) }, }; } diff --git a/packages/aml-backoffice-ui/src/hooks/useCases.ts b/packages/aml-backoffice-ui/src/hooks/useCases.ts index 59d1c9001..d3a1c1018 100644 --- a/packages/aml-backoffice-ui/src/hooks/useCases.ts +++ b/packages/aml-backoffice-ui/src/hooks/useCases.ts @@ -19,11 +19,11 @@ import { useState } from "preact/hooks"; import { OfficerAccount, OperationOk, + TalerExchangeApi, TalerExchangeResultByMethod, TalerHttpError, } from "@gnu-taler/taler-util"; import _useSWR, { SWRHook } from "swr"; -import { AmlExchangeBackend } from "../utils/types.js"; import { useOfficer } from "./officer.js"; import { useExchangeApiContext } from "@gnu-taler/web-util/browser"; const useSWR = _useSWR as unknown as SWRHook; @@ -39,7 +39,7 @@ export const PAGINATED_LIST_REQUEST = PAGINATED_LIST_SIZE + 1; * @param args * @returns */ -export function useCases(state: AmlExchangeBackend.AmlState) { +export function useCases(state: TalerExchangeApi.AmlState) { const officer = useOfficer(); const session = officer.state === "ready" ? officer.account : undefined; const { @@ -50,7 +50,7 @@ export function useCases(state: AmlExchangeBackend.AmlState) { async function fetcher([officer, state, offset]: [ OfficerAccount, - AmlExchangeBackend.AmlState, + TalerExchangeApi.AmlState, string | undefined, ]) { return await api.getDecisionsByState(officer, state, { diff --git a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx deleted file mode 100644 index 0c82a4a0e..000000000 --- a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import * as tests from "@gnu-taler/web-util/testing"; -import { - AntiMoneyLaunderingForm as TestedComponent, -} from "./AntiMoneyLaunderingForm.js"; - -export default { - title: "aml form", -}; - -export const SimpleComment = tests.createExample(TestedComponent, { - account: "the_account", - formId: "simple_comment", - onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) - } -}); - -export const Identification = tests.createExample(TestedComponent, { - account: "the_account", - formId: "902.1e", - onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) - } -}); - -export const OperationalLegalEntity = tests.createExample(TestedComponent, { - account: "the_account", - formId: "902.11e", - - onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) - } -}); -export const Foundations = tests.createExample(TestedComponent, { - account: "the_account", - formId: "902.12e", - - onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) - } -}); -export const DelcarationOfTrusts = tests.createExample(TestedComponent, { - account: "the_account", - formId: "902.13e", - - onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) - } -}); - -export const InformationOnLifeInsurance = tests.createExample(TestedComponent, { - account: "the_account", - formId: "902.15e", - - onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) - } -}); -export const DeclarationOfBeneficialOwner = tests.createExample(TestedComponent, { - account: "the_account", - formId: "902.9e", - - onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) - } -}); -export const CustomerProfile = tests.createExample(TestedComponent, { - account: "the_account", - formId: "902.5e", - - onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) - } -}); -export const RiskProfile = tests.createExample(TestedComponent, { - account: "the_account", - formId: "902.4e", - - onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) - } -}); - diff --git a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx deleted file mode 100644 index db034c996..000000000 --- a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx +++ /dev/null @@ -1,185 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - AbsoluteTime, - AmountJson, - Amounts, - Codec, - OperationFail, - OperationOk, - TalerErrorDetail, - buildCodecForObject, - codecForNumber, - codecForString, - codecOptional, -} from "@gnu-taler/taler-util"; -import { - DefaultForm, - useExchangeApiContext, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { h } from "preact"; -import { BaseForm, FormMetadata, uiForms } from "../forms/declaration.js"; -import { AmlExchangeBackend } from "../utils/types.js"; -import { privatePages } from "../Routing.js"; - -export function AntiMoneyLaunderingForm({ - account, - formId, - onSubmit, -}: { - account: string; - formId: string; - onSubmit: ( - justification: Justification, - state: AmlExchangeBackend.AmlState, - threshold: AmountJson, - ) => Promise<void>; -}) { - const { i18n } = useTranslationContext(); - const theForm = uiForms.forms(i18n).find((v) => v.id === formId); - if (!theForm) { - return <div>form with id {formId} not found</div>; - } - - const { config } = useExchangeApiContext(); - - const initial = { - when: AbsoluteTime.now(), - state: AmlExchangeBackend.AmlState.pending, - threshold: Amounts.zeroOfCurrency(config.currency), - }; - return ( - <DefaultForm - initial={initial} - form={theForm.impl(initial)} - onUpdate={() => {}} - onSubmit={(formValue) => { - if ( - formValue.state === undefined || - formValue.threshold === undefined - ) { - return; - } - const validatedForm = formValue as BaseForm; - const st = formValue.state; - const amount = formValue.threshold; - - const justification: Justification = { - id: theForm.id, - label: theForm.label, - version: theForm.version, - value: validatedForm, - }; - - onSubmit(justification, st, amount); - }} - > - <div class="mt-6 flex items-center justify-end gap-x-6"> - <a - href={privatePages.caseDetails.url({ cid: account })} - class="text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cancel</i18n.Translate> - </a> - <button - type="submit" - class="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</i18n.Translate> - </button> - </div> - </DefaultForm> - ); -} - -export type Justification<T extends BaseForm = BaseForm> = { - // form values - value: T; -} & Omit<Omit<FormMetadata<BaseForm>, "icon">, "impl">; - -export function stringifyJustification(j: Justification): string { - return JSON.stringify(j); -} - -type SimpleFormMetadata = { - version?: number; - id?: string; -}; - -export const codecForSimpleFormMetadata = (): Codec<SimpleFormMetadata> => - buildCodecForObject<SimpleFormMetadata>() - .property("id", codecOptional(codecForString())) - .property("version", codecOptional(codecForNumber())) - .build("SimpleFormMetadata"); - -type ParseJustificationFail = - | "not-json" - | "id-not-found" - | "form-not-found" - | "version-not-found"; - -export function parseJustification( - s: string, - listOfAllKnownForms: FormMetadata<BaseForm>[], -): - | OperationOk<{ - justification: Justification; - metadata: FormMetadata<BaseForm>; - }> - | OperationFail<ParseJustificationFail> { - try { - const justification = JSON.parse(s); - const info = codecForSimpleFormMetadata().decode(justification); - if (!info.id) { - return { - type: "fail", - case: "id-not-found", - detail: {} as TalerErrorDetail, - }; - } - if (!info.version) { - return { - type: "fail", - case: "version-not-found", - detail: {} as TalerErrorDetail, - }; - } - const found = listOfAllKnownForms.find((f) => { - return f.id === info.id && f.version === info.version; - }); - if (!found) { - return { - type: "fail", - case: "form-not-found", - detail: {} as TalerErrorDetail, - }; - } - return { - type: "ok", - body: { - justification, - metadata: found, - }, - }; - } catch (e) { - return { - type: "fail", - case: "not-json", - detail: {} as TalerErrorDetail, - }; - } -} diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx index 576cdbbb9..e16a6a103 100644 --- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx +++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -17,10 +17,19 @@ import { AbsoluteTime, AmountJson, Amounts, + Codec, HttpStatusCode, + OperationFail, + OperationOk, TalerError, + TalerErrorDetail, + TalerExchangeApi, TranslatedString, assertUnreachable, + buildCodecForObject, + codecForNumber, + codecForString, + codecOptional, } from "@gnu-taler/taler-util"; import { DefaultForm, @@ -32,15 +41,10 @@ import { import { format } from "date-fns"; import { VNode, h } from "preact"; import { useState } from "preact/hooks"; +import { privatePages } from "../Routing.js"; import { BaseForm, FormMetadata, uiForms } from "../forms/declaration.js"; import { useCaseDetails } from "../hooks/useCaseDetails.js"; -import { AmlExchangeBackend } from "../utils/types.js"; -import { - Justification, - parseJustification, -} from "./AntiMoneyLaunderingForm.js"; import { ShowConsolidated } from "./ShowConsolidated.js"; -import { privatePages } from "../Routing.js"; export type AmlEvent = | AmlFormEvent @@ -53,7 +57,7 @@ type AmlFormEvent = { title: TranslatedString; justification: Justification; metadata: FormMetadata<BaseForm>; - state: AmlExchangeBackend.AmlState; + state: TalerExchangeApi.AmlState; threshold: AmountJson; }; type AmlFormEventError = { @@ -62,7 +66,7 @@ type AmlFormEventError = { title: TranslatedString; justification: undefined; metadata: undefined; - state: AmlExchangeBackend.AmlState; + state: TalerExchangeApi.AmlState; threshold: AmountJson; }; type KycCollectionEvent = { @@ -108,8 +112,8 @@ function titleForJustification( } export function getEventsFromAmlHistory( - aml: AmlExchangeBackend.AmlDecisionDetail[], - kyc: AmlExchangeBackend.KycDetail[], + aml: TalerExchangeApi.AmlDecisionDetail[], + kyc: TalerExchangeApi.KycDetail[], i18n: InternationalizationAPI, ): AmlEvent[] { const ae: AmlEvent[] = aml.map((a) => { @@ -242,24 +246,24 @@ export function CaseDetails({ account }: { account: string }) { function AmlStateBadge({ state, }: { - state: AmlExchangeBackend.AmlState; + state: TalerExchangeApi.AmlState; }): VNode { switch (state) { - case AmlExchangeBackend.AmlState.normal: { + case TalerExchangeApi.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: { + case TalerExchangeApi.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: { + case TalerExchangeApi.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 @@ -384,3 +388,78 @@ function ShowTimeline({ </div> ); } + + +export type Justification<T extends BaseForm = BaseForm> = { + // form values + value: T; +} & Omit<Omit<FormMetadata<BaseForm>, "icon">, "impl">; + +type SimpleFormMetadata = { + version?: number; + id?: string; +}; + +export const codecForSimpleFormMetadata = (): Codec<SimpleFormMetadata> => + buildCodecForObject<SimpleFormMetadata>() + .property("id", codecOptional(codecForString())) + .property("version", codecOptional(codecForNumber())) + .build("SimpleFormMetadata"); + +type ParseJustificationFail = + | "not-json" + | "id-not-found" + | "form-not-found" + | "version-not-found"; + +function parseJustification( + s: string, + listOfAllKnownForms: FormMetadata<BaseForm>[], +): + | OperationOk<{ + justification: Justification; + metadata: FormMetadata<BaseForm>; + }> + | OperationFail<ParseJustificationFail> { + try { + const justification = JSON.parse(s); + const info = codecForSimpleFormMetadata().decode(justification); + if (!info.id) { + return { + type: "fail", + case: "id-not-found", + detail: {} as TalerErrorDetail, + }; + } + if (!info.version) { + return { + type: "fail", + case: "version-not-found", + detail: {} as TalerErrorDetail, + }; + } + const found = listOfAllKnownForms.find((f) => { + return f.id === info.id && f.version === info.version; + }); + if (!found) { + return { + type: "fail", + case: "form-not-found", + detail: {} as TalerErrorDetail, + }; + } + return { + type: "ok", + body: { + justification, + metadata: found, + }, + }; + } catch (e) { + return { + type: "fail", + case: "not-json", + detail: {} as TalerErrorDetail, + }; + } +} diff --git a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx index c4bff1f9f..47c8f8ab4 100644 --- a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx +++ b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx @@ -19,24 +19,27 @@ import { HttpStatusCode, TalerExchangeApi, TalerProtocolTimestamp, - TranslatedString, + assertUnreachable } from "@gnu-taler/taler-util"; import { + Button, LocalNotificationBanner, + RenderAllFieldsByUiConfig, useExchangeApiContext, - useLocalNotification, - useTranslationContext, + useLocalNotificationHandler, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { privatePages } from "../Routing.js"; -import { uiForms } from "../forms/declaration.js"; +import { BaseForm, uiForms } from "../forms/declaration.js"; +import { useFormState } from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; -import { AntiMoneyLaunderingForm } from "./AntiMoneyLaunderingForm.js"; import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; +import { Justification } from "./CaseDetails.js"; export function CaseUpdate({ account, - type, + type: formId, }: { account: string; type: string; @@ -46,67 +49,132 @@ export function CaseUpdate({ const { lib: { exchange: api }, } = useExchangeApiContext(); - const [notification, notify, handleError] = useLocalNotification(); + + // const [notification, notify, handleError] = useLocalNotification(); + const [notification, withErrorHandler] = useLocalNotificationHandler(); + const { config } = useExchangeApiContext(); + + const initial = { + when: AbsoluteTime.now(), + state: TalerExchangeApi.AmlState.pending, + threshold: Amounts.zeroOfCurrency(config.currency), + }; if (officer.state !== "ready") { return <HandleAccountNotReady officer={officer} />; } + const theForm = uiForms.forms(i18n).find((v) => v.id === formId); + if (!theForm) { + return <div>form with id {formId} not found</div>; + } - return ( - <Fragment> - <LocalNotificationBanner notification={notification} /> + const [form, state] = useFormState<BaseForm>(initial, (st) => { + return { + status: "ok", + result: st as any, + errors: undefined, + }; + }); - <AntiMoneyLaunderingForm - account={account} - formId={type} - onSubmit={async (justification, new_state, new_threshold) => { - const decision: Omit<TalerExchangeApi.AmlDecision, "officer_sig"> = { - justification: JSON.stringify(justification), - decision_time: TalerProtocolTimestamp.now(), - h_payto: account, - new_state, - new_threshold: Amounts.stringify(new_threshold), - kyc_requirements: undefined, - }; - await handleError(async () => { - const resp = await api.addDecisionDetails( - officer.account, - decision, - ); - if (resp.type === "ok") { - window.location.href = privatePages.cases.url({}); - return; - } - switch (resp.case) { + const ff = theForm.impl(state.result as any); + + const validatedForm = state.status === "fail" ? undefined : state.result; + + const submitHandler = + validatedForm === undefined + ? undefined + : withErrorHandler( + () => { + const justification: Justification = { + id: theForm.id, + label: theForm.label, + version: theForm.version, + value: validatedForm, + }; + + const decision: Omit<TalerExchangeApi.AmlDecision, "officer_sig"> = + { + justification: JSON.stringify(justification), + decision_time: TalerProtocolTimestamp.now(), + h_payto: account, + new_state: justification.value.state, + new_threshold: Amounts.stringify(justification.value.threshold), + kyc_requirements: undefined, + }; + + return api.addDecisionDetails(officer.account, decision); + }, + () => { + window.location.href = privatePages.cases.url({}); + }, + (fail) => { + switch (fail.case) { case HttpStatusCode.Forbidden: case HttpStatusCode.Unauthorized: - return notify({ - type: "error", - title: i18n.str`Wrong credentials for "${officer.account}"`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); + return i18n.str`Wrong credentials for "${officer.account}"`; case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`Officer or account not found`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); + return i18n.str`Officer or account not found`; case HttpStatusCode.Conflict: - return notify({ - type: "error", - title: i18n.str`Officer disabled or more recent decision was already submitted.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); + return i18n.str`Officer disabled or more recent decision was already submitted.`; + default: + assertUnreachable(fail); } - }); - }} - /> + }, + ); + + // const asd = ff.design[0]?.fields[0]?.props + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> + {ff.design.map((section, i) => { + if (!section) return <Fragment />; + return ( + <div + key={i} + class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3" + > + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + {section.title} + </h2> + {section.description && ( + <p class="mt-1 text-sm leading-6 text-gray-600"> + {section.description} + </p> + )} + </div> + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> + <div class="p-3"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <RenderAllFieldsByUiConfig + key={i} + fields={section.fields} + /> + </div> + </div> + </div> + </div> + ); + })} + </div> + + <div class="mt-6 flex items-center justify-end gap-x-6"> + <a + href={privatePages.caseDetails.url({ cid: account })} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <Button + type="submit" + handler={submitHandler} + class="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</i18n.Translate> + </Button> + </div> </Fragment> ); } diff --git a/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx index dcbd366a4..22a6d1867 100644 --- a/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx +++ b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx @@ -21,19 +21,18 @@ import * as tests from "@gnu-taler/web-util/testing"; import { CasesUI as TestedComponent } from "./Cases.js"; -import { AmountString } from "@gnu-taler/taler-util"; -import { AmlExchangeBackend } from "../utils/types.js"; +import { AmountString, TalerExchangeApi } from "@gnu-taler/taler-util"; export default { title: "cases", }; export const OneRow = tests.createExample(TestedComponent, { - filter: AmlExchangeBackend.AmlState.normal, + filter: TalerExchangeApi.AmlState.normal, onChangeFilter: () => null, records: [ { - current_state: AmlExchangeBackend.AmlState.normal, + current_state: TalerExchangeApi.AmlState.normal, h_payto: "QWEQWEQWEQWE", rowid: 1, threshold: "USD:1" as AmountString, diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx index 6b59b2736..2e92c111e 100644 --- a/packages/aml-backoffice-ui/src/pages/Cases.tsx +++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -22,19 +22,23 @@ import { import { Attention, ErrorLoading, + InputChoiceHorizontal, Loading, - createNewForm, - useTranslationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { useCases } from "../hooks/useCases.js"; import { privatePages } from "../Routing.js"; -import { amlStateConverter } from "../utils/converter.js"; -import { AmlExchangeBackend } from "../utils/types.js"; +import { FormErrors, RecursivePartial, useFormState } from "../hooks/form.js"; +import { undefinedIfEmpty } from "./CreateAccount.js"; import { Officer } from "./Officer.js"; +type FormType = { + state: TalerExchangeApi.AmlState; +}; + export function CasesUI({ records, filter, @@ -44,13 +48,45 @@ export function CasesUI({ }: { onFirstPage?: () => void; onNext?: () => void; - filter: AmlExchangeBackend.AmlState; - onChangeFilter: (f: AmlExchangeBackend.AmlState) => void; + filter: TalerExchangeApi.AmlState; + onChangeFilter: (f: TalerExchangeApi.AmlState) => void; records: TalerExchangeApi.AmlRecord[]; }): VNode { const { i18n } = useTranslationContext(); - const form = createNewForm<{ state: AmlExchangeBackend.AmlState }>(); + const [form, status] = useFormState<FormType>( + { + state: filter, + }, + (state) => { + const errors = undefinedIfEmpty<FormErrors<FormType>>({ + state: state.state === undefined ? i18n.str`required` : undefined, + }); + if (errors === undefined) { + const result: FormType = { + state: state.state!, + }; + return { + status: "ok", + result, + errors, + }; + } + const result: RecursivePartial<FormType> = { + state: state.state, + }; + return { + status: "fail", + result, + errors, + }; + }, + ); + useEffect(() => { + if (status.status === "ok" && filter !== status.result.state) { + onChangeFilter(status.result.state); + } + }, [form?.state?.value]); return ( <div> @@ -66,33 +102,25 @@ export function CasesUI({ </p> </div> <div class="px-2"> - <form.Provider - initial={{ state: filter }} - onUpdate={(v) => { - onChangeFilter(v.state ?? filter); - }} - onSubmit={(_v) => {}} - > - <form.InputChoiceHorizontal - name="state" - label={i18n.str`Filter`} - converter={amlStateConverter} - choices={[ - { - label: i18n.str`Pending`, - value: AmlExchangeBackend.AmlState.pending, - }, - { - label: i18n.str`Frozen`, - value: AmlExchangeBackend.AmlState.frozen, - }, - { - label: i18n.str`Normal`, - value: AmlExchangeBackend.AmlState.normal, - }, - ]} - /> - </form.Provider> + <InputChoiceHorizontal<FormType, "state"> + name="state" + label={i18n.str`Filter`} + handler={form.state} + choices={[ + { + label: i18n.str`Pending`, + value: TalerExchangeApi.AmlState.pending, + }, + { + label: i18n.str`Frozen`, + value: TalerExchangeApi.AmlState.frozen, + }, + { + label: i18n.str`Normal`, + value: TalerExchangeApi.AmlState.normal, + }, + ]} + /> </div> </div> <div class="mt-8 flow-root"> @@ -141,23 +169,23 @@ export function CasesUI({ </div> </td> <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500"> - {((state: AmlExchangeBackend.AmlState): VNode => { + {((state: TalerExchangeApi.AmlState): VNode => { switch (state) { - case AmlExchangeBackend.AmlState.normal: { + case TalerExchangeApi.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: { + case TalerExchangeApi.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: { + case TalerExchangeApi.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 @@ -186,7 +214,7 @@ export function CasesUI({ export function Cases() { const [stateFilter, setStateFilter] = useState( - AmlExchangeBackend.AmlState.pending, + TalerExchangeApi.AmlState.pending, ); const list = useCases(stateFilter); @@ -204,12 +232,10 @@ export function Cases() { case HttpStatusCode.Forbidden: { return ( <Fragment> - <Attention - type="danger" - title={i18n.str`Operation denied`} - > + <Attention type="danger" title={i18n.str`Operation denied`}> <i18n.Translate> - This account doesnt have access. Request account activation sending your public key. + This account doesnt have access. Request account activation + sending your public key. </i18n.Translate> </Attention> <Officer /> @@ -219,10 +245,7 @@ export function Cases() { case HttpStatusCode.Unauthorized: { return ( <Fragment> - <Attention - type="danger" - title={i18n.str`Operation denied`} - > + <Attention type="danger" title={i18n.str`Operation denied`}> <i18n.Translate> This account is not allowed to perform list the cases. </i18n.Translate> @@ -245,7 +268,9 @@ export function Cases() { onFirstPage={list.isFirstPage ? undefined : list.loadFirst} onNext={list.isLastPage ? undefined : list.loadNext} filter={stateFilter} - onChangeFilter={setStateFilter} + onChangeFilter={(d) => { + setStateFilter(d) + }} /> ); } diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx index 094e78531..a8a853bc1 100644 --- a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx +++ b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx @@ -15,9 +15,9 @@ */ import { Button, + InputLine, InternationalizationAPI, LocalNotificationBanner, - ShowInputErrorLabel, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -66,15 +66,23 @@ function createFormValidator( }); if (errors === undefined) { + const result: FormType = { + password: state.password!, + repeat: state.repeat!, + }; return { status: "ok", - result: state as FormType, + result, errors, }; } + const result: RecursivePartial<FormType> = { + password: state.password, + repeat: state.repeat, + }; return { status: "fail", - result: state, + result, errors, }; }; @@ -88,13 +96,8 @@ export function undefinedIfEmpty<T extends object>(obj: T): T | undefined { : undefined; } -export function CreateAccount({ - onNewAccount, -}: { - onNewAccount: () => void; -}): VNode { +export function CreateAccount(): VNode { const { i18n } = useTranslationContext(); - // const Form = createNewForm<FormType>(); const [settings] = usePreferences(); const officer = useOfficer(); @@ -113,9 +116,9 @@ export function CreateAccount({ ? undefined : withErrorHandler( async () => officer.create(form.password!.value!), - onNewAccount, + () => {}, ); - + form.password; return ( <div class="flex min-h-full flex-col "> <LocalNotificationBanner notification={notification} /> @@ -137,66 +140,24 @@ export function CreateAccount({ autoCapitalize="none" autoCorrect="off" > - <div> - <label - for="password" - class="block text-sm font-medium leading-6 text-gray-900" - > - <i18n.Translate>Password</i18n.Translate> - </label> - <div class="mt-2"> - <input - ref={doAutoFocus} - type="text" - name="password" - id="password" - class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - value={form.password?.value ?? ""} - enterkeyhint="next" - placeholder="strong password" - autocomplete="password" - title={i18n.str`Password`} - required - onChange={(e) => { - console.log("ASDASD", form.password?.onUpdate); - form.password?.onUpdate(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={form.password?.error} - isDirty={form.password?.value !== undefined} - /> - </div> + <div class="mt-2"> + <InputLine<FormType, "password"> + label={i18n.str`Password`} + name="password" + type="password" + required + handler={form.password} + /> </div> - <div> - <label - for="repeat" - class="block text-sm font-medium leading-6 text-gray-900" - > - <i18n.Translate>Repeat password</i18n.Translate> - </label> - <div class="mt-2"> - <input - type="text" - name="repeat" - id="repeat" - class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - value={form.repeat?.value ?? ""} - enterkeyhint="next" - placeholder="identification" - autocomplete="repeat" - title={i18n.str`Repeat password`} - required - onChange={(e): void => { - form.repeat?.onUpdate(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={form.repeat?.error} - isDirty={form.repeat?.value !== undefined} - /> - </div> + <div class="mt-2"> + <InputLine<FormType, "repeat"> + label={i18n.str`Repeat password`} + name="repeat" + type="password" + required + handler={form.repeat} + /> </div> <div class="mt-8"> diff --git a/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx b/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx index b23798172..3d6e14f22 100644 --- a/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx +++ b/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx @@ -25,26 +25,11 @@ export function HandleAccountNotReady({ officer: OfficerNotReady; }): VNode { if (officer.state === "not-found") { - return ( - <CreateAccount - onNewAccount={(password) => { - officer.create(password); - }} - /> - ); + return <CreateAccount />; } if (officer.state === "locked") { - return ( - <UnlockAccount - onRemoveAccount={() => { - officer.forget(); - }} - onAccountUnlocked={async (pwd) => { - await officer.tryUnlock(pwd); - }} - /> - ); + return <UnlockAccount />; } assertUnreachable(officer); } diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx index 1cb50efd2..11b25575b 100644 --- a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx +++ b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { AbsoluteTime, Duration, TranslatedString } from "@gnu-taler/taler-util"; +import { AbsoluteTime, AmountString, Duration, TranslatedString } from "@gnu-taler/taler-util"; import { InternationalizationAPI } from "@gnu-taler/web-util/browser"; import * as tests from "@gnu-taler/web-util/testing"; import { getEventsFromAmlHistory } from "./CaseDetails.js"; @@ -48,7 +48,7 @@ export const WithSomeEvents = tests.createExample(TestedComponent, { { "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}", - "new_threshold": "STATER:0", + "new_threshold": "STATER:0" as AmountString, "new_state": 1, "decision_time": { "t_s": 1700208199 @@ -57,7 +57,7 @@ export const WithSomeEvents = tests.createExample(TestedComponent, { { "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}", - "new_threshold": "STATER:0", + "new_threshold": "STATER:0" as AmountString, "new_state": 1, "decision_time": { "t_s": 1700208211 @@ -66,7 +66,7 @@ export const WithSomeEvents = tests.createExample(TestedComponent, { { "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}", - "new_threshold": "STATER:0", + "new_threshold": "STATER:0" as AmountString, "new_state": 1, "decision_time": { "t_s": 1700208220 @@ -75,7 +75,7 @@ export const WithSomeEvents = tests.createExample(TestedComponent, { { "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", "justification": "{\"index\":4,\"name\":\"Declaration for trusts (902.13e)\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700208362854},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"contractingPartner\":\"f\",\"knownAs\":\"a\",\"trust\":{\"name\":\"b\",\"type\":\"discretionary\",\"revocability\":\"irrevocable\"}}}", - "new_threshold": "STATER:0", + "new_threshold": "STATER:0" as AmountString, "new_state": 1, "decision_time": { "t_s": 1700208385 @@ -84,7 +84,7 @@ export const WithSomeEvents = tests.createExample(TestedComponent, { { "decider_pub": "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG", "justification": "{\"id\":\"simple_comment\",\"label\":\"Simple comment\",\"version\":1,\"value\":{\"when\":{\"t_ms\":1700488420810},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"qwe\"}}", - "new_threshold": "STATER:0", + "new_threshold": "STATER:0" as AmountString, "new_state": 1, "decision_time": { "t_s": 1700488423 @@ -93,7 +93,7 @@ export const WithSomeEvents = tests.createExample(TestedComponent, { { "decider_pub": "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG", "justification": "{\"id\":\"simple_comment\",\"label\":\"Simple comment\",\"version\":1,\"value\":{\"when\":{\"t_ms\":1700488671251},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"asd asd asd \"}}", - "new_threshold": "STATER:0", + "new_threshold": "STATER:0" as AmountString, "new_state": 1, "decision_time": { "t_s": 1700488677 diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx index c1f7e02cb..1115414c0 100644 --- a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx +++ b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx @@ -16,6 +16,7 @@ import { AbsoluteTime, AmountJson, + TalerExchangeApi, TranslatedString, } from "@gnu-taler/taler-util"; import { @@ -26,8 +27,6 @@ import { } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; -import { amlStateConverter } from "../utils/converter.js"; -import { AmlExchangeBackend } from "../utils/types.js"; import { AmlEvent } from "./CaseDetails.js"; export function ShowConsolidated({ @@ -73,19 +72,18 @@ export function ShowConsolidated({ props: { label: i18n.str`State`, name: "aml.state", - converter: amlStateConverter, choices: [ { label: i18n.str`Frozen`, - value: AmlExchangeBackend.AmlState.frozen, + value: TalerExchangeApi.AmlState.frozen, }, { label: i18n.str`Pending`, - value: AmlExchangeBackend.AmlState.pending, + value: TalerExchangeApi.AmlState.pending, }, { label: i18n.str`Normal`, - value: AmlExchangeBackend.AmlState.normal, + value: TalerExchangeApi.AmlState.normal, }, ], }, @@ -135,7 +133,7 @@ export function ShowConsolidated({ interface Consolidated { aml: { - state: AmlExchangeBackend.AmlState; + state: TalerExchangeApi.AmlState; threshold: AmountJson; since: AbsoluteTime; }; @@ -154,7 +152,7 @@ function getConsolidated( ): Consolidated { const initial: Consolidated = { aml: { - state: AmlExchangeBackend.AmlState.normal, + state: TalerExchangeApi.AmlState.normal, threshold: { currency: "ARS", value: 1000, diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx index de634c9e0..9552f2b0c 100644 --- a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx +++ b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx @@ -13,81 +13,116 @@ 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, UnwrapKeyError } from "@gnu-taler/taler-util"; -import { createNewForm, notifyError, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + Button, + InputLine, + LocalNotificationBanner, + useLocalNotificationHandler, + useTranslationContext +} from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; +import { FormErrors, useFormState } from "../hooks/form.js"; +import { useOfficer } from "../hooks/officer.js"; +import { undefinedIfEmpty } from "./CreateAccount.js"; -export function UnlockAccount({ - onAccountUnlocked, - onRemoveAccount, -}: { - onAccountUnlocked: (password: string) => Promise<void>; - onRemoveAccount: () => void; -}): VNode { - const { i18n } = useTranslationContext() - const Form = createNewForm<{ - password: string; - }>(); +type FormType = { + password: string; +}; + +export function UnlockAccount(): VNode { + const { i18n } = useTranslationContext(); + + const officer = useOfficer(); + const [notification, withErrorHandler] = useLocalNotificationHandler(); + + const [form, status] = useFormState<FormType>( + { + password: undefined, + }, + (state) => { + const errors = undefinedIfEmpty<FormErrors<FormType>>({ + password: !state.password ? i18n.str`required` : undefined, + }); + if (errors === undefined) { + return { + status: "ok", + result: state as FormType, + errors, + }; + } + return { + status: "fail", + result: state, + errors, + }; + }, + ); + + const unlockHandler = + status.status === "fail" || officer.state !== "locked" + ? undefined + : withErrorHandler( + async () => officer.tryUnlock(form.password!.value!), + () => {}, + ); + + const forgetHandler = + status.status === "fail" || officer.state !== "locked" + ? undefined + : withErrorHandler( + 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>Account locked</i18n.Translate> </h1> <p class="mt-6 text-lg leading-8 text-gray-600"> - <i18n.Translate>Your account is normally locked anytime you reload. To unlock type - your password again.</i18n.Translate> + <i18n.Translate> + Your account is normally locked anytime you reload. To unlock type + your password again. + </i18n.Translate> </p> </div> <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 - onSubmit={async (v) => { - try { - await onAccountUnlocked(v.password!); - notifyInfo(i18n.str`Account unlocked`); - } catch (e) { - if (e instanceof UnwrapKeyError) { - notifyError( - i18n.str`Could not unlock account`, - e.message as TranslatedString, - ); - } else { - throw e; - } - } - }} - > - <div class="mb-4"> - <Form.InputLine - label={i18n.str`Password`} - name="password" - type="password" - required - /> - </div> - <div class="mt-8"> - <button - type="submit" - class="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> - </div> - </Form.Provider> + <div class="mb-4"> + <InputLine<FormType, "password"> + label={i18n.str`Password`} + name="password" + type="password" + required + handler={form.password} + /> + </div> + + <div class="mt-8"> + <Button + type="submit" + handler={unlockHandler} + disabled={!unlockHandler} + 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> + </div> + </div> - <button + <Button type="button" - onClick={() => { - onRemoveAccount(); - }} - class="m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 " + handler={forgetHandler} + disabled={!forgetHandler} + 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> + </Button> </div> </div> ); diff --git a/packages/aml-backoffice-ui/src/pages/index.stories.ts b/packages/aml-backoffice-ui/src/pages/index.stories.ts index b2cbf485e..f11028de8 100644 --- a/packages/aml-backoffice-ui/src/pages/index.stories.ts +++ b/packages/aml-backoffice-ui/src/pages/index.stories.ts @@ -14,5 +14,4 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ export * as a1 from "./ShowConsolidated.stories.js"; -export * as a2 from "./AntiMoneyLaunderingForm.stories.js"; export * as a3 from "./Cases.stories.js"; diff --git a/packages/aml-backoffice-ui/src/utils/converter.ts b/packages/aml-backoffice-ui/src/utils/converter.ts index d2f05ed84..cca764a81 100644 --- a/packages/aml-backoffice-ui/src/utils/converter.ts +++ b/packages/aml-backoffice-ui/src/utils/converter.ts @@ -1,30 +1,46 @@ -import { AmlExchangeBackend } from "./types.js"; +/* + This file is part of GNU Taler + (C) 2022-2024 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 { TalerExchangeApi } from "@gnu-taler/taler-util"; export const amlStateConverter = { toStringUI: stringifyAmlState, fromStringUI: parseAmlState, }; -function stringifyAmlState(s: AmlExchangeBackend.AmlState | undefined): string { +function stringifyAmlState(s: TalerExchangeApi.AmlState | undefined): string { if (s === undefined) return ""; switch (s) { - case AmlExchangeBackend.AmlState.normal: + case TalerExchangeApi.AmlState.normal: return "normal"; - case AmlExchangeBackend.AmlState.pending: + case TalerExchangeApi.AmlState.pending: return "pending"; - case AmlExchangeBackend.AmlState.frozen: + case TalerExchangeApi.AmlState.frozen: return "frozen"; } } -function parseAmlState(s: string | undefined): AmlExchangeBackend.AmlState { +function parseAmlState(s: string | undefined): TalerExchangeApi.AmlState { switch (s) { case "normal": - return AmlExchangeBackend.AmlState.normal; + return TalerExchangeApi.AmlState.normal; case "pending": - return AmlExchangeBackend.AmlState.pending; + return TalerExchangeApi.AmlState.pending; case "frozen": - return AmlExchangeBackend.AmlState.frozen; + return TalerExchangeApi.AmlState.frozen; default: throw Error(`unknown AML state: ${s}`); } diff --git a/packages/aml-backoffice-ui/src/utils/types.ts b/packages/aml-backoffice-ui/src/utils/types.ts deleted file mode 100644 index fd70d4e4d..000000000 --- a/packages/aml-backoffice-ui/src/utils/types.ts +++ /dev/null @@ -1,124 +0,0 @@ -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; - // EdDSA signatures are transmitted as 64-bytes base32 - // binary-encoded objects with just the R and S values (base32_ binary-only). - type EddsaSignature = 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 interface AmlDecision { - - // Human-readable justification for the decision. - justification: string; - - // At what monthly transaction volume should the - // decision be automatically reviewed? - new_threshold: Amount; - - // Which payto-address is the decision about? - // Identifies a GNU Taler wallet or an affected bank account. - h_payto: PaytoHash; - - // What is the new AML state (e.g. frozen, unfrozen, etc.) - // Numerical values are defined in AmlDecisionState. - new_state: Integer; - - // Signature by the AML officer over a - // TALER_MasterAmlOfficerStatusPS. - // Must have purpose TALER_SIGNATURE_MASTER_AML_KEY. - officer_sig: EddsaSignature; - - // When was the decision made? - decision_time: Timestamp; - - // Optional argument to impose new KYC requirements - // that the customer has to satisfy to unblock transactions. - kyc_requirements?: string[]; - } - - -} diff --git a/packages/web-util/src/forms/DefaultForm.tsx b/packages/web-util/src/forms/DefaultForm.tsx index 118699487..1c635e089 100644 --- a/packages/web-util/src/forms/DefaultForm.tsx +++ b/packages/web-util/src/forms/DefaultForm.tsx @@ -1,4 +1,4 @@ -import { Fragment, h } from "preact"; +import { Fragment, VNode, h } from "preact"; import { FormProvider, FormProviderProps, FormState } from "./FormProvider.js"; import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; import { TranslatedString } from "@gnu-taler/taler-util"; @@ -39,7 +39,7 @@ export function DefaultForm<T extends object>({ onSubmit, children, readOnly, -}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm<T> }) { +}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm<T> }): VNode { return ( <FormProvider initial={initial} diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts index 1ad3508ae..d2ff9c37e 100644 --- a/packages/web-util/src/forms/forms.ts +++ b/packages/web-util/src/forms/forms.ts @@ -1,6 +1,5 @@ import { h as create, Fragment, VNode } from "preact"; import { Caption } from "./Caption.js"; -import { FormProvider } from "./FormProvider.js"; import { Group } from "./Group.js"; import { InputAbsoluteTime } from "./InputAbsoluteTime.js"; import { InputAmount } from "./InputAmount.js"; @@ -9,7 +8,6 @@ import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js"; import { InputChoiceStacked } from "./InputChoiceStacked.js"; import { InputFile } from "./InputFile.js"; import { InputInteger } from "./InputInteger.js"; -import { InputLine } from "./InputLine.js"; import { InputSelectMultiple } from "./InputSelectMultiple.js"; import { InputSelectOne } from "./InputSelectOne.js"; import { InputText } from "./InputText.js"; |