diff options
author | Christian Blättler <blatc2@bfh.ch> | 2024-05-07 18:12:54 +0200 |
---|---|---|
committer | Christian Blättler <blatc2@bfh.ch> | 2024-05-07 18:12:54 +0200 |
commit | 349cd9a1f46a2ea8e8d9e502577196c7ed1847eb (patch) | |
tree | 75d0824bd6143a0a206e5b013d4d3e47116c8034 /packages | |
parent | 04447178039569db4da0fd60c819e9fe1bc1df20 (diff) | |
parent | aaf4ffc9411d394897c72f39c132ee24cee28013 (diff) | |
download | wallet-core-349cd9a1f46a2ea8e8d9e502577196c7ed1847eb.tar.xz |
Merge branch 'master' into feature/tokens
Diffstat (limited to 'packages')
78 files changed, 2466 insertions, 1290 deletions
diff --git a/packages/aml-backoffice-ui/build.mjs b/packages/aml-backoffice-ui/build.mjs index bd7a088cf..04a6f646b 100755 --- a/packages/aml-backoffice-ui/build.mjs +++ b/packages/aml-backoffice-ui/build.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /* This file is part of GNU Taler - (C) 2022 Taler Systems S.A. + (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 @@ -20,7 +20,7 @@ import { build } from "@gnu-taler/web-util/build"; await build({ type: "production", source: { - js: ["src/index.tsx", "src/forms.ts"], + js: ["src/index.tsx"], assets: [{ base: "src", files: ["src/index.html"] }], }, destination: "./dist/prod", diff --git a/packages/aml-backoffice-ui/dev.mjs b/packages/aml-backoffice-ui/dev.mjs index bc6fcd6c1..e91b48f9d 100755 --- a/packages/aml-backoffice-ui/dev.mjs +++ b/packages/aml-backoffice-ui/dev.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /* This file is part of GNU Taler - (C) 2022 Taler Systems S.A. + (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 @@ -18,13 +18,13 @@ import { serve } from "@gnu-taler/web-util/node"; import { initializeDev } from "@gnu-taler/web-util/build"; -const devEntryPoints = ["src/stories.tsx", "src/index.tsx", "src/forms.ts"]; +const devEntryPoints = ["src/stories.tsx", "src/index.tsx"]; const build = initializeDev({ type: "development", source: { js: devEntryPoints, - assets: [{ base: "src", files: ["src/index.html"] }], + assets: [{ base: "src", files: ["src/index.html","src/forms.json","src/settings.json"] }], }, destination: "./dist/dev", css: "postcss", diff --git a/packages/aml-backoffice-ui/src/App.tsx b/packages/aml-backoffice-ui/src/App.tsx index 9ccf21755..e9be84441 100644 --- a/packages/aml-backoffice-ui/src/App.tsx +++ b/packages/aml-backoffice-ui/src/App.tsx @@ -19,6 +19,7 @@ import { ExchangeApiProvider, Loading, TranslationProvider, + UiForms, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -29,15 +30,18 @@ import { UiSettingsProvider } from "./context/ui-settings.js"; import { strings } from "./i18n/strings.js"; import "./scss/main.css"; import { UiSettings, fetchUiSettings } from "./context/ui-settings.js"; +import { UiFormsProvider, fetchUiForms } from "./context/ui-forms.js"; const WITH_LOCAL_STORAGE_CACHE = false; export function App(): VNode { const [settings, setSettings] = useState<UiSettings>(); + const [forms, setForms] = useState<UiForms>(); useEffect(() => { fetchUiSettings(setSettings); + fetchUiForms(setForms); }, []); - if (!settings) return <Loading />; + if (!settings || !forms) return <Loading />; const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL); return ( @@ -81,7 +85,9 @@ export function App(): VNode { }} > <BrowserHashNavigationProvider> - <Routing /> + <UiFormsProvider value={forms}> + <Routing /> + </UiFormsProvider> </BrowserHashNavigationProvider> </SWRConfig> </ExchangeApiProvider> diff --git a/packages/aml-backoffice-ui/src/context/ui-forms.ts b/packages/aml-backoffice-ui/src/context/ui-forms.ts index 2e0b8a76d..3a25234d2 100644 --- a/packages/aml-backoffice-ui/src/context/ui-forms.ts +++ b/packages/aml-backoffice-ui/src/context/ui-forms.ts @@ -14,20 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - buildCodecForObject, - buildCodecForUnion, - Codec, - codecForBoolean, - codecForConstString, - codecForList, - codecForNumber, - codecForString, - codecForTimestamp, - codecOptional, - Integer, - TalerProtocolTimestamp, -} from "@gnu-taler/taler-util"; +import { codecForUIForms, UiForms } from "@gnu-taler/web-util/browser"; import { ComponentChildren, createContext, h, VNode } from "preact"; import { useContext } from "preact/hooks"; @@ -39,7 +26,7 @@ import { useContext } from "preact/hooks"; export type Type = UiForms; const defaultForms: UiForms = { - forms: [] + forms: [], }; const Context = createContext<Type>(defaultForms); @@ -60,425 +47,7 @@ export const UiFormsProvider = ({ }); }; -export type FormMetadata = { - label: string; - id: string; - version: number; - config: FlexibleForm; -}; - -type FlexibleForm = DoubleColumnForm; - -export interface DoubleColumnForm { - type: "double-column"; - design: Array<DoubleColumnFormSection>; - // behavior?: (form: Partial<T>) => FormState<T>; -} - -export type DoubleColumnFormSection = { - title: string; - description?: string; - fields: UIFormFieldConfig[]; -}; - -// export interface BaseForm { -// state: TalerExchangeApi.AmlState; -// threshold: AmountJson; -// } - -export interface UiForms { - // Where libeufin backend is localted - // default: window.origin without "webui/" - forms: Array<FormMetadata>; -} - -export type UIFormFieldConfig = - | UIFormFieldConfigAbsoluteTime - | UIFormFieldConfigAmount - | UIFormFieldConfigArray - | UIFormFieldConfigCaption - | UIFormFieldConfigChoiseHorizontal - | UIFormFieldConfigChoiseStacked - | UIFormFieldConfigFile - | UIFormFieldConfigGroup - | UIFormFieldConfigInteger - | UIFormFieldConfigSelectMultiple - | UIFormFieldConfigSelectOne - | UIFormFieldConfigText - | UIFormFieldConfigTextArea - | UIFormFieldConfigToggle; - -type UIFormFieldConfigAbsoluteTime = { - type: "absoluteTime"; - properties: UIFormFieldBaseConfig & { - max?: TalerProtocolTimestamp; - min?: TalerProtocolTimestamp; - pattern: string; - }; -}; - -type UIFormFieldConfigAmount = { - type: "amount"; - properties: UIFormFieldBaseConfig & { - max?: Integer; - min?: Integer; - currency: string; - }; -}; - -type UIFormFieldConfigArray = { - type: "array"; - properties: UIFormFieldBaseConfig & { - // id of the field shown when the array is collapsed - labelFieldId: UIHandlerId; - fields: UIFormFieldConfig[]; - }; -}; - -type UIFormFieldConfigCaption = { - type: "caption"; - properties: UIFieldBaseDescription; -}; - -type UIFormFieldConfigGroup = { - type: "group"; - properties: UIFormFieldBaseConfig & { - fields: UIFormFieldConfig[]; - }; -}; - -type UIFormFieldConfigChoiseHorizontal = { - type: "choiceHorizontal"; - properties: UIFormFieldBaseConfig & { - choices: Array<SelectUiChoice>; - }; -}; - -type UIFormFieldConfigChoiseStacked = { - type: "choiceStacked"; - properties: UIFormFieldBaseConfig & { - choices: Array<SelectUiChoice>; - }; -}; - -type UIFormFieldConfigFile = { - type: "file"; - properties: UIFormFieldBaseConfig & { - maxBytes?: Integer; - minBytes?: Integer; - // comma-separated list of one or more file types - // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept#unique_file_type_specifiers - accept?: string; - }; -}; -type UIFormFieldConfigInteger = { - type: "integer"; - properties: UIFormFieldBaseConfig & { - max?: Integer; - min?: Integer; - }; -}; - -interface SelectUiChoice { - label: string; - description?: string; - value: string; -} - -type UIFormFieldConfigSelectMultiple = { - type: "selectMultiple"; - properties: UIFormFieldBaseConfig & { - max?: Integer; - min?: Integer; - unique?: boolean; - choices: Array<SelectUiChoice>; - }; -}; -type UIFormFieldConfigSelectOne = { - type: "selectOne"; - properties: UIFormFieldBaseConfig & { - choices: Array<SelectUiChoice>; - }; -}; -type UIFormFieldConfigText = { - type: "text"; - properties: UIFormFieldBaseConfig; -}; -type UIFormFieldConfigTextArea = { - type: "textArea"; - properties: UIFormFieldBaseConfig; -}; -type UIFormFieldConfigToggle = { - type: "toggle"; - properties: UIFormFieldBaseConfig; -}; - -type UIFieldBaseDescription = { - /* label if the field, visible for the user */ - label: string; - /* long text to be shown on user demand */ - tooltip?: string; - - /* short text to be shown close to the field */ - help?: string; - - /* if the field should be initialy hidden */ - hidden?: boolean; - /* ui element to show before */ - addonBeforeId?: string; - /* ui element to show after */ - addonAfterId?: string; -}; - -type UIFormFieldBaseConfig = UIFieldBaseDescription & { - /* example to be shown inside the field */ - placeholder?: string; - - /* show a mark as required */ - required?: boolean; - - /* readonly and dim */ - disabled?: boolean; - - /* name of the field, useful for a11y */ - name: string; - - /* conversion id to conver the string into the value type - the id should be known to the ui impl - */ - converterId?: string; - - /* property id of the form */ - id: UIHandlerId; -}; - -declare const __handlerId: unique symbol; -export type UIHandlerId = string & { [__handlerId]: true }; - -// FIXME: validate well formed ui field id -const codecForUiFieldId = codecForString as () => Codec<UIHandlerId>; - -const codecForUIFormFieldBaseConfigTemplate = < - T extends UIFormFieldBaseConfig, ->() => - buildCodecForObject<T>() - .property("id", codecForUiFieldId()) - .property("addonAfterId", codecOptional(codecForString())) - .property("addonBeforeId", codecOptional(codecForString())) - .property("converterId", codecOptional(codecForString())) - .property("disabled", codecOptional(codecForBoolean())) - .property("hidden", codecOptional(codecForBoolean())) - .property("required", codecOptional(codecForBoolean())) - .property("help", codecOptional(codecForString())) - .property("label", codecForString()) - .property("name", codecForString()) - .property("placeholder", codecOptional(codecForString())) - .property("tooltip", codecOptional(codecForString())); - -const codecForUIFormFieldBaseConfig = (): Codec<UIFormFieldBaseConfig> => - codecForUIFormFieldBaseConfigTemplate().build("UIFieldToggleProperties"); - -const codecForUIFormFieldAbsoluteTimeConfig = (): Codec< - UIFormFieldConfigAbsoluteTime["properties"] -> => - codecForUIFormFieldBaseConfigTemplate< - UIFormFieldConfigAbsoluteTime["properties"] - >() - .property("pattern", codecForString()) - .property("max", codecOptional(codecForTimestamp)) - .property("min", codecOptional(codecForTimestamp)) - .build("UIFormFieldConfigAbsoluteTime.properties"); - -const codecForUiFormFieldAbsoluteTime = - (): Codec<UIFormFieldConfigAbsoluteTime> => - buildCodecForObject<UIFormFieldConfigAbsoluteTime>() - .property("type", codecForConstString("absoluteTime")) - .property("properties", codecForUIFormFieldAbsoluteTimeConfig()) - .build("UIFormFieldConfigAbsoluteTime"); - -const codecForUIFormFieldAmountConfig = (): Codec< - UIFormFieldConfigAmount["properties"] -> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigAmount["properties"]>() - .property("currency", codecForString()) - .property("max", codecOptional(codecForNumber())) - .property("min", codecOptional(codecForNumber())) - .build("UIFormFieldConfigAmount.properties"); - -const codecForUiFormFieldAmount = (): Codec<UIFormFieldConfigAmount> => - buildCodecForObject<UIFormFieldConfigAmount>() - .property("type", codecForConstString("amount")) - .property("properties", codecForUIFormFieldAmountConfig()) - .build("UIFormFieldConfigAmount"); - -const codecForUIFormFieldArrayConfig = (): Codec< - UIFormFieldConfigArray["properties"] -> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigArray["properties"]>() - .property("labelFieldId", codecForUiFieldId()) - .property("fields", codecForList(codecForUiFormField())) - .build("UIFormFieldConfigArray.properties"); - -const codecForUiFormFieldArray = (): Codec<UIFormFieldConfigArray> => - buildCodecForObject<UIFormFieldConfigArray>() - .property("type", codecForConstString("array")) - .property("properties", codecForUIFormFieldArrayConfig()) - .build("UIFormFieldConfigArray"); - -const codecForUiFormFieldCaption = (): Codec<UIFormFieldConfigCaption> => - buildCodecForObject<UIFormFieldConfigCaption>() - .property("type", codecForConstString("caption")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigCaption"); - -const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => - buildCodecForObject<SelectUiChoice>() - .property("description", codecForString()) - .property("label", codecForString()) - .property("value", codecForString()) - .build("SelectUiChoice"); - -const codecForUIFormFieldWithChoiseConfig = (): Codec< - UIFormFieldConfigChoiseHorizontal["properties"] -> => - codecForUIFormFieldBaseConfigTemplate< - UIFormFieldConfigChoiseHorizontal["properties"] - >() - .property("choices", codecForList(codecForUiFormSelectUiChoice())) - .build("UIFormFieldConfigChoiseHorizontal.properties"); - -const codecForUiFormFieldChoiceHorizontal = - (): Codec<UIFormFieldConfigChoiseHorizontal> => - buildCodecForObject<UIFormFieldConfigChoiseHorizontal>() - .property("type", codecForConstString("choiceHorizontal")) - .property("properties", codecForUIFormFieldWithChoiseConfig()) - .build("UIFormFieldConfigChoiseHorizontal"); - -const codecForUiFormFieldChoiceStacked = - (): Codec<UIFormFieldConfigChoiseStacked> => - buildCodecForObject<UIFormFieldConfigChoiseStacked>() - .property("type", codecForConstString("choiceStacked")) - .property("properties", codecForUIFormFieldWithChoiseConfig()) - .build("UIFormFieldConfigChoiseStacked"); - -const codecForUiFormFieldFile = (): Codec<UIFormFieldConfigFile> => - buildCodecForObject<UIFormFieldConfigFile>() - .property("type", codecForConstString("file")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigFile"); - -const codecForUIFormFieldWithFieldsConfig = (): Codec< - UIFormFieldConfigGroup["properties"] -> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigGroup["properties"]>() - .property("fields", codecForList(codecForUiFormField())) - .build("UIFormFieldConfigGroup.properties"); - -const codecForUiFormFieldGroup = (): Codec<UIFormFieldConfigGroup> => - buildCodecForObject<UIFormFieldConfigGroup>() - .property("type", codecForConstString("group")) - .property("properties", codecForUIFormFieldWithFieldsConfig()) - .build("UiFormFieldGroup"); - -const codecForUiFormFieldInteger = (): Codec<UIFormFieldConfigInteger> => - buildCodecForObject<UIFormFieldConfigInteger>() - .property("type", codecForConstString("integer")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigInteger"); - -const codecForUIFormFieldSelectMultipleConfig = (): Codec< - UIFormFieldConfigSelectMultiple["properties"] -> => - codecForUIFormFieldBaseConfigTemplate< - UIFormFieldConfigSelectMultiple["properties"] - >() - .property("max", codecOptional(codecForNumber())) - .property("min", codecOptional(codecForNumber())) - .property("unique", codecOptional(codecForBoolean())) - .property("choices", codecForList(codecForUiFormSelectUiChoice())) - .build("UIFormFieldConfigSelectMultiple.properties"); - -const codecForUiFormFieldSelectMultiple = - (): Codec<UIFormFieldConfigSelectMultiple> => - buildCodecForObject<UIFormFieldConfigSelectMultiple>() - .property("type", codecForConstString("selectMultiple")) - .property("properties", codecForUIFormFieldSelectMultipleConfig()) - .build("UiFormFieldSelectMultiple"); - -const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldConfigSelectOne> => - buildCodecForObject<UIFormFieldConfigSelectOne>() - .property("type", codecForConstString("selectOne")) - .property("properties", codecForUIFormFieldWithChoiseConfig()) - .build("UIFormFieldConfigSelectOne"); - -const codecForUiFormFieldText = (): Codec<UIFormFieldConfigText> => - buildCodecForObject<UIFormFieldConfigText>() - .property("type", codecForConstString("text")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigText"); - -const codecForUiFormFieldTextArea = (): Codec<UIFormFieldConfigTextArea> => - buildCodecForObject<UIFormFieldConfigTextArea>() - .property("type", codecForConstString("textArea")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigTextArea"); - -const codecForUiFormFieldToggle = (): Codec<UIFormFieldConfigToggle> => - buildCodecForObject<UIFormFieldConfigToggle>() - .property("type", codecForConstString("toggle")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigToggle"); - -const codecForUiFormField = (): Codec<UIFormFieldConfig> => - buildCodecForUnion<UIFormFieldConfig>() - .discriminateOn("type") - .alternative("absoluteTime", codecForUiFormFieldAbsoluteTime()) - .alternative("amount", codecForUiFormFieldAmount()) - .alternative("array", codecForUiFormFieldArray()) - .alternative("caption", codecForUiFormFieldCaption()) - .alternative("choiceHorizontal", codecForUiFormFieldChoiceHorizontal()) - .alternative("choiceStacked", codecForUiFormFieldChoiceStacked()) - .alternative("file", codecForUiFormFieldFile()) - .alternative("group", codecForUiFormFieldGroup()) - .alternative("integer", codecForUiFormFieldInteger()) - .alternative("selectMultiple", codecForUiFormFieldSelectMultiple()) - .alternative("selectOne", codecForUiFormFieldSelectOne()) - .alternative("text", codecForUiFormFieldText()) - .alternative("textArea", codecForUiFormFieldTextArea()) - .alternative("toggle", codecForUiFormFieldToggle()) - .build("UIFormField"); - -const codecForDoubleColumnFormSection = (): Codec<DoubleColumnFormSection> => - buildCodecForObject<DoubleColumnFormSection>() - .property("title", codecForString()) - .property("description", codecForString()) - .property("fields", codecForList(codecForUiFormField())) - .build("DoubleColumnFormSection"); - -const codecForDoubleColumnForm = (): Codec<DoubleColumnForm> => - buildCodecForObject<DoubleColumnForm>() - .property("type", codecForConstString("double-column")) - .property("design", codecForList(codecForDoubleColumnFormSection())) - .build("DoubleColumnForm"); - -const codecForFlexibleForm = (): Codec<FlexibleForm> => - buildCodecForUnion<FlexibleForm>() - .discriminateOn("type") - .alternative("double-column", codecForDoubleColumnForm()) - .build<FlexibleForm>("FlexibleForm"); - -const codecForFormMetadata = (): Codec<FormMetadata> => - buildCodecForObject<FormMetadata>() - .property("label", codecForString()) - .property("id", codecForString()) - .property("version", codecForNumber()) - .property("config", codecForFlexibleForm()) - .build("FormMetadata"); -const codecForUIForms = (): Codec<UiForms> => - buildCodecForObject<UiForms>() - .property("forms", codecForList(codecForFormMetadata())) - .build("UiForms"); function removeUndefineField<T extends object>(obj: T): T { const keys = Object.keys(obj) as Array<keyof T>; diff --git a/packages/aml-backoffice-ui/src/forms.json b/packages/aml-backoffice-ui/src/forms.json new file mode 100644 index 000000000..ed556307b --- /dev/null +++ b/packages/aml-backoffice-ui/src/forms.json @@ -0,0 +1,553 @@ +{ + "forms": [ + { + "label": "Information on customer", + "id": "902_1e", + "version": 1, + "config": { + "type": "double-column", + "design": [ + { + "title": "Information on customer", + "description": "The customer is the person with whom the member concludes the contract with regard to the financial service provided (civil law). Does the member act as director of a domiciliary company, this domiciliary company is the customer.", + "fields": [ + { + "type": "choiceStacked", + "properties": { + "name": "customerType", + "id": ".customerType", + "label": "Type of customer", + "help": "Select one and complete the next form", + "required": true, + "choices": [ + { + "label": "Natural person", + "value": "natural" + }, + { + "label": "Legal entity", + "value": "legal" + } + ] + } + }, + { + "type": "group", + "properties": { + "label": "Natural customer form", + "name": "algo", + "id": "algo", + "before": "a) Country risk (nationality)", + "after": "a) Country risk (nationality)", + "fields": [ + { + "type": "text", + "properties": { + "name": "naturalCustomer.fullName", + "id": ".naturalCustomer.fullName", + "label": "Full name", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.address", + "id": ".naturalCustomer.address", + "label": "Residential address", + "required": true + } + }, + { + "type": "integer", + "properties": { + "name": "naturalCustomer.telephone", + "id": ".naturalCustomer.telephone", + "label": "Telephone" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.email", + "id": ".naturalCustomer.email", + "label": "E-mail" + } + }, + { + "type": "absoluteTime", + "properties": { + "pattern": "dd/MM/yyyy", + "name": "naturalCustomer.dateOfBirth", + "id": ".naturalCustomer.dateOfBirth", + "label": "Date of birth", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.nationality", + "id": ".naturalCustomer.nationality", + "label": "Nationality", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.document", + "id": ".naturalCustomer.document", + "label": "Identification document", + "required": true + } + }, + { + "type": "file", + "properties": { + "name": "naturalCustomer.documentAttachment", + "id": ".naturalCustomer.documentAttachment", + "label": "Document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".pdf", + "help": "PDF file with max size of 2 mega bytes" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.companyName", + "id": ".naturalCustomer.companyName", + "label": "Company name" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.office", + "id": ".naturalCustomer.office", + "label": "Registered office" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.companyDocument", + "id": ".naturalCustomer.companyDocument", + "label": "Company identification document" + } + }, + { + "type": "file", + "properties": { + "name": "naturalCustomer.companyDocumentAttachment", + "id": ".naturalCustomer.companyDocumentAttachment", + "label": "Document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".png", + "help": "PNG file with max size of 2 mega bytes" + } + } + ] + } + }, + + + { + "type": "group", + "properties": { + "label": "Natural customer form", + "name": "algo", + "id": "algo", + "before": "a) Country risk (nationality)", + "after": "a) Country risk (nationality)", + "fields": [ + { + "type": "text", + "properties": { + "name": "legalCustomer.companyName", + "id": ".legalCustomer.companyName", + "label": "Company name", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.domicile", + "id": ".legalCustomer.domicile", + "label": "Domicile", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.contactPerson", + "id": ".legalCustomer.contactPerson", + "label": "Contact person" + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.telephone", + "id": ".legalCustomer.telephone", + "label": "Telephone" + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.email", + "id": ".legalCustomer.email", + "label": "E-mail" + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.document", + "id": ".legalCustomer.document", + "label": "Identification document", + "help": "Not older than 12 month" + } + }, + { + "type": "file", + "properties": { + "name": "legalCustomer.documentAttachment", + "id": ".legalCustomer.documentAttachment", + "label": "Document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".png", + "help": "PNG file with max size of 2 mega bytes" + } + } + ] + } + } + ] + }, + { + "title": "Information on the natural persons who establish the business relationship for legal entities and partnerships", + "description": "For legal entities and partnerships the identity of the natural persons who establish the business relationship must be verified.", + "fields": [ + { + "type": "array", + "properties": { + "name": "businessEstablisher", + "id": ".businessEstablisher", + "label": "Persons", + "required": true, + "labelFieldId": "fullName", + "placeholder": "this is the placeholder", + "fields": [ + { + "type": "text", + "properties": { + "name": "fullName", + "id": ".fullName", + "label": "Full name", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "address", + "id": ".address", + "label": "Residential address", + "required": true + } + }, + { + "type": "absoluteTime", + "properties": { + "pattern": "dd/MM/yyyy", + "name": "dateOfBirth", + "id": ".dateOfBirth", + "label": "Date of birth", + "required": true + } + }, + + { + "type": "text", + "properties": { + "name": "nationality", + "id": ".nationality", + "label": "Nationality", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "typeOfAuthorization", + "id": ".typeOfAuthorization", + "label": "Type of authorization (signatory of representation)", + "required": true + } + }, + { + "type": "file", + "properties": { + "name": "documentAttachment", + "id": ".documentAttachment", + "label": "Identification document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".pdf", + "help": "PDF file with max size of 2 mega bytes" + } + }, + { + "type": "choiceStacked", + "properties": { + "name": "powerOfAttorneyArrangements", + "id": ".powerOfAttorneyArrangements", + "label": "Power of attorney arrangements", + "required": true, + "choices": [ + { + "label": "CR extract", + "value": "cr" + }, + { + "label": "Mandate", + "value": "mandate" + }, + { + "label": "Other", + "value": "other" + } + ] + } + }, + { + "type": "text", + "properties": { + "name": "powerOfAttorneyArrangementsOther", + "id": ".powerOfAttorneyArrangementsOther", + "label": "Power of attorney arrangements", + "required": true + } + } + ], + "labelField": "fullName" + } + } + ] + }, + { + "title": "Acceptance of business relationship", + "fields": [ + { + "type": "absoluteTime", + "properties": { + "name": "acceptance.when", + "id": ".acceptance.when", + "pattern": "dd/MM/yyyy", + "converterId": "Taler.AbsoluteTime", + "label": "Date (conclusion of contract)" + } + }, + { + "type": "choiceStacked", + "properties": { + "name": "acceptance.acceptedBy", + "id": ".acceptance.acceptedBy", + "label": "Accepted by", + "required": true, + "choices": [ + { + "label": "Face-to-face meeting with customer", + "value": "face-to-face" + }, + { + "label": "Correspondence: authenticated copy of identification document obtained", + "value": "correspondence-document" + }, + { + "label": "Correspondence: residential address validated", + "value": "correspondence-address" + } + ] + } + }, + { + "type": "choiceStacked", + "properties": { + "name": "acceptance.typeOfCorrespondence", + "id": ".acceptance.typeOfCorrespondence", + "label": "Type of correspondence service", + "choices": [ + { + "label": "to the customer", + "value": "customer" + }, + { + "label": "hold at bank", + "value": "bank" + }, + { + "label": "to the member", + "value": "member" + }, + { + "label": "to a third party", + "value": "third-party" + } + ] + } + }, + { + "type": "text", + "properties": { + "name": "acceptance.thirdPartyFullName", + "id": ".acceptance.thirdPartyFullName", + "label": "Third party full name", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "acceptance.thirdPartyAddress", + "id": ".acceptance.thirdPartyAddress", + "label": "Third party address", + "required": true + } + }, + { + "type": "selectMultiple", + "properties": { + "name": "acceptance.language", + "id": ".acceptance.language", + "label": "Languages", + "choices": [ + { + "label": "Espanol", + "value": "es" + } + ], + "unique": true + } + }, + { + "type": "textArea", + "properties": { + "name": "acceptance.furtherInformation", + "id": ".acceptance.furtherInformation", + "label": "Further information" + } + } + ] + }, + { + "title": "Information on the beneficial owner of the assets and/or controlling person", + "description": "Establishment of the beneficial owner of the assets and/or controlling person", + "fields": [ + { + "type": "choiceStacked", + "properties": { + "name": "establishment", + "id": ".establishment", + "label": "The customer is", + "required": true, + "choices": [ + { + "label": "a natural person and there are no doubts that this person is the sole beneficial owner of the assets", + "value": "natural" + }, + { + "label": "a foundation (or a similar construct; incl. underlying companies)", + "value": "foundation" + }, + { + "label": "a trust (incl. underlying companies)", + "value": "trust" + }, + { + "label": "a life insurance policy with separately managed accounts/securities accounts", + "value": "insurance-wrapper" + }, + { + "label": "all other cases", + "value": "other" + } + ] + } + } + ] + }, + { + "title": "Evaluation with regard to embargo procedures/terrorism lists on establishing the business relationship", + "description": "Verification whether the customer, beneficial owners of the assets, controlling persons, authorized representatives or other involved persons are listed on an embargo/terrorism list (date of verification/result)", + "fields": [ + { + "type": "textArea", + "properties": { + "name": "embargoEvaluation", + "id": ".embargoEvaluation", + "help": "The evaluation must be made at the beginning of the business relationship and has to be repeated in the case of permanent business relationship every time the according lists are updated.", + "label": "Evaluation" + } + } + ] + }, + { + "title": "In the case of cash transactions/occasional customers: Information on type and purpose of business relationship", + "description": "These details are only necessary for occasional customers, i.e. money exchange, money and asset transfer or other cash transactions provided that no customer profile (VQF doc. No. 902.5) is created", + "fields": [ + { + "type": "choiceStacked", + "properties": { + "name": "cashTransactions.typeOfBusiness", + "id": ".cashTransactions.typeOfBusiness", + "label": "Type of business relationship", + "choices": [ + { + "label": "Money exchange", + "value": "money-exchange" + }, + { + "label": "Money and asset transfer", + "value": "money-and-asset-transfer" + }, + { + "label": "Other cash transactions. Specify below", + "value": "other" + } + ] + } + }, + { + "type": "text", + "properties": { + "name": "cashTransactions.otherTypeOfBusiness", + "id": ".cashTransactions.otherTypeOfBusiness", + "required": true, + "label": "Specify other cash transactions:" + } + }, + { + "type": "textArea", + "properties": { + "name": "cashTransactions.purpose", + "id": ".cashTransactions.purpose", + "label": "Purpose of the business relationship (purpose of service requested)" + } + } + ] + } + ] + } + } + ], + "not_yet_supported": [] +} diff --git a/packages/aml-backoffice-ui/src/forms.ts b/packages/aml-backoffice-ui/src/forms.ts deleted file mode 100644 index 3ecec2bb0..000000000 --- a/packages/aml-backoffice-ui/src/forms.ts +++ /dev/null @@ -1,24 +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/> - */ - -export * from "./forms/index.js"; - -/** - * this file is here to have a flat dist folder - * - * this file is being build in a bundle separated - * from the main one. - */ diff --git a/packages/aml-backoffice-ui/src/forms/index.ts b/packages/aml-backoffice-ui/src/forms/index.ts index b32978c29..e89a8fb10 100644 --- a/packages/aml-backoffice-ui/src/forms/index.ts +++ b/packages/aml-backoffice-ui/src/forms/index.ts @@ -13,8 +13,7 @@ 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 { InternationalizationAPI } from "@gnu-taler/web-util/browser"; -import { FormMetadata } from "../context/ui-forms.js"; +import type { FormMetadata, InternationalizationAPI } from "@gnu-taler/web-util/browser"; import { v1 as simplest } from "./simplest.js"; const languages = (i18n: InternationalizationAPI) => [ @@ -129,10 +128,10 @@ const languages = (i18n: InternationalizationAPI) => [ ]; -const forms: (i18n: InternationalizationAPI) => Array<FormMetadata> = (i18n) => [ +export const preloadedForms: (i18n: InternationalizationAPI) => Array<FormMetadata> = (i18n) => [ { label: i18n.str`Simple comment`, - id: "simple_comment", + id: "__simple_comment", version: 1, config: simplest(i18n), // }, { diff --git a/packages/aml-backoffice-ui/src/forms/simplest.ts b/packages/aml-backoffice-ui/src/forms/simplest.ts index d32c759cb..37ab0913d 100644 --- a/packages/aml-backoffice-ui/src/forms/simplest.ts +++ b/packages/aml-backoffice-ui/src/forms/simplest.ts @@ -15,9 +15,11 @@ */ import type { - InternationalizationAPI + DoubleColumnForm, + DoubleColumnFormSection, + InternationalizationAPI, + UIHandlerId } from "@gnu-taler/web-util/browser"; -import { BaseForm, DoubleColumnForm, DoubleColumnFormSection, UIHandlerId } from "../context/ui-forms.js"; export const v1 = (i18n: InternationalizationAPI): DoubleColumnForm => ({ type: "double-column" as const, @@ -30,7 +32,7 @@ export const v1 = (i18n: InternationalizationAPI): DoubleColumnForm => ({ properties: { id: ".comment" as UIHandlerId, name: "comment", - label: i18n.str`Comments`, + label: i18n.str`Comment`, }, }, ], @@ -84,8 +86,9 @@ export function resolutionSection( type: "amount", properties: { id: ".threshold" as UIHandlerId, - currency: "USD", + currency: "NETZBON", name: "threshold", + converterId: "Taler.Amount", label: i18n.str`New threshold`, }, }, diff --git a/packages/aml-backoffice-ui/src/hooks/form.ts b/packages/aml-backoffice-ui/src/hooks/form.ts index e14e29819..e9194d86d 100644 --- a/packages/aml-backoffice-ui/src/hooks/form.ts +++ b/packages/aml-backoffice-ui/src/hooks/form.ts @@ -15,12 +15,18 @@ */ import { + AbsoluteTime, AmountJson, TalerExchangeApi, TranslatedString, } from "@gnu-taler/taler-util"; +import { + UIFieldHandler, + UIFormFieldConfig, + UIHandlerId, +} from "@gnu-taler/web-util/browser"; import { useState } from "preact/hooks"; -import { UIField } from "@gnu-taler/web-util/browser"; +import { undefinedIfEmpty } from "../pages/CreateAccount.js"; // export type UIField = { // value: string | undefined; @@ -28,13 +34,13 @@ import { UIField } from "@gnu-taler/web-util/browser"; // error: TranslatedString | undefined; // }; -type FormHandler<T> = { +export type FormHandler<T> = { [k in keyof T]?: T[k] extends string - ? UIField + ? UIFieldHandler : T[k] extends AmountJson - ? UIField + ? UIFieldHandler : T[k] extends TalerExchangeApi.AmlState - ? UIField + ? UIFieldHandler : FormHandler<T[k]>; }; @@ -57,9 +63,11 @@ export type FormErrors<T> = { ? TranslatedString : T[k] extends AmountJson ? TranslatedString - : T[k] extends TalerExchangeApi.AmlState + : T[k] extends AbsoluteTime ? TranslatedString - : FormErrors<T[k]>; + : T[k] extends TalerExchangeApi.AmlState + ? TranslatedString + : FormErrors<T[k]>; }; export type FormStatus<T> = @@ -75,42 +83,32 @@ export type FormStatus<T> = }; function constructFormHandler<T>( + shape: Array<UIHandlerId>, form: RecursivePartial<FormValues<T>>, updateForm: (d: RecursivePartial<FormValues<T>>) => void, errors: FormErrors<T> | undefined, ): FormHandler<T> { - const keys = Object.keys(form) as Array<keyof T>; + const handler = shape.reduce((handleForm, fieldId) => { + const path = fieldId.split("."); - const handler = keys.reduce((prev, fieldName) => { - const currentValue: unknown = form[fieldName]; - 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); - // @ts-expect-error FIXME better typing - prev[fieldName] = group; - return prev; + updateForm(setValueDeeper(form, path, newValue)); } - const field: UIField = { - // @ts-expect-error FIXME better typing + const currentValue = getValueDeeper<string>(form as any, path, undefined); + const currentError = getValueDeeper<TranslatedString>( + errors as any, + path, + undefined, + ); + const field: UIFieldHandler = { error: currentError, - // @ts-expect-error FIXME better typing value: currentValue, onChange: updater, - state: {}, + state: {}, //FIXME: add the state of the field (hidden, ) }; - // @ts-expect-error FIXME better typing - prev[fieldName] = field; - return prev; + + return setValueDeeper(handleForm, path, field); }, {} as FormHandler<T>); return handler; @@ -125,6 +123,7 @@ function constructFormHandler<T>( * @returns */ export function useFormState<T>( + shape: Array<UIHandlerId>, defaultValue: RecursivePartial<FormValues<T>>, check: (f: RecursivePartial<FormValues<T>>) => FormStatus<T>, ): [FormHandler<T>, FormStatus<T>] { @@ -132,7 +131,97 @@ export function useFormState<T>( useState<RecursivePartial<FormValues<T>>>(defaultValue); const status = check(form); - const handler = constructFormHandler(form, updateForm, status.errors); + const handler = constructFormHandler(shape, form, updateForm, status.errors); return [handler, status]; } + +interface Tree<T> extends Record<string, Tree<T> | T> {} + +export function getValueDeeper<T>( + object: Tree<T> | undefined, + names: string[], + notFoundValue?: T, +): T | undefined { + if (names.length === 0) return object as T; + const [head, ...rest] = names; + if (!head) { + return getValueDeeper(object, rest, notFoundValue); + } + if (object === undefined) { + return notFoundValue; + } + return getValueDeeper(object[head] as Tree<T>, rest, notFoundValue); +} + +export function setValueDeeper(object: any, names: string[], value: any): any { + if (names.length === 0) return value; + const [head, ...rest] = names; + if (!head) { + return setValueDeeper(object, rest, value); + } + if (object === undefined) { + return undefinedIfEmpty({ [head]: setValueDeeper({}, rest, value) }); + } + return undefinedIfEmpty({ ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) }); +} + +export function getShapeFromFields( + fields: UIFormFieldConfig[], +): Array<UIHandlerId> { + const shape: Array<UIHandlerId> = []; + fields.forEach((field) => { + if ("id" in field.properties) { + // FIXME: this should be a validation when loading the form + // consistency check + if (shape.indexOf(field.properties.id) !== -1) { + throw Error(`already present: ${field.properties.id}`); + } + shape.push(field.properties.id); + } else if (field.type === "group") { + Array.prototype.push.apply( + shape, + getShapeFromFields(field.properties.fields), + ); + } + }); + return shape; +} + +export function getRequiredFields( + fields: UIFormFieldConfig[], +): Array<UIHandlerId> { + const shape: Array<UIHandlerId> = []; + fields.forEach((field) => { + if ("id" in field.properties) { + // FIXME: this should be a validation when loading the form + // consistency check + if (shape.indexOf(field.properties.id) !== -1) { + throw Error(`already present: ${field.properties.id}`); + } + if (!field.properties.required) { + return; + } + shape.push(field.properties.id); + } else if (field.type === "group") { + Array.prototype.push.apply( + shape, + getRequiredFields(field.properties.fields), + ); + } + }); + return shape; +} +export function validateRequiredFields<FormType>( + errors: FormErrors<FormType> | undefined, + form: object, + fields: Array<UIHandlerId>, +): FormErrors<FormType> | undefined { + let result: FormErrors<FormType> | undefined = errors; + fields.forEach((f) => { + const path = f.split("."); + const v = getValueDeeper(form as any, path); + result = setValueDeeper(result, path, !v ? "required" : undefined); + }); + return result; +} diff --git a/packages/aml-backoffice-ui/src/index.html b/packages/aml-backoffice-ui/src/index.html index b7f73d0a2..0ed2f8178 100644 --- a/packages/aml-backoffice-ui/src/index.html +++ b/packages/aml-backoffice-ui/src/index.html @@ -30,7 +30,6 @@ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> <title>Exchange Backoffice</title> <!-- Entry point for the SPA. --> - <script type="module" src="forms.js"></script> <script type="module" src="index.js"></script> <link rel="stylesheet" href="index.css" /> </head> diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx index 1ad8c9453..bb936cebf 100644 --- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx +++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -34,7 +34,7 @@ import { import { DefaultForm, ErrorLoading, - FlexibleForm, + FormMetadata, InternationalizationAPI, Loading, useTranslationContext, @@ -43,15 +43,17 @@ import { format } from "date-fns"; import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import { privatePages } from "../Routing.js"; +import { useUiFormsContext } from "../context/ui-forms.js"; +import { preloadedForms } from "../forms/index.js"; import { useCaseDetails } from "../hooks/useCaseDetails.js"; import { ShowConsolidated } from "./ShowConsolidated.js"; -import { FormMetadata, useUiFormsContext } from "../context/ui-forms.js"; export type AmlEvent = | AmlFormEvent | AmlFormEventError | KycCollectionEvent | KycExpirationEvent; + type AmlFormEvent = { type: "aml-form"; when: AbsoluteTime; @@ -163,8 +165,9 @@ export function CaseDetails({ account }: { account: string }) { const { i18n } = useTranslationContext(); const details = useCaseDetails(account); - const {forms} = useUiFormsContext() + const { forms } = useUiFormsContext(); + const allForms = [...forms, ...preloadedForms(i18n)]; if (!details) { return <Loading />; } @@ -184,7 +187,12 @@ export function CaseDetails({ account }: { account: string }) { } const { aml_history, kyc_attributes } = details.body; - const events = getEventsFromAmlHistory(aml_history, kyc_attributes, i18n, forms); + const events = getEventsFromAmlHistory( + aml_history, + kyc_attributes, + i18n, + allForms, + ); if (showForm !== undefined) { return ( diff --git a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx index 64bfb90f1..712a1fed9 100644 --- a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx +++ b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx @@ -24,21 +24,57 @@ import { } from "@gnu-taler/taler-util"; import { Button, + FormMetadata, + InternationalizationAPI, LocalNotificationBanner, RenderAllFieldsByUiConfig, - UIFormField, + UIHandlerId, + convertUiField, + getConverterById, useExchangeApiContext, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { privatePages } from "../Routing.js"; -import { UIFormFieldConfig, useUiFormsContext } from "../context/ui-forms.js"; -import { useFormState } from "../hooks/form.js"; +import { useUiFormsContext } from "../context/ui-forms.js"; +import { preloadedForms } from "../forms/index.js"; +import { + FormErrors, + getRequiredFields, + getShapeFromFields, + useFormState, + validateRequiredFields, +} from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; import { Justification } from "./CaseDetails.js"; +import { undefinedIfEmpty } from "./CreateAccount.js"; import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; +function searchForm( + i18n: InternationalizationAPI, + forms: FormMetadata[], + formId: string, +): FormMetadata | undefined { + { + const found = forms.find((v) => v.id === formId); + if (found) return found; + } + { + const pf = preloadedForms(i18n); + const found = pf.find((v) => v.id === formId); + if (found) return found; + } + return undefined; +} + +type FormType = { + when: AbsoluteTime; + state: TalerExchangeApi.AmlState; + threshold: AmountJson; + comment: string; +}; + export function CaseUpdate({ account, type: formId, @@ -52,34 +88,64 @@ export function CaseUpdate({ lib: { exchange: api }, } = useExchangeApiContext(); - // const [notification, notify, handleError] = useLocalNotification(); const [notification, withErrorHandler] = useLocalNotificationHandler(); const { config } = useExchangeApiContext(); const { forms } = useUiFormsContext(); - const initial = { + const initial: FormType = { when: AbsoluteTime.now(), state: TalerExchangeApi.AmlState.pending, threshold: Amounts.zeroOfCurrency(config.currency), + comment: "", }; if (officer.state !== "ready") { return <HandleAccountNotReady officer={officer} />; } - const theForm = forms.find((v) => v.id === formId); + const theForm = searchForm(i18n, forms, formId); if (!theForm) { return <div>form with id {formId} not found</div>; } - const [form, state] = useFormState(initial, (st) => { + const shape: Array<UIHandlerId> = []; + const requiredFields: Array<UIHandlerId> = []; + + theForm.config.design.forEach((section) => { + Array.prototype.push.apply(shape, getShapeFromFields(section.fields)); + Array.prototype.push.apply( + requiredFields, + getRequiredFields(section.fields), + ); + }); + + const [form, state] = useFormState<FormType>(shape, initial, (st) => { + const partialErrors = undefinedIfEmpty<FormErrors<FormType>>({ + state: st.state === undefined ? i18n.str`required` : undefined, + threshold: !st.threshold ? i18n.str`required` : undefined, + when: !st.when ? i18n.str`required` : undefined, + }); + + const errors = undefinedIfEmpty<FormErrors<FormType> | undefined>( + validateRequiredFields(partialErrors, st, requiredFields), + ); + + if (errors === undefined) { + return { + status: "ok", + result: st as any, + errors: undefined, + }; + } + return { - status: "ok", + status: "fail", result: st as any, - errors: undefined, + errors, }; }); - const validatedForm = state.status === "fail" ? undefined : state.result; + const validatedForm = state.status !== "ok" ? undefined : state.result; + console.log(state.errors); const submitHandler = validatedForm === undefined ? undefined @@ -124,11 +190,6 @@ export function CaseUpdate({ } }, ); - - function convertUiField(_f: UIFormFieldConfig[]): UIFormField[] { - return []; - } - return ( <Fragment> <LocalNotificationBanner notification={notification} /> @@ -155,7 +216,12 @@ export function CaseUpdate({ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> <RenderAllFieldsByUiConfig key={i} - fields={convertUiField(section.fields)} + fields={convertUiField( + i18n, + section.fields, + form, + getConverterById, + )} /> </div> </div> @@ -175,7 +241,8 @@ export function CaseUpdate({ <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" + 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> @@ -185,7 +252,9 @@ export function CaseUpdate({ } export function SelectForm({ account }: { account: string }) { + const { i18n } = useTranslationContext(); const { forms } = useUiFormsContext(); + const pf = preloadedForms(i18n); return ( <div> <pre>New form for account: {account.substring(0, 16)}...</pre> @@ -200,6 +269,17 @@ export function SelectForm({ account }: { account: string }) { </a> ); })} + {pf.map((form) => { + return ( + <a + key={form.id} + href={privatePages.caseUpdate.url({ cid: account, type: form.id })} + class="m-4 block rounded-md w-fit border-0 p-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-600" + > + {form.label} + </a> + ); + })} </div> ); } diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx index 2e92c111e..f66eca33f 100644 --- a/packages/aml-backoffice-ui/src/pages/Cases.tsx +++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -24,7 +24,9 @@ import { ErrorLoading, InputChoiceHorizontal, Loading, - useTranslationContext + UIHandlerId, + amlStateConverter, + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -55,6 +57,7 @@ export function CasesUI({ const { i18n } = useTranslationContext(); const [form, status] = useFormState<FormType>( + [".state"] as Array<UIHandlerId>, { state: filter, }, @@ -106,18 +109,19 @@ export function CasesUI({ name="state" label={i18n.str`Filter`} handler={form.state} + converter={amlStateConverter} choices={[ { label: i18n.str`Pending`, - value: TalerExchangeApi.AmlState.pending, + value: "pending", }, { label: i18n.str`Frozen`, - value: TalerExchangeApi.AmlState.frozen, + value: "frozen", }, { label: i18n.str`Normal`, - value: TalerExchangeApi.AmlState.normal, + value: "normal", }, ]} /> @@ -234,7 +238,7 @@ export function Cases() { <Fragment> <Attention type="danger" title={i18n.str`Operation denied`}> <i18n.Translate> - This account doesnt have access. Request account activation + This account doesn't have access. Request account activation sending your public key. </i18n.Translate> </Attention> @@ -269,7 +273,7 @@ export function Cases() { onNext={list.isLastPage ? undefined : list.loadNext} filter={stateFilter} onChangeFilter={(d) => { - setStateFilter(d) + setStateFilter(d); }} /> ); diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx index abcaaa2a6..87310aa27 100644 --- a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx +++ b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx @@ -18,6 +18,7 @@ import { InputLine, InternationalizationAPI, LocalNotificationBanner, + UIHandlerId, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -88,7 +89,8 @@ function createFormValidator( }; } -export function undefinedIfEmpty<T extends object>(obj: T): T | undefined { +export function undefinedIfEmpty<T extends object | undefined>(obj: T): T | undefined { + if (obj === undefined) return undefined; return Object.keys(obj).some( (k) => (obj as Record<string, T>)[k] !== undefined, ) @@ -104,6 +106,7 @@ export function CreateAccount(): VNode { const [notification, withErrorHandler] = useLocalNotificationHandler(); const [form, status] = useFormState<FormType>( + [".password", ".repeat"] as Array<UIHandlerId>, { password: undefined, repeat: undefined, diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx index 0169572bf..3c0301e9f 100644 --- a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx +++ b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx @@ -23,6 +23,8 @@ import { DefaultForm, FlexibleForm, UIFormField, + UIFormFieldConfig, + UIHandlerId, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; @@ -40,22 +42,8 @@ export function ShowConsolidated({ const cons = getConsolidated(history, until); - const form: FlexibleForm<Consolidated> = { - behavior: (form) => { - return { - aml: { - threshold: { - hidden: !form.aml, - }, - since: { - hidden: !form.aml, - }, - state: { - hidden: !form.aml, - }, - }, - }; - }, + const form: FlexibleForm = { + type: "double-column", design: [ { title: i18n.str`AML`, @@ -63,6 +51,8 @@ export function ShowConsolidated({ { type: "amount", properties: { + id: ".aml.threshold" as UIHandlerId, + currency: "NETZBON", label: i18n.str`Threshold`, name: "aml.threshold", }, @@ -72,18 +62,19 @@ export function ShowConsolidated({ properties: { label: i18n.str`State`, name: "aml.state", + id: ".aml.state" as UIHandlerId, choices: [ { label: i18n.str`Frozen`, - value: TalerExchangeApi.AmlState.frozen, + value: "frozen", }, { label: i18n.str`Pending`, - value: TalerExchangeApi.AmlState.pending, + value: "pending", }, { label: i18n.str`Normal`, - value: TalerExchangeApi.AmlState.normal, + value: "normal", }, ], }, @@ -94,10 +85,11 @@ export function ShowConsolidated({ ? { title: i18n.str`KYC`, fields: Object.entries(cons.kyc).map(([key, field]) => { - const result: UIFormField = { + const result: UIFormFieldConfig = { type: "text", properties: { label: key as TranslatedString, + id: `kyc.${key}.value` as UIHandlerId, name: `kyc.${key}.value`, help: `${field.provider} since ${ field.since.t_ms === "never" @@ -109,7 +101,7 @@ export function ShowConsolidated({ return result; }), } - : undefined, + : undefined!, ], }; return ( @@ -122,7 +114,7 @@ export function ShowConsolidated({ </h1> <DefaultForm key={`${String(Date.now())}`} - form={form} + form={form as any} initial={cons} readOnly onUpdate={() => {}} diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx index 9552f2b0c..084e639bf 100644 --- a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx +++ b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx @@ -17,6 +17,7 @@ import { Button, InputLine, LocalNotificationBanner, + UIHandlerId, useLocalNotificationHandler, useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -36,6 +37,7 @@ export function UnlockAccount(): VNode { const [notification, withErrorHandler] = useLocalNotificationHandler(); const [form, status] = useFormState<FormType>( + [".password"] as Array<UIHandlerId>, { password: undefined, }, diff --git a/packages/aml-backoffice-ui/src/settings.json b/packages/aml-backoffice-ui/src/settings.json new file mode 100644 index 000000000..932202b81 --- /dev/null +++ b/packages/aml-backoffice-ui/src/settings.json @@ -0,0 +1,4 @@ +{ + "backendBaseURL": "http://exchange.taler.test:1180/", + "signupEmail": "do-not-contact-me@exchange.taler.test" +}
\ No newline at end of file diff --git a/packages/aml-backoffice-ui/src/stories.test.ts b/packages/aml-backoffice-ui/src/stories.test.ts index a4f32cf43..265a2165b 100644 --- a/packages/aml-backoffice-ui/src/stories.test.ts +++ b/packages/aml-backoffice-ui/src/stories.test.ts @@ -71,7 +71,7 @@ function DefaultTestingContext({ const value: ExchangeContextType = { cancelRequest: () => null, config, - url: new URL("/", "http://locahost"), + url: new URL("/", "http://localhost"), hints: [], lib: { exchange: undefined!, //FIXME: mock diff --git a/packages/aml-backoffice-ui/src/stories.tsx b/packages/aml-backoffice-ui/src/stories.tsx index a66396696..9a23d82fa 100644 --- a/packages/aml-backoffice-ui/src/stories.tsx +++ b/packages/aml-backoffice-ui/src/stories.tsx @@ -60,7 +60,7 @@ function getWrapperForGroup(): FunctionComponent { const value: ExchangeContextType = { cancelRequest: () => null, config, - url: new URL("/", "http://locahost"), + url: new URL("/", "http://localhost"), hints: [], lib: { exchange: undefined!, //FIXME: mock diff --git a/packages/aml-backoffice-ui/src/utils/converter.ts b/packages/aml-backoffice-ui/src/utils/converter.ts deleted file mode 100644 index cca764a81..000000000 --- a/packages/aml-backoffice-ui/src/utils/converter.ts +++ /dev/null @@ -1,47 +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 { TalerExchangeApi } from "@gnu-taler/taler-util"; - -export const amlStateConverter = { - toStringUI: stringifyAmlState, - fromStringUI: parseAmlState, -}; - -function stringifyAmlState(s: TalerExchangeApi.AmlState | undefined): string { - if (s === undefined) return ""; - switch (s) { - case TalerExchangeApi.AmlState.normal: - return "normal"; - case TalerExchangeApi.AmlState.pending: - return "pending"; - case TalerExchangeApi.AmlState.frozen: - return "frozen"; - } -} - -function parseAmlState(s: string | undefined): TalerExchangeApi.AmlState { - switch (s) { - case "normal": - return TalerExchangeApi.AmlState.normal; - case "pending": - return TalerExchangeApi.AmlState.pending; - case "frozen": - return TalerExchangeApi.AmlState.frozen; - default: - throw Error(`unknown AML state: ${s}`); - } -} diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx index 23635d4cd..380b267a2 100644 --- a/packages/bank-ui/src/Routing.tsx +++ b/packages/bank-ui/src/Routing.tsx @@ -31,6 +31,7 @@ import { HttpStatusCode, TranslatedString, assertUnreachable, + createRFC8959AccessTokenEncoded } from "@gnu-taler/taler-util"; import { useEffect } from "preact/hooks"; import { useSessionState } from "./hooks/session.js"; @@ -121,7 +122,7 @@ function PublicRounting({ refreshable: true, }); if (resp.type === "ok") { - onLoggedUser(username, resp.body.access_token); + onLoggedUser(username, createRFC8959AccessTokenEncoded(resp.body.access_token)); } else { switch (resp.case) { case HttpStatusCode.Unauthorized: @@ -394,6 +395,9 @@ function PrivateRouting({ routeMyAccountDetails={privatePages.myAccountDetails} routeMyAccountPassword={privatePages.myAccountPassword} routeConversionConfig={privatePages.conversionConfig} + onCashout={() => + navigateTo(privatePages.home.url({})) + } onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({})) } @@ -461,6 +465,9 @@ function PrivateRouting({ routeMyAccountDetails={privatePages.myAccountDetails} routeMyAccountPassword={privatePages.myAccountPassword} routeConversionConfig={privatePages.conversionConfig} + onCashout={() => + navigateTo(privatePages.home.url({})) + } onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({})) } @@ -515,6 +522,7 @@ function PrivateRouting({ onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({})) } + onCashout={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} /> ); diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx index 2f967895c..600025400 100644 --- a/packages/bank-ui/src/pages/LoginForm.tsx +++ b/packages/bank-ui/src/pages/LoginForm.tsx @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { HttpStatusCode, createRFC8959AccessTokenEncoded, createRFC8959AccessTokenPlain } from "@gnu-taler/taler-util"; import { Button, LocalNotificationBanner, @@ -87,7 +87,7 @@ export function LoginForm({ refreshable: true, }), (result) => { - session.logIn({ username, token: result.body.access_token }); + session.logIn({ username, token: createRFC8959AccessTokenEncoded(result.body.access_token) }); }, (fail) => { switch (fail.case) { diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx index a3bb091c1..3bf891504 100644 --- a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -30,7 +30,7 @@ import { assertUnreachable, buildPayto, parsePaytoUri, - stringifyPaytoUri + stringifyPaytoUri, } from "@gnu-taler/taler-util"; import { InternationalizationAPI, @@ -190,11 +190,7 @@ export function PaytoWireTransferForm({ amount: sAmount, }; const check = IdempotencyRetry.tryFiveTimes(); - const resp = await api.createTransaction( - credentials, - request, - check, - ); + const resp = await api.createTransaction(credentials, request, check); mutate(() => true); if (resp.type === "fail") { switch (resp.case) { @@ -294,78 +290,6 @@ export function PaytoWireTransferForm({ return ( <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 my-4 md:grid-cols-3 bg-gray-100 px-4 pb-4 rounded-lg"> - {/* <div class=""> - <div class="px-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> - <label - class={ - "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + - (!isRawPayto - ? "border-indigo-600 ring-2 ring-indigo-600" - : "border-gray-300") - } - > - <input - type="radio" - name="project-type" - value="Newsletter" - class="sr-only" - aria-labelledby="project-type-0-label" - aria-describedby="project-type-0-description-0 project-type-0-description-1" - onChange={() => { - setIsRawPayto(false); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Using a form</i18n.Translate> - </span> - </span> - </span> - </label> - - {sendingToFixedAccount ? undefined : ( - <label - class={ - "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + - (isRawPayto - ? "border-indigo-600 ring-2 ring-indigo-600" - : "border-gray-300") - } - > - <input - type="radio" - name="project-type" - value="Existing Customers" - class="sr-only" - aria-labelledby="project-type-1-label" - aria-describedby="project-type-1-description-0 project-type-1-description-1" - onChange={() => { - - setIsRawPayto(true); - }} - /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Import payto:// URI</i18n.Translate> - </span> - </span> - </span> - </label> - )} - {routeCashout ? ( - <a - name="do cashout" - href={routeCashout.url({})} - class="bg-white p-4 rounded-lg text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Cashout</i18n.Translate> - </a> - ) : undefined} - </div> - </div> */} - <div> <fieldset class="px-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> <legend class="sr-only"> diff --git a/packages/bank-ui/src/pages/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx index 5dd19a63f..61939c3d6 100644 --- a/packages/bank-ui/src/pages/RegistrationPage.tsx +++ b/packages/bank-ui/src/pages/RegistrationPage.tsx @@ -13,10 +13,7 @@ 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 { - HttpStatusCode, - TalerErrorCode -} from "@gnu-taler/taler-util"; +import { HttpStatusCode, TalerErrorCode } from "@gnu-taler/taler-util"; import { LocalNotificationBanner, RouteDefinition, @@ -143,6 +140,8 @@ function RegistrationForm({ return i18n.str`Authentication channel is not supported.`; case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: return i18n.str`Only admin is allow to set debt limit.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: + return i18n.str`Only the administrator can change the minimum cashout limit.`; case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: return i18n.str`Only admin can create accounts with second factor authentication.`; } diff --git a/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx index 301978eaa..fd6379895 100644 --- a/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx +++ b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx @@ -25,6 +25,7 @@ interface Props { account: string; routeClose: RouteDefinition; onAuthorizationRequired: () => void; + onCashout: () => void; routeCashoutDetails: RouteDefinition<{ cid: string }>; routeMyAccountDetails: RouteDefinition; routeMyAccountDelete: RouteDefinition; @@ -37,6 +38,7 @@ interface Props { export function CashoutListForAccount({ account, onAuthorizationRequired, + onCashout, routeCreateCashout, routeCashoutDetails, routeMyAccountCashout, @@ -76,6 +78,7 @@ export function CashoutListForAccount({ focus routeHere={routeCreateCashout} routeClose={routeClose} + onCashout={onCashout} onAuthorizationRequired={onAuthorizationRequired} account={account} /> diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx index 69a186ca1..6db0e5512 100644 --- a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx +++ b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -183,6 +183,15 @@ export function ShowAccountDetails({ when: AbsoluteTime.now(), }); } + case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: { + return notify({ + type: "error", + title: i18n.str`Only the administrator can change the minimum cashout limit.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } default: assertUnreachable(resp); } diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx index c8195ddb0..ba5da609f 100644 --- a/packages/bank-ui/src/pages/admin/AccountForm.tsx +++ b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -52,6 +52,7 @@ const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; export type AccountFormData = { debit_threshold?: string; + min_cashout?: string; isExchange?: boolean; isPublic?: boolean; name?: string; @@ -111,6 +112,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ debit_threshold: Amounts.stringifyValue( template?.debit_threshold ?? config.default_debit_threshold, ), + min_cashout: Amounts.stringifyValue( + template?.min_cashout ?? `${config.currency}:0`, + ), isExchange: template?.is_taler_exchange, isPublic: template?.is_public, name: template?.name ?? "", @@ -140,12 +144,18 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ (config.allow_edit_cashout_payto_uri || userIsAdmin)); const editableThreshold = userIsAdmin && (purpose === "create" || purpose === "update"); + const editableMinCashout = + userIsAdmin && (purpose === "create" || purpose === "update"); const editableAccount = purpose === "create" && userIsAdmin; function updateForm(newForm: typeof defaultValue): void { - const trimmedAmountStr = newForm.debit_threshold?.trim(); - const parsedAmount = Amounts.parse( - `${config.currency}:${trimmedAmountStr}`, + const trimmedMinCashoutStr = newForm.min_cashout?.trim(); + const parsedMinCashout = Amounts.parse( + `${config.currency}:${trimmedMinCashoutStr}`, + ); + const trimmedDebitThresholdStr = newForm.debit_threshold?.trim(); + const parsedDebitThreshold = Amounts.parse( + `${config.currency}:${trimmedDebitThresholdStr}`, ); const errors = undefinedIfEmpty< @@ -189,13 +199,21 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ : undefined, debit_threshold: !editableThreshold ? undefined - : !trimmedAmountStr + : !trimmedDebitThresholdStr + ? undefined + : !parsedDebitThreshold + ? i18n.str`Not valid` + : undefined, + min_cashout: !editableMinCashout + ? undefined + : !trimmedMinCashoutStr ? undefined - : !parsedAmount + : !parsedMinCashout ? i18n.str`Not valid` : undefined, name: !editableName ? undefined // disabled + : purpose === "update" && newForm.name === undefined ? undefined // the field hasn't been changed : !newForm.name ? i18n.str`Required` : undefined, @@ -248,9 +266,12 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ } const internalURI = !internal ? undefined : stringifyPaytoUri(internal); - const threshold = !parsedAmount + const threshold = !parsedDebitThreshold + ? undefined + : Amounts.stringify(parsedDebitThreshold); + const minCashout = !parsedMinCashout ? undefined - : Amounts.stringify(parsedAmount); + : Amounts.stringify(parsedMinCashout); switch (purpose) { case "create": { @@ -265,6 +286,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ phone: !newForm.phone ? undefined : newForm.phone, }), debit_threshold: threshold ?? config.default_debit_threshold, + min_cashout: minCashout, cashout_payto_uri: cashoutURI, payto_uri: internalURI, is_public: newForm.isPublic, @@ -288,6 +310,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ phone: !newForm.phone ? undefined : newForm.phone, }), debit_threshold: threshold, + min_cashout: minCashout, is_public: newForm.isPublic, name: newForm.name, tan_channel: @@ -533,6 +556,38 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </div> <div class="sm:col-span-5"> + <label + for="minCashout" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Minimum cashout`}</label> + <InputAmount + name="minCashout" + left + currency={config.currency} + value={form.min_cashout ?? defaultValue.min_cashout} + onChange={ + !editableMinCashout + ? undefined + : (e) => { + form.min_cashout = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.min_cashout ? String(errors?.min_cashout) : undefined + } + isDirty={form.min_cashout !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Custom minimum cashout amount for this account. + </i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx index acae09b40..34c121235 100644 --- a/packages/bank-ui/src/pages/admin/AdminHome.tsx +++ b/packages/bank-ui/src/pages/admin/AdminHome.tsx @@ -276,10 +276,9 @@ function Metrics({ name="tabs" class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" onChange={(e) => { - // const op = e.currentTarget.value as typeof metricType setMetricType( - e.currentTarget - .value as unknown as TalerCorebankApi.MonitorTimeframeParam, + parseInt(e.currentTarget + .value, 10) as TalerCorebankApi.MonitorTimeframeParam, ); }} > diff --git a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx index 7d2d449b0..68f39fb9f 100644 --- a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx +++ b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx @@ -146,6 +146,15 @@ export function CreateNewAccount({ debug: resp.detail, when: AbsoluteTime.now(), }); + case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: { + return notify({ + type: "error", + title: i18n.str`Only the administrator can change the minimum cashout limit.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } default: assertUnreachable(resp); } diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx index 8e54bbd4e..c51b96b8b 100644 --- a/packages/bank-ui/src/pages/regional/CreateCashout.tsx +++ b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -59,6 +59,7 @@ interface Props { account: string; focus?: boolean; onAuthorizationRequired: () => void; + onCashout: () => void; routeClose: RouteDefinition; routeHere: RouteDefinition; } @@ -76,6 +77,7 @@ type ErrorFrom<T> = { export function CreateCashout({ account: accountName, onAuthorizationRequired, + onCashout, focus, routeHere, routeClose, @@ -93,7 +95,6 @@ export function CreateCashout({ const { lib: { bank: api }, config, - hints, } = useBankCoreApiContext(); const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); const [notification, notify, handleError] = useLocalNotification(); @@ -167,13 +168,6 @@ export function CreateCashout({ ); } - const account = { - balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), - balanceIsDebit: - resultAccount.body.balance.credit_debit_indicator == "debit", - debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), - }; - const { fiat_currency, regional_currency, @@ -182,6 +176,15 @@ export function CreateCashout({ } = info.body; const regionalZero = Amounts.zeroOfCurrency(regional_currency); const fiatZero = Amounts.zeroOfCurrency(fiat_currency); + + const account = { + balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), + balanceIsDebit: + resultAccount.body.balance.credit_debit_indicator == "debit", + debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), + minCashout: resultAccount.body.min_cashout === undefined ? regionalZero : Amounts.parseOrThrow(resultAccount.body.min_cashout) + }; + const limit = account.balanceIsDebit ? Amounts.sub(account.debitThreshold, account.balance).amount : Amounts.add(account.balance, account.debitThreshold).amount; @@ -241,16 +244,23 @@ export function CreateCashout({ ? i18n.str`Invalid` : Amounts.cmp(limit, calc.debit) === -1 ? i18n.str`Balance is not enough` - : form.isDebit && - Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1 - ? i18n.str`Needs to be higher than ${ + : calculationResult === "amount-is-too-small" + ? i18n.str`Amount needs to be higher` + : Amounts.cmp(calc.debit, conversionInfo.cashout_min_amount) < 0 + ? i18n.str`No account can't cashout less than ${ Amounts.stringifyValueWithSpec( Amounts.parseOrThrow(conversionInfo.cashout_min_amount), regional_currency_specification, ).normal }` - : calculationResult === "amount-is-too-small" - ? i18n.str`Amount needs to be higher` + : Amounts.cmp(calc.debit, account.minCashout) < 0 + ? i18n.str`Your account can't cashout less than ${ + Amounts.stringifyValueWithSpec( + Amounts.parseOrThrow(account.minCashout), + regional_currency_specification, + ).normal + }` + : Amounts.isZero(calc.credit) ? i18n.str`The total transfer at destination will be zero` : undefined, @@ -260,21 +270,17 @@ export function CreateCashout({ async function createCashout() { const request_uid = encodeCrock(getRandomBytes(32)); await handleError(async () => { - // new cashout api doesn't require channel - const validChannel = - config.supported_tan_channels.length === 0 || form.channel; - - if (!creds || !form.subject || !validChannel) return; + if (!creds || !form.subject) return; const request = { request_uid, amount_credit: Amounts.stringify(calc.credit), amount_debit: Amounts.stringify(calc.debit), subject: form.subject, - tan_channel: form.channel, }; const resp = await api.createCashout(creds, request); if (resp.type === "ok") { notifyInfo(i18n.str`Cashout created`); + onCashout(); } else { switch (resp.case) { case HttpStatusCode.Accepted: { @@ -335,6 +341,15 @@ export function CreateCashout({ debug: resp.detail, when: AbsoluteTime.now(), }); + case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL: + return notify({ + type: "error", + title: i18n.str`The amount is less than the minimum allowed.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: return notify({ type: "error", diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx index 4a5ab440b..a28992a2f 100644 --- a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx @@ -22,7 +22,7 @@ import { Duration, TalerMerchantApi, - createAccessToken, + createRFC8959AccessTokenPlain, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; @@ -132,7 +132,7 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { newValue.auth = newToken === null || newToken === undefined ? { method: "external" } - : { method: "token", token: createAccessToken(newToken) }; + : { method: "token", token: createRFC8959AccessTokenPlain(newToken) }; if (!newValue.address) newValue.address = {}; if (!newValue.jurisdiction) newValue.jurisdiction = {}; // remove above use conversion diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx index f75ee89b8..d718ffb69 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx @@ -27,7 +27,7 @@ import { FormProvider } from "../../../components/form/FormProvider.js"; import { Input } from "../../../components/form/Input.js"; import { NotificationCard } from "../../../components/menu/index.js"; import { useSessionContext } from "../../../context/session.js"; -import { AccessToken, createAccessToken } from "@gnu-taler/taler-util"; +import { AccessToken, createRFC8959AccessTokenPlain } from "@gnu-taler/taler-util"; interface Props { hasToken: boolean | undefined; @@ -78,9 +78,9 @@ export function DetailPage({ if (hasErrors) return; const oldToken = form.old_token !== undefined && hasToken - ? createAccessToken(form.old_token) + ? createRFC8959AccessTokenPlain(form.old_token) : undefined; - const newToken = createAccessToken(form.new_token!); + const newToken = createRFC8959AccessTokenPlain(form.new_token!); onNewToken(oldToken, newToken); } @@ -134,7 +134,7 @@ export function DetailPage({ class="button" onClick={() => { if (hasToken) { - onClearToken(form.old_token ? createAccessToken(form.old_token) : undefined); + onClearToken(form.old_token ? createRFC8959AccessTokenPlain(form.old_token) : undefined); } else { onClearToken(undefined); } diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx index 272c40b55..d77bc75fd 100644 --- a/packages/merchant-backoffice-ui/src/paths/login/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { HttpStatusCode, createAccessToken } from "@gnu-taler/taler-util"; +import { HttpStatusCode, createRFC8959AccessTokenEncoded } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -49,7 +49,7 @@ export function LoginPage(_p: Props): VNode { async function doLoginImpl() { const result = await lib.authenticate.createAccessTokenBearer( - createAccessToken(token), + createRFC8959AccessTokenEncoded(token), tokenRequest, ); if (result.type === "ok") { diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts index 68c0744fc..b27eaa371 100644 --- a/packages/taler-harness/src/harness/harness.ts +++ b/packages/taler-harness/src/harness/harness.ts @@ -651,7 +651,7 @@ export class FakebankService config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`); config.setString("bank", "ram_limit", `${1024}`); const cfgFilename = testDir + "/bank.conf"; - config.write(cfgFilename, { excludeDefaults: true }); + config.writeTo(cfgFilename, { excludeDefaults: true }); return new FakebankService(gc, bc, cfgFilename); } @@ -680,7 +680,7 @@ export class FakebankService } const config = Configuration.load(this.configFile); config.setString("bank", "suggested_exchange", e.baseUrl); - config.write(this.configFile, { excludeDefaults: true }); + config.writeTo(this.configFile, { excludeDefaults: true }); } get baseUrl(): string { @@ -790,7 +790,7 @@ export class LibeufinBankService `${bc.currency}:100`, ); const cfgFilename = testDir + "/bank.conf"; - config.write(cfgFilename, { excludeDefaults: true }); + config.writeTo(cfgFilename, { excludeDefaults: true }); return new LibeufinBankService(gc, bc, cfgFilename); } @@ -828,7 +828,7 @@ export class LibeufinBankService "suggested_withdrawal_exchange", e.baseUrl, ); - config.write(this.configFile, { excludeDefaults: true }); + config.writeTo(this.configFile, { excludeDefaults: true }); } get baseUrl(): string { @@ -1052,7 +1052,7 @@ export class ExchangeService implements ExchangeServiceInterface { changeConfig(f: (config: Configuration) => void) { const config = Configuration.load(this.configFilename); f(config); - config.write(this.configFilename, { excludeDefaults: true }); + config.writeTo(this.configFilename, { excludeDefaults: true }); } static create(gc: GlobalTestState, e: ExchangeConfig) { @@ -1118,7 +1118,7 @@ export class ExchangeService implements ExchangeServiceInterface { fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv)); const cfgFilename = testDir + `/exchange-${e.name}.conf`; - config.write(cfgFilename, { excludeDefaults: true }); + config.writeTo(cfgFilename, { excludeDefaults: true }); return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey); } @@ -1127,13 +1127,13 @@ export class ExchangeService implements ExchangeServiceInterface { offeredCoins.forEach((cc) => setCoin(config, cc(this.exchangeConfig.currency)), ); - config.write(this.configFilename, { excludeDefaults: true }); + config.writeTo(this.configFilename, { excludeDefaults: true }); } addCoinConfigList(ccs: CoinConfig[]) { const config = Configuration.load(this.configFilename); ccs.forEach((cc) => setCoin(config, cc)); - config.write(this.configFilename, { excludeDefaults: true }); + config.writeTo(this.configFilename, { excludeDefaults: true }); } enableAgeRestrictions(maskStr: string) { @@ -1144,7 +1144,7 @@ export class ExchangeService implements ExchangeServiceInterface { "age_groups", maskStr, ); - config.write(this.configFilename, { excludeDefaults: true }); + config.writeTo(this.configFilename, { excludeDefaults: true }); } get masterPub() { @@ -1165,7 +1165,7 @@ export class ExchangeService implements ExchangeServiceInterface { ): Promise<void> { const config = Configuration.load(this.configFilename); await f(config); - config.write(this.configFilename, { excludeDefaults: true }); + config.writeTo(this.configFilename, { excludeDefaults: true }); } async addBankAccount( @@ -1206,7 +1206,7 @@ export class ExchangeService implements ExchangeServiceInterface { "password", exchangeBankAccount.accountPassword, ); - config.write(this.configFilename, { excludeDefaults: true }); + config.writeTo(this.configFilename, { excludeDefaults: true }); } exchangeHttpProc: ProcessWrapper | undefined; @@ -1701,7 +1701,7 @@ export class MerchantService implements MerchantServiceInterface { config.setString("merchantdb-postgres", "config", mc.database); // Do not contact demo.taler.net exchange in tests config.setString("merchant-exchange-kudos", "disabled", "yes"); - config.write(cfgFilename, { excludeDefaults: true }); + config.writeTo(cfgFilename, { excludeDefaults: true }); return new MerchantService(gc, mc, cfgFilename); } @@ -1719,7 +1719,7 @@ export class MerchantService implements MerchantServiceInterface { this.merchantConfig.currency, ); config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub); - config.write(this.configFilename, { excludeDefaults: true }); + config.writeTo(this.configFilename, { excludeDefaults: true }); } async addDefaultInstance(): Promise<void> { diff --git a/packages/taler-harness/src/harness/sync.ts b/packages/taler-harness/src/harness/sync.ts index 64c9acaef..567a2e92d 100644 --- a/packages/taler-harness/src/harness/sync.ts +++ b/packages/taler-harness/src/harness/sync.ts @@ -85,7 +85,7 @@ export class SyncService { config.setString("syncdb-postgres", "config", sc.database); config.setString("sync", "payment_backend_url", sc.paymentBackendUrl); config.setString("sync", "upload_limit_mb", `${sc.uploadLimitMb}`); - config.write(cfgFilename); + config.writeTo(cfgFilename); return new SyncService(gc, sc, cfgFilename); } diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts index 0f282e123..315173b7f 100644 --- a/packages/taler-harness/src/index.ts +++ b/packages/taler-harness/src/index.ts @@ -30,11 +30,11 @@ import { TalerAuthenticationHttpClient, TalerBankConversionHttpClient, TalerCoreBankHttpClient, - TalerErrorCode, TalerMerchantInstanceHttpClient, TalerMerchantManagementHttpClient, TransactionsResponse, - createAccessToken, + createRFC8959AccessTokenEncoded, + createRFC8959AccessTokenPlain, decodeCrock, encodeCrock, generateIban, @@ -42,7 +42,6 @@ import { randomBytes, rsaBlind, setGlobalLogLevelFromString, - setPrintHttpRequestAsCurl, stringifyPayTemplateUri, } from "@gnu-taler/taler-util"; import { clk } from "@gnu-taler/taler-util/clk"; @@ -80,7 +79,6 @@ import { } from "./harness/helpers.js"; import { getTestInfo, runTests } from "./integrationtests/testrunner.js"; import { lintExchangeDeployment } from "./lint.js"; -import { randomUUID } from "crypto"; const logger = new Logger("taler-harness:index.ts"); @@ -356,25 +354,46 @@ advancedCli ); }); -const configCli = testingCli.subcommand("configArgs", "config", { - help: "Subcommands for handling the Taler configuration.", -}); +const configCli = testingCli + .subcommand("configArgs", "config", { + help: "Subcommands for handling the Taler configuration.", + }) + .maybeOption("configEntryFile", ["-c", "--config"], clk.STRING, { + help: "Configuration file to use.", + }) + .maybeOption("project", ["--project"], clk.STRING, { + help: `Selection of the project to inspect/change the config (default: taler).`, + }); -configCli.subcommand("show", "show").action(async (args) => { - const config = Configuration.load(); - const cfgStr = config.stringify({ - diagnostics: true, +configCli + .subcommand("show", "show", { + help: "Show the current configuration.", + }) + .action(async (args) => { + const config = Configuration.load( + args.configArgs.configEntryFile, + args.configArgs.project, + ); + const cfgStr = config.stringify({ + diagnostics: true, + }); + console.log(cfgStr); }); - console.log(cfgStr); -}); configCli - .subcommand("get", "get") + .subcommand("get", "get", { + help: "Get a configuration option.", + }) .requiredArgument("section", clk.STRING) .requiredArgument("option", clk.STRING) - .flag("file", ["-f"]) + .flag("file", ["-f"], { + help: "Treat the value as a filename, expanding placeholders.", + }) .action(async (args) => { - const config = Configuration.load(); + const config = Configuration.load( + args.configArgs.configEntryFile, + args.configArgs.project, + ); let res; if (args.get.file) { res = config.getPath(args.get.section, args.get.option); @@ -389,6 +408,35 @@ configCli } }); +configCli + .subcommand("set", "set", { + help: "Set a configuration option.", + }) + .requiredArgument("section", clk.STRING) + .requiredArgument("option", clk.STRING) + .requiredArgument("value", clk.STRING) + .flag("dry", ["--dry"], { + help: "Do not write the changed config to disk, only write it to stdout.", + }) + .action(async (args) => { + const config = Configuration.load( + args.configArgs.configEntryFile, + args.configArgs.project, + ); + config.setString(args.set.section, args.set.option, args.set.value); + if (args.set.dry) { + console.log( + config.stringify({ + excludeDefaults: true, + }), + ); + } else { + config.write({ + excludeDefaults: true, + }); + } + }); + const deploymentCli = testingCli.subcommand("deploymentArgs", "deployment", { help: "Subcommands for handling GNU Taler deployments.", }); @@ -643,12 +691,12 @@ deploymentCli help: "if everything worked ok, change the password of the accounts at the end", }) .action(async (args) => { - const managementToken = createAccessToken( + const managementToken = createRFC8959AccessTokenPlain( args.provisionBankMerchant.merchantToken, ); const bankAdminPassword = args.provisionBankMerchant.bankPassword; const bankAdminTokenArg = args.provisionBankMerchant.bankToken - ? createAccessToken(args.provisionBankMerchant.bankToken) + ? createRFC8959AccessTokenPlain(args.provisionBankMerchant.bankToken) : undefined; const id = args.provisionBankMerchant.id; const name = args.provisionBankMerchant.name; @@ -765,7 +813,7 @@ deploymentCli address: {}, auth: { method: "token", - token: createAccessToken(password), + token: createRFC8959AccessTokenPlain(password), }, default_pay_delay: Duration.toTalerProtocolDuration( Duration.fromSpec({ hours: 1 }), @@ -797,7 +845,7 @@ deploymentCli */ { const resp = await merchantInstance.addBankAccount( - createAccessToken(password), + createRFC8959AccessTokenEncoded(password), { payto_uri: accountPayto, credit_facade_url: bank.getRevenueAPI(id).href, @@ -840,7 +888,7 @@ deploymentCli { const resp = await merchantInstance.addTemplate( - createAccessToken(password), + createRFC8959AccessTokenEncoded(password), { template_id: "default", template_description: "First template", @@ -920,10 +968,10 @@ deploymentCli { const resp = await merchantInstance.updateCurrentInstanceAuthentication( - createAccessToken(prevPassword), + createRFC8959AccessTokenEncoded(prevPassword), { method: "token", - token: createAccessToken(randomPassword), + token: createRFC8959AccessTokenPlain(randomPassword), }, ); if (resp.type === "fail") { @@ -937,7 +985,7 @@ deploymentCli { const resp = await merchantInstance.updateBankAccount( - createAccessToken(randomPassword), + createRFC8959AccessTokenEncoded(randomPassword), wireAccount, { credit_facade_url: bank.getRevenueAPI(id).href, @@ -995,10 +1043,13 @@ deploymentCli const httpLib = createPlatformHttpLib({}); const baseUrl = args.provisionMerchantInstance.merchantApiBaseUrl; const api = new TalerMerchantManagementHttpClient(baseUrl, httpLib); - const managementToken = createAccessToken( + const managementToken = createRFC8959AccessTokenEncoded( args.provisionMerchantInstance.managementToken, ); - const instanceToken = createAccessToken( + const instanceTokenEnc = createRFC8959AccessTokenPlain( + args.provisionMerchantInstance.instanceToken, + ); + const instanceTokenPlain = createRFC8959AccessTokenPlain( args.provisionMerchantInstance.instanceToken, ); const instanceId = args.provisionMerchantInstance.id; @@ -1012,7 +1063,7 @@ deploymentCli address: {}, auth: { method: "token", - token: instanceToken, + token: instanceTokenPlain, }, default_pay_delay: Duration.toTalerProtocolDuration( Duration.fromSpec({ hours: 1 }), @@ -1035,7 +1086,7 @@ deploymentCli process.exit(2); } - const createAccountResp = await api.addBankAccount(instanceToken, { + const createAccountResp = await api.addBankAccount(instanceTokenEnc, { payto_uri: accountPayto, credit_facade_url: bankURL, credit_facade_credentials: @@ -1147,23 +1198,6 @@ deploymentCli console.log(out); }); -const deploymentConfigCli = deploymentCli.subcommand("configArgs", "config", { - help: "Subcommands the Taler configuration.", -}); - -deploymentConfigCli - .subcommand("show", "show") - .flag("diagnostics", ["-d", "--diagnostics"]) - .maybeArgument("cfgfile", clk.STRING, {}) - .action(async (args) => { - const cfg = Configuration.load(args.show.cfgfile); - console.log( - cfg.stringify({ - diagnostics: args.show.diagnostics, - }), - ); - }); - testingCli.subcommand("logtest", "logtest").action(async (args) => { logger.trace("This is a trace message."); logger.info("This is an info message."); diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts index 678c3f092..54d450d82 100644 --- a/packages/taler-util/src/codec.ts +++ b/packages/taler-util/src/codec.ts @@ -491,6 +491,19 @@ export function codecOptional<V>(innerCodec: Codec<V>): Codec<V | undefined> { }; } +export function codecForLazy<V>(innerCodec: () => Codec<V>): Codec<V> { + let instance: Codec<V> | undefined = undefined + return { + decode(x: any, c?: Context): V { + if (instance === undefined) { + instance = innerCodec() + } + return instance.decode(x, c); + }, + }; +} + + export type CodecType<T> = T extends Codec<infer X> ? X : any; export function codecForEither<T extends Array<Codec<unknown>>>( @@ -514,5 +527,3 @@ export function codecForEither<T extends Array<Codec<unknown>>>( }, }; } - -const x = codecForEither(codecForString(), codecForNumber()); diff --git a/packages/taler-util/src/http-client/bank-core.ts b/packages/taler-util/src/http-client/bank-core.ts index 97c1727ff..6c8051ada 100644 --- a/packages/taler-util/src/http-client/bank-core.ts +++ b/packages/taler-util/src/http-client/bank-core.ts @@ -27,7 +27,7 @@ import { codecForTanTransmission, opKnownAlternativeFailure, opKnownHttpFailure, - opKnownTalerFailure + opKnownTalerFailure, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, @@ -184,6 +184,8 @@ export class TalerCoreBankHttpClient { return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: return opKnownTalerFailure(details.code, details); + case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: + return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: @@ -280,6 +282,8 @@ export class TalerCoreBankHttpClient { return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: return opKnownTalerFailure(details.code, details); + case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: + return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_MISSING_TAN_INFO: @@ -513,7 +517,7 @@ export class TalerCoreBankHttpClient { > { const url = new URL(`accounts/${auth.username}/transactions`, this.baseUrl); if (idempotencyCheck) { - body.request_uid = idempotencyCheck.uid + body.request_uid = idempotencyCheck.uid; } const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -752,6 +756,8 @@ export class TalerCoreBankHttpClient { switch (details.code) { case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: return opKnownTalerFailure(details.code, details); + case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL: + return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_BAD_CONVERSION: return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_UNALLOWED_DEBIT: diff --git a/packages/taler-util/src/http-client/types.ts b/packages/taler-util/src/http-client/types.ts index a2f709769..c0004a218 100644 --- a/packages/taler-util/src/http-client/types.ts +++ b/packages/taler-util/src/http-client/types.ts @@ -196,16 +196,19 @@ export type AccessToken = string & { /** * Create a rfc8959 access token. * Adds secret-token: prefix if there is none. + * Encode the token with rfc7230 to send in a http header. * - * @deprecated use createRFC8959AccessToken * @param token * @returns */ -export function createAccessToken(token: string): AccessToken { +export function createRFC8959AccessTokenEncoded(token: string): AccessToken { return ( - token.startsWith("secret-token:") ? token : `secret-token:${token}` + token.startsWith("secret-token:") + ? token + : `secret-token:${encodeURIComponent(token)}` ) as AccessToken; } + /** * Create a rfc8959 access token. * Adds secret-token: prefix if there is none. @@ -213,11 +216,12 @@ export function createAccessToken(token: string): AccessToken { * @param token * @returns */ -export function createRFC8959AccessToken(token: string): AccessToken { +export function createRFC8959AccessTokenPlain(token: string): AccessToken { return ( token.startsWith("secret-token:") ? token : `secret-token:${token}` ) as AccessToken; } + /** * Convert string to access token. * @@ -336,6 +340,7 @@ export const codecForCoreBankConfig = (): Codec<TalerCorebankApi.Config> => .property("name", codecForConstString("libeufin-bank")) .property("version", codecForString()) .property("bank_name", codecForString()) + .property("base_url", codecForString()) .property("allow_conversion", codecForBoolean()) .property("allow_registrations", codecForBoolean()) .property("allow_deletions", codecForBoolean()) @@ -1033,10 +1038,20 @@ export const codecForAccountMinimalData = .property("name", codecForString()) .property("payto_uri", codecForPaytoString()) .property("balance", codecForBalance()) + .property("row_id", codecForNumber()) .property("debit_threshold", codecForAmountString()) + .property("min_cashout", codecOptional(codecForAmountString())) .property("is_public", codecForBoolean()) .property("is_taler_exchange", codecForBoolean()) - .property("row_id", codecOptional(codecForNumber())) + .property( + "status", + codecOptional( + codecForEither( + codecForConstString("active"), + codecForConstString("deleted"), + ), + ), + ) .build("TalerCorebankApi.AccountMinimalData"); export const codecForListBankAccountsResponse = @@ -1051,6 +1066,7 @@ export const codecForAccountData = (): Codec<TalerCorebankApi.AccountData> => .property("balance", codecForBalance()) .property("payto_uri", codecForPaytoString()) .property("debit_threshold", codecForAmountString()) + .property("min_cashout", codecOptional(codecForAmountString())) .property("contact_data", codecOptional(codecForChallengeContactData())) .property("cashout_payto_uri", codecOptional(codecForPaytoString())) .property("is_public", codecForBoolean()) @@ -1064,6 +1080,15 @@ export const codecForAccountData = (): Codec<TalerCorebankApi.AccountData> => ), ), ) + .property( + "status", + codecOptional( + codecForEither( + codecForConstString("active"), + codecForConstString("deleted"), + ), + ), + ) .build("TalerCorebankApi.AccountData"); export const codecForChallengeContactData = @@ -1387,7 +1412,7 @@ export const codecForAddIncomingResponse = export const codecForAmlRecords = (): Codec<TalerExchangeApi.AmlRecords> => buildCodecForObject<TalerExchangeApi.AmlRecords>() .property("records", codecForList(codecForAmlRecord())) - .build("TalerExchangeApi.PublicAccountsResponse"); + .build("TalerExchangeApi.AmlRecords"); export const codecForAmlRecord = (): Codec<TalerExchangeApi.AmlRecord> => buildCodecForObject<TalerExchangeApi.AmlRecord>() @@ -2039,6 +2064,11 @@ export namespace TalerCorebankApi { // @since v4, will become mandatory in the next version. bank_name: string; + // Advertised base URL to use when you sharing an URL with another + // program. + // @since v4. + base_url?: string; + // If 'true' the server provides local currency conversion support // If 'false' some parts of the API are not supported and return 501 allow_conversion: boolean; @@ -2197,6 +2227,11 @@ export namespace TalerCorebankApi { // Only admin can set this property. debit_threshold?: AmountString; + // If present, set a custom minimum cashout amount for this account. + // Only admin can set this property + // @since v4 + min_cashout?: AmountString; + // If present, enables 2FA and set the TAN channel used for challenges // Only admin can set this property, other user can reconfig their account // after creation. @@ -2238,7 +2273,11 @@ export namespace TalerCorebankApi { // Only admin can change this property. debit_threshold?: AmountString; - //FIX: missing in SPEC + // If present, change the custom minimum cashout amount for this account. + // Only admin can set this property + // @since v4 + min_cashout?: AmountString; + // If present, enables 2FA and set the TAN channel used for challenges tan_channel?: TanChannel | null; } @@ -2295,6 +2334,11 @@ export namespace TalerCorebankApi { // Number indicating the max debit allowed for the requesting user. debit_threshold: AmountString; + // Custom minimum cashout amount for this account. + // If null or absent, the global conversion fee is used. + // @since v4 + min_cashout?: AmountString; + // Is this account visible to anyone? is_public: boolean; @@ -2304,6 +2348,14 @@ export namespace TalerCorebankApi { // Opaque unique ID used for pagination. // @since v4, will become mandatory in the future. row_id?: Integer; + + // Current status of the account + // active: the account can be used + // deleted: the account has been deleted but is retained for compliance + // reasons, only the administrator can access it + // Default to 'active' is missing + // @since v4, will become mandatory in the next version. + status?: "active" | "deleted"; } export interface AccountData { @@ -2319,6 +2371,11 @@ export namespace TalerCorebankApi { // Number indicating the max debit allowed for the requesting user. debit_threshold: AmountString; + // Custom minimum cashout amount for this account. + // If null or absent, the global conversion fee is used. + // @since v4 + min_cashout?: AmountString; + contact_data?: ChallengeContactData; // 'payto' address pointing the bank account @@ -2337,6 +2394,14 @@ export namespace TalerCorebankApi { // Is 2FA enabled and what channel is used for challenges? tan_channel?: TanChannel; + + // Current status of the account + // active: the account can be used + // deleted: the account has been deleted but is retained for compliance + // reasons, only the administrator can access it + // Default to 'active' is missing + // @since v4, will become mandatory in the next version. + status?: "active" | "deleted"; } export interface CashoutRequest { @@ -4399,173 +4464,6 @@ export namespace TalerMerchantApi { confirmed?: boolean; } - interface ReserveCreateRequest { - // Amount that the merchant promises to put into the reserve. - initial_balance: AmountString; - - // Exchange the merchant intends to use for rewards. - exchange_url: string; - - // Desired wire method, for example "iban" or "x-taler-bank". - wire_method: string; - } - interface ReserveCreateConfirmation { - // Public key identifying the reserve. - reserve_pub: EddsaPublicKey; - - // Wire accounts of the exchange where to transfer the funds. - accounts: TalerExchangeApi.WireAccount[]; - } - - interface RewardReserveStatus { - // Array of all known reserves (possibly empty!). - reserves: ReserveStatusEntry[]; - } - interface ReserveStatusEntry { - // Public key of the reserve. - reserve_pub: EddsaPublicKey; - - // Timestamp when it was established. - creation_time: Timestamp; - - // Timestamp when it expires. - expiration_time: Timestamp; - - // Initial amount as per reserve creation call. - merchant_initial_amount: AmountString; - - // Initial amount as per exchange, 0 if exchange did - // not confirm reserve creation yet. - exchange_initial_amount: AmountString; - - // Amount picked up so far. - pickup_amount: AmountString; - - // Amount approved for rewards that exceeds the pickup_amount. - committed_amount: AmountString; - - // Is this reserve active (false if it was deleted but not purged)? - active: boolean; - } - - interface ReserveDetail { - // Timestamp when it was established. - creation_time: Timestamp; - - // Timestamp when it expires. - expiration_time: Timestamp; - - // Initial amount as per reserve creation call. - merchant_initial_amount: AmountString; - - // Initial amount as per exchange, 0 if exchange did - // not confirm reserve creation yet. - exchange_initial_amount: AmountString; - - // Amount picked up so far. - pickup_amount: AmountString; - - // Amount approved for rewards that exceeds the pickup_amount. - committed_amount: AmountString; - - // Array of all rewards created by this reserves (possibly empty!). - // Only present if asked for explicitly. - rewards?: RewardStatusEntry[]; - - // Is this reserve active (false if it was deleted but not purged)? - active: boolean; - - // Array of wire accounts of the exchange that could - // be used to fill the reserve, can be NULL - // if the reserve is inactive or was already filled - accounts?: TalerExchangeApi.WireAccount[]; - - // URL of the exchange hosting the reserve, - // NULL if the reserve is inactive - exchange_url: string; - } - interface RewardStatusEntry { - // Unique identifier for the reward. - reward_id: HashCode; - - // Total amount of the reward that can be withdrawn. - total_amount: AmountString; - - // Human-readable reason for why the reward was granted. - reason: string; - } - - interface RewardCreateRequest { - // Amount that the customer should be rewarded. - amount: AmountString; - - // Justification for giving the reward. - justification: string; - - // URL that the user should be directed to after receiving the reward, - // will be included in the reward_token. - next_url: string; - } - interface RewardCreateConfirmation { - // Unique reward identifier for the reward that was created. - reward_id: HashCode; - - // taler://reward URI for the reward. - taler_reward_uri: string; - - // URL that will directly trigger processing - // the reward when the browser is redirected to it. - reward_status_url: string; - - // When does the reward expire? - reward_expiration: Timestamp; - } - - interface RewardDetails { - // Amount that we authorized for this reward. - total_authorized: AmountString; - - // Amount that was picked up by the user already. - total_picked_up: AmountString; - - // Human-readable reason given when authorizing the reward. - reason: string; - - // Timestamp indicating when the reward is set to expire (may be in the past). - expiration: Timestamp; - - // Reserve public key from which the reward is funded. - reserve_pub: EddsaPublicKey; - - // Array showing the pickup operations of the wallet (possibly empty!). - // Only present if asked for explicitly. - pickups?: PickupDetail[]; - } - interface PickupDetail { - // Unique identifier for the pickup operation. - pickup_id: HashCode; - - // Number of planchets involved. - num_planchets: Integer; - - // Total amount requested for this pickup_id. - requested_amount: AmountString; - } - - interface RewardsResponse { - // List of rewards that are present in the backend. - rewards: Reward[]; - } - interface Reward { - // ID of the reward in the backend database. - row_id: number; - - // Unique identifier for the reward. - reward_id: HashCode; - - // (Remaining) amount of the reward (including fees). - reward_amount: AmountString; - } export interface OtpDeviceAddDetails { // Device ID to use. diff --git a/packages/taler-util/src/taler-error-codes.ts b/packages/taler-util/src/taler-error-codes.ts index c3c008a1c..9985e74b3 100644 --- a/packages/taler-util/src/taler-error-codes.ts +++ b/packages/taler-util/src/taler-error-codes.ts @@ -2505,6 +2505,62 @@ export enum TalerErrorCode { /** + * The payment requires the wallet to select a choice from the choices array and pass it in the 'choice_index' field of the request. + * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_MISSING = 2176, + + + /** + * The 'choice_index' field is invalid. + * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_OUT_OF_BOUNDS = 2177, + + + /** + * The provided 'tokens' array does not match with the required input tokens of the order. + * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_POST_ORDERS_ID_PAY_INPUT_TOKENS_MISMATCH = 2178, + + + /** + * Invalid token issue signature (blindly signed by merchant) for provided token. + * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_POST_ORDERS_ID_PAY_TOKEN_ISSUE_SIG_INVALID = 2179, + + + /** + * Invalid token use signature (EdDSA, signed by wallet) for provided token. + * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_POST_ORDERS_ID_PAY_TOKEN_USE_SIG_INVALID = 2180, + + + /** + * The provided number of tokens does not match the required number. + * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_POST_ORDERS_ID_PAY_TOKEN_COUNT_MISMATCH = 2181, + + + /** + * The provided number of token envelopes does not match the specified number. + * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_POST_ORDERS_ID_PAY_TOKEN_ENVELOPE_COUNT_MISMATCH = 2182, + + + /** * The contract hash does not match the given order ID. * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). * (A value of 0 indicates that the error is generated client-side). @@ -2857,6 +2913,14 @@ export enum TalerErrorCode { /** + * The token family slug provided in this order could not be found in the merchant database. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_SLUG_UNKNOWN = 2533, + + + /** * The exchange says it does not know this transfer. * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502). * (A value of 0 indicates that the error is generated client-side). @@ -3185,6 +3249,22 @@ export enum TalerErrorCode { /** + * The requested resource could not be found. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + AUDITOR_RESOURCE_NOT_FOUND = 3102, + + + /** + * The URI is missing a path component. + * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). + * (A value of 0 indicates that the error is generated client-side). + */ + AUDITOR_URI_MISSING_PATH_COMPONENT = 3103, + + + /** * Wire transfer attempted with credit and debit party being the same bank account. * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). * (A value of 0 indicates that the error is generated client-side). @@ -3537,6 +3617,22 @@ export enum TalerErrorCode { /** + * A non-admin user has tried to set their minimum cashout amount. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + BANK_NON_ADMIN_SET_MIN_CASHOUT = 5146, + + + /** + * Amount of currency conversion it less than the minimum allowed. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + BANK_CONVERSION_AMOUNT_TO_SMALL = 5147, + + + /** * The sync service failed find the account in its database. * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). * (A value of 0 indicates that the error is generated client-side). @@ -4505,6 +4601,14 @@ export enum TalerErrorCode { /** + * The donation amount specified in the request exceeds the limit of the charity. + * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_EXCEEDING_DONATION_LIMIT = 8610, + + + /** * A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information. * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). * (A value of 0 indicates that the error is generated client-side). diff --git a/packages/taler-util/src/talerconfig.ts b/packages/taler-util/src/talerconfig.ts index 83c0044be..2bd7b355f 100644 --- a/packages/taler-util/src/talerconfig.ts +++ b/packages/taler-util/src/talerconfig.ts @@ -23,13 +23,12 @@ /** * Imports */ -import { AmountJson } from "./amounts.js"; -import { Amounts } from "./amounts.js"; +import { AmountJson, Amounts } from "./amounts.js"; import { Logger } from "./logging.js"; -import nodejs_path from "path"; -import nodejs_os from "os"; import nodejs_fs from "fs"; +import nodejs_os from "os"; +import nodejs_path from "path"; const logger = new Logger("talerconfig.ts"); @@ -76,6 +75,54 @@ interface Section { type SectionMap = { [sectionName: string]: Section }; +/** + * Different projects use the GNUnet/Taler-Style config. + * + * The config source determines where to locate the configuration. + */ +export interface ConfigSource { + projectName: string; + componentName: string; + installPathBinary: string; + baseConfigVarname: string; + prefixVarname: string; +} + +export type ConfigSourceDef = { [x: string]: ConfigSource | undefined }; + +export const ConfigSources = { + ["taler"]: { + projectName: "taler", + componentName: "taler", + installPathBinary: "taler-config", + baseConfigVarname: "TALER_BASE_CONFIG", + prefixVarname: "TALER_PREFIX", + } satisfies ConfigSource, + ["libeufin-bank"]: { + projectName: "libeufin", + componentName: "libeufin-bank", + installPathBinary: "libeufin-bank", + baseConfigVarname: "LIBEUFIN_BASE_CONFIG", + prefixVarname: "LIBEUFIN_PREFIX", + } satisfies ConfigSource, + ["libeufin-nexus"]: { + projectName: "libeufin", + componentName: "libeufin-nexus", + installPathBinary: "libeufin-nexus", + baseConfigVarname: "LIBEUFIN_BASE_CONFIG", + prefixVarname: "LIBEUFIN_PREFIX", + } satisfies ConfigSource, + ["gnunet"]: { + projectName: "gnunet", + componentName: "gnunet", + installPathBinary: "gnunet-config", + baseConfigVarname: "GNUNET_BASE_CONFIG", + prefixVarname: "GNUNET_PREFIX", + } satisfies ConfigSource, +} satisfies ConfigSourceDef; + +const defaultConfigSource: ConfigSource = ConfigSources.taler; + export class ConfigValue<T> { constructor( private sectionName: string, @@ -215,7 +262,7 @@ export function pathsub( return s; } -export interface LoadOptions { +interface LoadOptions { filename?: string; banDirectives?: boolean; } @@ -310,6 +357,14 @@ export class Configuration { private nestLevel = 0; + /** + * Does the entrypoint config file contain complex + * directives? + */ + private entrypointIsComplex: boolean = false; + + constructor(private configSource: ConfigSource = defaultConfigSource) {} + private loadFromFilename( filename: string, isDefaultSource: boolean, @@ -434,6 +489,9 @@ export class Configuration { `invalid configuration, directive in ${fn}:${lineNo} forbidden`, ); } + if (!isDefaultSource) { + this.entrypointIsComplex = true; + } const directive = directiveMatch[1].toLowerCase(); switch (directive) { case "inline": { @@ -521,10 +579,6 @@ export class Configuration { } } - loadFromString(s: string, opts: LoadOptions = {}): void { - return this.internalLoadFromString(s, false, opts); - } - private provideSection(section: string): Section { const secNorm = section.toUpperCase(); if (this.sectionMap[secNorm]) { @@ -653,7 +707,7 @@ export class Configuration { ); } - loadDefaultsFromDir(dirname: string): void { + private loadDefaultsFromDir(dirname: string): void { const files = nodejs_fs.readdirSync(dirname); for (const f of files) { const fn = nodejs_path.join(dirname, f); @@ -662,26 +716,28 @@ export class Configuration { } private loadDefaults(): void { - let baseConfigDir = process.env["TALER_BASE_CONFIG"]; + const { projectName, prefixVarname, baseConfigVarname, installPathBinary } = + this.configSource; + let baseConfigDir = process.env[baseConfigVarname]; if (!baseConfigDir) { /* Try to locate the configuration based on the location * of the taler-config binary. */ - const path = which("taler-config"); + const path = which(installPathBinary); if (path) { baseConfigDir = nodejs_fs.realpathSync( - nodejs_path.dirname(path) + "/../share/taler/config.d", + nodejs_path.dirname(path) + `/../share/${projectName}/config.d`, ); } } if (!baseConfigDir) { - baseConfigDir = "/usr/share/taler/config.d"; + baseConfigDir = `/usr/share/${projectName}/config.d`; } - let installPrefix = process.env["TALER_PREFIX"]; + let installPrefix = process.env[prefixVarname]; if (!installPrefix) { /* Try to locate install path based on the location * of the taler-config binary. */ - const path = which("taler-config"); + const path = which(installPathBinary); if (path) { installPrefix = nodejs_fs.realpathSync( nodejs_path.dirname(path) + "/..", @@ -695,12 +751,12 @@ export class Configuration { this.setStringSystemDefault( "PATHS", "LIBEXECDIR", - `${installPrefix}/taler/libexec/`, + `${installPrefix}/${projectName}/libexec/`, ); this.setStringSystemDefault( "PATHS", "DOCDIR", - `${installPrefix}/share/doc/taler/`, + `${installPrefix}/share/doc/${projectName}/`, ); this.setStringSystemDefault( "PATHS", @@ -717,58 +773,80 @@ export class Configuration { this.setStringSystemDefault( "PATHS", "LIBDIR", - `${installPrefix}/lib/taler/`, + `${installPrefix}/lib/${projectName}/`, ); this.setStringSystemDefault( "PATHS", "DATADIR", - `${installPrefix}/share/taler/`, + `${installPrefix}/share/${projectName}/`, ); this.loadDefaultsFromDir(baseConfigDir); } - getDefaultConfigFilename(): string | undefined { + private findDefaultConfigFilename(): string | undefined { const xdg = process.env["XDG_CONFIG_HOME"]; const home = process.env["HOME"]; let fn: string | undefined; + const { projectName, componentName } = this.configSource; if (xdg) { - fn = nodejs_path.join(xdg, "taler.conf"); + fn = nodejs_path.join(xdg, `${componentName}.conf`); } else if (home) { - fn = nodejs_path.join(home, ".config/taler.conf"); + fn = nodejs_path.join(home, `.config/${componentName}.conf`); } if (fn && nodejs_fs.existsSync(fn)) { return fn; } - const etc1 = "/etc/taler.conf"; + const etc1 = `/etc/${componentName}.conf`; if (nodejs_fs.existsSync(etc1)) { return etc1; } - const etc2 = "/etc/taler/taler.conf"; + const etc2 = `/etc/${projectName}/${componentName}.conf`; if (nodejs_fs.existsSync(etc2)) { return etc2; } return undefined; } - static load(filename?: string): Configuration { - const cfg = new Configuration(); + static load( + filename?: string, + configSource?: ConfigSource | string, + ): Configuration { + let cs: ConfigSource; + if (configSource == null) { + cs = defaultConfigSource; + } else if (typeof configSource === "string") { + if (configSource in ConfigSources) { + cs = ConfigSources[configSource as keyof typeof ConfigSources]; + } else { + throw Error("invalid config source"); + } + } else { + cs = configSource; + } + const cfg = new Configuration(cs); cfg.loadDefaults(); if (filename) { cfg.loadFromFilename(filename, false); + cfg.hintEntrypoint = filename; } else { - const fn = cfg.getDefaultConfigFilename(); + const fn = cfg.findDefaultConfigFilename(); if (fn) { // It's the default filename for the main config file, // but we don't consider the values default values. cfg.loadFromFilename(fn, false); + cfg.hintEntrypoint = fn; } } - cfg.hintEntrypoint = filename; return cfg; } stringify(opts: StringifyOptions = {}): string { + if (opts.excludeDefaults && this.entrypointIsComplex) { + throw Error( + "unable to do diff serialization of config file, as entry point contains complex directives", + ); + } let s = ""; if (opts.diagnostics) { s += "# Configuration file diagnostics\n"; @@ -824,7 +902,20 @@ export class Configuration { return s; } - write(filename: string, opts: { excludeDefaults?: boolean } = {}): void { + write(opts: { excludeDefaults?: boolean } = {}): void { + const filename = this.hintEntrypoint; + if (!filename) { + throw Error( + "unknown configuration entrypoing, unable to write back config file", + ); + } + nodejs_fs.writeFileSync( + filename, + this.stringify({ excludeDefaults: opts.excludeDefaults }), + ); + } + + writeTo(filename: string, opts: { excludeDefaults?: boolean } = {}): void { nodejs_fs.writeFileSync( filename, this.stringify({ excludeDefaults: opts.excludeDefaults }), diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index a52b3e6ba..ec6cb6f58 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -50,6 +50,7 @@ import { CurrencySpecification, TemplateParams, WithdrawalOperationStatus, + canonicalizeBaseUrl, } from "./index.js"; import { VersionMatchResult } from "./libtool-version.js"; import { PaytoUri } from "./payto.js"; @@ -149,6 +150,27 @@ function codecForTombstoneIdStr(): Codec<TombstoneIdStr> { }; } +export function codecForCanonBaseUrl(): Codec<string> { + return { + decode(x: any, c?: Context): string { + if (typeof x === "string") { + const canon = canonicalizeBaseUrl(x); + if (x !== canon) { + throw new DecodingError( + `expected canonicalized base URL at ${renderContext( + c, + )} but got value '${x}'`, + ); + } + return x; + } + throw new DecodingError( + `expected base URL at ${renderContext(c)} but got type ${typeof x}`, + ); + }, + }; +} + /** * Response for the create reserve request to the wallet. */ @@ -294,15 +316,10 @@ interface GetPlanForPaymentRequest extends GetPlanToCompleteOperation { maxDepositFee: AmountString; } -// interface GetPlanForTipRequest extends GetPlanForOperationBase { -// type: TransactionType.Tip; -// } -// interface GetPlanForRefundRequest extends GetPlanForOperationBase { -// type: TransactionType.Refund; -// } interface GetPlanForPullDebitRequest extends GetPlanToCompleteOperation { type: TransactionType.PeerPullDebit; } + interface GetPlanForPushCreditRequest extends GetPlanToCompleteOperation { type: TransactionType.PeerPushCredit; } @@ -745,71 +762,6 @@ export interface PrepareRefundResult { info: OrderShortInfo; } -export interface PrepareTipResult { - /** - * Unique ID for the tip assigned by the wallet. - * Typically different from the merchant-generated tip ID. - * - * @deprecated use transactionId instead - */ - walletRewardId: string; - - /** - * Tip transaction ID. - */ - transactionId: TransactionIdStr; - - /** - * Has the tip already been accepted? - */ - accepted: boolean; - - /** - * Amount that the merchant gave. - */ - rewardAmountRaw: AmountString; - - /** - * Amount that arrived at the wallet. - * Might be lower than the raw amount due to fees. - */ - rewardAmountEffective: AmountString; - - /** - * Base URL of the merchant backend giving then tip. - */ - merchantBaseUrl: string; - - /** - * Base URL of the exchange that is used to withdraw the tip. - * Determined by the merchant, the wallet/user has no choice here. - */ - exchangeBaseUrl: string; - - /** - * Time when the tip will expire. After it expired, it can't be picked - * up anymore. - */ - expirationTimestamp: TalerProtocolTimestamp; -} - -export interface AcceptTipResponse { - transactionId: TransactionIdStr; - next_url?: string; -} - -export const codecForPrepareTipResult = (): Codec<PrepareTipResult> => - buildCodecForObject<PrepareTipResult>() - .property("accepted", codecForBoolean()) - .property("rewardAmountRaw", codecForAmountString()) - .property("rewardAmountEffective", codecForAmountString()) - .property("exchangeBaseUrl", codecForString()) - .property("merchantBaseUrl", codecForString()) - .property("expirationTimestamp", codecForTimestamp) - .property("walletRewardId", codecForString()) - .property("transactionId", codecForTransactionIdStr()) - .build("PrepareRewardResult"); - export interface BenchmarkResult { time: { [s: string]: number }; repetitions: number; @@ -1063,7 +1015,7 @@ export interface TalerErrorDetail { /** * Minimal information needed about a planchet for unblinding a signature. * - * Can be a withdrawal/tipping/refresh planchet. + * Can be a withdrawal/refresh planchet. */ export interface PlanchetUnblindInfo { denomPub: DenominationPubKey; @@ -1470,7 +1422,7 @@ export const codecForFeesByOperations = (): Codec< export const codecForExchangeFullDetails = (): Codec<ExchangeFullDetails> => buildCodecForObject<ExchangeFullDetails>() .property("currency", codecForString()) - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("paytoUris", codecForList(codecForString())) .property("auditors", codecForList(codecForExchangeAuditor())) .property("wireInfo", codecForWireInfo()) @@ -1485,7 +1437,7 @@ export const codecForExchangeFullDetails = (): Codec<ExchangeFullDetails> => export const codecForExchangeListItem = (): Codec<ExchangeListItem> => buildCodecForObject<ExchangeListItem>() .property("currency", codecForString()) - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("masterPub", codecOptional(codecForString())) .property("paytoUris", codecForList(codecForString())) .property("tosStatus", codecForAny()) @@ -1709,7 +1661,7 @@ export interface TestPayArgs { export const codecForTestPayArgs = (): Codec<TestPayArgs> => buildCodecForObject<TestPayArgs>() - .property("merchantBaseUrl", codecForString()) + .property("merchantBaseUrl", codecForCanonBaseUrl()) .property("merchantAuthToken", codecOptional(codecForString())) .property("amount", codecForAmountString()) .property("summary", codecForString()) @@ -1727,12 +1679,12 @@ export interface IntegrationTestArgs { export const codecForIntegrationTestArgs = (): Codec<IntegrationTestArgs> => buildCodecForObject<IntegrationTestArgs>() - .property("exchangeBaseUrl", codecForString()) - .property("merchantBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) + .property("merchantBaseUrl", codecForCanonBaseUrl()) .property("merchantAuthToken", codecOptional(codecForString())) .property("amountToSpend", codecForAmountString()) .property("amountToWithdraw", codecForAmountString()) - .property("corebankApiBaseUrl", codecForString()) + .property("corebankApiBaseUrl", codecForCanonBaseUrl()) .build("IntegrationTestArgs"); export interface IntegrationTestV2Args { @@ -1744,10 +1696,10 @@ export interface IntegrationTestV2Args { export const codecForIntegrationTestV2Args = (): Codec<IntegrationTestV2Args> => buildCodecForObject<IntegrationTestV2Args>() - .property("exchangeBaseUrl", codecForString()) - .property("merchantBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) + .property("merchantBaseUrl", codecForCanonBaseUrl()) .property("merchantAuthToken", codecOptional(codecForString())) - .property("corebankApiBaseUrl", codecForString()) + .property("corebankApiBaseUrl", codecForCanonBaseUrl()) .build("IntegrationTestV2Args"); export interface GetExchangeEntryByUrlRequest { @@ -1757,7 +1709,7 @@ export interface GetExchangeEntryByUrlRequest { export const codecForGetExchangeEntryByUrlRequest = (): Codec<GetExchangeEntryByUrlRequest> => buildCodecForObject<GetExchangeEntryByUrlRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .build("GetExchangeEntryByUrlRequest"); export type GetExchangeEntryByUrlResponse = ExchangeListItem; @@ -1775,7 +1727,7 @@ export interface AddExchangeRequest { export const codecForAddExchangeRequest = (): Codec<AddExchangeRequest> => buildCodecForObject<AddExchangeRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("forceUpdate", codecOptional(codecForBoolean())) .property("masterPub", codecOptional(codecForString())) .build("AddExchangeRequest"); @@ -1788,7 +1740,7 @@ export interface UpdateExchangeEntryRequest { export const codecForUpdateExchangeEntryRequest = (): Codec<UpdateExchangeEntryRequest> => buildCodecForObject<UpdateExchangeEntryRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("force", codecOptional(codecForBoolean())) .build("UpdateExchangeEntryRequest"); @@ -1799,7 +1751,7 @@ export interface GetExchangeResourcesRequest { export const codecForGetExchangeResourcesRequest = (): Codec<GetExchangeResourcesRequest> => buildCodecForObject<GetExchangeResourcesRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .build("GetExchangeResourcesRequest"); export interface GetExchangeResourcesResponse { @@ -1813,7 +1765,7 @@ export interface DeleteExchangeRequest { export const codecForDeleteExchangeRequest = (): Codec<DeleteExchangeRequest> => buildCodecForObject<DeleteExchangeRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("purge", codecOptional(codecForBoolean())) .build("DeleteExchangeRequest"); @@ -1824,7 +1776,7 @@ export interface ForceExchangeUpdateRequest { export const codecForForceExchangeUpdateRequest = (): Codec<AddExchangeRequest> => buildCodecForObject<AddExchangeRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .build("AddExchangeRequest"); export interface GetExchangeTosRequest { @@ -1835,7 +1787,7 @@ export interface GetExchangeTosRequest { export const codecForGetExchangeTosRequest = (): Codec<GetExchangeTosRequest> => buildCodecForObject<GetExchangeTosRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("acceptedFormat", codecOptional(codecForList(codecForString()))) .property("acceptLanguage", codecOptional(codecForString())) .build("GetExchangeTosRequest"); @@ -1858,7 +1810,7 @@ export interface AcceptManualWithdrawalRequest { export const codecForAcceptManualWithdrawalRequest = (): Codec<AcceptManualWithdrawalRequest> => buildCodecForObject<AcceptManualWithdrawalRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("amount", codecForAmountString()) .property("restrictAge", codecOptional(codecForNumber())) .property("forceReservePriv", codecOptional(codecForString())) @@ -1893,7 +1845,7 @@ export interface PrepareBankIntegratedWithdrawalRequest { export const codecForPrepareBankIntegratedWithdrawalRequest = (): Codec<PrepareBankIntegratedWithdrawalRequest> => buildCodecForObject<PrepareBankIntegratedWithdrawalRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("talerWithdrawUri", codecForString()) .property("forcedDenomSel", codecForAny()) .property("restrictAge", codecOptional(codecForNumber())) @@ -1923,7 +1875,7 @@ export interface AcceptBankIntegratedWithdrawalRequest { export const codecForAcceptBankIntegratedWithdrawalRequest = (): Codec<AcceptBankIntegratedWithdrawalRequest> => buildCodecForObject<AcceptBankIntegratedWithdrawalRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("talerWithdrawUri", codecForString()) .property("forcedDenomSel", codecForAny()) .property("restrictAge", codecOptional(codecForNumber())) @@ -1932,7 +1884,7 @@ export const codecForAcceptBankIntegratedWithdrawalRequest = export const codecForGetWithdrawalDetailsForAmountRequest = (): Codec<GetWithdrawalDetailsForAmountRequest> => buildCodecForObject<GetWithdrawalDetailsForAmountRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("amount", codecForAmountString()) .property("restrictAge", codecOptional(codecForNumber())) .property("clientCancellationId", codecOptional(codecForString())) @@ -1945,7 +1897,7 @@ export interface AcceptExchangeTosRequest { export const codecForAcceptExchangeTosRequest = (): Codec<AcceptExchangeTosRequest> => buildCodecForObject<AcceptExchangeTosRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .build("AcceptExchangeTosRequest"); export interface ForgetExchangeTosRequest { @@ -1955,7 +1907,7 @@ export interface ForgetExchangeTosRequest { export const codecForForgetExchangeTosRequest = (): Codec<ForgetExchangeTosRequest> => buildCodecForObject<ForgetExchangeTosRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .build("ForgetExchangeTosRequest"); export interface AcceptRefundRequest { @@ -2059,7 +2011,7 @@ export interface SharePaymentRequest { } export const codecForSharePaymentRequest = (): Codec<SharePaymentRequest> => buildCodecForObject<SharePaymentRequest>() - .property("merchantBaseUrl", codecForString()) + .property("merchantBaseUrl", codecForCanonBaseUrl()) .property("orderId", codecForString()) .build("SharePaymentRequest"); @@ -2208,9 +2160,9 @@ export const codecForWithdrawTestBalance = (): Codec<WithdrawTestBalanceRequest> => buildCodecForObject<WithdrawTestBalanceRequest>() .property("amount", codecForAmountString()) - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("forcedDenomSel", codecForAny()) - .property("corebankApiBaseUrl", codecForString()) + .property("corebankApiBaseUrl", codecForCanonBaseUrl()) .build("WithdrawTestBalanceRequest"); export interface SetCoinSuspendedRequest { @@ -2271,32 +2223,6 @@ export const codecForStartRefundQueryRequest = .property("transactionId", codecForTransactionIdStr()) .build("StartRefundQueryRequest"); -export interface PrepareRewardRequest { - talerRewardUri: string; -} - -export const codecForPrepareRewardRequest = (): Codec<PrepareRewardRequest> => - buildCodecForObject<PrepareRewardRequest>() - .property("talerRewardUri", codecForString()) - .build("PrepareRewardRequest"); - -export interface AcceptRewardRequest { - /** - * @deprecated use transactionId - */ - walletRewardId?: string; - /** - * it will be required when "walletRewardId" is removed - */ - transactionId?: TransactionIdStr; -} - -export const codecForAcceptTipRequest = (): Codec<AcceptRewardRequest> => - buildCodecForObject<AcceptRewardRequest>() - .property("walletRewardId", codecOptional(codecForString())) - .property("transactionId", codecOptional(codecForTransactionIdStr())) - .build("AcceptRewardRequest"); - export interface FailTransactionRequest { transactionId: TransactionIdStr; } @@ -2414,7 +2340,7 @@ export const codecForWithdrawUriInfoResponse = ), ) .property("amount", codecForAmountString()) - .property("defaultExchangeBaseUrl", codecOptional(codecForString())) + .property("defaultExchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) .property("possibleExchanges", codecForList(codecForExchangeListItem())) .build("WithdrawUriInfoResponse"); @@ -2779,7 +2705,7 @@ export interface CheckPeerPushDebitRequest { export const codecForCheckPeerPushDebitRequest = (): Codec<CheckPeerPushDebitRequest> => buildCodecForObject<CheckPeerPushDebitRequest>() - .property("exchangeBaseUrl", codecOptional(codecForString())) + .property("exchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) .property("amount", codecForAmountString()) .build("CheckPeerPushDebitRequest"); @@ -2918,7 +2844,7 @@ export const codecForPreparePeerPullPaymentRequest = (): Codec<CheckPeerPullCreditRequest> => buildCodecForObject<CheckPeerPullCreditRequest>() .property("amount", codecForAmountString()) - .property("exchangeBaseUrl", codecOptional(codecForString())) + .property("exchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) .build("CheckPeerPullCreditRequest"); export interface CheckPeerPullCreditResponse { @@ -2941,7 +2867,7 @@ export const codecForInitiatePeerPullPaymentRequest = (): Codec<InitiatePeerPullCreditRequest> => buildCodecForObject<InitiatePeerPullCreditRequest>() .property("partialContractTerms", codecForPeerContractTerms()) - .property("exchangeBaseUrl", codecOptional(codecForString())) + .property("exchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) .build("InitiatePeerPullCreditRequest"); export interface InitiatePeerPullCreditResponse { @@ -2956,6 +2882,20 @@ export interface InitiatePeerPullCreditResponse { transactionId: TransactionIdStr; } +export interface CanonicalizeBaseUrlRequest { + url: string; +} + +export const codecForCanonicalizeBaseUrlRequest = + (): Codec<CanonicalizeBaseUrlRequest> => + buildCodecForObject<CanonicalizeBaseUrlRequest>() + .property("url", codecForString()) + .build("CanonicalizeBaseUrlRequest"); + +export interface CanonicalizeBaseUrlResponse { + url: string; +} + export interface ValidateIbanRequest { iban: string; } @@ -3083,7 +3023,7 @@ export interface TestingGetDenomStatsResponse { export const codecForTestingGetDenomStatsRequest = (): Codec<TestingGetDenomStatsRequest> => buildCodecForObject<TestingGetDenomStatsRequest>() - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .build("TestingGetDenomStatsRequest"); export interface WithdrawalExchangeAccountDetails { @@ -3203,7 +3143,7 @@ export const codecForAddGlobalCurrencyExchangeRequest = (): Codec<AddGlobalCurrencyExchangeRequest> => buildCodecForObject<AddGlobalCurrencyExchangeRequest>() .property("currency", codecForString()) - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("exchangeMasterPub", codecForString()) .build("AddGlobalCurrencyExchangeRequest"); @@ -3217,7 +3157,7 @@ export const codecForRemoveGlobalCurrencyExchangeRequest = (): Codec<RemoveGlobalCurrencyExchangeRequest> => buildCodecForObject<RemoveGlobalCurrencyExchangeRequest>() .property("currency", codecForString()) - .property("exchangeBaseUrl", codecForString()) + .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("exchangeMasterPub", codecForString()) .build("RemoveGlobalCurrencyExchangeRequest"); @@ -3231,7 +3171,7 @@ export const codecForAddGlobalCurrencyAuditorRequest = (): Codec<AddGlobalCurrencyAuditorRequest> => buildCodecForObject<AddGlobalCurrencyAuditorRequest>() .property("currency", codecForString()) - .property("auditorBaseUrl", codecForString()) + .property("auditorBaseUrl", codecForCanonBaseUrl()) .property("auditorPub", codecForString()) .build("AddGlobalCurrencyAuditorRequest"); @@ -3245,7 +3185,7 @@ export const codecForRemoveGlobalCurrencyAuditorRequest = (): Codec<RemoveGlobalCurrencyAuditorRequest> => buildCodecForObject<RemoveGlobalCurrencyAuditorRequest>() .property("currency", codecForString()) - .property("auditorBaseUrl", codecForString()) + .property("auditorBaseUrl", codecForCanonBaseUrl()) .property("auditorPub", codecForString()) .build("RemoveGlobalCurrencyAuditorRequest"); diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts index 16b5488e7..15904b470 100644 --- a/packages/taler-wallet-core/src/backup/index.ts +++ b/packages/taler-wallet-core/src/backup/index.ts @@ -46,7 +46,6 @@ import { buildCodecForUnion, bytesToString, canonicalJson, - canonicalizeBaseUrl, checkDbInvariant, checkLogicInvariant, codecForBoolean, @@ -570,7 +569,7 @@ export async function addBackupProvider( ): Promise<AddBackupProviderResponse> { logger.info(`adding backup provider ${j2s(req)}`); await provideBackupState(wex); - const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl); + const canonUrl = req.backupProviderBaseUrl; await wex.db.runReadWriteTx( { storeNames: ["backupProviders"] }, async (tx) => { diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 1edafb315..b75e48c39 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -351,7 +351,7 @@ export enum WithdrawalGroupStatus { /** * Another wallet confirmed the withdrawal - * (by POSTing the reseve pub to the bank) + * (by POSTing the reserve pub to the bank) * before we had the chance. * * In this situation, we'll let the other wallet continue diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts index d5ca7abbf..9072a4e6e 100644 --- a/packages/taler-wallet-core/src/exchanges.ts +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -78,7 +78,6 @@ import { WireFeesJson, WireInfo, assertUnreachable, - canonicalizeBaseUrl, checkDbInvariant, codecForExchangeKeysJson, durationMul, @@ -914,10 +913,8 @@ async function startUpdateExchangeEntry( exchangeBaseUrl: string, options: { forceUpdate?: boolean } = {}, ): Promise<void> { - const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - logger.info( - `starting update of exchange entry ${canonBaseUrl}, forced=${ + `starting update of exchange entry ${exchangeBaseUrl}, forced=${ options.forceUpdate ?? false }`, ); @@ -940,7 +937,7 @@ async function startUpdateExchangeEntry( await wex.db.runReadWriteTx( { storeNames: ["exchanges", "operationRetries"] }, async (tx) => { - const r = await tx.exchanges.get(canonBaseUrl); + const r = await tx.exchanges.get(exchangeBaseUrl); if (!r) { throw Error("exchange not found"); } @@ -988,7 +985,7 @@ async function startUpdateExchangeEntry( ); wex.ws.notify({ type: NotificationType.ExchangeStateTransition, - exchangeBaseUrl: canonBaseUrl, + exchangeBaseUrl, newExchangeState: newExchangeState, oldExchangeState: oldExchangeState, }); @@ -1160,10 +1157,8 @@ export async function fetchFreshExchange( expectedMasterPub?: string; } = {}, ): Promise<ReadyExchangeSummary> { - const canonUrl = canonicalizeBaseUrl(baseUrl); - if (!options.forceUpdate) { - const cachedResp = wex.ws.exchangeCache.get(canonUrl); + const cachedResp = wex.ws.exchangeCache.get(baseUrl); if (cachedResp) { return cachedResp; } @@ -1173,12 +1168,12 @@ export async function fetchFreshExchange( await wex.taskScheduler.ensureRunning(); - await startUpdateExchangeEntry(wex, canonUrl, { + await startUpdateExchangeEntry(wex, baseUrl, { forceUpdate: options.forceUpdate, }); - const resp = await waitReadyExchange(wex, canonUrl, options); - wex.ws.exchangeCache.put(canonUrl, resp); + const resp = await waitReadyExchange(wex, baseUrl, options); + wex.ws.exchangeCache.put(baseUrl, resp); return resp; } @@ -1292,7 +1287,6 @@ export async function updateExchangeFromUrlHandler( exchangeBaseUrl: string, ): Promise<TaskRunResult> { logger.trace(`updating exchange info for ${exchangeBaseUrl}`); - exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); const oldExchangeRec = await wex.db.runReadOnlyTx( { storeNames: ["exchanges"] }, @@ -2233,7 +2227,6 @@ export async function markExchangeUsed( tx: WalletDbReadWriteTransaction<["exchanges"]>, exchangeBaseUrl: string, ): Promise<{ notif: WalletNotification | undefined }> { - exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); logger.info(`marking exchange ${exchangeBaseUrl} as used`); const exch = await tx.exchanges.get(exchangeBaseUrl); if (!exch) { @@ -2529,7 +2522,7 @@ export async function deleteExchange( req: DeleteExchangeRequest, ): Promise<void> { let inUse: boolean = false; - const exchangeBaseUrl = canonicalizeBaseUrl(req.exchangeBaseUrl); + const exchangeBaseUrl = req.exchangeBaseUrl; await wex.db.runReadWriteTx( { storeNames: [ diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts index ad58a66ec..d33a23cdd 100644 --- a/packages/taler-wallet-core/src/versions.ts +++ b/packages/taler-wallet-core/src/versions.ts @@ -52,7 +52,7 @@ export const WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION = "2:0:0"; /** * Libtool version of the wallet-core API. */ -export const WALLET_CORE_API_PROTOCOL_VERSION = "4:0:0"; +export const WALLET_CORE_API_PROTOCOL_VERSION = "5:0:0"; /** * Libtool rules: diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index f83db6039..ed882708c 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -38,6 +38,8 @@ import { ApplyDevExperimentRequest, BackupRecovery, BalancesResponse, + CanonicalizeBaseUrlRequest, + CanonicalizeBaseUrlResponse, CheckPeerPullCreditRequest, CheckPeerPullCreditResponse, CheckPeerPushDebitRequest, @@ -258,6 +260,7 @@ export enum WalletApiOperation { RemoveGlobalCurrencyAuditor = "removeGlobalCurrencyAuditor", ListAssociatedRefreshes = "listAssociatedRefreshes", Shutdown = "shutdown", + CanonicalizeBaseUrl = "canonicalizeBaseUrl", TestingWaitTransactionsFinal = "testingWaitTransactionsFinal", TestingWaitRefreshesFinal = "testingWaitRefreshesFinal", TestingWaitTransactionState = "testingWaitTransactionState", @@ -958,6 +961,12 @@ export type ValidateIbanOp = { response: ValidateIbanResponse; }; +export type CanonicalizeBaseUrlOp = { + op: WalletApiOperation.CanonicalizeBaseUrl; + request: CanonicalizeBaseUrlRequest; + response: CanonicalizeBaseUrlResponse; +}; + // group: Database Management /** @@ -1319,6 +1328,7 @@ export type WalletOperations = { [WalletApiOperation.Shutdown]: ShutdownOp; [WalletApiOperation.PrepareBankIntegratedWithdrawal]: PrepareBankIntegratedWithdrawalOp; [WalletApiOperation.ConfirmWithdrawal]: ConfirmWithdrawalOp; + [WalletApiOperation.CanonicalizeBaseUrl]: CanonicalizeBaseUrlOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index fc612b189..ea47ffad7 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -70,6 +70,7 @@ import { WalletCoreVersion, WalletNotification, WalletRunConfig, + canonicalizeBaseUrl, checkDbInvariant, codecForAbortTransaction, codecForAcceptBankIntegratedWithdrawalRequest, @@ -82,6 +83,7 @@ import { codecForAddKnownBankAccounts, codecForAny, codecForApplyDevExperiment, + codecForCanonicalizeBaseUrlRequest, codecForCheckPeerPullPaymentRequest, codecForCheckPeerPushDebitRequest, codecForConfirmPayRequest, @@ -1477,6 +1479,12 @@ async function dispatchRequestInternal( const req = codecForGetExchangeResourcesRequest().decode(payload); return await getExchangeResources(wex, req.exchangeBaseUrl); } + case WalletApiOperation.CanonicalizeBaseUrl: { + const req = codecForCanonicalizeBaseUrlRequest().decode(payload); + return { + url: canonicalizeBaseUrl(req.url), + }; + } case WalletApiOperation.TestingInfiniteTransactionLoop: { const myDelayMs = (payload as any).delayMs ?? 5; const shouldFetch = !!(payload as any).shouldFetch; diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts index 106bd93a4..fd23fef5b 100644 --- a/packages/taler-wallet-core/src/withdraw.ts +++ b/packages/taler-wallet-core/src/withdraw.ts @@ -78,7 +78,6 @@ import { WithdrawalType, addPaytoQueryParams, assertUnreachable, - canonicalizeBaseUrl, checkDbInvariant, checkLogicInvariant, codeForBankWithdrawalOperationPostResponse, @@ -2568,7 +2567,7 @@ export async function internalPrepareCreateWithdrawalGroup( args.reserveKeyPair ?? (await wex.cryptoApi.createEddsaKeypair({})); const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); const secretSeed = encodeCrock(getRandomBytes(32)); - const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl); + const exchangeBaseUrl = args.exchangeBaseUrl; const amount = args.amount; const currency = Amounts.currencyOf(amount); @@ -2595,10 +2594,10 @@ export async function internalPrepareCreateWithdrawalGroup( withdrawalGroupId = encodeCrock(getRandomBytes(32)); } - await updateWithdrawalDenoms(wex, canonExchange); + await updateWithdrawalDenoms(wex, exchangeBaseUrl); const denoms = await getCandidateWithdrawalDenoms( wex, - canonExchange, + exchangeBaseUrl, currency, ); @@ -2623,7 +2622,7 @@ export async function internalPrepareCreateWithdrawalGroup( const withdrawalGroup: WithdrawalGroupRecord = { denomSelUid, denomsSel: initialDenomSel, - exchangeBaseUrl: canonExchange, + exchangeBaseUrl: exchangeBaseUrl, instructedAmount: Amounts.stringify(amount), timestampStart: timestampPreciseToDb(now), rawWithdrawalAmount: initialDenomSel.totalWithdrawCost, @@ -2639,7 +2638,7 @@ export async function internalPrepareCreateWithdrawalGroup( wgInfo: args.wgInfo, }; - await fetchFreshExchange(wex, canonExchange); + await fetchFreshExchange(wex, exchangeBaseUrl); const transactionId = constructTransactionIdentifier({ tag: TransactionType.Withdrawal, withdrawalGroupId: withdrawalGroup.withdrawalGroupId, @@ -2649,7 +2648,7 @@ export async function internalPrepareCreateWithdrawalGroup( withdrawalGroup, transactionId, creationInfo: { - canonExchange, + canonExchange: exchangeBaseUrl, amount, }, }; @@ -2822,7 +2821,7 @@ export async function prepareBankIntegratedWithdrawal( }; } - const selectedExchange = canonicalizeBaseUrl(req.selectedExchange); + const selectedExchange = req.selectedExchange; const exchange = await fetchFreshExchange(wex, selectedExchange); const withdrawInfo = await getBankWithdrawalInfo( @@ -2934,7 +2933,7 @@ export async function acceptWithdrawalFromUri( restrictAge?: number; }, ): Promise<AcceptWithdrawalResponse> { - const selectedExchange = canonicalizeBaseUrl(req.selectedExchange); + const selectedExchange = req.selectedExchange; logger.info( `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, ); diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts index f2fa04902..044f2434f 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts @@ -207,7 +207,7 @@ export function useComponentStateFromURI({ WalletApiOperation.GetWithdrawalDetailsForUri, { talerWithdrawUri, - notifyChangeFromPendingTimeoutMs: 30 * 1000, + // notifyChangeFromPendingTimeoutMs: 30 * 1000, }, ); const { diff --git a/packages/web-util/src/forms/Caption.tsx b/packages/web-util/src/forms/Caption.tsx index 8facddec3..be4725ffa 100644 --- a/packages/web-util/src/forms/Caption.tsx +++ b/packages/web-util/src/forms/Caption.tsx @@ -1,27 +1,22 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; -import { - LabelWithTooltipMaybeRequired -} from "./InputLine.js"; +import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { Addon } from "./FormProvider.js"; interface Props { label: TranslatedString; tooltip?: TranslatedString; help?: TranslatedString; - before?: VNode; - after?: VNode; + before?: Addon; + after?: Addon; } export function Caption({ before, after, label, tooltip, help }: Props): VNode { return ( <div class="sm:col-span-6 flex"> - {before !== undefined && ( - <span class="pointer-events-none flex items-center pr-2">{before}</span> - )} + {before !== undefined && <RenderAddon addon={before} />} <LabelWithTooltipMaybeRequired label={label} tooltip={tooltip} /> - {after !== undefined && ( - <span class="pointer-events-none flex items-center pl-2">{after}</span> - )} + {after !== undefined && <RenderAddon addon={after} />} {help && ( <p class="mt-2 text-sm text-gray-500" id="email-description"> {help} diff --git a/packages/web-util/src/forms/DefaultForm.tsx b/packages/web-util/src/forms/DefaultForm.tsx index 1c635e089..338460170 100644 --- a/packages/web-util/src/forms/DefaultForm.tsx +++ b/packages/web-util/src/forms/DefaultForm.tsx @@ -2,14 +2,15 @@ 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"; +// import { FlexibleForm } from "./ui-form.js"; /** * Flexible form uses a DoubleColumForm for design * and may have a dynamic properties defined by * behavior function. */ -export interface FlexibleForm<T extends object> { - design: DoubleColumnForm; +export interface FlexibleForm_Deprecated<T extends object> { + design: DoubleColumnForm_Deprecated; behavior?: (form: Partial<T>) => FormState<T>; } @@ -20,9 +21,9 @@ export interface FlexibleForm<T extends object> { * have a description. * Every sections contain a set of fields. */ -export type DoubleColumnForm = Array<DoubleColumnFormSection | undefined>; +export type DoubleColumnForm_Deprecated = Array<DoubleColumnFormSection_Deprecated | undefined>; -export type DoubleColumnFormSection = { +export type DoubleColumnFormSection_Deprecated = { title: TranslatedString; description?: TranslatedString; fields: UIFormField[]; @@ -39,14 +40,14 @@ export function DefaultForm<T extends object>({ onSubmit, children, readOnly, -}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm<T> }): VNode { +}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm_Deprecated<T> }): VNode { return ( <FormProvider initial={initial} onUpdate={onUpdate} onSubmit={onSubmit} readOnly={readOnly} - computeFormState={form.behavior} + // computeFormState={form.behavior} > <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> {form.design.map((section, i) => { diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx index f4cdf8a68..5e08efb32 100644 --- a/packages/web-util/src/forms/FormProvider.tsx +++ b/packages/web-util/src/forms/FormProvider.tsx @@ -75,10 +75,10 @@ export interface UIFormProps<T extends object, K extends keyof T> // converter to string and back converter?: StringConverter<T[K]>; - handler?: UIField; + handler?: UIFieldHandler; } -export type UIField = { +export type UIFieldHandler = { value: string | undefined; onChange: (s: string) => void; state: FieldUIOptions; diff --git a/packages/web-util/src/forms/Group.tsx b/packages/web-util/src/forms/Group.tsx index 0645f6d97..f63fa4a9b 100644 --- a/packages/web-util/src/forms/Group.tsx +++ b/packages/web-util/src/forms/Group.tsx @@ -1,40 +1,43 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; +import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { RenderAllFieldsByUiConfig, UIFormField, convertUiField } from "./forms.js"; +import { Addon, FormProvider } from "./FormProvider.js"; +import { useField } from "./useField.js"; +import { useTranslationContext } from "../index.browser.js"; +import { getConverterById } from "./converter.js"; interface Props { - before?: TranslatedString; - after?: TranslatedString; - tooltipBefore?: TranslatedString; - tooltipAfter?: TranslatedString; + label: TranslatedString; + tooltip?: TranslatedString; + help?: TranslatedString; + before?: Addon; + after?: Addon; fields: UIFormField[]; } export function Group({ before, after, - tooltipAfter, - tooltipBefore, + label, + tooltip, + help, fields, }: Props): VNode { return ( <div class="sm:col-span-6 p-4 rounded-lg border-r-2 border-2 bg-gray-50"> - <div class="pb-4"> - {before && ( - <LabelWithTooltipMaybeRequired - label={before} - tooltip={tooltipBefore} - /> - )} - </div> + {before !== undefined && <RenderAddon addon={before} />} + <LabelWithTooltipMaybeRequired label={label} tooltip={tooltip} /> + {after !== undefined && <RenderAddon addon={after} />} + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-6"> - <RenderAllFieldsByUiConfig fields={fields} /> - </div> - <div class="pt-4"> - {after && ( - <LabelWithTooltipMaybeRequired label={after} tooltip={tooltipAfter} /> - )} + <RenderAllFieldsByUiConfig + fields={fields} + /> </div> </div> ); diff --git a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx index 3887efe37..0d54c3f69 100644 --- a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx +++ b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx @@ -22,7 +22,7 @@ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { today: AbsoluteTime.now() } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputAmount.stories.tsx b/packages/web-util/src/forms/InputAmount.stories.tsx index d5073ed86..f05887515 100644 --- a/packages/web-util/src/forms/InputAmount.stories.tsx +++ b/packages/web-util/src/forms/InputAmount.stories.tsx @@ -22,7 +22,7 @@ import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { amount: Amounts.parseOrThrow("USD:10") } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputAmount.tsx b/packages/web-util/src/forms/InputAmount.tsx index 31e83350e..e8683468e 100644 --- a/packages/web-util/src/forms/InputAmount.tsx +++ b/packages/web-util/src/forms/InputAmount.tsx @@ -23,15 +23,15 @@ export function InputAmount<T extends object, K extends keyof T>( type: "text", text: currency as TranslatedString, }} - converter={{ - //@ts-ignore + //@ts-ignore + converter={ props.converter ?? { + fromStringUI: (v): AmountJson => { return ( Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency) ); }, - //@ts-ignore toStringUI: (v: AmountJson) => { return v === undefined ? "" : Amounts.stringifyValue(v); }, diff --git a/packages/web-util/src/forms/InputArray.stories.tsx b/packages/web-util/src/forms/InputArray.stories.tsx index eb61b04e3..143e73f02 100644 --- a/packages/web-util/src/forms/InputArray.stories.tsx +++ b/packages/web-util/src/forms/InputArray.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -49,7 +49,7 @@ const initial: TargetObject = { }] } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx index ac4617c8c..1ac96437c 100644 --- a/packages/web-util/src/forms/InputArray.tsx +++ b/packages/web-util/src/forms/InputArray.tsx @@ -157,7 +157,8 @@ export function InputArray<T extends object, K extends keyof T>( // elements should be present in the state object since this is expected to be an array //@ts-ignore - return state.elements[selectedIndex]; + // return state.elements[selectedIndex]; + return {} }} onSubmit={(v) => { const newValue = [...list]; diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx index e1da89353..786dfe5bc 100644 --- a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx +++ b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "0" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.tsx index 82a7c3115..d8361718d 100644 --- a/packages/web-util/src/forms/InputChoiceHorizontal.tsx +++ b/packages/web-util/src/forms/InputChoiceHorizontal.tsx @@ -12,10 +12,10 @@ export interface ChoiceH<V> { export function InputChoiceHorizontal<T extends object, K extends keyof T>( props: { - choices: ChoiceH<T[K]>[]; + choices: ChoiceH<string>[]; } & UIFormProps<T, K>, ): VNode { - const { choices, label, tooltip, help, required } = props; + const { choices, label, tooltip, help, required, converter } = props; //FIXME: remove deprecated const fieldCtx = useField<T, K>(props.name); const { value, onChange, state } = @@ -38,7 +38,7 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( const isLast = idx === choices.length - 1; let clazz = "relative inline-flex items-center px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:z-10"; - if (choice.value === value) { + if (converter?.fromStringUI(choice.value as any) === value) { clazz += " text-white bg-indigo-600 hover:bg-indigo-500 ring-2 ring-indigo-600 hover:ring-indigo-500"; } else { @@ -61,7 +61,7 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( class={clazz} onClick={(e) => { onChange( - (value === choice.value ? undefined : choice.value) as any, + (value === choice.value ? undefined : converter?.fromStringUI(choice.value as any)) as any, ); }} > diff --git a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx index 89f252e96..9a634d05c 100644 --- a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx +++ b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputFile.stories.tsx b/packages/web-util/src/forms/InputFile.stories.tsx index 9d9ad0bd7..eff18d071 100644 --- a/packages/web-util/src/forms/InputFile.stories.tsx +++ b/packages/web-util/src/forms/InputFile.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputFile.tsx b/packages/web-util/src/forms/InputFile.tsx index 6147eae59..cd0a96d1c 100644 --- a/packages/web-util/src/forms/InputFile.tsx +++ b/packages/web-util/src/forms/InputFile.tsx @@ -1,16 +1,14 @@ import { Fragment, VNode, h } from "preact"; import { UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; export function InputFile<T extends object, K extends keyof T>( props: { maxBites: number; accept?: string } & UIFormProps<T, K>, ): VNode { const { - name, label, - placeholder, tooltip, required, help: propsHelp, @@ -26,6 +24,20 @@ export function InputFile<T extends object, K extends keyof T>( if (state.hidden) { return <div />; } + + const valueStr = !value ? "" : value.toString(); + const firstColon = valueStr.indexOf(";"); + + const { fileName, dataUri } = valueStr.startsWith("file:") + ? { + fileName: valueStr.substring(5, firstColon), + dataUri: valueStr.substring(firstColon + 1), + } + : { + fileName: "", + dataUri: valueStr, + }; + return ( <div class="col-span-full"> <LabelWithTooltipMaybeRequired @@ -33,7 +45,7 @@ export function InputFile<T extends object, K extends keyof T>( tooltip={tooltip} required={required} /> - {!value || !(value as string).startsWith("data:image/") ? ( + {!dataUri ? ( <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1"> <div class="text-center"> <svg @@ -51,13 +63,12 @@ export function InputFile<T extends object, K extends keyof T>( {!state.disabled && ( <div class="my-2 flex text-sm leading-6 text-gray-600"> <label - for="file-upload" + for={String(props.name)} class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" > <span>Upload a file</span> <input - id="file-upload" - name="file-upload" + id={String(props.name)} type="file" class="sr-only" accept={accept} @@ -69,6 +80,7 @@ export function InputFile<T extends object, K extends keyof T>( if (f[0].size > maxBites) { return onChange(undefined!); } + const fileName = f[0].name; return f[0].arrayBuffer().then((b) => { const b64 = window.btoa( new Uint8Array(b).reduce( @@ -76,9 +88,15 @@ export function InputFile<T extends object, K extends keyof T>( "", ), ); - return onChange( - `data:${f[0].type};base64,${b64}` as any, - ); + if (fileName) { + return onChange( + `file:${fileName};data:${f[0].type};base64,${b64}` as any, + ); + } else { + return onChange( + `data:${f[0].type};base64,${b64}` as any, + ); + } }); }} /> @@ -90,10 +108,18 @@ export function InputFile<T extends object, K extends keyof T>( </div> ) : ( <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative"> - <img - src={value as string} - class=" h-24 w-full object-cover relative" - /> + {(dataUri as string).startsWith("data:image/") ? ( + <img src={dataUri} class=" h-24 w-full object-cover relative" /> + ) : ( + <div /> + )} + {fileName ? ( + <div class="absolute rounded-lg border flex justify-center text-xl items-center text-white "> + {fileName} + </div> + ) : ( + <Fragment /> + )} {!state.disabled && ( <div diff --git a/packages/web-util/src/forms/InputInteger.stories.tsx b/packages/web-util/src/forms/InputInteger.stories.tsx index 04a6d1049..378736a24 100644 --- a/packages/web-util/src/forms/InputInteger.stories.tsx +++ b/packages/web-util/src/forms/InputInteger.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -38,7 +38,7 @@ const initial: TargetObject = { age: 5, } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputLine.stories.tsx b/packages/web-util/src/forms/InputLine.stories.tsx index 1c62a6164..dea5c142a 100644 --- a/packages/web-util/src/forms/InputLine.stories.tsx +++ b/packages/web-util/src/forms/InputLine.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx index ee9150492..eb3238ef9 100644 --- a/packages/web-util/src/forms/InputLine.tsx +++ b/packages/web-util/src/forms/InputLine.tsx @@ -1,6 +1,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { UIFormProps } from "./FormProvider.js"; +import { Addon, UIFormProps } from "./FormProvider.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { useField } from "./useField.js"; @@ -68,6 +68,37 @@ export function LabelWithTooltipMaybeRequired({ return WithTooltip; } +export function RenderAddon({ disabled, addon }: { disabled?: boolean, addon: Addon }): VNode { + switch (addon.type) { + case "text": { + return ( + <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> + {addon.text} + </span> + ); + } + case "icon": { + return ( + <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> + {addon.icon} + </div> + ); + } + case "button": { + return ( + <button + type="button" + disabled={disabled} + onClick={addon.onClick} + class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-l-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" + > + {addon.children} + </button> + ); + } + } +} + function InputWrapper<T extends object, K extends keyof T>({ children, label, @@ -91,47 +122,11 @@ function InputWrapper<T extends object, K extends keyof T>({ tooltip={tooltip} /> <div class="relative mt-2 flex rounded-md shadow-sm"> - {before && - (before.type === "text" ? ( - <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> - {before.text} - </span> - ) : before.type === "icon" ? ( - <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> - {before.icon} - </div> - ) : before.type === "button" ? ( - <button - type="button" - disabled={disabled} - onClick={before.onClick} - class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-l-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" - > - {before.children} - </button> - ) : undefined)} + {before && <RenderAddon disabled={disabled} addon={before} />} {children} - {after && - (after.type === "text" ? ( - <span class="inline-flex items-center rounded-r-md border border-l-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> - {after.text} - </span> - ) : after.type === "icon" ? ( - <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"> - {after.icon} - </div> - ) : after.type === "button" ? ( - <button - type="button" - disabled={disabled} - onClick={after.onClick} - class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" - > - {after.children} - </button> - ) : undefined)} + {after && <RenderAddon disabled={disabled} addon={after} />} </div> {error && ( <p class="mt-2 text-sm text-red-600" id="email-error"> @@ -259,13 +254,13 @@ export function InputLine<T extends object, K extends keyof T>( name={String(name)} type={type} onChange={(e) => { - onChange(e.currentTarget.value as any); + onChange(fromString(e.currentTarget.value)); }} placeholder={placeholder ? placeholder : undefined} - value={value as string} - onBlur={() => { - onChange(fromString(value as any)); - }} + value={toString(value) ?? ""} + // onBlur={() => { + // onChange(fromString(value as any)); + // }} // defaultValue={toString(value)} disabled={state.disabled} aria-invalid={showError} diff --git a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx index b1649fecf..ab17545f5 100644 --- a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx +++ b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -45,7 +45,7 @@ const initial: TargetObject = { things: [], } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx b/packages/web-util/src/forms/InputSelectMultiple.tsx index 12e88fcc1..1bcf85061 100644 --- a/packages/web-util/src/forms/InputSelectMultiple.tsx +++ b/packages/web-util/src/forms/InputSelectMultiple.tsx @@ -13,7 +13,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( max?: number; } & UIFormProps<T, K>, ): VNode { - const { label, choices, placeholder, tooltip, required, unique, max } = props; + const { converter, label, choices, placeholder, tooltip, required, unique, max } = props; //FIXME: remove deprecated const fieldCtx = useField<T, K>(props.name); const { value, onChange, state } = diff --git a/packages/web-util/src/forms/InputSelectOne.stories.tsx b/packages/web-util/src/forms/InputSelectOne.stories.tsx index f87aeda66..2ebde3096 100644 --- a/packages/web-util/src/forms/InputSelectOne.stories.tsx +++ b/packages/web-util/src/forms/InputSelectOne.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { things: "one" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputText.stories.tsx b/packages/web-util/src/forms/InputText.stories.tsx index 8eced7736..60b6ca224 100644 --- a/packages/web-util/src/forms/InputText.stories.tsx +++ b/packages/web-util/src/forms/InputText.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputTextArea.stories.tsx b/packages/web-util/src/forms/InputTextArea.stories.tsx index 6713548a8..ab1a695f5 100644 --- a/packages/web-util/src/forms/InputTextArea.stories.tsx +++ b/packages/web-util/src/forms/InputTextArea.stories.tsx @@ -23,7 +23,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { DefaultForm as TestedComponent, - FlexibleForm, + FlexibleForm_Deprecated, } from "./DefaultForm.js"; export default { @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputToggle.stories.tsx b/packages/web-util/src/forms/InputToggle.stories.tsx index e10098718..fcc57ffe2 100644 --- a/packages/web-util/src/forms/InputToggle.stories.tsx +++ b/packages/web-util/src/forms/InputToggle.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/converter.ts b/packages/web-util/src/forms/converter.ts new file mode 100644 index 000000000..3a522bf7e --- /dev/null +++ b/packages/web-util/src/forms/converter.ts @@ -0,0 +1,119 @@ +/* + 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, + TalerExchangeApi, +} from "@gnu-taler/taler-util"; +import { format, parse } from "date-fns"; +import { StringConverter } from "./FormProvider.js"; + +export const amlStateConverter = { + toStringUI: stringifyAmlState, + fromStringUI: parseAmlState, +}; + +function stringifyAmlState(s: TalerExchangeApi.AmlState | undefined): string { + if (s === undefined) return ""; + switch (s) { + case TalerExchangeApi.AmlState.normal: + return "normal"; + case TalerExchangeApi.AmlState.pending: + return "pending"; + case TalerExchangeApi.AmlState.frozen: + return "frozen"; + } +} + +function parseAmlState(s: string | undefined): TalerExchangeApi.AmlState { + switch (s) { + case "normal": + return TalerExchangeApi.AmlState.normal; + case "pending": + return TalerExchangeApi.AmlState.pending; + case "frozen": + return TalerExchangeApi.AmlState.frozen; + default: + throw Error(`unknown AML state: ${s}`); + } +} + +function amountConverter(config: any): StringConverter<AmountJson> { + const currency = config["currency"]; + if (!currency || typeof currency !== "string") { + throw Error(`amount converter needs a currency`); + } + return { + fromStringUI(v: string | undefined): AmountJson { + // FIXME: requires currency + return Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency); + }, + toStringUI(v: unknown): string { + return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); + }, + }; +} + +function absTimeConverter(config: any): StringConverter<AbsoluteTime> { + const pattern = config["pattern"]; + if (!pattern || typeof pattern !== "string") { + throw Error(`absTime converter needs a pattern`); + } + return { + fromStringUI(v: string | undefined): AbsoluteTime { + if (v === undefined) { + return AbsoluteTime.never(); + } + try { + const time = parse(v, pattern, new Date()); + return AbsoluteTime.fromMilliseconds(time.getTime()); + } catch(e) { + return AbsoluteTime.never(); + } + }, + toStringUI(v: unknown): string { + if (v === undefined) return ""; + const d = v as AbsoluteTime; + if (d.t_ms === "never") return "never"; + try { + return format(d.t_ms, pattern) + } catch (e) { + return "" + } + }, + }; +} + +export function getConverterById( + id: string | undefined, + config: unknown, +): StringConverter<unknown> { + if (id === "Taler.AbsoluteTime") { + // @ts-expect-error check this + return absTimeConverter(config); + } + if (id === "Taler.Amount") { + // @ts-expect-error check this + return amountConverter(config); + } + if (id === "TalerExchangeApi.AmlState") { + // @ts-expect-error check this + return amlStateConverter; + } + return undefined!; +} diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts index 6bda2f674..4bd6b4924 100644 --- a/packages/web-util/src/forms/forms.ts +++ b/packages/web-util/src/forms/forms.ts @@ -13,7 +13,10 @@ import { InputSelectOne } from "./InputSelectOne.js"; import { InputText } from "./InputText.js"; import { InputTextArea } from "./InputTextArea.js"; import { InputToggle } from "./InputToggle.js"; - +import { Addon, StringConverter, UIFieldHandler } from "./FormProvider.js"; +import { InternationalizationAPI, UIFieldBaseDescription } from "../index.browser.js"; +import { assertUnreachable, TranslatedString } from "@gnu-taler/taler-util"; +import {UIFormFieldBaseConfig, UIFormFieldConfig} from "./ui-form.js"; /** * Constrain the type with the ui props */ @@ -142,3 +145,227 @@ export function RenderAllFieldsByUiConfig({ // InputChoiceHorizontal: res.InputChoiceHorizontal(), // }; // } + +/** + * convert field configuration to render function + * + * @param i18n_ + * @param fieldConfig + * @param form + * @returns + */ +export function convertUiField( + i18n_: InternationalizationAPI, + fieldConfig: UIFormFieldConfig[], + form: object, + getConverterById: GetConverterById, +): UIFormField[] { + return fieldConfig.map((config) => { + // NON input fields + switch (config.type) { + case "caption": { + const resp: UIFormField = { + type: config.type, + properties: converBaseFieldsProps(i18n_, config.properties), + }; + return resp; + } + case "group": { + const resp: UIFormField = { + type: config.type, + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + fields: convertUiField(i18n_, config.properties.fields, form, getConverterById), + }, + }; + return resp; + } + } + // Input Fields + switch (config.type) { + case "array": { + return { + type: "array", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + labelField: config.properties.labelFieldId, + fields: convertUiField(i18n_, config.properties.fields, form, getConverterById), + }, + } as UIFormField; + } + case "absoluteTime": { + return { + type: "absoluteTime", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + case "amount": { + return { + type: "amount", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + case "choiceHorizontal": { + return { + type: "choiceHorizontal", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + choices: config.properties.choices, + }, + } as UIFormField; + } + case "choiceStacked": { + return { + type: "choiceStacked", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + choices: config.properties.choices, + + }, + }as UIFormField; + } + case "file":{ + return { + type: "file", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + accept: config.properties.accept, + maxBites: config.properties.maxBytes, + }, + } as UIFormField; + } + case "integer":{ + return { + type: "integer", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + case "selectMultiple":{ + return { + type: "selectMultiple", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + choices: config.properties.choices, + }, + } as UIFormField; + } + case "selectOne": { + return { + type: "selectOne", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + choices: config.properties.choices, + }, + } as UIFormField; + } + case "text": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + case "textArea": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + case "toggle": { + return { + type: "toggle", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + default: { + assertUnreachable(config); + } + } + }); +} + + + +function getAddonById(_id: string | undefined): Addon { + return undefined!; +} + + +type GetConverterById = ( + id: string | undefined, + config: unknown, +) => StringConverter<unknown>; + + +function converInputFieldsProps( + form: object, + p: UIFormFieldBaseConfig, + getConverterById: GetConverterById, +) { + return { + converter: getConverterById(p.converterId, p), + handler: getValueDeeper2(form, p.id.split(".")), + name: p.name, + required: p.required, + disabled: p.disabled, + help: p.help, + placeholder: p.placeholder, + tooltip: p.tooltip, + label: p.label as TranslatedString, + }; +} + +function converBaseFieldsProps( + i18n_: InternationalizationAPI, + p: UIFieldBaseDescription, +) { + return { + after: getAddonById(p.addonAfterId), + before: getAddonById(p.addonBeforeId), + hidden: p.hidden, + name: p.name, + help: i18n_.str`${p.help}`, + label: i18n_.str`${p.label}`, + tooltip: i18n_.str`${p.tooltip}`, + }; +} + +function getValueDeeper2( + object: Record<string, any>, + names: string[], +): UIFieldHandler { + if (names.length === 0) return object as UIFieldHandler; + const [head, ...rest] = names; + if (!head) { + return getValueDeeper2(object, rest); + } + if (object === undefined) { + throw Error("handler not found"); + } + return getValueDeeper2(object[head], rest); +} + + diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts index 4ff71f197..7320c70d0 100644 --- a/packages/web-util/src/forms/index.ts +++ b/packages/web-util/src/forms/index.ts @@ -19,5 +19,7 @@ export * from "./InputTextArea.js" export * from "./InputToggle.js" export * from "./TimePicker.js" export * from "./forms.js" +export * from "./ui-form.js" +export * from "./converter.js" export * from "./useField.js" diff --git a/packages/web-util/src/forms/ui-form.ts b/packages/web-util/src/forms/ui-form.ts new file mode 100644 index 000000000..ef9ad96e1 --- /dev/null +++ b/packages/web-util/src/forms/ui-form.ts @@ -0,0 +1,453 @@ +import { + buildCodecForObject, + buildCodecForUnion, + Codec, + codecForBoolean, + codecForConstString, + codecForLazy, + codecForList, + codecForNumber, + codecForString, + codecForTimestamp, + codecOptional, + Integer, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; + +export type FlexibleForm = DoubleColumnForm; + +export interface DoubleColumnForm { + type: "double-column"; + design: Array<DoubleColumnFormSection>; + // behavior?: (form: Partial<T>) => FormState<T>; +} + +export type DoubleColumnFormSection = { + title: string; + description?: string; + fields: UIFormFieldConfig[]; +}; + +// export interface BaseForm { +// state: TalerExchangeApi.AmlState; +// threshold: AmountJson; +// } + +export type UIFormFieldConfig = + | UIFormFieldConfigAbsoluteTime + | UIFormFieldConfigAmount + | UIFormFieldConfigArray + | UIFormFieldConfigCaption + | UIFormFieldConfigChoiseHorizontal + | UIFormFieldConfigChoiseStacked + | UIFormFieldConfigFile + | UIFormFieldConfigGroup + | UIFormFieldConfigInteger + | UIFormFieldConfigSelectMultiple + | UIFormFieldConfigSelectOne + | UIFormFieldConfigText + | UIFormFieldConfigTextArea + | UIFormFieldConfigToggle; + +type UIFormFieldConfigAbsoluteTime = { + type: "absoluteTime"; + properties: UIFormFieldBaseConfig & { + max?: TalerProtocolTimestamp; + min?: TalerProtocolTimestamp; + pattern: string; + }; +}; + +type UIFormFieldConfigAmount = { + type: "amount"; + properties: UIFormFieldBaseConfig & { + max?: Integer; + min?: Integer; + currency: string; + }; +}; + +type UIFormFieldConfigArray = { + type: "array"; + properties: UIFormFieldBaseConfig & { + // id of the field shown when the array is collapsed + labelFieldId: UIHandlerId; + fields: UIFormFieldConfig[]; + }; +}; + +type UIFormFieldConfigCaption = { + type: "caption"; + properties: UIFieldBaseDescription; +}; + +type UIFormFieldConfigGroup = { + type: "group"; + properties: UIFieldBaseDescription & { + fields: UIFormFieldConfig[]; + }; +}; + +type UIFormFieldConfigChoiseHorizontal = { + type: "choiceHorizontal"; + properties: UIFormFieldBaseConfig & { + choices: Array<SelectUiChoice>; + }; +}; + +type UIFormFieldConfigChoiseStacked = { + type: "choiceStacked"; + properties: UIFormFieldBaseConfig & { + choices: Array<SelectUiChoice>; + }; +}; + +type UIFormFieldConfigFile = { + type: "file"; + properties: UIFormFieldBaseConfig & { + maxBytes?: Integer; + minBytes?: Integer; + // comma-separated list of one or more file types + // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept#unique_file_type_specifiers + accept?: string; + }; +}; +type UIFormFieldConfigInteger = { + type: "integer"; + properties: UIFormFieldBaseConfig & { + max?: Integer; + min?: Integer; + }; +}; + +interface SelectUiChoice { + label: string; + description?: string; + value: string; +} + +type UIFormFieldConfigSelectMultiple = { + type: "selectMultiple"; + properties: UIFormFieldBaseConfig & { + max?: Integer; + min?: Integer; + unique?: boolean; + choices: Array<SelectUiChoice>; + }; +}; +type UIFormFieldConfigSelectOne = { + type: "selectOne"; + properties: UIFormFieldBaseConfig & { + choices: Array<SelectUiChoice>; + }; +}; +type UIFormFieldConfigText = { + type: "text"; + properties: UIFormFieldBaseConfig; +}; +type UIFormFieldConfigTextArea = { + type: "textArea"; + properties: UIFormFieldBaseConfig; +}; +type UIFormFieldConfigToggle = { + type: "toggle"; + properties: UIFormFieldBaseConfig; +}; + +export type UIFieldBaseDescription = { + /* label if the field, visible for the user */ + label: string; + /* long text to be shown on user demand */ + tooltip?: string; + + /* short text to be shown close to the field */ + help?: string; + + /* name of the field, useful for a11y */ + name: string; + + /* if the field should be initially hidden */ + hidden?: boolean; + /* ui element to show before */ + addonBeforeId?: string; + /* ui element to show after */ + addonAfterId?: string; +}; + +export type UIFormFieldBaseConfig = UIFieldBaseDescription & { + /* example to be shown inside the field */ + placeholder?: string; + + /* show a mark as required */ + required?: boolean; + + /* readonly and dim */ + disabled?: boolean; + + /* conversion id to convert the string into the value type + the id should be known to the ui impl + */ + converterId?: string; + + /* property id of the form */ + id: UIHandlerId; +}; + +declare const __handlerId: unique symbol; +export type UIHandlerId = string & { [__handlerId]: true }; + +// FIXME: validate well formed ui field id +const codecForUiFieldId = codecForString as () => Codec<UIHandlerId>; + +const codecForUIFormFieldBaseDescriptionTemplate = < + T extends UIFieldBaseDescription, +>() => + buildCodecForObject<T>() + .property("addonAfterId", codecOptional(codecForString())) + .property("addonBeforeId", codecOptional(codecForString())) + .property("hidden", codecOptional(codecForBoolean())) + .property("help", codecOptional(codecForString())) + .property("label", codecForString()) + .property("name", codecForString()) + .property("tooltip", codecOptional(codecForString())); + +const codecForUIFormFieldBaseConfigTemplate = < + T extends UIFormFieldBaseConfig, +>() => + codecForUIFormFieldBaseDescriptionTemplate<T>() + .property("id", codecForUiFieldId()) + .property("converterId", codecOptional(codecForString())) + .property("disabled", codecOptional(codecForBoolean())) + .property("required", codecOptional(codecForBoolean())) + .property("placeholder", codecOptional(codecForString())); + +const codecForUIFormFieldBaseConfig = (): Codec<UIFormFieldBaseConfig> => + codecForUIFormFieldBaseConfigTemplate().build("UIFieldToggleProperties"); + +const codecForUIFormFieldAbsoluteTimeConfig = (): Codec< + UIFormFieldConfigAbsoluteTime["properties"] +> => + codecForUIFormFieldBaseConfigTemplate< + UIFormFieldConfigAbsoluteTime["properties"] + >() + .property("pattern", codecForString()) + .property("max", codecOptional(codecForTimestamp)) + .property("min", codecOptional(codecForTimestamp)) + .build("UIFormFieldConfigAbsoluteTime.properties"); + +const codecForUiFormFieldAbsoluteTime = + (): Codec<UIFormFieldConfigAbsoluteTime> => + buildCodecForObject<UIFormFieldConfigAbsoluteTime>() + .property("type", codecForConstString("absoluteTime")) + .property("properties", codecForUIFormFieldAbsoluteTimeConfig()) + .build("UIFormFieldConfigAbsoluteTime"); + +const codecForUIFormFieldAmountConfig = (): Codec< + UIFormFieldConfigAmount["properties"] +> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigAmount["properties"]>() + .property("currency", codecForString()) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .build("UIFormFieldConfigAmount.properties"); + +const codecForUiFormFieldAmount = (): Codec<UIFormFieldConfigAmount> => + buildCodecForObject<UIFormFieldConfigAmount>() + .property("type", codecForConstString("amount")) + .property("properties", codecForUIFormFieldAmountConfig()) + .build("UIFormFieldConfigAmount"); + +const codecForUIFormFieldArrayConfig = (): Codec< + UIFormFieldConfigArray["properties"] +> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigArray["properties"]>() + .property("labelFieldId", codecForUiFieldId()) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .property("fields", codecForList(codecForUiFormField())) + .build("UIFormFieldConfigArray.properties"); + +const codecForUiFormFieldArray = (): Codec<UIFormFieldConfigArray> => + buildCodecForObject<UIFormFieldConfigArray>() + .property("type", codecForConstString("array")) + .property("properties", codecForUIFormFieldArrayConfig()) + .build("UIFormFieldConfigArray"); + +const codecForUiFormFieldCaption = (): Codec<UIFormFieldConfigCaption> => + buildCodecForObject<UIFormFieldConfigCaption>() + .property("type", codecForConstString("caption")) + .property("properties", codecForUIFormFieldBaseConfig()) + .build("UIFormFieldConfigCaption"); + +const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => + buildCodecForObject<SelectUiChoice>() + .property("description", codecOptional(codecForString())) + .property("label", codecForString()) + .property("value", codecForString()) + .build("SelectUiChoice"); + +const codecForUIFormFieldWithChoiseConfig = (): Codec< + UIFormFieldConfigChoiseHorizontal["properties"] +> => + codecForUIFormFieldBaseConfigTemplate< + UIFormFieldConfigChoiseHorizontal["properties"] + >() + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldConfigChoiseHorizontal.properties"); + +const codecForUiFormFieldChoiceHorizontal = + (): Codec<UIFormFieldConfigChoiseHorizontal> => + buildCodecForObject<UIFormFieldConfigChoiseHorizontal>() + .property("type", codecForConstString("choiceHorizontal")) + .property("properties", codecForUIFormFieldWithChoiseConfig()) + .build("UIFormFieldConfigChoiseHorizontal"); + +const codecForUiFormFieldChoiceStacked = + (): Codec<UIFormFieldConfigChoiseStacked> => + buildCodecForObject<UIFormFieldConfigChoiseStacked>() + .property("type", codecForConstString("choiceStacked")) + .property("properties", codecForUIFormFieldWithChoiseConfig()) + .build("UIFormFieldConfigChoiseStacked"); + +const codecForUIFormFieldFileConfig = (): Codec< + UIFormFieldConfigFile["properties"] +> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigFile["properties"]>() + .property("accept", codecOptional(codecForString())) + .property("maxBytes", codecOptional(codecForNumber())) + .property("minBytes", codecOptional(codecForNumber())) + .build("UIFormFieldConfigFile.properties"); + +const codecForUiFormFieldFile = (): Codec<UIFormFieldConfigFile> => + buildCodecForObject<UIFormFieldConfigFile>() + .property("type", codecForConstString("file")) + .property("properties", codecForUIFormFieldFileConfig()) + .build("UIFormFieldConfigFile"); + +const codecForUIFormFieldWithFieldsConfig = (): Codec< + UIFormFieldConfigGroup["properties"] +> => + codecForUIFormFieldBaseDescriptionTemplate< + UIFormFieldConfigGroup["properties"] + >() + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .property("fields", codecForList(codecForUiFormField())) + .build("UIFormFieldConfigGroup.properties"); + +const codecForUiFormFieldGroup = (): Codec<UIFormFieldConfigGroup> => + buildCodecForObject<UIFormFieldConfigGroup>() + .property("type", codecForConstString("group")) + .property("properties", codecForUIFormFieldWithFieldsConfig()) + .build("UiFormFieldGroup"); + +const codecForUiFormFieldInteger = (): Codec<UIFormFieldConfigInteger> => + buildCodecForObject<UIFormFieldConfigInteger>() + .property("type", codecForConstString("integer")) + .property("properties", codecForUIFormFieldBaseConfig()) + .build("UIFormFieldConfigInteger"); + +const codecForUIFormFieldSelectMultipleConfig = (): Codec< + UIFormFieldConfigSelectMultiple["properties"] +> => + codecForUIFormFieldBaseConfigTemplate< + UIFormFieldConfigSelectMultiple["properties"] + >() + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .property("unique", codecOptional(codecForBoolean())) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldConfigSelectMultiple.properties"); + +const codecForUiFormFieldSelectMultiple = + (): Codec<UIFormFieldConfigSelectMultiple> => + buildCodecForObject<UIFormFieldConfigSelectMultiple>() + .property("type", codecForConstString("selectMultiple")) + .property("properties", codecForUIFormFieldSelectMultipleConfig()) + .build("UiFormFieldSelectMultiple"); + +const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldConfigSelectOne> => + buildCodecForObject<UIFormFieldConfigSelectOne>() + .property("type", codecForConstString("selectOne")) + .property("properties", codecForUIFormFieldWithChoiseConfig()) + .build("UIFormFieldConfigSelectOne"); + +const codecForUiFormFieldText = (): Codec<UIFormFieldConfigText> => + buildCodecForObject<UIFormFieldConfigText>() + .property("type", codecForConstString("text")) + .property("properties", codecForUIFormFieldBaseConfig()) + .build("UIFormFieldConfigText"); + +const codecForUiFormFieldTextArea = (): Codec<UIFormFieldConfigTextArea> => + buildCodecForObject<UIFormFieldConfigTextArea>() + .property("type", codecForConstString("textArea")) + .property("properties", codecForUIFormFieldBaseConfig()) + .build("UIFormFieldConfigTextArea"); + +const codecForUiFormFieldToggle = (): Codec<UIFormFieldConfigToggle> => + buildCodecForObject<UIFormFieldConfigToggle>() + .property("type", codecForConstString("toggle")) + .property("properties", codecForUIFormFieldBaseConfig()) + .build("UIFormFieldConfigToggle"); + +const codecForUiFormField = (): Codec<UIFormFieldConfig> => + buildCodecForUnion<UIFormFieldConfig>() + .discriminateOn("type") + .alternative("array", codecForLazy(codecForUiFormFieldArray)) + .alternative("group", codecForLazy(codecForUiFormFieldGroup)) + .alternative("absoluteTime", codecForUiFormFieldAbsoluteTime()) + .alternative("amount", codecForUiFormFieldAmount()) + .alternative("caption", codecForUiFormFieldCaption()) + .alternative("choiceHorizontal", codecForUiFormFieldChoiceHorizontal()) + .alternative("choiceStacked", codecForUiFormFieldChoiceStacked()) + .alternative("file", codecForUiFormFieldFile()) + .alternative("integer", codecForUiFormFieldInteger()) + .alternative("selectMultiple", codecForUiFormFieldSelectMultiple()) + .alternative("selectOne", codecForUiFormFieldSelectOne()) + .alternative("text", codecForUiFormFieldText()) + .alternative("textArea", codecForUiFormFieldTextArea()) + .alternative("toggle", codecForUiFormFieldToggle()) + .build("UIFormField"); + +const codecForDoubleColumnFormSection = (): Codec<DoubleColumnFormSection> => + buildCodecForObject<DoubleColumnFormSection>() + .property("title", codecForString()) + .property("description", codecOptional(codecForString())) + .property("fields", codecForList(codecForUiFormField())) + .build("DoubleColumnFormSection"); + +const codecForDoubleColumnForm = (): Codec<DoubleColumnForm> => + buildCodecForObject<DoubleColumnForm>() + .property("type", codecForConstString("double-column")) + .property("design", codecForList(codecForDoubleColumnFormSection())) + .build("DoubleColumnForm"); + +const codecForFlexibleForm = (): Codec<FlexibleForm> => + buildCodecForUnion<FlexibleForm>() + .discriminateOn("type") + .alternative("double-column", codecForDoubleColumnForm()) + .build<FlexibleForm>("FlexibleForm"); + +const codecForFormMetadata = (): Codec<FormMetadata> => + buildCodecForObject<FormMetadata>() + .property("label", codecForString()) + .property("id", codecForString()) + .property("version", codecForNumber()) + .property("config", codecForFlexibleForm()) + .build("FormMetadata"); + +export const codecForUIForms = (): Codec<UiForms> => + buildCodecForObject<UiForms>() + .property("forms", codecForList(codecForFormMetadata())) + .build("UiForms"); + +export type FormMetadata = { + label: string; + id: string; + version: number; + config: FlexibleForm; +}; + +export interface UiForms { + // Where libeufin backend is localted + // default: window.origin without "webui/" + forms: Array<FormMetadata>; +} diff --git a/packages/web-util/src/forms/useField.ts b/packages/web-util/src/forms/useField.ts index d30962c6f..a250d3100 100644 --- a/packages/web-util/src/forms/useField.ts +++ b/packages/web-util/src/forms/useField.ts @@ -10,7 +10,7 @@ export interface InputFieldHandler<Type> { } /** - * @depreacted removing this so we don't depend on context to create a form + * @deprecated removing this so we don't depend on context to create a form * @param name * @returns */ |