taler-typescript-core

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

commit 69e89b4ea0c88ed2f5cb3706d6583b9cf37b24e9
parent c480e088520e301f9dfd5ea27948b5a0de0dd3f2
Author: Sebastian <sebasjm@gmail.com>
Date:   Wed,  2 Oct 2024 14:14:52 -0300

ask for justification when making a decision

Diffstat:
Mpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 241++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/aml-backoffice-ui/src/pages/Search.tsx | 6++----
2 files changed, 185 insertions(+), 62 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -15,11 +15,13 @@ */ import { AbsoluteTime, + AmlDecisionRequest, AmountJson, Amounts, Codec, CurrencySpecification, HttpStatusCode, + LegitimizationRuleSet, OperationFail, OperationOk, PaytoString, @@ -35,13 +37,21 @@ import { } from "@gnu-taler/taler-util"; import { Attention, + Button, + convertUiField, DefaultForm, FormMetadata, + getConverterById, InternationalizationAPI, Loading, + LocalNotificationBanner, + RenderAllFieldsByUiConfig, ShowInputErrorLabel, Time, + UIFormElementConfig, + UIHandlerId, useExchangeApiContext, + useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format, formatDuration, intervalToDuration } from "date-fns"; @@ -54,6 +64,8 @@ import { useAccountInformation } from "../hooks/account.js"; import { useAccountDecisions } from "../hooks/decisions.js"; import { ShowConsolidated } from "./ShowConsolidated.js"; import { useOfficer } from "../hooks/officer.js"; +import { getShapeFromFields, useFormState } from "../hooks/form.js"; +import { privatePages } from "../Routing.js"; export type AmlEvent = | AmlFormEvent @@ -174,13 +186,8 @@ export function getEventsFromAmlHistory( export function CaseDetails({ account, paytoString }: { account: string, paytoString?: PaytoString }) { const [selected, setSelected] = useState<AbsoluteTime>(AbsoluteTime.now()); - const [showForm, setShowForm] = useState<{ - justification: Justification; - metadata: FormMetadata; - }>(); - const { config, lib } = useExchangeApiContext(); - const officer = useOfficer(); - const session = officer.state === "ready" ? officer.account : undefined; + const [request, setDesicionRequest] = useState<Omit<AmlDecisionRequest, "officer_sig"> | undefined>(undefined) + const { config } = useExchangeApiContext(); const { i18n } = useTranslationContext(); const details = useAccountInformation(account); @@ -228,26 +235,13 @@ export function CaseDetails({ account, paytoString }: { account: string, paytoSt const events = getEventsFromAmlHistory(accountDetails, i18n, allForms); - // if (showForm !== undefined) { - // return ( - // <DefaultForm - // readOnly={true} - // initial={showForm.justification.value} - // form={showForm.metadata as any} // FIXME: HERE - // > - // <div class="mt-6 flex items-center justify-end gap-x-6"> - // <button - // class="text-sm font-semibold leading-6 text-gray-900" - // onClick={() => { - // setShowForm(undefined); - // }} - // > - // <i18n.Translate>Cancel</i18n.Translate> - // </button> - // </div> - // </DefaultForm> - // ); - // } + + + if (request) { + return <SubmitNewDecision request={request} onComplete={() => { + setDesicionRequest(undefined) + }} /> + } return ( <div class="min-w-60"> @@ -272,8 +266,7 @@ export function CaseDetails({ account, paytoString }: { account: string, paytoSt <div> <button onClick={async () => { - if (!session) return; - lib.exchange.makeAmlDesicion(session, { + setDesicionRequest({ payto_uri: paytoString, decision_time: AbsoluteTime.toProtocolTimestamp( AbsoluteTime.now(), @@ -290,7 +283,7 @@ export function CaseDetails({ account, paytoString }: { account: string, paytoSt rules: FREEZE_RULES(config.currency), successor_measure: "verboten", }, - }); + }) }} class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" > @@ -298,8 +291,7 @@ export function CaseDetails({ account, paytoString }: { account: string, paytoSt </button> <button onClick={async () => { - if (!session) return; - lib.exchange.makeAmlDesicion(session, { + setDesicionRequest({ payto_uri: paytoString, decision_time: AbsoluteTime.toProtocolTimestamp( AbsoluteTime.now(), @@ -324,8 +316,7 @@ export function CaseDetails({ account, paytoString }: { account: string, paytoSt </button> <button onClick={async () => { - if (!session) return; - lib.exchange.makeAmlDesicion(session, { + setDesicionRequest({ payto_uri: paytoString, decision_time: AbsoluteTime.toProtocolTimestamp( AbsoluteTime.now(), @@ -350,8 +341,7 @@ export function CaseDetails({ account, paytoString }: { account: string, paytoSt </button> <button onClick={async () => { - if (!session) return; - lib.exchange.makeAmlDesicion(session, { + setDesicionRequest({ payto_uri: paytoString, decision_time: AbsoluteTime.toProtocolTimestamp( AbsoluteTime.now(), @@ -384,7 +374,17 @@ export function CaseDetails({ account, paytoString }: { account: string, paytoSt <h1 class="my-4 text-base font-semibold leading-6 text-black"> <i18n.Translate>Current active rules</i18n.Translate> </h1> - <ShowDecisionInfo decision={activeDecision} startOpen /> + <ShowDecisionLimitInfo + since={AbsoluteTime.fromProtocolTimestamp( + activeDecision.decision_time, + )} + until={AbsoluteTime.fromProtocolTimestamp( + activeDecision.limits.expiration_time, + )} + justification={activeDecision.justification} + ruleSet={activeDecision.limits} + + startOpen /> </Fragment> )} <h1 class="my-4 text-base font-semibold leading-6 text-black"> @@ -420,7 +420,15 @@ export function CaseDetails({ account, paytoString }: { account: string, paytoSt <i18n.Translate>Previous AML decisions</i18n.Translate> </h1> {restDecisions.map((d) => { - return <ShowDecisionInfo decision={d} />; + return <ShowDecisionLimitInfo since={AbsoluteTime.fromProtocolTimestamp( + d.decision_time, + )} + until={AbsoluteTime.fromProtocolTimestamp( + d.limits.expiration_time, + )} + justification={d.justification} + ruleSet={d.limits} + />; })} </Fragment> ) : ( @@ -433,17 +441,141 @@ export function CaseDetails({ account, paytoString }: { account: string, paytoSt ); } -function ShowDecisionInfo({ - decision, + +function SubmitNewDecision({ request, onComplete }: { onComplete: () => void; request: Omit<AmlDecisionRequest, "officer_sig"> }): VNode { + const { i18n } = useTranslationContext(); + const { lib } = useExchangeApiContext(); + const [notification, withErrorHandler] = useLocalNotificationHandler(); + + const formDesign: UIFormElementConfig[] = [{ + id: "justification" as UIHandlerId, + type: "textArea", + required: true, + label: i18n.str`Justification`, + }] + const officer = useOfficer(); + const session = officer.state === "ready" ? officer.account : undefined; + const decisionForm = useFormState<{ justification: string }>( + getShapeFromFields(formDesign), + { justification: "" }, + (d) => { + d.justification; + return { + status: "ok", + errors: undefined, + result: d as any + } + }, + ); + + const submitHandler = + decisionForm === undefined || !session + ? undefined + : withErrorHandler( + () => { + request.justification = decisionForm.status.result.justification ?? "empty" + return lib.exchange.makeAmlDesicion(session, request); + }, + onComplete, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Forbidden: + if (session) { + return i18n.str`Wrong credentials for "${session}"`; + } else { + return i18n.str`Wrong credentials.`; + } + case HttpStatusCode.NotFound: + return i18n.str`The account was not found`; + case HttpStatusCode.Conflict: + return i18n.str`Officer disabled or more recent decision was already submitted.`; + default: + assertUnreachable(fail); + } + }, + ); + + + + return <div> + <LocalNotificationBanner notification={notification} /> + <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 "> + <i18n.Translate>Submit decision</i18n.Translate> + </h1> + <form + class="space-y-6" + noValidate + onSubmit={(e) => { + e.preventDefault(); + }} + autoCapitalize="none" + autoCorrect="off" + > + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> + <RenderAllFieldsByUiConfig + fields={convertUiField( + i18n, + formDesign, + decisionForm.handler, + getConverterById, + )} + /> + </div> + + <div class="mt-6 flex items-center justify-end gap-x-6"> + <button + onClick={onComplete} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + + <Button + type="submit" + handler={submitHandler} + disabled={!submitHandler} + class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Confirm</i18n.Translate> + </Button> + </div> + + </form> + + <ShowDecisionLimitInfo + since={AbsoluteTime.fromProtocolTimestamp( + request.decision_time, + )} + until={AbsoluteTime.fromProtocolTimestamp( + request.new_rules.expiration_time, + )} + ruleSet={request.new_rules} + + startOpen /> + + </div> + +} + +function ShowDecisionLimitInfo({ + ruleSet, + since, + until, startOpen, + justification }: { - decision: TalerExchangeApi.AmlDecision; + + since: AbsoluteTime; + until: AbsoluteTime; + justification?: string; + ruleSet: LegitimizationRuleSet; startOpen?: boolean; }): VNode { const { i18n } = useTranslationContext(); const { config } = useExchangeApiContext(); const [opened, setOpened] = useState(startOpen ?? false); + function Header() { return ( <ul @@ -459,9 +591,7 @@ function ShowDecisionInfo({ <div class="p-2 disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"> <Time format="dd/MM/yyyy HH:mm:ss" - timestamp={AbsoluteTime.fromProtocolTimestamp( - decision.decision_time, - )} + timestamp={since} /> </div> </div> @@ -469,11 +599,7 @@ function ShowDecisionInfo({ <div class="flex shrink-0 items-center gap-x-4"> <div class="flex mt-2 rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"> <div class="pointer-events-none bg-gray-200 inset-y-0 flex items-center px-3"> - {AbsoluteTime.isExpired( - AbsoluteTime.fromProtocolTimestamp( - decision.limits.expiration_time, - ), - ) ? ( + {AbsoluteTime.isExpired(until) ? ( <i18n.Translate>Expired</i18n.Translate> ) : ( <i18n.Translate>Expires</i18n.Translate> @@ -482,9 +608,7 @@ function ShowDecisionInfo({ <div class="p-2 disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"> <Time format="dd/MM/yyyy HH:mm:ss" - timestamp={AbsoluteTime.fromProtocolTimestamp( - decision.limits.expiration_time, - )} + timestamp={until} /> </div> </div> @@ -501,7 +625,7 @@ function ShowDecisionInfo({ </div> ); } - const balanceLimit = decision.limits.rules.find( + const balanceLimit = ruleSet.rules.find( (r) => r.operation_type === "BALANCE", ); @@ -511,22 +635,23 @@ function ShowDecisionInfo({ <Header /> </div> - {!decision.justification ? undefined : ( - <div> + {!justification ? undefined : ( + <div class="mt-4"> <label for="comment" class="block text-sm font-medium leading-6 text-gray-900" > - Description + <i18n.Translate>AML officer justification</i18n.Translate> </label> <div class="mt-2"> <textarea rows={2} + readOnly name="comment" id="comment" class="block w-full 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" > - {decision.justification} + {justification} </textarea> </div> </div> @@ -570,7 +695,7 @@ function ShowDecisionInfo({ </tr> </thead> <tbody class="divide-y divide-gray-200"> - {decision.limits.rules.map((r) => { + {ruleSet.rules.map((r) => { if (r.operation_type === "BALANCE") return; return ( <tr> diff --git a/packages/aml-backoffice-ui/src/pages/Search.tsx b/packages/aml-backoffice-ui/src/pages/Search.tsx @@ -259,8 +259,6 @@ function ShowResult({ payto }: { payto: PaytoUri }): VNode { </div> </div> } - // const detailsUrl = new URL(, window.location.href) - // detailsUrl.searchParams.set("payto", encodeCrockForURI(paytoStr)) return <div class="mt-4"> <Attention title={i18n.str`Account not found`} type="warning"> <i18n.Translate> @@ -303,7 +301,7 @@ function XTalerBankForm({ form.status.result.hostname, form.status.result.account, { - "receiver-name": encodeURIComponent(form.status.result.name), + "receiver-name": (form.status.result.name), }, ); @@ -348,7 +346,7 @@ function IbanForm({ form.status.status === "fail" ? undefined : buildPayto("iban", form.status.result.account, form.status.result.bic, { - "receiver-name": encodeURIComponent(form.status.result.name), + "receiver-name": (form.status.result.name), }); return (