taler-typescript-core

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

commit cbf261e2b81b4db6a0e55620a8e85b70f21bb52e
parent effe8b91907c38ad8841c79f8491e43599101952
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu, 12 Jun 2025 10:19:26 -0300

fix #9736

Diffstat:
Mpackages/aml-backoffice-ui/src/pages/NewMeasure.tsx | 783+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Events.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/decision/Justification.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/decision/Measures.tsx | 238++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Properties.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/decision/Rules.tsx | 4++--
Mpackages/challenger-ui/src/pages/AskChallenge.tsx | 16++++++++--------
Mpackages/web-util/src/forms/forms-types.ts | 2+-
Mpackages/web-util/src/forms/gana/challenger_email.ts | 22+++++++++-------------
Mpackages/web-util/src/forms/gana/challenger_postal.ts | 52++++++++++++++++++++++++----------------------------
Mpackages/web-util/src/forms/gana/challenger_sms.ts | 22+++++++++-------------
Mpackages/web-util/src/hooks/useForm.ts | 2+-
12 files changed, 806 insertions(+), 341 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/pages/NewMeasure.tsx b/packages/aml-backoffice-ui/src/pages/NewMeasure.tsx @@ -1,5 +1,6 @@ import { AmlProgramRequirement, + assertUnreachable, AvailableMeasureSummary, KycCheckInformation, KycRule, @@ -7,16 +8,23 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; import { + design_challenger_email, + design_challenger_phone, + design_challenger_postal, ErrorsSummary, + form_challenger_email, FormDesign, FormUI, + InputToggle, InternationalizationAPI, + RecursivePartial, useForm, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useCurrentDecisionRequest } from "../hooks/decision-request.js"; import { useServerMeasures } from "../hooks/server-info.js"; +import { useState } from "preact/hooks"; export type MeasureDefinition = { name: string; @@ -29,6 +37,12 @@ export type MeasureDefinition = { }[]; }; +type VerificationMeasureDefinition = { + name: string; + readOnly: boolean; + address: any; +}; + /** * Defined new limits for the account * @param param0 @@ -78,7 +92,7 @@ export function NewMeasure({ ); } -export function MeasureForm({ +function NormalMeasureForm({ summary, onCancel, onAdded, @@ -94,9 +108,9 @@ export function MeasureForm({ onAdded: (name: string) => void; onChanged: (name: string) => void; onRemoved: (name: string) => void; -}) { - const { i18n } = useTranslationContext(); +}): VNode { const [request, updateRequest] = useCurrentDecisionRequest(); + const { i18n } = useTranslationContext(); const names = { measures: Object.entries(summary.roots).map(([key, value]) => ({ @@ -125,37 +139,64 @@ export function MeasureForm({ const name = !form.status.result ? undefined : form.status.result.name; - const program = - !form.status.result || - !form.status.result.program || - !summary.programs[form.status.result.program] - ? undefined - : { - ...summary.programs[form.status.result.program], - name: form.status.result.program, - }; + function addNewCustomMeasure() { + const newMeasure = form.status.result as MeasureDefinition; + const currentMeasures = { ...request.custom_measures }; + currentMeasures[newMeasure.name] = { + check_name: newMeasure.check, + prog_name: newMeasure.program, + context: (newMeasure.context ?? []).reduce( + (prev, cur) => { + prev[cur.key] = getContextValueByType(cur.type, cur.value); + return prev; + }, + {} as Record<string, any>, + ), + }; + updateRequest("add new measure", { + custom_measures: currentMeasures, + }); + if (onAdded) { + onAdded(newMeasure.name); + } + } - const check = - !form.status.result || - !form.status.result.check || - !summary.checks[form.status.result.check] - ? undefined - : { - ...summary.checks[form.status.result.check], - name: form.status.result.check, - }; + function updateCurrentCustomMeasure() { + const newMeasure = form.status.result as MeasureDefinition; - const context = - !form.status.result || !form.status.result.context - ? [] - : (form.status.result.context as MeasureDefinition["context"]); + const CURRENT_MEASURES = { ...request.custom_measures }; + CURRENT_MEASURES[newMeasure.name] = { + check_name: newMeasure.check, + prog_name: newMeasure.program, + context: (newMeasure.context ?? []).reduce( + (prev, cur) => { + prev[cur.key] = getContextValueByType(cur.type, cur.value); + return prev; + }, + {} as Record<string, any>, + ), + }; + updateRequest("update measure", { + custom_measures: CURRENT_MEASURES, + }); + if (onChanged) { + onChanged(newMeasure.name); + } + } - return ( - <div> - <h2 class="mt-4 mb-2"> - <i18n.Translate>Add measure</i18n.Translate> - </h2> + function removeCustomMeasure() { + const currentMeasures = { ...request.custom_measures }; + delete currentMeasures[name!]; + updateRequest("remove measure", { + custom_measures: currentMeasures, + }); + if (onRemoved) { + onRemoved(name!); + } + } + return ( + <Fragment> <FormUI design={design} model={form.model} /> <button @@ -170,27 +211,7 @@ export function MeasureForm({ {addingNew ? ( <button disabled={form.status.status === "fail"} - onClick={() => { - const newMeasure = form.status.result as MeasureDefinition; - const currentMeasures = { ...request.custom_measures }; - currentMeasures[newMeasure.name] = { - check_name: newMeasure.check, - prog_name: newMeasure.program, - context: (newMeasure.context ?? []).reduce( - (prev, cur) => { - prev[cur.key] = getContextValueByType(cur.type, cur.value); - return prev; - }, - {} as Record<string, any>, - ), - }; - updateRequest("add new measure", { - custom_measures: currentMeasures, - }); - if (onAdded) { - onAdded(newMeasure.name); - } - }} + onClick={addNewCustomMeasure} 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 disabled:bg-gray-600" > <i18n.Translate>Add</i18n.Translate> @@ -199,43 +220,14 @@ export function MeasureForm({ <Fragment> <button disabled={form.status.status === "fail"} - onClick={() => { - const newMeasure = form.status.result as MeasureDefinition; - - const CURRENT_MEASURES = { ...request.custom_measures }; - CURRENT_MEASURES[newMeasure.name] = { - check_name: newMeasure.check, - prog_name: newMeasure.program, - context: (newMeasure.context ?? []).reduce( - (prev, cur) => { - prev[cur.key] = getContextValueByType(cur.type, cur.value); - return prev; - }, - {} as Record<string, any>, - ), - }; - updateRequest("update measure", { - custom_measures: CURRENT_MEASURES, - }); - if (onChanged) { - onChanged(newMeasure.name); - } - }} + onClick={updateCurrentCustomMeasure} 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 disabled:bg-gray-600" > <i18n.Translate>Update</i18n.Translate> </button> + <button - onClick={() => { - const currentMeasures = { ...request.custom_measures }; - delete currentMeasures[name!]; - updateRequest("remove measure", { - custom_measures: currentMeasures, - }); - if (onRemoved) { - onRemoved(name!); - } - }} + onClick={removeCustomMeasure} 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 disabled:bg-gray-600" > <i18n.Translate>Remove</i18n.Translate> @@ -243,123 +235,287 @@ export function MeasureForm({ </Fragment> )} - <h2 class="mt-4 mb-2"> - <i18n.Translate>Description</i18n.Translate> - </h2> + <DescribeMeasure measure={form.status.result} summary={summary} /> + </Fragment> + ); +} +function VerificationMeasureForm({ + summary, + onCancel, + onAdded, + onChanged, + onRemoved, + initial, + addingNew, + challengeType, +}: { + initial?: Partial<MeasureDefinition>; + addingNew?: boolean; + summary: AvailableMeasureSummary; + onCancel: () => void; + onAdded: (name: string) => void; + onChanged: (name: string) => void; + onRemoved: (name: string) => void; + challengeType: "email" | "phone" | "postal"; +}): VNode { + const [request, updateRequest] = useCurrentDecisionRequest(); + const { i18n } = useTranslationContext(); - {!program ? undefined : ( - <div class="rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 "> - <dl class="flex flex-wrap"> - <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg"> - <dt class="text-sm/6 text-white"> - <i18n.Translate>Program</i18n.Translate> - </dt> - <dd class="mt-1 text-base font-semibold text-white"> - {program.name} - </dd> - </div> - <div class="mt-4 flex w-full flex-none gap-x-4 px-6"> - <dt class="flex-none text-gray-500"> - <i18n.Translate>Description</i18n.Translate> - </dt> - <dd class="text-sm/6 "> - <i18n.Translate>{program.description}</i18n.Translate> - </dd> - </div> - <div class="mt-2 flex w-full flex-none gap-x-4 border-t border-gray-900/5 px-6 pt-2"> - <dt class="flex-none text-gray-500"> - <i18n.Translate>Context</i18n.Translate> - </dt> - <dd class="text-sm/6 font-medium text-gray-900"> - <pre>{program.context.join(",")}</pre> - </dd> - </div> - <div class="mt-4 flex w-full flex-none gap-x-4 px-6"> - <dt class="flex-none text-gray-500"> - <i18n.Translate>Inputs</i18n.Translate> - </dt> - <dd class="text-sm/6 "> - <pre class="whitespace-pre-wrap"> - {program.inputs.join(",")} - </pre> - </dd> - </div> - </dl> - <div class="px-4 pb-2"></div> - </div> + const design = verificationFormDesign( + i18n, + summary, + !addingNew, + challengeType, + ); + + const initAddr = (initial?.context ?? []).find( + (d) => d.key === "initial_address", + ); + + let readOnly: boolean | undefined; + let rest = {}; + if (initAddr && initAddr.value) { + const va = JSON.parse(initAddr.value); + readOnly = va.read_only; + delete va.read_only; + rest = { ...va }; + } + + const template: Partial<VerificationMeasureDefinition> = { + name: initial?.name, + readOnly, + address: rest, + }; + + const form = useForm<VerificationMeasureDefinition>(design, template ?? {}); + + // const name = !form.status.result ? undefined : form.status.result.name; + + if (!initial) { + throw Error("verification doesn't have initial value"); + } + if (!initial.check) { + throw Error("verification doesn't have check"); + } + if (!initial.program) { + throw Error("verification doesn't have program"); + } + if (!initial.context) { + throw Error("verification doesn't have program"); + } + if (!initial.name) { + throw Error("verification doesn't have name"); + } + + const check_name = initial.check; + const measure_name = initial.name; + const prog_name = initial.program; + const context = initial.context.reduce( + (prev, cur) => { + prev[cur.key] = getContextValueByType(cur.type, cur.value); + return prev; + }, + {} as Record<string, any>, + ); + + function addNewCustomMeasure() { + const newMeasure = form.status.result as VerificationMeasureDefinition; + const currentMeasures = { ...request.custom_measures }; + delete currentMeasures[measure_name]; + + currentMeasures[newMeasure.name] = { + check_name, + prog_name, + context: { + ...context, + initial_address: { + read_only: newMeasure.readOnly, + ...newMeasure.address, + }, + }, + }; + updateRequest("add new measure", { + custom_measures: currentMeasures, + }); + if (onAdded) { + onAdded(newMeasure.name); + } + } + + function updateCurrentCustomMeasure() { + const newMeasure = form.status.result as VerificationMeasureDefinition; + + const CURRENT_MEASURES = { ...request.custom_measures }; + CURRENT_MEASURES[newMeasure.name] = { + check_name, + prog_name, + context: { + ...context, + initial_address: { + read_only: newMeasure.readOnly, + ...newMeasure.address, + }, + }, + }; + updateRequest("update measure", { + custom_measures: CURRENT_MEASURES, + }); + if (onChanged) { + onChanged(newMeasure.name); + } + } + + function removeCustomMeasure() { + const newMeasure = form.status.result as VerificationMeasureDefinition; + const currentMeasures = { ...request.custom_measures }; + delete currentMeasures[newMeasure.name]; + updateRequest("remove measure", { + custom_measures: currentMeasures, + }); + if (onRemoved) { + onRemoved(name!); + } + } + + return ( + <Fragment> + <FormUI design={design} model={form.model} /> + + <button + onClick={() => { + onCancel(); + }} + class="m-4 rounded-md w-fit border-1 px-3 py-2 text-center text-sm shadow-sm " + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + + {addingNew ? ( + <button + disabled={form.status.status === "fail"} + onClick={addNewCustomMeasure} + 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 disabled:bg-gray-600" + > + <i18n.Translate>Add</i18n.Translate> + </button> + ) : ( + <Fragment> + <button + disabled={form.status.status === "fail"} + onClick={updateCurrentCustomMeasure} + 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 disabled:bg-gray-600" + > + <i18n.Translate>Update</i18n.Translate> + </button> + + <button + onClick={removeCustomMeasure} + 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 disabled:bg-gray-600" + > + <i18n.Translate>Remove</i18n.Translate> + </button> + </Fragment> )} - {!check ? undefined : ( - <div class="mt-6 rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 "> - <dl class="flex flex-wrap"> - <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg"> - <dt class="text-sm/6 text-white"> - <i18n.Translate>Check</i18n.Translate> - </dt> - <dd class="mt-1 text-base font-semibold text-white"> - {check.name} - </dd> - </div> - <div class="mt-4 flex w-full flex-none gap-x-4 px-6"> - <dt class="flex-none text-gray-500">Description</dt> - <dd class="text-sm/6 "> - <i18n.Translate>{check.description}</i18n.Translate> - </dd> - </div> - <div class="mt-2 flex w-full flex-none gap-x-4 border-t border-gray-900/5 px-6 pt-2"> - <dt class="flex-none text-gray-500"> - <i18n.Translate>Output</i18n.Translate> - </dt> - <dd class="text-sm/6 font-medium "> - <pre class="whitespace-break-spaces"> - {check.outputs.join(", ")} - </pre> - </dd> - </div> - <div class="mt-4 flex w-full flex-none gap-x-4 px-6"> - <dt class="flex-none text-gray-500"> - <i18n.Translate>Requires</i18n.Translate> - </dt> - <dd class="text-sm/6 "> - <pre>{check.requires.join(",")}</pre> - </dd> - </div> - <div class="mt-4 flex w-full flex-none gap-x-4 px-6"> - <dt class="flex-none text-gray-500"> - <i18n.Translate>Fallback</i18n.Translate> - </dt> - <dd class="text-sm/6 "> - <pre>{check.fallback}</pre> - </dd> - </div> - </dl> - <div class="px-4 pb-2"></div> + + <DescribeMeasure measure={form.status.result} summary={summary} /> + </Fragment> + ); +} + +export function MeasureForm({ + summary, + onCancel, + onAdded, + onChanged, + onRemoved, + initial, + addingNew, +}: { + initial?: Partial<MeasureDefinition>; + addingNew?: boolean; + summary: AvailableMeasureSummary; + onCancel: () => void; + onAdded: (name: string) => void; + onChanged: (name: string) => void; + onRemoved: (name: string) => void; +}) { + const challengeType = (initial?.context ?? []).find( + (c) => c.key === "challenge-type", + ); + const measureIsVerificationType = challengeType !== undefined; + const [formType, setFormType] = useState<"verification" | "normal">( + measureIsVerificationType ? "verification" : "normal", + ); + + const { i18n } = useTranslationContext(); + + switch (formType) { + case "verification": { + const cType = JSON.parse(challengeType?.value as any) + return ( + <div> + <h2 class="mt-4 mb-2"> + <i18n.Translate>Configure verification type: {cType}</i18n.Translate> + </h2> + <div> + <button + onClick={async () => { + setFormType("normal"); + }} + 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" + > + <i18n.Translate>Show complete form</i18n.Translate> + </button> + </div> + + <VerificationMeasureForm + onAdded={onAdded} + onCancel={onCancel} + onChanged={onChanged} + onRemoved={onRemoved} + summary={summary} + addingNew={addingNew} + initial={initial} + challengeType={cType} + /> </div> - )} - {!context || !context.length ? undefined : ( - <div class="mt-6 rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 "> - <dl class="flex flex-wrap"> - <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg"> - <dt class="text-sm/6 text-white"> - <i18n.Translate>Context</i18n.Translate> - </dt> - <dd class="mt-1 text-base font-semibold text-white"></dd> + ); + } + case "normal": { + return ( + <div> + <h2 class="mt-4 mb-2"> + <i18n.Translate>Configure measure</i18n.Translate> + </h2> + {measureIsVerificationType ? ( + <div> + <button + onClick={async () => { + setFormType("verification"); + }} + 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" + > + <i18n.Translate>Show as verification</i18n.Translate> + </button> </div> - {context.map(({ key, value }) => { - return ( - <div key={key} class="mt-4 flex w-full flex-none gap-x-4 px-6"> - <dt class="flex-none text-gray-500">{key}</dt> - <dd class="text-sm/6 "> - <i18n.Translate>{value}</i18n.Translate> - </dd> - </div> - ); - })} - </dl> - <div class="px-4 pb-2"></div> + ) : undefined} + + <NormalMeasureForm + onAdded={onAdded} + onCancel={onCancel} + onChanged={onChanged} + onRemoved={onRemoved} + summary={summary} + addingNew={addingNew} + initial={initial} + /> </div> - )} - </div> - ); + ); + } + default: { + assertUnreachable(formType); + } + } } const formDesign = ( @@ -368,7 +524,7 @@ const formDesign = ( checks: { key: string; value: KycCheckInformation }[], summary: AvailableMeasureSummary, cantChangeName: boolean, -): FormDesign<KycRule> => ({ +): FormDesign => ({ type: "single-column", fields: [ { @@ -601,3 +757,238 @@ function validateContextValueByType( } return undefined; } + +function DescribeProgram({ + name, + program, +}: { + name: string; + program: AmlProgramRequirement; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 "> + <dl class="flex flex-wrap"> + <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg"> + <dt class="text-sm/6 text-white"> + <i18n.Translate>Program</i18n.Translate> + </dt> + <dd class="mt-1 text-base font-semibold text-white">{name}</dd> + </div> + <div class="mt-4 flex w-full flex-none gap-x-4 px-6"> + <dt class="flex-none text-gray-500"> + <i18n.Translate>Description</i18n.Translate> + </dt> + <dd class="text-sm/6 "> + <i18n.Translate>{program.description}</i18n.Translate> + </dd> + </div> + <div class="mt-2 flex w-full flex-none gap-x-4 border-t border-gray-900/5 px-6 pt-2"> + <dt class="flex-none text-gray-500"> + <i18n.Translate>Context</i18n.Translate> + </dt> + <dd class="text-sm/6 font-medium text-gray-900"> + <pre>{program.context.join(",")}</pre> + </dd> + </div> + <div class="mt-4 flex w-full flex-none gap-x-4 px-6"> + <dt class="flex-none text-gray-500"> + <i18n.Translate>Inputs</i18n.Translate> + </dt> + <dd class="text-sm/6 "> + <pre class="whitespace-pre-wrap">{program.inputs.join(",")}</pre> + </dd> + </div> + </dl> + <div class="px-4 pb-2"></div> + </div> + ); +} +function DescribeCheck({ + name, + check, +}: { + name: string; + check: KycCheckInformation; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="mt-6 rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 "> + <dl class="flex flex-wrap"> + <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg"> + <dt class="text-sm/6 text-white"> + <i18n.Translate>Check</i18n.Translate> + </dt> + <dd class="mt-1 text-base font-semibold text-white">{name}</dd> + </div> + <div class="mt-4 flex w-full flex-none gap-x-4 px-6"> + <dt class="flex-none text-gray-500">Description</dt> + <dd class="text-sm/6 "> + <i18n.Translate>{check.description}</i18n.Translate> + </dd> + </div> + <div class="mt-2 flex w-full flex-none gap-x-4 border-t border-gray-900/5 px-6 pt-2"> + <dt class="flex-none text-gray-500"> + <i18n.Translate>Output</i18n.Translate> + </dt> + <dd class="text-sm/6 font-medium "> + <pre class="whitespace-break-spaces"> + {check.outputs.join(", ")} + </pre> + </dd> + </div> + <div class="mt-4 flex w-full flex-none gap-x-4 px-6"> + <dt class="flex-none text-gray-500"> + <i18n.Translate>Requires</i18n.Translate> + </dt> + <dd class="text-sm/6 "> + <pre>{check.requires.join(",")}</pre> + </dd> + </div> + <div class="mt-4 flex w-full flex-none gap-x-4 px-6"> + <dt class="flex-none text-gray-500"> + <i18n.Translate>Fallback</i18n.Translate> + </dt> + <dd class="text-sm/6 "> + <pre>{check.fallback}</pre> + </dd> + </div> + </dl> + <div class="px-4 pb-2"></div> + </div> + ); +} +function DescribeContext({ + context, +}: { + context: { + key: string; + type: "string" | "number" | "boolean" | "json"; + value: string; + }[]; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="mt-6 rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 "> + <dl class="flex flex-wrap"> + <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg"> + <dt class="text-sm/6 text-white"> + <i18n.Translate>Context</i18n.Translate> + </dt> + <dd class="mt-1 text-base font-semibold text-white"></dd> + </div> + {context.map(({ key, value }) => { + return ( + <div key={key} class="mt-4 flex w-full flex-none gap-x-4 px-6"> + <dt class="flex-none text-gray-500">{key}</dt> + <dd class="text-sm/6 "> + <i18n.Translate>{value}</i18n.Translate> + </dd> + </div> + ); + })} + </dl> + <div class="px-4 pb-2"></div> + </div> + ); +} +function DescribeMeasure({ + measure, + summary, +}: { + measure: RecursivePartial<MeasureDefinition>; + summary: AvailableMeasureSummary; +}): VNode { + const { i18n } = useTranslationContext(); + const programName: string | undefined = measure.program; + const program: AmlProgramRequirement | undefined = + !programName || !summary.programs[programName] + ? undefined + : summary.programs[programName]; + + const checkName: string | undefined = measure.check; + const check = + !checkName || !summary.checks[checkName] + ? undefined + : summary.checks[checkName]; + + const context = + !measure || !measure.context + ? [] + : (measure.context as MeasureDefinition["context"]); + + return ( + <Fragment> + <h2 class="mt-4 mb-2"> + <i18n.Translate>Description</i18n.Translate> + </h2> + + {!program || !programName ? undefined : ( + <DescribeProgram name={programName} program={program} /> + )} + {!check || !checkName ? undefined : ( + <DescribeCheck name={checkName} check={check} /> + )} + {!context || !context.length ? undefined : ( + <DescribeContext context={context} /> + )} + </Fragment> + ); +} + +const verificationFormDesign = ( + i18n: InternationalizationAPI, + summary: AvailableMeasureSummary, + cantChangeName: boolean, + challengeType: "email" | "phone" | "postal", +): FormDesign => { + const em = + challengeType === "email" + ? design_challenger_email(i18n) + : challengeType === "phone" + ? design_challenger_phone(i18n) + : challengeType === "postal" + ? design_challenger_postal(i18n) + : undefined; + + if (!em) { + throw Error(`unkown challenge type ${challengeType} `); + } + + const fields = em.fields.map((f) => { + f.disabled = false; + f.required = false; + if ("id" in f) { + f.id = `address.${f.id}`; + } + return f; + }); + + return { + type: "single-column", + fields: [ + { + id: "name", + type: "text", + required: true, + disabled: cantChangeName, + label: i18n.str`Name`, + help: i18n.str`Name of the verfication measure`, + validator(value) { + return !value + ? i18n.str`required` + : summary.roots[value] + ? i18n.str`There is already a measure with that name` + : undefined; + }, + }, + { + type: "toggle", + id: "readOnly", + label: i18n.str`Read only`, + help: i18n.str`Prevent the customer of changing the address`, + }, + ...fields, + ], + }; +}; diff --git a/packages/aml-backoffice-ui/src/pages/decision/Events.tsx b/packages/aml-backoffice-ui/src/pages/decision/Events.tsx @@ -108,7 +108,7 @@ const formDesign = ( triggered: string[]; rest: string[]; }, -): FormDesign<MeasureInformation> => ({ +): FormDesign => ({ type: "double-column", sections: [ { diff --git a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx @@ -54,7 +54,7 @@ type FormType = { const formDesign = ( i18n: InternationalizationAPI, unknownAccount: boolean, -): FormDesign<FormType> => ({ +): FormDesign => ({ type: "single-column", fields: [ { diff --git a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx @@ -1,5 +1,6 @@ import { assertUnreachable, + KycCheckInformation, MeasureInformation, TalerError, TalerExchangeApi, @@ -35,6 +36,34 @@ export function Measures({}: {}): VNode { isNew: boolean; template: Partial<MeasureDefinition>; }>(); //test; + + const measures = useServerMeasures(); + + const measureBody = + !measures || measures instanceof TalerError || measures.type === "fail" + ? undefined + : measures.body; + + const measureList = ( + !measureBody ? [] : Object.entries(measureBody.roots) + ).map(convertToMeasureType); + + const requestCustomMeasures = request?.custom_measures ?? {}; + const customMeasures = Object.entries(requestCustomMeasures).map( + convertToMeasureType, + ); + + const checkList = !measureBody ? [] : Object.entries(measureBody.checks); + const simpleChecks = checkList + .map(convertCheckToMeasureType) + .filter((d): d is MeasureType => d !== undefined); + + const allMeasures: MeasureType[] = [ + ...measureList, + ...customMeasures, + ...simpleChecks, + ]; + if (addMeasure) { return ( <NewMeasure @@ -67,7 +96,19 @@ export function Measures({}: {}): VNode { return ( <Fragment> - <ActiveMeasureForm /> + {!allMeasures.length ? undefined : ( + <ActiveMeasureForm + editMeasure={(template) => { + template.name! + setAddMeasure({ + isNew: true, + template, + }); + }} + measures={allMeasures} + newMeasures={!request.new_measures ? [] : request.new_measures} + /> + )} <ShowAllMeasures addNewMeasure={(template) => { setAddMeasure({ @@ -86,83 +127,76 @@ export function Measures({}: {}): VNode { ); } -function ActiveMeasureForm(): VNode { - const { i18n } = useTranslationContext(); - const [request, updateRequest] = useCurrentDecisionRequest(); - const measures = useServerMeasures(); - - const measureBody = - !measures || measures instanceof TalerError || measures.type === "fail" - ? undefined - : measures.body; - - const measureList = (!measureBody ? [] : Object.keys(measureBody.roots)).map( - (m) => - ({ - type: "normal", - name: m, - }) satisfies NormalMeasure, - ); - - const requestCustomMeasures = request?.custom_measures ?? {} - const customMeasures = Object.keys(requestCustomMeasures).map( - (m) => - ({ - type: "normal", - name: m, - }) satisfies NormalMeasure, - ); - - const checkList = !measureBody ? [] : Object.entries(measureBody.checks); - const simpleChecks = checkList - .filter( - ([, check]) => check.outputs.length > 0 && check.requires.length > 0, - ) - .map( - ([key]) => - ({ - type: "simple-check-form", - checkName: key, - }) satisfies SimpleCheckMeasure, - ); +function convertCheckToMeasureType([checkName, check]: [ + string, + KycCheckInformation, +]): MeasureType | undefined { + if (check.outputs.length === 0 && check.requires.length === 0) { + return { + type: "simple-check-form", + name: `check-${checkName}`, + checkName, + }; + } + return undefined; +} - const allMeasures: MeasureType[] = [ - ...measureList, - ...customMeasures, - ...simpleChecks, - ]; +const validChallengeType = ["email", "phone", "postal"]; +function convertToMeasureType([name, measure]: [ + string, + MeasureInformation, +]): MeasureType { + if (measure.context) { + // @ts-expect-error + const challengeType = measure.context["challenge-type"] as string; + if (validChallengeType.indexOf(challengeType) !== -1) { + return { + type: "verify-template", + name, + measure, + // @ts-expect-error challenger type is validated + challengerType: challengeType, + }; + } + } + return { + type: "normal", + name, + }; +} - const design = formDesign(i18n, allMeasures); +function ActiveMeasureForm({ + measures, + editMeasure, + newMeasures, +}: { + measures: MeasureType[]; + newMeasures: string[]; + editMeasure: (m: Partial<MeasureDefinition>) => void; +}): VNode { + const { i18n } = useTranslationContext(); + const [request, updateRequest] = useCurrentDecisionRequest(); - const nm = (!request.new_measures ? [] : request.new_measures).map( - (m) => - ({ - type: "normal", - name: m, - }) satisfies NormalMeasure, - ); + const design = formDesign(i18n, measures); - const initValue = useMemo<FormType>( - () => ({ measures: nm }), - [request.new_measures], - ); + const form = useForm<FormType>(design, { measures: newMeasures }); + const requestCustomMeasures = request?.custom_measures ?? {}; - const form = useForm<FormType>(design, initValue); onComponentUnload(() => { const newMeasures: string[] = []; const formMeasures = form.status.result.measures ?? []; - for (const m of formMeasures) { + for (const name of formMeasures) { + newMeasures.push(name); + const m = measures.find((d) => d.name === name)!; switch (m.type) { - case "normal": { - newMeasures.push(m.name) - break; - } case "simple-check-form": { - const generatedId = `check-${m.checkName}` - requestCustomMeasures[generatedId] = { + requestCustomMeasures[m.name] = { check_name: m.checkName, - } - newMeasures.push(generatedId) + }; + break; + } + case "normal": + case "verify-template": { break; } default: { @@ -176,7 +210,44 @@ function ActiveMeasureForm(): VNode { }); }); - return <FormUI design={design} model={form.model} />; + const selected = form.status.result.measures ?? []; + + const selectedVerifyMeasure = selected + .map((s) => measures.find((d) => d.name === s)) + .filter((d) => d !== undefined && d.type === "verify-template") + .filter((c) => requestCustomMeasures[c.name] === undefined); + + return ( + <Fragment> + <FormUI design={design} model={form.model} /> + + <div> + {selectedVerifyMeasure.map((ver) => { + return ( + <button + onClick={() => { + editMeasure({ + check: ver.measure.check_name, + context: !ver.measure.context + ? [] + : Object.entries(ver.measure.context).map(([key, value]) => ({ + key, + type: "json", + value: JSON.stringify(value), + })), + name: ver.name, + program: ver.measure.prog_name, + }); + }} + 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 disabled:bg-gray-600" + > + <i18n.Translate>Configure verfication measure: "{ver.name}" </i18n.Translate> + </button> + ); + })} + </div> + </Fragment> + ); } function ShowAllMeasures({ @@ -269,7 +340,7 @@ function ShowAllMeasures({ ); } -type MeasureType = NormalMeasure | SimpleCheckMeasure; +type MeasureType = NormalMeasure | SimpleCheckMeasure | VerifyMeasure; /** * Normal measures are custom measures or server defined measure. @@ -288,17 +359,25 @@ type NormalMeasure = { */ type SimpleCheckMeasure = { type: "simple-check-form"; + name: string; checkName: string; }; +type VerifyMeasure = { + type: "verify-template"; + name: string; + measure: MeasureInformation, + challengeType: "email" | "phone" | "postal"; +}; + type FormType = { - measures: MeasureType[]; + measures: string[]; }; function formDesign( i18n: InternationalizationAPI, measureNames: MeasureType[], -): FormDesign<FormType> { +): FormDesign { return { type: "single-column", fields: [ @@ -310,19 +389,26 @@ function formDesign( case "normal": { return { label: me.name, - value: me, + value: me.name, }; } case "simple-check-form": { return { label: `CHECK: ${me.checkName}`, - value: me, + value: `check-${me.checkName}`, }; } + case "verify-template": { + return { + label: me.name, + value: me.name, + }; + } + default: { + assertUnreachable(me); + } } - // FIXME: choises should allow value to be any type - // check: why do we require value to be string? - }) as any, + }), id: "measures", label: i18n.str`Active measures`, help: i18n.str`Measures that the customer will need to satisfy while the rules are active.`, diff --git a/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx b/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx @@ -144,7 +144,7 @@ export type PropertiesForm = { export const propertiesForm = ( i18n: InternationalizationAPI, props: UIFormElementConfig[], -): FormDesign<PropertiesForm> => ({ +): FormDesign => ({ type: "double-column", sections: [ { diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx @@ -535,7 +535,7 @@ const ruleFormDesignTemplate = ( currency: string, measureNames: string[], isWallet: boolean, -): FormDesign<KycRule> => ({ +): FormDesign => ({ type: "single-column", fields: [ { @@ -595,7 +595,7 @@ const ruleFormDesignTemplate = ( const expirationFormDesignTemplate = ( i18n: InternationalizationAPI, measureNames: string[], -): FormDesign<KycRule> => ({ +): FormDesign => ({ type: "single-column", fields: [ { diff --git a/packages/challenger-ui/src/pages/AskChallenge.tsx b/packages/challenger-ui/src/pages/AskChallenge.tsx @@ -578,10 +578,10 @@ function getFormDesignBasedOnAddressType( if (restriction.regex && !restriction.regex.test(text)) { return restriction.hint; } - const prev = prevValue[TalerFormAttributes.CONTACT_EMAIL]; - if (prev === text) { - return i18n.str`Can't use the same address`; - } + // const prev = prevValue[TalerFormAttributes.CONTACT_EMAIL]; + // if (prev === text) { + // return i18n.str`Can't use the same address`; + // } return undefined; }, }, @@ -606,10 +606,10 @@ function getFormDesignBasedOnAddressType( if (restriction.regex && !restriction.regex.test(text)) { return restriction.hint; } - const prev = prevValue[TalerFormAttributes.CONTACT_PHONE]; - if (prev === text) { - return i18n.str`Can't use the same number`; - } + // const prev = prevValue[TalerFormAttributes.CONTACT_PHONE]; + // if (prev === text) { + // return i18n.str`Can't use the same number`; + // } return undefined; }, }, diff --git a/packages/web-util/src/forms/forms-types.ts b/packages/web-util/src/forms/forms-types.ts @@ -34,7 +34,7 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; -export type FormDesign<T = unknown> = +export type FormDesign = | DoubleColumnFormDesign | SingleColumnFormDesign; diff --git a/packages/web-util/src/forms/gana/challenger_email.ts b/packages/web-util/src/forms/gana/challenger_email.ts @@ -20,6 +20,7 @@ import { DoubleColumnFormDesign, FormMetadata, InternationalizationAPI, + SingleColumnFormDesign, } from "../../index.browser.js"; export const form_challenger_email = ( @@ -37,23 +38,18 @@ export const form_challenger_email = ( */ export function design_challenger_email( i18n: InternationalizationAPI, -): DoubleColumnFormDesign { +): SingleColumnFormDesign { const today = format(new Date(), "yyyy-MM-dd"); return { - type: "double-column", - sections: [ + type: "single-column", + fields: [ { - title: i18n.str`Challenge`, - fields: [ - { - id: TalerFormAttributes.CONTACT_EMAIL, - label: i18n.str`E-Mail`, - type: "text", - required: true, - disabled: true, - }, - ], + id: TalerFormAttributes.CONTACT_EMAIL, + label: i18n.str`E-Mail`, + type: "text", + required: true, + disabled: true, }, ], }; diff --git a/packages/web-util/src/forms/gana/challenger_postal.ts b/packages/web-util/src/forms/gana/challenger_postal.ts @@ -21,6 +21,7 @@ import { DoubleColumnFormDesign, FormMetadata, InternationalizationAPI, + SingleColumnFormDesign, } from "../../index.browser.js"; export const form_challenger_postal = ( @@ -38,38 +39,33 @@ export const form_challenger_postal = ( */ export function design_challenger_postal( i18n: InternationalizationAPI, -): DoubleColumnFormDesign { +): SingleColumnFormDesign { const today = format(new Date(), "yyyy-MM-dd"); return { - type: "double-column", - sections: [ + type: "single-column", + fields: [ { - title: i18n.str`Challenge`, - fields: [ - { - id: TalerFormAttributes.CONTACT_NAME, - label: i18n.str`Name`, - type: "text", - required: true, - disabled: true, - }, - { - id: TalerFormAttributes.ADDRESS_LINES, - label: i18n.str`Address`, - type: "textArea", - required: true, - disabled: true, - }, - { - id: TalerFormAttributes.ADDRESS_COUNTRY, - label: i18n.str`Country`, - type: "selectOne", - choices: countryNameList(i18n), - required: true, - disabled: true, - }, - ], + id: TalerFormAttributes.CONTACT_NAME, + label: i18n.str`Name`, + type: "text", + required: true, + disabled: true, + }, + { + id: TalerFormAttributes.ADDRESS_LINES, + label: i18n.str`Address`, + type: "textArea", + required: true, + disabled: true, + }, + { + id: TalerFormAttributes.ADDRESS_COUNTRY, + label: i18n.str`Country`, + type: "selectOne", + choices: countryNameList(i18n), + required: true, + disabled: true, }, ], }; diff --git a/packages/web-util/src/forms/gana/challenger_sms.ts b/packages/web-util/src/forms/gana/challenger_sms.ts @@ -20,6 +20,7 @@ import { DoubleColumnFormDesign, FormMetadata, InternationalizationAPI, + SingleColumnFormDesign, } from "../../index.browser.js"; export const form_challenger_sms = ( @@ -37,23 +38,18 @@ export const form_challenger_sms = ( */ export function design_challenger_phone( i18n: InternationalizationAPI, -): DoubleColumnFormDesign { +): SingleColumnFormDesign { const today = format(new Date(), "yyyy-MM-dd"); return { - type: "double-column", - sections: [ + type: "single-column", + fields: [ { - title: i18n.str`Challenge`, - fields: [ - { - id: TalerFormAttributes.CONTACT_PHONE, - label: i18n.str`Phone`, - type: "text", - required: true, - disabled: true, - }, - ], + id: TalerFormAttributes.CONTACT_PHONE, + label: i18n.str`Phone`, + type: "text", + required: true, + disabled: true, }, ], }; diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -140,7 +140,7 @@ export type FormState<T> = { * Hook to instantiate a form from its design. */ export function useForm<T>( - design: FormDesign<T>, + design: FormDesign, initialValue: RecursivePartial<FormValues<T>>, ): FormState<T> { const { i18n } = useTranslationContext();