summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--API_CHANGES.md34
-rw-r--r--Makefile9
-rw-r--r--README13
-rwxr-xr-xcontrib/bump-taler-version.mjs10
-rwxr-xr-xcontrib/copy-anastasis-into-prebuilt.sh10
-rwxr-xr-xpackages/aml-backoffice-ui/build.mjs6
-rw-r--r--packages/aml-backoffice-ui/copyleft-header.js2
-rwxr-xr-xpackages/aml-backoffice-ui/dev.mjs6
-rw-r--r--packages/aml-backoffice-ui/package.json4
-rw-r--r--packages/aml-backoffice-ui/src/App.tsx120
-rw-r--r--packages/aml-backoffice-ui/src/Dashboard.tsx234
-rw-r--r--packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx273
-rw-r--r--packages/aml-backoffice-ui/src/Routing.tsx151
-rw-r--r--packages/aml-backoffice-ui/src/context/config.ts100
-rw-r--r--packages/aml-backoffice-ui/src/context/ui-forms.ts76
-rw-r--r--packages/aml-backoffice-ui/src/context/ui-settings.ts110
-rw-r--r--packages/aml-backoffice-ui/src/declaration.d.ts15
-rw-r--r--packages/aml-backoffice-ui/src/forms.json529
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_11e.ts38
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_12e.ts147
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_13e.ts177
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_15e.ts59
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_1e.ts360
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_4e.ts135
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_5e.ts86
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_9e.ts49
-rw-r--r--packages/aml-backoffice-ui/src/forms/declaration.ts70
-rw-r--r--packages/aml-backoffice-ui/src/forms/icons.tsx15
-rw-r--r--packages/aml-backoffice-ui/src/forms/index.ts113
-rw-r--r--packages/aml-backoffice-ui/src/forms/simplest.ts124
-rw-r--r--packages/aml-backoffice-ui/src/hooks/form.ts227
-rw-r--r--packages/aml-backoffice-ui/src/hooks/officer.ts (renamed from packages/aml-backoffice-ui/src/hooks/useOfficer.ts)92
-rw-r--r--packages/aml-backoffice-ui/src/hooks/preferences.ts (renamed from packages/aml-backoffice-ui/src/hooks/useSettings.ts)75
-rw-r--r--packages/aml-backoffice-ui/src/hooks/useBackend.ts48
-rw-r--r--packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts82
-rw-r--r--packages/aml-backoffice-ui/src/hooks/useCases.ts75
-rw-r--r--packages/aml-backoffice-ui/src/i18n/bank.pot2
-rw-r--r--packages/aml-backoffice-ui/src/i18n/fr.po2
-rw-r--r--packages/aml-backoffice-ui/src/i18n/poheader2
-rw-r--r--packages/aml-backoffice-ui/src/i18n/strings-prelude2
-rw-r--r--packages/aml-backoffice-ui/src/i18n/strings.ts2
-rw-r--r--packages/aml-backoffice-ui/src/index.html3
-rw-r--r--packages/aml-backoffice-ui/src/index.tsx9
-rw-r--r--packages/aml-backoffice-ui/src/pages.ts44
-rw-r--r--packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx104
-rw-r--r--packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx160
-rw-r--r--packages/aml-backoffice-ui/src/pages/CaseDetails.tsx458
-rw-r--r--packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx284
-rw-r--r--packages/aml-backoffice-ui/src/pages/Cases.stories.tsx25
-rw-r--r--packages/aml-backoffice-ui/src/pages/Cases.tsx156
-rw-r--r--packages/aml-backoffice-ui/src/pages/CreateAccount.tsx219
-rw-r--r--packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx40
-rw-r--r--packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx104
-rw-r--r--packages/aml-backoffice-ui/src/pages/Officer.tsx42
-rw-r--r--packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx179
-rw-r--r--packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx148
-rw-r--r--packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx162
-rw-r--r--packages/aml-backoffice-ui/src/pages/index.stories.ts16
-rw-r--r--packages/aml-backoffice-ui/src/route.ts197
-rw-r--r--packages/aml-backoffice-ui/src/settings.json4
-rw-r--r--packages/aml-backoffice-ui/src/stories.test.ts30
-rw-r--r--packages/aml-backoffice-ui/src/stories.tsx60
-rw-r--r--packages/aml-backoffice-ui/src/utils/QR.tsx2
-rw-r--r--packages/aml-backoffice-ui/src/utils/converter.ts31
-rw-r--r--packages/aml-backoffice-ui/src/utils/types.ts124
-rw-r--r--packages/anastasis-cli/package.json2
-rw-r--r--packages/anastasis-core/package.json2
-rw-r--r--packages/anastasis-core/src/anastasis-data.ts12
-rw-r--r--packages/anastasis-core/src/index.ts41
-rw-r--r--packages/anastasis-core/tsconfig.json2
-rw-r--r--packages/anastasis-webui/package.json2
-rw-r--r--packages/anastasis-webui/src/components/menu/SideBar.tsx5
-rw-r--r--packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts6
-rw-r--r--packages/anastasis-webui/src/index.ts2
-rw-r--r--packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts89
-rw-r--r--packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts24
-rw-r--r--packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx4
-rw-r--r--packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx7
-rw-r--r--packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx13
-rw-r--r--packages/auditor-backoffice-ui/package.json4
-rw-r--r--packages/bank-ui/package.json4
-rw-r--r--packages/bank-ui/postcss.config.js15
-rw-r--r--packages/bank-ui/src/Routing.tsx10
-rw-r--r--packages/bank-ui/src/app.tsx7
-rw-r--r--packages/bank-ui/src/context/config.ts320
-rw-r--r--packages/bank-ui/src/context/navigation.ts92
-rw-r--r--packages/bank-ui/src/context/settings.ts8
-rw-r--r--packages/bank-ui/src/hooks/form.ts9
-rw-r--r--packages/bank-ui/src/i18n/bank.pot2
-rw-r--r--packages/bank-ui/src/i18n/de.po10
-rw-r--r--packages/bank-ui/src/i18n/es.po2
-rw-r--r--packages/bank-ui/src/i18n/ru.po (renamed from packages/bank-ui/src/i18n/en.po)766
-rw-r--r--packages/bank-ui/src/pages/LoginForm.tsx4
-rw-r--r--packages/bank-ui/src/pages/PaytoWireTransferForm.tsx94
-rw-r--r--packages/bank-ui/src/pages/RegistrationPage.tsx12
-rw-r--r--packages/bank-ui/src/pages/SolveChallengePage.tsx3
-rw-r--r--packages/bank-ui/src/pages/account/CashoutListForAccount.tsx3
-rw-r--r--packages/bank-ui/src/pages/account/ShowAccountDetails.tsx9
-rw-r--r--packages/bank-ui/src/pages/admin/AccountForm.tsx69
-rw-r--r--packages/bank-ui/src/pages/admin/AdminHome.tsx5
-rw-r--r--packages/bank-ui/src/pages/admin/CreateNewAccount.tsx9
-rw-r--r--packages/bank-ui/src/pages/regional/CreateCashout.tsx53
-rw-r--r--packages/bank-ui/src/route.ts139
-rw-r--r--packages/bank-ui/src/settings.ts14
-rw-r--r--packages/bank-ui/tailwind.config.js16
-rwxr-xr-xpackages/challenger-ui/build.mjs5
-rw-r--r--packages/challenger-ui/copyleft-header.js2
-rwxr-xr-xpackages/challenger-ui/dev.mjs8
-rw-r--r--packages/challenger-ui/package.json43
-rw-r--r--packages/challenger-ui/postcss.config.js15
-rw-r--r--packages/challenger-ui/src/Routing.tsx270
-rw-r--r--packages/challenger-ui/src/app.tsx168
-rw-r--r--packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx132
-rw-r--r--packages/challenger-ui/src/context/settings.ts44
-rw-r--r--packages/challenger-ui/src/hooks/challenge.ts58
-rw-r--r--packages/challenger-ui/src/hooks/session.ts143
-rw-r--r--packages/challenger-ui/src/i18n/challenger-ui.pot199
-rw-r--r--packages/challenger-ui/src/i18n/poheader26
-rw-r--r--packages/challenger-ui/src/i18n/strings.ts90
-rw-r--r--packages/challenger-ui/src/index.html41
-rw-r--r--packages/challenger-ui/src/index.tsx (renamed from packages/aml-backoffice-ui/src/settings.ts)24
-rw-r--r--packages/challenger-ui/src/pages/AnswerChallenge.tsx279
-rw-r--r--packages/challenger-ui/src/pages/AskChallenge.tsx263
-rw-r--r--packages/challenger-ui/src/pages/CallengeCompleted.tsx26
-rw-r--r--packages/challenger-ui/src/pages/Frame.tsx69
-rw-r--r--packages/challenger-ui/src/pages/MissingParams.tsx (renamed from packages/aml-backoffice-ui/src/forms.ts)16
-rw-r--r--packages/challenger-ui/src/pages/NonceNotFound.tsx42
-rw-r--r--packages/challenger-ui/src/pages/Setup.tsx82
-rw-r--r--packages/challenger-ui/src/settings.json3
-rw-r--r--packages/challenger-ui/src/settings.ts83
-rw-r--r--packages/challenger-ui/tailwind.config.js16
-rw-r--r--packages/challenger-ui/tsconfig.json46
-rw-r--r--packages/idb-bridge/package.json2
-rw-r--r--packages/merchant-backend-ui/package.json6
-rw-r--r--packages/merchant-backoffice-ui/package.json5
-rw-r--r--packages/merchant-backoffice-ui/src/Application.tsx384
-rw-r--r--packages/merchant-backoffice-ui/src/Routing.tsx10
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx3
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx112
-rw-r--r--packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx34
-rw-r--r--packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx21
-rw-r--r--packages/merchant-backoffice-ui/src/components/menu/index.tsx13
-rw-r--r--packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx17
-rw-r--r--packages/merchant-backoffice-ui/src/context/session.ts289
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/bank.ts9
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/instance.ts11
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/order.ts7
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/otp.ts9
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/product.ts7
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/templates.ts7
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/transfer.ts5
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/webhooks.ts9
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/de.po60
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx45
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx7
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx22
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx9
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx7
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx7
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx8
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx7
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx6
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx7
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx10
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx8
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx3
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx10
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx4
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx24
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx10
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx6
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx18
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx98
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx9
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx7
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/index.tsx10
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/index.tsx7
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx5
-rw-r--r--packages/merchant-backoffice-ui/src/paths/login/index.tsx119
-rw-r--r--packages/merchant-backoffice-ui/src/paths/notfound/index.tsx4
-rw-r--r--packages/pogen/package.json2
-rw-r--r--packages/pogen/src/potextract.ts6
-rw-r--r--packages/taler-harness/Makefile1
-rw-r--r--packages/taler-harness/debian/changelog6
-rw-r--r--packages/taler-harness/package.json2
-rw-r--r--packages/taler-harness/src/bench1.ts13
-rw-r--r--packages/taler-harness/src/bench3.ts12
-rw-r--r--packages/taler-harness/src/harness/harness.ts26
-rw-r--r--packages/taler-harness/src/harness/sync.ts2
-rw-r--r--packages/taler-harness/src/index.ts196
-rw-r--r--packages/taler-harness/src/integrationtests/test-currency-scope.ts2
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-pull-large.ts194
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-push-large.ts177
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts26
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts3
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-handover.ts187
-rw-r--r--packages/taler-harness/src/integrationtests/testrunner.ts6
-rw-r--r--packages/taler-util/package.json8
-rw-r--r--packages/taler-util/src/argon2-impl.wasm.ts (renamed from packages/taler-util/src/argon2-impl.node.ts)0
-rw-r--r--packages/taler-util/src/codec.ts49
-rw-r--r--packages/taler-util/src/errors.ts4
-rw-r--r--packages/taler-util/src/http-client/authentication.ts4
-rw-r--r--packages/taler-util/src/http-client/bank-core.ts35
-rw-r--r--packages/taler-util/src/http-client/challenger.ts291
-rw-r--r--packages/taler-util/src/http-client/exchange.ts31
-rw-r--r--packages/taler-util/src/http-client/merchant.ts29
-rw-r--r--packages/taler-util/src/http-client/types.ts643
-rw-r--r--packages/taler-util/src/http-client/utils.ts28
-rw-r--r--packages/taler-util/src/http-common.ts41
-rw-r--r--packages/taler-util/src/http-impl.node.ts8
-rw-r--r--packages/taler-util/src/index.ts1
-rw-r--r--packages/taler-util/src/notifications.ts11
-rw-r--r--packages/taler-util/src/operation.ts5
-rw-r--r--packages/taler-util/src/taler-crypto.ts17
-rw-r--r--packages/taler-util/src/taler-error-codes.ts104
-rw-r--r--packages/taler-util/src/taler-types.ts1
-rw-r--r--packages/taler-util/src/talerconfig.ts151
-rw-r--r--packages/taler-util/src/transactions-types.ts20
-rw-r--r--packages/taler-util/src/wallet-types.ts290
-rw-r--r--packages/taler-wallet-cli/debian/changelog6
-rw-r--r--packages/taler-wallet-cli/package.json2
-rw-r--r--packages/taler-wallet-cli/src/index.ts134
-rw-r--r--packages/taler-wallet-core/package.json2
-rw-r--r--packages/taler-wallet-core/src/attention.ts70
-rw-r--r--packages/taler-wallet-core/src/backup/index.ts368
-rw-r--r--packages/taler-wallet-core/src/balance.ts86
-rw-r--r--packages/taler-wallet-core/src/coinSelection.ts38
-rw-r--r--packages/taler-wallet-core/src/common.ts52
-rw-r--r--packages/taler-wallet-core/src/crypto/cryptoImplementation.ts38
-rw-r--r--packages/taler-wallet-core/src/db.ts66
-rw-r--r--packages/taler-wallet-core/src/deposits.ts260
-rw-r--r--packages/taler-wallet-core/src/dev-experiments.ts74
-rw-r--r--packages/taler-wallet-core/src/exchanges.ts165
-rw-r--r--packages/taler-wallet-core/src/instructedAmountConversion.ts9
-rw-r--r--packages/taler-wallet-core/src/observable-wrappers.ts51
-rw-r--r--packages/taler-wallet-core/src/pay-merchant.ts538
-rw-r--r--packages/taler-wallet-core/src/pay-peer-common.ts112
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-credit.ts138
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-debit.ts193
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-credit.ts42
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-debit.ts268
-rw-r--r--packages/taler-wallet-core/src/query.ts156
-rw-r--r--packages/taler-wallet-core/src/recoup.ts81
-rw-r--r--packages/taler-wallet-core/src/refresh.ts128
-rw-r--r--packages/taler-wallet-core/src/reward.ts165
-rw-r--r--packages/taler-wallet-core/src/shepherd.ts160
-rw-r--r--packages/taler-wallet-core/src/testing.ts306
-rw-r--r--packages/taler-wallet-core/src/transactions.ts155
-rw-r--r--packages/taler-wallet-core/src/versions.ts2
-rw-r--r--packages/taler-wallet-core/src/wallet-api-types.ts78
-rw-r--r--packages/taler-wallet-core/src/wallet.ts606
-rw-r--r--packages/taler-wallet-core/src/withdraw.ts507
-rw-r--r--packages/taler-wallet-embedded/package.json2
-rw-r--r--packages/taler-wallet-embedded/src/wallet-qjs.ts31
-rw-r--r--packages/taler-wallet-webextension/manifest-common.json4
-rw-r--r--packages/taler-wallet-webextension/package.json2
-rw-r--r--packages/taler-wallet-webextension/src/NavigationBar.tsx33
-rw-r--r--packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx2
-rw-r--r--packages/taler-wallet-webextension/src/components/HistoryItem.tsx2
-rw-r--r--packages/taler-wallet-webextension/src/components/PendingTransactions.tsx81
-rw-r--r--packages/taler-wallet-webextension/src/components/WalletActivity.tsx1534
-rw-r--r--packages/taler-wallet-webextension/src/context/alert.ts71
-rw-r--r--packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts12
-rw-r--r--packages/taler-wallet-webextension/src/cta/Withdraw/state.ts19
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts3
-rw-r--r--packages/taler-wallet-webextension/src/i18n/de.po16
-rw-r--r--packages/taler-wallet-webextension/src/i18n/ru.po1977
-rw-r--r--packages/taler-wallet-webextension/src/mui/Button.tsx6
-rw-r--r--packages/taler-wallet-webextension/src/platform/api.ts51
-rw-r--r--packages/taler-wallet-webextension/src/platform/background.ts3
-rw-r--r--packages/taler-wallet-webextension/src/platform/chrome.ts40
-rw-r--r--packages/taler-wallet-webextension/src/platform/dev.ts2
-rw-r--r--packages/taler-wallet-webextension/src/svg/search_24px.inline.svg4
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Application.tsx43
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx14
-rw-r--r--packages/taler-wallet-webextension/src/wallet/History.stories.tsx257
-rw-r--r--packages/taler-wallet-webextension/src/wallet/History.tsx144
-rw-r--r--packages/taler-wallet-webextension/src/wallet/QrReader.tsx2
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Settings.tsx3
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Transaction.tsx100
-rw-r--r--packages/taler-wallet-webextension/src/wxApi.ts12
-rw-r--r--packages/taler-wallet-webextension/src/wxBackend.ts252
-rw-r--r--packages/web-util/package.json2
-rw-r--r--packages/web-util/src/components/Button.tsx176
-rw-r--r--packages/web-util/src/context/activity.ts12
-rw-r--r--packages/web-util/src/context/challenger-api.ts213
-rw-r--r--packages/web-util/src/context/exchange-api.ts217
-rw-r--r--packages/web-util/src/context/index.ts2
-rw-r--r--packages/web-util/src/context/merchant-api.ts34
-rw-r--r--packages/web-util/src/context/navigation.ts46
-rw-r--r--packages/web-util/src/forms/Calendar.tsx253
-rw-r--r--packages/web-util/src/forms/Caption.tsx17
-rw-r--r--packages/web-util/src/forms/DefaultForm.tsx17
-rw-r--r--packages/web-util/src/forms/FormProvider.tsx48
-rw-r--r--packages/web-util/src/forms/Group.tsx47
-rw-r--r--packages/web-util/src/forms/InputAbsoluteTime.stories.tsx8
-rw-r--r--packages/web-util/src/forms/InputAbsoluteTime.tsx61
-rw-r--r--packages/web-util/src/forms/InputAmount.stories.tsx6
-rw-r--r--packages/web-util/src/forms/InputAmount.tsx33
-rw-r--r--packages/web-util/src/forms/InputArray.stories.tsx10
-rw-r--r--packages/web-util/src/forms/InputArray.tsx58
-rw-r--r--packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx6
-rw-r--r--packages/web-util/src/forms/InputChoiceHorizontal.tsx27
-rw-r--r--packages/web-util/src/forms/InputChoiceStacked.stories.tsx6
-rw-r--r--packages/web-util/src/forms/InputChoiceStacked.tsx14
-rw-r--r--packages/web-util/src/forms/InputFile.stories.tsx6
-rw-r--r--packages/web-util/src/forms/InputFile.tsx67
-rw-r--r--packages/web-util/src/forms/InputInteger.stories.tsx6
-rw-r--r--packages/web-util/src/forms/InputLine.stories.tsx6
-rw-r--r--packages/web-util/src/forms/InputLine.tsx120
-rw-r--r--packages/web-util/src/forms/InputSelectMultiple.stories.tsx8
-rw-r--r--packages/web-util/src/forms/InputSelectMultiple.tsx174
-rw-r--r--packages/web-util/src/forms/InputSelectOne.stories.tsx6
-rw-r--r--packages/web-util/src/forms/InputSelectOne.tsx27
-rw-r--r--packages/web-util/src/forms/InputText.stories.tsx6
-rw-r--r--packages/web-util/src/forms/InputTextArea.stories.tsx6
-rw-r--r--packages/web-util/src/forms/InputToggle.stories.tsx6
-rw-r--r--packages/web-util/src/forms/InputToggle.tsx50
-rw-r--r--packages/web-util/src/forms/converter.ts130
-rw-r--r--packages/web-util/src/forms/forms.ts304
-rw-r--r--packages/web-util/src/forms/index.ts2
-rw-r--r--packages/web-util/src/forms/ui-form.ts363
-rw-r--r--packages/web-util/src/forms/useField.ts36
-rw-r--r--packages/web-util/src/hooks/useNotifications.ts23
-rw-r--r--packages/web-util/src/utils/http-impl.sw.ts2
-rw-r--r--packages/web-util/src/utils/route.ts9
-rw-r--r--pnpm-lock.yaml93
339 files changed, 18095 insertions, 9003 deletions
diff --git a/API_CHANGES.md b/API_CHANGES.md
index f6fbf17f5..dbf54d456 100644
--- a/API_CHANGES.md
+++ b/API_CHANGES.md
@@ -4,33 +4,7 @@ This files contains all the API changes for the current release:
## wallet-core
-- AcceptManualWithdrawalResult.exchangePaytoUris is deprecated
-- WithdrawalExchangeAccountDetails.transferAmount is now optional (if conversion applies)
-- added WithdrawalExchangeAccountDetails.currencySpecification about the transferAmount currency
-- 2023-12-05 dold: added WithdrawalExchangeAccountDetails.{status,conversionError} to inform the client
- about errors with a particular conversion account instead of failing the whole withdrawal(-info) request.
-- 2023-12-06 dold: added the exchangeBaseUrl to PreparePeerPushCreditResponse, allowing the UI
- to check the exchange status for the peer push credit.
-- 2023-12-06 dold: added a new getExchangeEntryForUri request, which allows the client to
- get information about an existing exchange entry with DD48 semantics.
- The older call "getExchangeDetailedInfo" also computes loads of information
- for fee comparison and we should eventually rename it to something more appropriate
- (like getExchangeFeeDetailsForUri).
-- 2023-12-06 dold: Deprecate the tosStatus in the withdrawal details response.
- This field does not conform to DD48 semantics and the client should
- request the ToS status separately via a getExchangeEntryForUri request.
-- 2023-12-07 dold: Add the prepareWithdrawExchange request for withdrawals
- via a taler://withdraw-exchange URI.
-- 2023-12-11 dold: Add exchangeBaseUrl to the checkPeerPushDebit response.
-- 2023-12-11 dold: Add scopeInfo to exchange entry list items.
-- BREAK 2023-12-12 dold: Remove forceUpdate and masterPub arguments from addExchange
- request. This request has previously been overloaded both to update an
- exchange entry as well as to add it.
- To update the entry, updateExchangeEntry should be used instead.
-- 2023-12-12 dold: the getExchangeTos request not accepts an additional
- acceptLanguage field in the request. The response now contains an optional
- contentLanguage field that is returned if the exchange reports it.
-- 2023-12-12 2:0:1 dold: The checkPeerPushDebit now returns a maximum
- expiration date based on the expiry of selected coins.
-- 2023-12-13 3:0:2 dold: getVersion now returns the supported API version
- ranges for all bank APIs separately.
+### v5
+
+- all base URLs passed to wallet-core requests must be canonicalized,
+ with the exception of the new `canonicalizeBaseUrl` request.
diff --git a/Makefile b/Makefile
index 85eeb748c..b1103edaf 100644
--- a/Makefile
+++ b/Makefile
@@ -51,8 +51,15 @@ prebuilt:
make bank-prebuilt
make challenger-prebuilt
make aml-backoffice-prebuilt
+ make anastasis-prebuilt
./contrib/publish-prebuilt-dir.sh
+.PHONY: anastasis-prebuilt
+anastasis-prebuilt:
+ pnpm install --frozen-lockfile --filter @gnu-taler/anastasis-webui...
+ pnpm run --filter @gnu-taler/anastasis-webui... build
+ ./contrib/copy-anastasis-into-prebuilt.sh
+
.PHONY: backoffice-prebuilt
backoffice-prebuilt:
pnpm install --frozen-lockfile --filter @gnu-taler/merchant-backoffice-ui...
@@ -120,7 +127,7 @@ anastasis-webui:
.PHONY: anastasis-webui-dist
anastasis-webui-dist: anastasis-webui
- (cd packages/anastasis-webui/dist && zip -r - fonts ui.html) > anastasis-webui.zip
+ (cd packages/anastasis-webui/dist/prod && zip -r - ./*) > anastasis-webui.zip
.PHONY: anastasis-webui-dev
diff --git a/README b/README
index 471815c0b..85c12e7e2 100644
--- a/README
+++ b/README
@@ -14,11 +14,20 @@ The following dependencies are required to build the wallet:
- pnpm
- zip
+## Preparing the repository
+
+After running clone you should bootstrap the repository.
+
+```shell
+./bootstrap
+```
+
## Installation
The CLI version of the wallet supports the normal GNU installation process.
```shell
+./bootstrap
./configure [ --prefix=$PREFIX ] && make install
```
@@ -171,10 +180,10 @@ make anastasis-webui
```
It will run the test suite and put everything into the dist folder under the project root (packages/anastasis-webui).
-You can run the SPA directly using the file:// protocol.
+You can copy the SPA directly to work local webserver.
```shell
-firefox packages/anastasis-webui/dist/ui.html
+cp -Tr ./packages/anastasis-webui/dist/prod /var/www/html/anastasis
```
Additionally you can create a zip file with the content to upload into a web server:
diff --git a/contrib/bump-taler-version.mjs b/contrib/bump-taler-version.mjs
index a0bf19f8c..3a57bd01d 100755
--- a/contrib/bump-taler-version.mjs
+++ b/contrib/bump-taler-version.mjs
@@ -45,15 +45,7 @@ if (!verMatched) {
}
}
-let packages = [
- "taler-util",
- "taler-wallet-core",
- "taler-harness",
- "taler-wallet-cli",
- "web-util",
- "taler-wallet-webextension",
- "taler-wallet-embedded",
-];
+const packages = fs.readdirSync("packages")
for (const pkg of packages) {
const p = `packages/${pkg}/package.json`;
diff --git a/contrib/copy-anastasis-into-prebuilt.sh b/contrib/copy-anastasis-into-prebuilt.sh
new file mode 100755
index 000000000..36335bfcf
--- /dev/null
+++ b/contrib/copy-anastasis-into-prebuilt.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
+
+find packages/anastasis-webui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/anastasis/bof
+
+while IFS= read -r file; do
+ cp packages/anastasis-webui/dist/prod/$file prebuilt/anastasis/$file
+done < prebuilt/anastasis/bof
+
diff --git a/packages/aml-backoffice-ui/build.mjs b/packages/aml-backoffice-ui/build.mjs
index bd7a088cf..b0742c692 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,8 +20,8 @@ import { build } from "@gnu-taler/web-util/build";
await build({
type: "production",
source: {
- js: ["src/index.tsx", "src/forms.ts"],
- assets: [{ base: "src", files: ["src/index.html"] }],
+ js: ["src/index.tsx"],
+ assets: [{ base: "src", files: ["src/index.html","src/forms.json"] }],
},
destination: "./dist/prod",
css: "postcss",
diff --git a/packages/aml-backoffice-ui/copyleft-header.js b/packages/aml-backoffice-ui/copyleft-header.js
index 2635717c5..7fa276bea 100644
--- a/packages/aml-backoffice-ui/copyleft-header.js
+++ b/packages/aml-backoffice-ui/copyleft-header.js
@@ -1,6 +1,6 @@
/*
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
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/package.json b/packages/aml-backoffice-ui/package.json
index 9be44bd76..749565946 100644
--- a/packages/aml-backoffice-ui/package.json
+++ b/packages/aml-backoffice-ui/package.json
@@ -1,7 +1,7 @@
{
"private": true,
"name": "@gnu-taler/aml-backoffice-ui",
- "version": "0.1.0",
+ "version": "0.10.7",
"author": "sebasjm",
"license": "AGPL-3.0-OR-LATER",
"description": "Back-office SPA for GNU Taler Exchange.",
@@ -32,7 +32,7 @@
"swr": "2.2.2"
},
"devDependencies": {
- "@gnu-taler/pogen": "^0.0.5",
+ "@gnu-taler/pogen": "workspace:*",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@types/chai": "^4.3.0",
diff --git a/packages/aml-backoffice-ui/src/App.tsx b/packages/aml-backoffice-ui/src/App.tsx
index 5244476d7..e9be84441 100644
--- a/packages/aml-backoffice-ui/src/App.tsx
+++ b/packages/aml-backoffice-ui/src/App.tsx
@@ -1,23 +1,62 @@
-import { TranslationProvider } from "@gnu-taler/web-util/browser";
-import { h, VNode } from "preact";
-import { ExchangeAmlFrame } from "./Dashboard.js";
-import "./scss/main.css";
-import { ExchangeApiProvider } from "./context/config.js";
-import { getInitialBackendBaseURL } from "./hooks/useBackend.js";
-import { HashPathProvider, Router } from "./route.js";
-import { Pages } from "./pages.js";
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { canonicalizeBaseUrl } from "@gnu-taler/taler-util";
+import {
+ BrowserHashNavigationProvider,
+ ExchangeApiProvider,
+ Loading,
+ TranslationProvider,
+ UiForms,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
import { SWRConfig } from "swr";
+import { ExchangeAmlFrame } from "./ExchangeAmlFrame.js";
+import { Routing } from "./Routing.js";
+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;
-const pageList = Object.values(Pages);
-
export function App(): VNode {
- const baseUrl = getInitialBackendBaseURL();
+ const [settings, setSettings] = useState<UiSettings>();
+ const [forms, setForms] = useState<UiForms>();
+ useEffect(() => {
+ fetchUiSettings(setSettings);
+ fetchUiForms(setForms);
+ }, []);
+ if (!settings || !forms) return <Loading />;
+
+ const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL);
return (
- <TranslationProvider source={{}}>
- <ExchangeApiProvider baseUrl={baseUrl} frameOnError={ExchangeAmlFrame}>
- <HashPathProvider>
+ <UiSettingsProvider value={settings}>
+ <TranslationProvider
+ source={strings}
+ completeness={{
+ es: strings["es"].completeness,
+ de: strings["de"].completeness,
+ }}
+ >
+ <ExchangeApiProvider
+ baseUrl={new URL("/", baseUrl)}
+ frameOnError={ExchangeAmlFrame}
+ >
<SWRConfig
value={{
provider: WITH_LOCAL_STORAGE_CACHE
@@ -45,24 +84,18 @@ export function App(): VNode {
keepPreviousData: true,
}}
>
-
- <ExchangeAmlFrame>
- <Router
- pageList={pageList}
- onNotFound={() => {
- window.location.href = Pages.cases.url
- return <div>not found</div>;
- }}
- />
- </ExchangeAmlFrame>
+ <BrowserHashNavigationProvider>
+ <UiFormsProvider value={forms}>
+ <Routing />
+ </UiFormsProvider>
+ </BrowserHashNavigationProvider>
</SWRConfig>
- </HashPathProvider>
- </ExchangeApiProvider>
- </TranslationProvider>
+ </ExchangeApiProvider>
+ </TranslationProvider>
+ </UiSettingsProvider>
);
}
-
function localStorageProvider(): Map<unknown, unknown> {
const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]"));
@@ -72,3 +105,34 @@ function localStorageProvider(): Map<unknown, unknown> {
});
return map;
}
+
+function getInitialBackendBaseURL(
+ backendFromSettings: string | undefined,
+): string {
+ const overrideUrl =
+ typeof localStorage !== "undefined"
+ ? localStorage.getItem("exchange-base-url")
+ : undefined;
+ let result: string;
+
+ if (!overrideUrl) {
+ // normal path
+ if (!backendFromSettings) {
+ console.error(
+ "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'",
+ );
+ result = window.origin;
+ } else {
+ result = backendFromSettings;
+ }
+ } else {
+ // testing/development path
+ result = overrideUrl;
+ }
+ try {
+ return canonicalizeBaseUrl(result);
+ } catch (e) {
+ // fall back
+ return canonicalizeBaseUrl(window.origin);
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/Dashboard.tsx b/packages/aml-backoffice-ui/src/Dashboard.tsx
deleted file mode 100644
index 3951b48c7..000000000
--- a/packages/aml-backoffice-ui/src/Dashboard.tsx
+++ /dev/null
@@ -1,234 +0,0 @@
-import { TranslatedString } from "@gnu-taler/taler-util";
-import { Footer, ToastBanner, Header, notifyError, notifyException, useTranslationContext } from "@gnu-taler/web-util/browser";
-import { ComponentChildren, Fragment, VNode, h } from "preact";
-import { useEffect, useErrorBoundary } from "preact/hooks";
-import { useOfficer } from "./hooks/useOfficer.js";
-import { getAllBooleanSettings, getLabelForSetting, useSettings } from "./hooks/useSettings.js";
-import { Pages } from "./pages.js";
-import { PageEntry, useChangeLocation } from "./route.js";
-import { uiSettings } from "./settings.js";
-
-function classNames(...classes: string[]) {
- return classes.filter(Boolean).join(" ");
-}
-
-/**
- * mapping route to view
- * not found (error page)
- * nested, index element, relative routes
- * link interception
- * form POST interception, call action
- * fromData => Object.fromEntries
- * segments in the URL
- * navigationState: idle, submitting, loading
- * form GET interception: does a navigateTo
- * form GET Sync:
- * 1.- back after submit: useEffect to sync URL to form
- * 2.- refresh after submit: input default value
- * useSubmit for form submission onChange, history replace
- *
- * post form without redirect
- *
- *
- * @param param0
- * @returns
- */
-
-const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
-const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
-
-const versionText = VERSION
- ? GIT_HASH
- ? `v${VERSION} (${GIT_HASH.substring(0, 8)})`
- : VERSION
- : "";
-
-/**
- * TO BE FIXED:
- *
- * 1.- when the form change to other form and both form share the same structure
- * the same input component may be rendered in the same place,
- * since input are uncontrolled the are not re-rendered and since they are
- * uncontrolled it will keep the value of the previous form.
- * One solutions could be to remove the form when unloading and when the new
- * form load it will start without previous vdom, preventing the cache
- * to create this behavior.
- * Other solutions could be using IDs in the fields that are constructed
- * with the ID of the form, so two fields of different form will need to re-render
- * cleaning up the state of the previous form.
- *
- * 2.- currently the design prop and the behavior prop of the flexible form
- * are two side of the same coin. From the design point of view, it is important
- * to design the form in a list-of-field manner and there may be additional
- * content that is not directly mapped to the form structure (object)
- * So maybe we want to change the current shape so the computation of the state
- * of the form is in a field level, but this computation required the field value and
- * the whole form values and state (since one field may be disabled/hidden) because
- * of the value of other field.
- *
- * 3.- given the previous requirement, maybe the name of the field of the form could be
- * a function (P: F -> V) where F is the form (or parent object) and V is the type of the
- * property. That will help with the typing of the forms props
- *
- * 4.- tooltip are not placed correctly: the arrow should point the question mark
- * and the text area should be bigger
- *
- */
-
-/**
- * check this fields
- *
- * Signature of Contracting partner, 902_9e
- * Currency and amount of deposited assets, 902_5e
- * Signature on declaration of trust, 902.13e
- * also fundations
- * also life insurance
- *
- * no all state are handled by all the inputs
- * all the input implementation should respect
- * ui props and state
- */
-
-export function ExchangeAmlFrame({
- children,
-}: {
- children?: ComponentChildren;
-}): VNode {
- const { i18n } = useTranslationContext();
-
- const [error, resetError] = useErrorBoundary();
-
- useEffect(() => {
- if (error) {
- if (error instanceof Error) {
- notifyException(i18n.str`Internal error, please report.`, error)
- } else {
- notifyError(i18n.str`Internal error, please report.`, String(error) as TranslatedString)
- }
- console.log(error)
- // resetError()
- }
- }, [error])
-
- const officer = useOfficer();
- const [settings, updateSettings] = useSettings();
-
- return (<div class="min-h-full flex flex-col m-0 bg-slate-200" style="min-height: 100vh;">
- <div class="bg-indigo-600 pb-32">
- <Header
- title="Exchange"
- iconLinkURL={uiSettings.backendBaseURL ?? "#"}
- onLogout={officer.state !== "ready" ? undefined : () => {
- officer.lock()
- }}
- sites={[]}
- supportedLangs={["en", "es", "de"]}
- >
- <li>
- <div class="text-xs font-semibold leading-6 text-gray-400">
- <i18n.Translate>Preferences</i18n.Translate>
- </div>
- <ul role="list" class="space-y-1">
- {getAllBooleanSettings().map(set => {
- const isOn: boolean = !!settings[set]
- return <li class="mt-2 pl-2">
- <div class="flex items-center justify-between">
- <span class="flex flex-grow flex-col">
- <span class="text-sm text-black font-medium leading-6 " id="availability-label">
- {getLabelForSetting(set, i18n)}
- </span>
- </span>
- <button type="button" data-enabled={isOn} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
-
- onClick={() => { updateSettings(set, !isOn); }}>
- <span aria-hidden="true" data-enabled={isOn} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
- </button>
- </div>
- </li>
- })}
- </ul>
- </li>
- </Header>
- </div>
-
- <div class="fixed z-20 w-full">
- <div class="mx-auto w-4/5">
- <ToastBanner />
- </div>
- </div>
-
- <div class="-mt-32 flex grow ">
- {officer.state !== "ready" ? undefined :
- <Navigation />
- }
- <div class="flex mx-auto my-4">
- <main class="rounded-lg bg-white px-5 py-6 shadow">
- {children}
- </main>
- </div>
-
- </div>
-
- <Footer
- testingUrlKey="exchange-base-url"
- GIT_HASH={GIT_HASH}
- VERSION={VERSION}
- />
- </div>
- );
-}
-
-function Navigation(): VNode {
- const { i18n } = useTranslationContext()
- const pageList: Array<PageEntry> = [
- Pages.officer,
- Pages.cases
- ]
- const location = useChangeLocation();
- return (
- <div class="hidden sm:block min-w-min bg-indigo-600 divide-y rounded-r-lg divide-cyan-800 overflow-y-auto overflow-x-clip">
-
- <nav class="flex flex-1 flex-col mx-4 mt-4 mb-2">
- <ul role="list" class="flex flex-1 flex-col gap-y-7">
- <li>
- <ul role="list" class="-mx-2 space-y-1">
- {pageList.map(p => {
-
- return <li>
- <a href={p.url} data-selected={location == p.url}
- class="data-[selected=true]:bg-indigo-700 pr-4 data-[selected=true]:text-white text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
- {p.Icon && <p.Icon />}
- <span class="hidden md:inline">
- {p.name}
- </span>
- </a>
- </li>
-
- })}
- {/* <li>
- <a href="#" class="text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
-
- <i18n.Translate>Officer</i18n.Translate>
- </a>
- </li> */}
- </ul>
- </li>
-
- {/* <li class="mt-auto ">
- <a href="#" class="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-indigo-200 hover:bg-indigo-700 hover:text-white">
- <svg class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
- <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
- <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
- </svg>
- Settings
- </a>
- </li> */}
-
- </ul>
- </nav>
- </div>
- )
-
-}
-
-
diff --git a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx
new file mode 100644
index 000000000..772fd1b70
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx
@@ -0,0 +1,273 @@
+/*
+ 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 { TranslatedString } from "@gnu-taler/taler-util";
+import {
+ Footer,
+ Header,
+ ToastBanner,
+ notifyError,
+ notifyException,
+ useNavigationContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, VNode, h } from "preact";
+import { useEffect, useErrorBoundary } from "preact/hooks";
+import { privatePages } from "./Routing.js";
+import { useUiSettingsContext } from "./context/ui-settings.js";
+import { OfficerState } from "./hooks/officer.js";
+import {
+ getAllBooleanPreferences,
+ getLabelForPreferences,
+ usePreferences,
+} from "./hooks/preferences.js";
+import { HomeIcon } from "./pages/Cases.js";
+
+/**
+ * mapping route to view
+ * not found (error page)
+ * nested, index element, relative routes
+ * link interception
+ * form POST interception, call action
+ * fromData => Object.fromEntries
+ * segments in the URL
+ * navigationState: idle, submitting, loading
+ * form GET interception: does a navigateTo
+ * form GET Sync:
+ * 1.- back after submit: useEffect to sync URL to form
+ * 2.- refresh after submit: input default value
+ * useSubmit for form submission onChange, history replace
+ *
+ * post form without redirect
+ *
+ *
+ * @param param0
+ * @returns
+ */
+
+const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
+const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
+
+/**
+ * TO BE FIXED:
+ *
+ * 1.- when the form change to other form and both form share the same structure
+ * the same input component may be rendered in the same place,
+ * since input are uncontrolled the are not re-rendered and since they are
+ * uncontrolled it will keep the value of the previous form.
+ * One solutions could be to remove the form when unloading and when the new
+ * form load it will start without previous vdom, preventing the cache
+ * to create this behavior.
+ * Other solutions could be using IDs in the fields that are constructed
+ * with the ID of the form, so two fields of different form will need to re-render
+ * cleaning up the state of the previous form.
+ *
+ * 2.- currently the design prop and the behavior prop of the flexible form
+ * are two side of the same coin. From the design point of view, it is important
+ * to design the form in a list-of-field manner and there may be additional
+ * content that is not directly mapped to the form structure (object)
+ * So maybe we want to change the current shape so the computation of the state
+ * of the form is in a field level, but this computation required the field value and
+ * the whole form values and state (since one field may be disabled/hidden) because
+ * of the value of other field.
+ *
+ * 3.- given the previous requirement, maybe the name of the field of the form could be
+ * a function (P: F -> V) where F is the form (or parent object) and V is the type of the
+ * property. That will help with the typing of the forms props
+ *
+ * 4.- tooltip are not placed correctly: the arrow should point the question mark
+ * and the text area should be bigger
+ *
+ */
+
+/**
+ * check this fields
+ *
+ * Signature of Contracting partner, 902_9e
+ * Currency and amount of deposited assets, 902_5e
+ * Signature on declaration of trust, 902.13e
+ * also fundations
+ * also life insurance
+ *
+ * no all state are handled by all the inputs
+ * all the input implementation should respect
+ * ui props and state
+ */
+
+export function ExchangeAmlFrame({
+ children,
+ officer,
+}: {
+ officer?: OfficerState,
+ children?: ComponentChildren;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [error] = useErrorBoundary();
+
+ useEffect(() => {
+ if (error) {
+ if (error instanceof Error) {
+ notifyException(i18n.str`Internal error, please report.`, error);
+ } else {
+ notifyError(
+ i18n.str`Internal error, please report.`,
+ String(error) as TranslatedString,
+ );
+ }
+ console.log(error);
+ // resetError()
+ }
+ }, [error]);
+
+ const [preferences, updatePreferences] = usePreferences();
+ const settings = useUiSettingsContext()
+
+ return (
+ <div
+ class="min-h-full flex flex-col m-0 bg-slate-200"
+ style="min-height: 100vh;"
+ >
+ <div class="bg-indigo-600 pb-32">
+ <Header
+ title="Exchange"
+ iconLinkURL={settings.backendBaseURL ?? "#"}
+ onLogout={
+ officer?.state !== "ready"
+ ? undefined
+ : () => {
+ officer.lock();
+ }
+ }
+ sites={[]}
+ supportedLangs={["en", "es", "de"]}
+ >
+ <li>
+ <div class="text-xs font-semibold leading-6 text-gray-400">
+ <i18n.Translate>Preferences</i18n.Translate>
+ </div>
+ <ul role="list" class="space-y-1">
+ {getAllBooleanPreferences().map((set) => {
+ const isOn: boolean = !!preferences[set];
+ return (
+ <li key={set} class="mt-2 pl-2">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ {getLabelForPreferences(set, i18n)}
+ </span>
+ </span>
+ <button
+ type="button"
+ data-enabled={isOn}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ updatePreferences(set, !isOn);
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={isOn}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ </li>
+ </Header>
+ </div>
+
+ <div class="fixed z-20 w-full">
+ <div class="mx-auto w-4/5">
+ <ToastBanner />
+ </div>
+ </div>
+
+ <div class="-mt-32 flex grow ">
+ {officer?.state !== "ready" ? undefined : <Navigation />}
+ <div class="flex mx-auto my-4">
+ <main class="rounded-lg bg-white px-5 py-6 shadow">{children}</main>
+ </div>
+ </div>
+
+ <Footer
+ testingUrlKey="exchange-base-url"
+ GIT_HASH={GIT_HASH}
+ VERSION={VERSION}
+ />
+ </div>
+ );
+}
+
+function Navigation(): VNode {
+ const { i18n } = useTranslationContext();
+ const pageList = [
+ { route: privatePages.account, Icon: HomeIcon, label: i18n.str`Account` },
+ { route: privatePages.cases, Icon: HomeIcon, label: i18n.str`Cases` },
+ ];
+ const { path } = useNavigationContext();
+ return (
+ <div class="hidden sm:block min-w-min bg-indigo-600 divide-y rounded-r-lg divide-cyan-800 overflow-y-auto overflow-x-clip">
+ <nav class="flex flex-1 flex-col mx-4 mt-4 mb-2">
+ <ul role="list" class="flex flex-1 flex-col gap-y-7">
+ <li>
+ <ul role="list" class="-mx-2 space-y-1">
+ {pageList.map((p, idx) => {
+ return (
+ <li key={idx}>
+ <a
+ href={p.route.url({})}
+ data-selected={path == p.route.url({})}
+ class="data-[selected=true]:bg-indigo-700 pr-4 data-[selected=true]:text-white text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
+ >
+ {p.Icon && <p.Icon />}
+ <span class="hidden md:inline">{p.label}</span>
+ </a>
+ </li>
+ );
+ })}
+ {/* <li>
+ <a href="#" class="text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
+
+ <i18n.Translate>Officer</i18n.Translate>
+ </a>
+ </li> */}
+ </ul>
+ </li>
+
+ {/* <li class="mt-auto ">
+ <a href="#" class="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-indigo-200 hover:bg-indigo-700 hover:text-white">
+ <svg class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+ </svg>
+ Settings
+ </a>
+ </li> */}
+ </ul>
+ </nav>
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/Routing.tsx b/packages/aml-backoffice-ui/src/Routing.tsx
new file mode 100644
index 000000000..f38fc29c2
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/Routing.tsx
@@ -0,0 +1,151 @@
+/*
+ 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 {
+ urlPattern,
+ useCurrentLocation,
+ useNavigationContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+
+import { assertUnreachable } from "@gnu-taler/taler-util";
+import { useEffect } from "preact/hooks";
+import { ExchangeAmlFrame } from "./ExchangeAmlFrame.js";
+import { useOfficer } from "./hooks/officer.js";
+import { Cases } from "./pages/Cases.js";
+import { Officer } from "./pages/Officer.js";
+import { CaseDetails } from "./pages/CaseDetails.js";
+import { CaseUpdate, SelectForm } from "./pages/CaseUpdate.js";
+import { HandleAccountNotReady } from "./pages/HandleAccountNotReady.js";
+
+export function Routing(): VNode {
+ const session = useOfficer();
+
+ if (session.state === "ready") {
+ return (
+ <ExchangeAmlFrame officer={session}>
+ <PrivateRouting />
+ </ExchangeAmlFrame>
+ );
+ }
+ return (
+ <ExchangeAmlFrame>
+ <PublicRounting />
+ </ExchangeAmlFrame>
+ );
+}
+
+const publicPages = {
+ config: urlPattern(/\/config/, () => "#/config"),
+ login: urlPattern(/\/login/, () => "#/login"),
+};
+
+function PublicRounting(): VNode {
+ const { i18n } = useTranslationContext();
+ const location = useCurrentLocation(publicPages);
+ // const { navigateTo } = useNavigationContext();
+ // const { config, lib } = useExchangeApiContext();
+ // const [notification, notify, handleError] = useLocalNotification();
+ const session = useOfficer();
+
+ if (location === undefined) {
+ if (session.state !== "ready") {
+ return <HandleAccountNotReady officer={session}/>;
+ } else {
+ return <div />
+ }
+ }
+
+ switch (location.name) {
+ case "config": {
+ return (
+ <Fragment>
+ <div class="sm:mx-auto sm:w-full sm:max-w-sm">
+ <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Welcome to exchange config!`}</h2>
+ </div>
+ </Fragment>
+ );
+ }
+ case "login": {
+ return (
+ <Fragment>
+ <div class="sm:mx-auto sm:w-full sm:max-w-sm">
+ <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Welcome to exchange config!`}</h2>
+ </div>
+ </Fragment>
+ );
+ }
+ default:
+ assertUnreachable(location);
+ }
+}
+
+export const privatePages = {
+ account: urlPattern(/\/account/, () => "#/account"),
+ cases: urlPattern(/\/cases/, () => "#/cases"),
+ caseUpdate: urlPattern<{ cid: string; type: string }>(
+ /\/case\/(?<cid>[a-zA-Z0-9]+)\/new\/(?<type>[a-zA-Z0-9_.]+)/,
+ ({ cid, type }) => `#/case/${cid}/new/${type}`,
+ ),
+ caseNew: urlPattern<{ cid: string }>(
+ /\/case\/(?<cid>[a-zA-Z0-9]+)\/new/,
+ ({ cid }) => `#/case/${cid}/new`,
+ ),
+ caseDetails: urlPattern<{ cid: string }>(
+ /\/case\/(?<cid>[a-zA-Z0-9]+)/,
+ ({ cid }) => `#/case/${cid}`,
+ ),
+};
+
+function PrivateRouting(): VNode {
+ const { navigateTo } = useNavigationContext();
+ const location = useCurrentLocation(privatePages);
+ useEffect(() => {
+ if (location === undefined) {
+ navigateTo(privatePages.account.url({}));
+ }
+ }, [location]);
+
+ if (location === undefined) {
+ return <Fragment />;
+ }
+
+ switch (location.name) {
+ case "account": {
+ return <Officer />;
+ }
+ case "caseDetails": {
+ return <CaseDetails account={location.values.cid} />;
+ }
+ case "caseUpdate": {
+ return (
+ <CaseUpdate
+ account={location.values.cid}
+ type={location.values.type}
+ />
+ );
+ }
+ case "caseNew": {
+ return <SelectForm account={location.values.cid} />;
+ }
+ case "cases": {
+ return <Cases />;
+ }
+ default:
+ assertUnreachable(location);
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/context/config.ts b/packages/aml-backoffice-ui/src/context/config.ts
deleted file mode 100644
index 42f73428a..000000000
--- a/packages/aml-backoffice-ui/src/context/config.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { TalerExchangeApi, TalerExchangeHttpClient, TalerError } from "@gnu-taler/taler-util";
-import { BrowserFetchHttpLib, useTranslationContext } from "@gnu-taler/web-util/browser";
-import { ComponentChildren, createContext, FunctionComponent, h, VNode } from "preact";
-import { useContext, useEffect, useState } from "preact/hooks";
-import { ErrorLoading } from "@gnu-taler/web-util/browser";
-
-/**
- *
- * @author Sebastian Javier Marchano (sebasjm)
- */
-
-export type Type = {
- url: URL,
- config: TalerExchangeApi.ExchangeVersionResponse,
- api: TalerExchangeHttpClient,
-};
-
-const Context = createContext<Type>(undefined as any);
-
-export const useExchangeApiContext = (): Type => useContext(Context);
-export const useMaybeExchangeApiContext = (): Type | undefined => useContext(Context);
-
-export function ExchangeApiContextTesting({ config, children }: { config: TalerExchangeApi.ExchangeVersionResponse, children?: ComponentChildren; }): VNode {
- return h(Context.Provider, {
- value: { url: new URL("http://testing"), config, api: null as any },
- children
- }
- )
-}
-
-export type ConfigResult = undefined
- | { type: "ok", config: TalerExchangeApi.ExchangeVersionResponse }
- | { type: "incompatible", result: TalerExchangeApi.ExchangeVersionResponse, supported: string }
- | { type: "error", error: TalerError }
-
-export const ExchangeApiProvider = ({
- baseUrl,
- children,
- frameOnError,
-}: {
- baseUrl: string,
- children: ComponentChildren;
- frameOnError: FunctionComponent<{ children: ComponentChildren }>,
-}): VNode => {
- const [checked, setChecked] = useState<ConfigResult>()
- const { i18n } = useTranslationContext();
- const url = new URL(baseUrl)
- const api = new TalerExchangeHttpClient(url.href, new BrowserFetchHttpLib())
- useEffect(() => {
- api.getConfig()
- .then((resp) => {
- if (resp.type === "fail") {
- setChecked({ type: "error", error: TalerError.fromUncheckedDetail(resp.detail) });
- } else if (api.isCompatible(resp.body.version)) {
- setChecked({ type: "ok", config: resp.body });
- } else {
- setChecked({ type: "incompatible", result: resp.body, supported: api.PROTOCOL_VERSION })
- }
- })
- .catch((error: unknown) => {
- if (error instanceof TalerError) {
- setChecked({ type: "error", error });
- }
- });
- }, []);
-
- if (checked === undefined) {
- return h(frameOnError, { children: h("div", {}, "loading...") })
- }
- if (checked.type === "error") {
- return h(frameOnError, { children: h(ErrorLoading, { error: checked.error, showDetail: true }) })
- }
- if (checked.type === "incompatible") {
- return h(frameOnError, { children: h("div", {}, i18n.str`the bank backend is not supported. supported version "${checked.supported}", server version "${checked.result.version}"`) })
- }
- const value: Type = {
- url, config: checked.config, api
- }
- return h(Context.Provider, {
- value,
- children,
- });
-};
-
diff --git a/packages/aml-backoffice-ui/src/context/ui-forms.ts b/packages/aml-backoffice-ui/src/context/ui-forms.ts
new file mode 100644
index 000000000..3a25234d2
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/context/ui-forms.ts
@@ -0,0 +1,76 @@
+/*
+ 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 { codecForUIForms, UiForms } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type Type = UiForms;
+
+const defaultForms: UiForms = {
+ forms: [],
+};
+const Context = createContext<Type>(defaultForms);
+
+export type BaseForm = Record<string, unknown>;
+
+export const useUiFormsContext = (): Type => useContext(Context);
+
+export const UiFormsProvider = ({
+ children,
+ value,
+}: {
+ value: UiForms;
+ children: ComponentChildren;
+}): VNode => {
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
+
+
+
+function removeUndefineField<T extends object>(obj: T): T {
+ const keys = Object.keys(obj) as Array<keyof T>;
+ return keys.reduce((prev, cur) => {
+ if (typeof prev[cur] === "undefined") {
+ delete prev[cur];
+ }
+ return prev;
+ }, obj);
+}
+
+export function fetchUiForms(listener: (s: UiForms) => void): void {
+ fetch("./forms.json")
+ .then((resp) => resp.json())
+ .then((json) => codecForUIForms().decode(json))
+ .then((result) =>
+ listener({
+ ...defaultForms,
+ ...removeUndefineField(result),
+ }),
+ )
+ .catch((e) => {
+ console.log("failed to fetch forms", e);
+ listener(defaultForms);
+ });
+}
diff --git a/packages/aml-backoffice-ui/src/context/ui-settings.ts b/packages/aml-backoffice-ui/src/context/ui-settings.ts
new file mode 100644
index 000000000..aa318a918
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/context/ui-settings.ts
@@ -0,0 +1,110 @@
+/*
+ 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 { buildCodecForObject, canonicalizeBaseUrl, Codec, codecForString, codecOptional } from "@gnu-taler/taler-util";
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type Type = UiSettings;
+
+/**
+ * Global settings for the UI.
+ */
+const defaultSettings: UiSettings = {
+ backendBaseURL: buildDefaultBackendBaseURL(),
+ signupEmail: undefined,
+};
+
+const Context = createContext<Type>(defaultSettings);
+
+export const useUiSettingsContext = (): Type => useContext(Context);
+
+export const UiSettingsProvider = ({
+ children,
+ value,
+}: {
+ value: UiSettings;
+ children: ComponentChildren;
+}): VNode => {
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
+
+export interface UiSettings {
+ // Where libeufin backend is localted
+ // default: window.origin without "webui/"
+ backendBaseURL?: string;
+ // Shows a button "create random account" in the registration form
+ // Useful for testing
+ // default: false
+ signupEmail?: string;
+}
+
+const codecForUISettings = (): Codec<UiSettings> =>
+ buildCodecForObject<UiSettings>()
+ .property("backendBaseURL", codecOptional(codecForString()))
+ .property("signupEmail", codecOptional(codecForString()))
+ .build("UiSettings");
+
+function removeUndefineField<T extends object>(obj: T): T {
+ const keys = Object.keys(obj) as Array<keyof T>;
+ return keys.reduce((prev, cur) => {
+ if (typeof prev[cur] === "undefined") {
+ delete prev[cur];
+ }
+ return prev;
+ }, obj);
+}
+
+export function fetchUiSettings(listener: (s: UiSettings) => void): void {
+ fetch("./settings.json")
+ .then((resp) => resp.json())
+ .then((json) => codecForUISettings().decode(json))
+ .then((result) =>
+ listener({
+ ...defaultSettings,
+ ...removeUndefineField(result),
+ }),
+ )
+ .catch((e) => {
+ console.log("failed to fetch settings", e);
+ listener(defaultSettings);
+ });
+}
+
+function buildDefaultBackendBaseURL(): string | undefined {
+ if (typeof window !== "undefined") {
+ const currentLocation = new URL(
+ window.location.pathname,
+ window.location.origin,
+ ).href;
+ /**
+ * By default, backend serves the html content
+ * from the /webui root.
+ */
+ return canonicalizeBaseUrl(currentLocation.replace("/webui", ""));
+ }
+ throw Error("No default URL");
+}
+
+
diff --git a/packages/aml-backoffice-ui/src/declaration.d.ts b/packages/aml-backoffice-ui/src/declaration.d.ts
index 6af72042c..7868e41bd 100644
--- a/packages/aml-backoffice-ui/src/declaration.d.ts
+++ b/packages/aml-backoffice-ui/src/declaration.d.ts
@@ -1,3 +1,18 @@
+/*
+ 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/>
+ */
declare const __VERSION__: string;
declare const __GIT_HASH__: string;
diff --git a/packages/aml-backoffice-ui/src/forms.json b/packages/aml-backoffice-ui/src/forms.json
new file mode 100644
index 000000000..94dcda317
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms.json
@@ -0,0 +1,529 @@
+{
+ "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",
+
+ "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",
+
+ "label": "Natural customer form",
+ "name": "algo",
+ "id": "algo",
+ "before": "a) Country risk (nationality)",
+ "after": "a) Country risk (nationality)",
+ "fields": [
+ {
+ "type": "text",
+
+ "name": "naturalCustomer.fullName",
+ "id": ".naturalCustomer.fullName",
+ "label": "Full name",
+ "required": true
+ },
+ {
+ "type": "text",
+
+ "name": "naturalCustomer.address",
+ "id": ".naturalCustomer.address",
+ "label": "Residential address",
+ "required": true
+ },
+ {
+ "type": "integer",
+
+ "name": "naturalCustomer.telephone",
+ "id": ".naturalCustomer.telephone",
+ "label": "Telephone"
+ },
+ {
+ "type": "text",
+
+ "name": "naturalCustomer.email",
+ "id": ".naturalCustomer.email",
+ "label": "E-mail"
+ },
+ {
+ "type": "absoluteTimeText",
+
+ "pattern": "dd/MM/yyyy",
+ "name": "naturalCustomer.dateOfBirth",
+ "id": ".naturalCustomer.dateOfBirth",
+ "label": "Date of birth",
+ "required": true
+ },
+ {
+ "type": "text",
+
+ "name": "naturalCustomer.nationality",
+ "id": ".naturalCustomer.nationality",
+ "label": "Nationality",
+ "required": true
+ },
+ {
+ "type": "text",
+
+ "name": "naturalCustomer.document",
+ "id": ".naturalCustomer.document",
+ "label": "Identification document",
+ "required": true
+ },
+ {
+ "type": "file",
+
+ "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",
+
+ "name": "naturalCustomer.companyName",
+ "id": ".naturalCustomer.companyName",
+ "label": "Company name"
+ },
+ {
+ "type": "text",
+
+ "name": "naturalCustomer.office",
+ "id": ".naturalCustomer.office",
+ "label": "Registered office"
+ },
+ {
+ "type": "text",
+
+ "name": "naturalCustomer.companyDocument",
+ "id": ".naturalCustomer.companyDocument",
+ "label": "Company identification document"
+ },
+ {
+ "type": "file",
+
+ "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",
+
+ "label": "Natural customer form",
+ "name": "algo",
+ "id": "algo",
+ "before": "a) Country risk (nationality)",
+ "after": "a) Country risk (nationality)",
+ "fields": [
+ {
+ "type": "text",
+
+ "name": "legalCustomer.companyName",
+ "id": ".legalCustomer.companyName",
+ "label": "Company name",
+ "required": true
+ },
+ {
+ "type": "text",
+
+ "name": "legalCustomer.domicile",
+ "id": ".legalCustomer.domicile",
+ "label": "Domicile",
+ "required": true
+ },
+ {
+ "type": "text",
+
+ "name": "legalCustomer.contactPerson",
+ "id": ".legalCustomer.contactPerson",
+ "label": "Contact person"
+ },
+ {
+ "type": "text",
+
+ "name": "legalCustomer.telephone",
+ "id": ".legalCustomer.telephone",
+ "label": "Telephone"
+ },
+ {
+ "type": "text",
+
+ "name": "legalCustomer.email",
+ "id": ".legalCustomer.email",
+ "label": "E-mail"
+ },
+ {
+ "type": "text",
+
+ "name": "legalCustomer.document",
+ "id": ".legalCustomer.document",
+ "label": "Identification document",
+ "help": "Not older than 12 month"
+ },
+ {
+ "type": "file",
+
+ "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",
+
+ "name": "businessEstablisher",
+ "id": ".businessEstablisher",
+ "label": "Persons",
+ "required": true,
+ "labelFieldId": "fullName",
+ "placeholder": "this is the placeholder",
+ "fields": [
+ {
+ "type": "text",
+
+ "name": "fullName",
+ "id": ".fullName",
+ "label": "Full name",
+ "required": true
+ },
+ {
+ "type": "text",
+
+ "name": "address",
+ "id": ".address",
+ "label": "Residential address",
+ "required": true
+ },
+ {
+ "type": "absoluteTimeText",
+
+ "pattern": "dd/MM/yyyy",
+ "name": "dateOfBirth",
+ "id": ".dateOfBirth",
+ "label": "Date of birth",
+ "required": true
+ },
+
+ {
+ "type": "text",
+
+ "name": "nationality",
+ "id": ".nationality",
+ "label": "Nationality",
+ "required": true
+ },
+ {
+ "type": "text",
+
+ "name": "typeOfAuthorization",
+ "id": ".typeOfAuthorization",
+ "label": "Type of authorization (signatory of representation)",
+ "required": true
+ },
+ {
+ "type": "file",
+
+ "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",
+
+ "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",
+
+ "name": "powerOfAttorneyArrangementsOther",
+ "id": ".powerOfAttorneyArrangementsOther",
+ "label": "Power of attorney arrangements",
+ "required": true
+ }
+ ],
+ "labelField": "fullName"
+ }
+ ]
+ },
+ {
+ "title": "Acceptance of business relationship",
+ "fields": [
+ {
+ "type": "absoluteTimeText",
+
+ "name": "acceptance.when",
+ "id": ".acceptance.when",
+ "pattern": "dd/MM/yyyy",
+ "converterId": "Taler.AbsoluteTime",
+ "label": "Date (conclusion of contract)"
+ },
+ {
+ "type": "choiceStacked",
+
+ "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",
+
+ "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",
+
+ "name": "acceptance.thirdPartyFullName",
+ "id": ".acceptance.thirdPartyFullName",
+ "label": "Third party full name",
+ "required": true
+ },
+ {
+ "type": "text",
+
+ "name": "acceptance.thirdPartyAddress",
+ "id": ".acceptance.thirdPartyAddress",
+ "label": "Third party address",
+ "required": true
+ },
+ {
+ "type": "selectMultiple",
+
+ "name": "acceptance.language",
+ "id": ".acceptance.language",
+ "label": "Languages",
+ "choices": [
+ {
+ "label": "Espanol",
+ "value": "es"
+ }
+ ],
+ "unique": true
+ },
+ {
+ "type": "textArea",
+
+ "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",
+
+ "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",
+
+ "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",
+
+ "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",
+
+ "name": "cashTransactions.otherTypeOfBusiness",
+ "id": ".cashTransactions.otherTypeOfBusiness",
+ "required": true,
+ "label": "Specify other cash transactions:"
+ },
+ {
+ "type": "textArea",
+ "name": "cashTransactions.purpose",
+ "id": ".cashTransactions.purpose",
+ "label": "Purpose of the business relationship (purpose of service requested)"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "label": "Example form",
+ "id": "example",
+ "version": 1,
+ "config": {
+ "type": "double-column",
+ "design": [
+ {
+ "title": "Boolean inputs",
+ "fields": [
+ {
+ "type": "toggle",
+ "name": "yes",
+ "id": ".yes",
+ "label": "Yes or no?"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "not_yet_supported": []
+}
diff --git a/packages/aml-backoffice-ui/src/forms/902_11e.ts b/packages/aml-backoffice-ui/src/forms/902_11e.ts
index 71ca8bcf4..7cf710741 100644
--- a/packages/aml-backoffice-ui/src/forms/902_11e.ts
+++ b/packages/aml-backoffice-ui/src/forms/902_11e.ts
@@ -1,9 +1,23 @@
-import type { TranslatedString } from "@gnu-taler/taler-util";
-import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
-import { BaseForm } from "./declaration.js";
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import type { FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "../context/ui-forms.js";
import { resolutionSection } from "./simplest.js";
-export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_11.Form> => ({
+export const v1 = (i18n: InternationalizationAPI) => ({
design: [
{
title:
@@ -13,14 +27,14 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "textArea",
- props: {
+ properties: {
name: "contractingPartner",
label: i18n.str`Contracting partner`,
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "declares",
label:
i18n.str`The contracting partner hereby declares that`,
@@ -46,7 +60,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "array",
- props: {
+ properties: {
name: "people",
label: i18n.str`People`,
required: true,
@@ -54,7 +68,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "lastName",
label: i18n.str`Last name(s)`,
required: true,
@@ -62,7 +76,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "firstName",
label: i18n.str`First name(s)`,
required: true,
@@ -70,7 +84,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label: i18n.str`Actual address of domicile`,
required: true,
@@ -82,7 +96,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "fiduciaryAssets",
label: i18n.str`Fiduciary holding assets`,
help: i18n.str`Is a third person the beneficial owner of the assets held in the account/securities account?`,
@@ -103,7 +117,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
],
},
- resolutionSection(current, i18n),
+ resolutionSection(i18n),
],
behavior: function formBehavior(
v: Partial<Form902_11.Form>,
diff --git a/packages/aml-backoffice-ui/src/forms/902_12e.ts b/packages/aml-backoffice-ui/src/forms/902_12e.ts
index 0c08d274c..5aa3f4cf9 100644
--- a/packages/aml-backoffice-ui/src/forms/902_12e.ts
+++ b/packages/aml-backoffice-ui/src/forms/902_12e.ts
@@ -1,23 +1,38 @@
-import type { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
-import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+/*
+ 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 type { AbsoluteTime } from "@gnu-taler/taler-util";
+import type { FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "../context/ui-forms.js";
import { resolutionSection } from "./simplest.js";
-import { BaseForm } from "./declaration.js";
-export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_12.Form> => ({
+export const v1 = (i18n: InternationalizationAPI) => ({
design: [
{
title: i18n.str`Foundations`,
fields: [
{
type: "textArea",
- props: {
+ properties: {
name: "contractingPartner",
label: i18n.str`Contracting partner`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "knownAs",
label:
i18n.str`The undersigned hereby declare(s) that as board member of the foundation, or of the highest supervisory body of an underlying company of a foundation, known as`,
@@ -25,7 +40,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "foundation.name",
label:
i18n.str`Name and information pertaining to the foundation`,
@@ -33,7 +48,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "foundation.type",
label: i18n.str`Type of foundation`,
choices: [
@@ -50,7 +65,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "foundation.revocability",
label: i18n.str`Revocability`,
choices: [
@@ -67,7 +82,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "array",
- props: {
+ properties: {
label:
i18n.str`Information pertaining to the (ultimate economic, not fiduciary) founder (individual(s) or entity/ies)`,
labelField: "fullName",
@@ -75,7 +90,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -83,7 +98,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label:
i18n.str`Actual address of domicile/registered office`,
@@ -91,28 +106,28 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "country",
label: i18n.str`Country`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "dateOfDeath",
label: i18n.str`Date of death`,
help: i18n.str`if deceased`,
@@ -120,7 +135,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "rightToRevoke",
required: true,
label:
@@ -142,7 +157,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "array",
- props: {
+ properties: {
label:
i18n.str`If the foundation results from the restructuring of pre-existing foundation (re-settlement) or the merger of pre-existing foundations, the following information pertaining to the (actual) founder(s) of the pre-existing foundation(s) has to be given`,
labelField: "fullName",
@@ -150,7 +165,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -158,7 +173,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label:
i18n.str`Actual address of domicile/registered office`,
@@ -166,28 +181,28 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "country",
label: i18n.str`Country`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "dateOfDeath",
label: i18n.str`Date of death`,
help: i18n.str`if deceased`,
@@ -198,7 +213,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "array",
- props: {
+ properties: {
label:
i18n.str`Pertaining to the beneficiary/-ies at the time of the signing of this form`,
labelField: "fullName",
@@ -206,7 +221,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -214,7 +229,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label:
i18n.str`Actual address of domicile/registered office`,
@@ -222,28 +237,28 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "country",
label: i18n.str`Country`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "rightToClaim",
label:
i18n.str`Has the beneficiary an actual right to claim distribution?`,
@@ -261,7 +276,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`in addition to certain beneficiaries or if there is/are no defined beneficiary/ies pertaining to (a) group(s) of beneficiaries (e.g. descendants of the founder) known at the time of the signing of this form`,
name: "beneficiaryExtra",
@@ -272,7 +287,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "array",
- props: {
+ properties: {
label:
i18n.str`Information pertaining to further persons having the right to determine or nominate representatives (e.g.) members of the foundation board), if these representatives may dispose over the assets or have the right to change the distribution of the assets or the nomination of beneficiaries`,
labelField: "fullName",
@@ -280,7 +295,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -288,7 +303,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label:
i18n.str`Actual address of domicile/registered office`,
@@ -296,28 +311,28 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "country",
label: i18n.str`Country`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "rightToClaim",
label:
i18n.str`has the person the right to revoke the foundation?`,
@@ -335,7 +350,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`in addition to certain beneficiaries or if there is/are no defined beneficiary/ies pertaining to (a) group(s) of beneficiaries (e.g. descendants of the founder) known at the time of the signing of this form`,
name: "beneficiaryExtra",
@@ -346,39 +361,39 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "signature",
label: i18n.str`Signature`,
},
},
],
},
- resolutionSection(current, i18n),
+ resolutionSection(i18n),
],
- behavior: function formBehavior(
- v: Partial<Form902_12.Form>,
- ): FormState<Form902_12.Form> {
- return {
- founders: {
- elements: (v.founders ?? []).map((f) => {
- return {
- rightToRevoke: {
- hidden: v.foundation?.revocability !== "revocable",
- },
- };
- }),
- },
- withRightToNominate: {
- elements: (v.withRightToNominate ?? []).map((f) => {
- return {
- rightToRevoke: {
- hidden: v.foundation?.revocability !== "revocable",
- },
- };
- }),
- },
- };
- },
+ // behavior: function formBehavior(
+ // v: Partial<Form902_12.Form>,
+ // ): FormState<Form902_12.Form> {
+ // return {
+ // founders: {
+ // elements: (v.founders ?? []).map(() => {
+ // return {
+ // rightToRevoke: {
+ // hidden: v.foundation?.revocability !== "revocable",
+ // },
+ // };
+ // }),
+ // },
+ // withRightToNominate: {
+ // elements: (v.withRightToNominate ?? []).map(() => {
+ // return {
+ // rightToRevoke: {
+ // hidden: v.foundation?.revocability !== "revocable",
+ // },
+ // };
+ // }),
+ // },
+ // };
+ // },
});
namespace Form902_12 {
diff --git a/packages/aml-backoffice-ui/src/forms/902_13e.ts b/packages/aml-backoffice-ui/src/forms/902_13e.ts
index f69884e0e..d71266489 100644
--- a/packages/aml-backoffice-ui/src/forms/902_13e.ts
+++ b/packages/aml-backoffice-ui/src/forms/902_13e.ts
@@ -1,23 +1,38 @@
+/*
+ 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 type { AbsoluteTime } from "@gnu-taler/taler-util";
-import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
-import { BaseForm } from "./declaration.js";
+import type { FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "../context/ui-forms.js";
import { resolutionSection } from "./simplest.js";
-export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_13.Form> => ({
+export const v1 = (i18n: InternationalizationAPI) => ({
design: [
{
title: i18n.str`Declaration for trusts`,
fields: [
{
type: "textArea",
- props: {
+ properties: {
name: "contractingPartner",
label: i18n.str`Contracting partner`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "knownAs",
label:
i18n.str`The undersigned hereby declare(s) that as trustee or a member of highest supervisory body of an underlying company of a trust known as`,
@@ -25,7 +40,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "trust.name",
label:
i18n.str`Name and information pertaining to the trust`,
@@ -33,7 +48,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "trust.type",
label: i18n.str`Type of trust`,
choices: [
@@ -50,7 +65,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "trust.revocability",
label: i18n.str`Revocability`,
choices: [
@@ -67,7 +82,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "array",
- props: {
+ properties: {
label:
i18n.str`Information pertaining to the (ultimate economic, not fiduciary) settlor of the trust (individual(s) or entity/ies)`,
labelField: "fullName",
@@ -75,7 +90,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -83,7 +98,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label:
i18n.str`Actual address of domicile/registered office`,
@@ -91,14 +106,14 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "country",
label: i18n.str`Country`,
},
},
{
type: "absoluteTime",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
pattern: "dd/MM/yyyy",
@@ -107,14 +122,14 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
},
},
{
type: "absoluteTime",
- props: {
+ properties: {
name: "dateOfDeath",
label: i18n.str`Date of death`,
pattern: "dd/MM/yyyy",
@@ -124,7 +139,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "rightToRevoke",
required: true,
label:
@@ -146,7 +161,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "array",
- props: {
+ properties: {
label:
i18n.str`If the trust results from the restructuring of pre-existing trust (re-settlement) or the merger of pre-existing trusts, the following information pertaining to the (actual) settlor of the pre-existing trust(s) has to be given`,
labelField: "fullName",
@@ -154,7 +169,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -162,7 +177,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label:
i18n.str`Actual address of domicile/registered office`,
@@ -170,14 +185,14 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "country",
label: i18n.str`Country`,
},
},
{
type: "absoluteTime",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
pattern: "dd/MM/yyyy",
@@ -186,14 +201,14 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
},
},
{
type: "absoluteTime",
- props: {
+ properties: {
name: "dateOfDeath",
label: i18n.str`Date of death`,
pattern: "dd/MM/yyyy",
@@ -206,7 +221,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "array",
- props: {
+ properties: {
label:
i18n.str`Pertaining to the beneficiary/-ies at the time of the signing of this form`,
labelField: "fullName",
@@ -214,7 +229,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -222,7 +237,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label:
i18n.str`Actual address of domicile/registered office`,
@@ -230,14 +245,14 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "country",
label: i18n.str`Country`,
},
},
{
type: "absoluteTime",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
pattern: "dd/MM/yyyy",
@@ -246,14 +261,14 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "rightToClaim",
label:
i18n.str`Has the beneficiary an actual right to claim distribution?`,
@@ -271,7 +286,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`in addition to certain beneficiaries or if there is/are no defined beneficiary/ies pertaining to (a) group(s) of beneficiaries (e.g. descendants of the settlor) known at the time of the signing of this form`,
name: "beneficiaryExtra",
@@ -282,7 +297,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "array",
- props: {
+ properties: {
label:
i18n.str`Information pertaining to the protector(s) as well as (a) further person(s) having the right to revoke the trust (in case of revocable trusts) or to appoint the trustee of a trust`,
labelField: "asd",
@@ -293,7 +308,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
{
type: "array",
- props: {
+ properties: {
label:
i18n.str`Information pertaining to the protectors`,
labelField: "fullName",
@@ -301,7 +316,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -309,7 +324,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label:
i18n.str`Actual address of domicile/registered office`,
@@ -317,28 +332,28 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "country",
label: i18n.str`Country`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "rightToClaim",
label:
i18n.str`Does the protector have the right to revoke the trust?`,
@@ -359,7 +374,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "array",
- props: {
+ properties: {
label:
i18n.str`Information pertaining to further persons`,
labelField: "fullName",
@@ -367,7 +382,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -375,7 +390,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label:
i18n.str`Actual address of domicile/registered office`,
@@ -383,28 +398,28 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "country",
label: i18n.str`Country`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "rightToClaim",
label:
i18n.str`Has this further person the right to revoke the trust?`,
@@ -425,48 +440,48 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "signature",
label: i18n.str`Signature`,
},
},
],
},
- resolutionSection(current, i18n),
+ resolutionSection(i18n),
],
- behavior: function formBehavior(
- v: Partial<Form902_13.Form>,
- ): FormState<Form902_13.Form> {
- return {
- settlors: {
- elements: (v.settlors ?? []).map((f) => {
- return {
- rightToRevoke: {
- hidden: v.foundation?.revocability !== "revocable",
- },
- };
- }),
- },
- protectors: {
- elements: (v.protectors ?? []).map((f) => {
- return {
- rightToRevoke: {
- hidden: v.foundation?.revocability !== "revocable",
- },
- };
- }),
- },
- furtherPersons: {
- elements: (v.furtherPersons ?? []).map((f) => {
- return {
- rightToRevoke: {
- hidden: v.foundation?.revocability !== "revocable",
- },
- };
- }),
- },
- };
- },
+ // behavior: function formBehavior(
+ // v: Partial<Form902_13.Form>,
+ // ): FormState<Form902_13.Form> {
+ // return {
+ // settlors: {
+ // elements: (v.settlors ?? []).map(() => {
+ // return {
+ // rightToRevoke: {
+ // hidden: v.foundation?.revocability !== "revocable",
+ // },
+ // };
+ // }),
+ // },
+ // protectors: {
+ // elements: (v.protectors ?? []).map(() => {
+ // return {
+ // rightToRevoke: {
+ // hidden: v.foundation?.revocability !== "revocable",
+ // },
+ // };
+ // }),
+ // },
+ // furtherPersons: {
+ // elements: (v.furtherPersons ?? []).map(() => {
+ // return {
+ // rightToRevoke: {
+ // hidden: v.foundation?.revocability !== "revocable",
+ // },
+ // };
+ // }),
+ // },
+ // };
+ // },
});
namespace Form902_13 {
diff --git a/packages/aml-backoffice-ui/src/forms/902_15e.ts b/packages/aml-backoffice-ui/src/forms/902_15e.ts
index 2375de389..eeda166c1 100644
--- a/packages/aml-backoffice-ui/src/forms/902_15e.ts
+++ b/packages/aml-backoffice-ui/src/forms/902_15e.ts
@@ -1,9 +1,24 @@
+/*
+ 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 type { AbsoluteTime } from "@gnu-taler/taler-util";
-import type { FlexibleForm, InternationalizationAPI } from "@gnu-taler/web-util/browser";
-import { BaseForm } from "./declaration.js";
+import type { InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "../context/ui-forms.js";
import { resolutionSection } from "./simplest.js";
-export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_15.Form> => ({
+export const v1 = (i18n: InternationalizationAPI) => ({
design: [
{
title:
@@ -11,14 +26,14 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "textArea",
- props: {
+ properties: {
name: "contractingPartner",
label: i18n.str`Contracting partner`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "contractualRelationship",
label:
i18n.str`Name or number of the contractual relationship between the contracting party and the financial intermediary`,
@@ -26,33 +41,33 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "insurancePolicy",
label: i18n.str`Insurance policy`,
},
},
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`The contracting partner confirms in accordance with Art. 41a SRO Regulations that it is a licensed and state-supervised insurance company and that it has entered into the above-mentioned contractual relationship the assets connected to the life insurance policy also mentioned above.`,
},
},
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`In relation with the above insurance policy, the contracting partner gives the following further details`,
},
},
{
type: "group",
- props: {
+ properties: {
before: i18n.str`Policy holder`,
fields: [
{
type: "text",
- props: {
+ properties: {
name: "holder.fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -60,7 +75,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "holder.address",
label:
i18n.str`Actual address of domicile/registered office (incl. country)`,
@@ -68,7 +83,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "absoluteTime",
- props: {
+ properties: {
name: "holder.dateOfBirth",
label: i18n.str`Date of birth`,
pattern: "dd/MM/yyyy",
@@ -77,7 +92,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "holder.nationality",
label: i18n.str`Nationality`,
},
@@ -87,13 +102,13 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "group",
- props: {
+ properties: {
before:
i18n.str`Person actually (not in a fiduciary capacity) paying the premiums (to be filled in if not identical with point 1 above)`,
fields: [
{
type: "text",
- props: {
+ properties: {
name: "premiumPayer.fullName",
label:
i18n.str`Last name(s), first name(s)/entity`,
@@ -101,7 +116,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "premiumPayer.address",
label:
i18n.str`Actual address of domicile/registered office (incl. country)`,
@@ -109,7 +124,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "absoluteTime",
- props: {
+ properties: {
name: "premiumPayer.dateOfBirth",
label: i18n.str`Date of birth`,
pattern: "dd/MM/yyyy",
@@ -118,7 +133,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "premiumPayer.nationality",
label: i18n.str`Nationality`,
},
@@ -128,28 +143,28 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`The contracting partner hereby undertakes to automatically inform the financial intermediary of any changes. The contracting partner hereby also declares having been given permission by the above individuals and/or entities to transmit their data to the financial intermediary`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "signature",
label: i18n.str`Signature`,
},
},
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`It is a criminal offense to deliberately provide false information on this form (article 251 of the Swiss Criminal Code, document forgery)`,
},
},
],
},
- resolutionSection(current, i18n),
+ resolutionSection(i18n),
],
});
diff --git a/packages/aml-backoffice-ui/src/forms/902_1e.ts b/packages/aml-backoffice-ui/src/forms/902_1e.ts
index 2287db369..58ef7e2e8 100644
--- a/packages/aml-backoffice-ui/src/forms/902_1e.ts
+++ b/packages/aml-backoffice-ui/src/forms/902_1e.ts
@@ -1,18 +1,32 @@
-import type { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
-import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
-import { BaseForm, uiForms } from "./declaration.js";
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import type { AbsoluteTime } from "@gnu-taler/taler-util";
+import type { InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "../context/ui-forms.js";
import { resolutionSection } from "./simplest.js";
-export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_1.Form> => ({
+export const v1 = (i18n: InternationalizationAPI) => ({
design: [
{
title: i18n.str`Information on customer`,
- description:
- i18n.str`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.`,
+ description: i18n.str`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",
- props: {
+ properties: {
name: "customerType",
label: i18n.str`Type of customer`,
required: true,
@@ -30,7 +44,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "naturalCustomer.fullName",
label: i18n.str`Full name`,
required: true,
@@ -38,7 +52,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "naturalCustomer.address",
label: i18n.str`Residential address`,
required: true,
@@ -46,21 +60,21 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "integer",
- props: {
+ properties: {
name: "naturalCustomer.telephone",
label: i18n.str`Telephone`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "naturalCustomer.email",
label: i18n.str`E-mail`,
},
},
{
type: "absoluteTime",
- props: {
+ properties: {
name: "naturalCustomer.dateOfBirth",
label: i18n.str`Date of birth`,
required: true,
@@ -69,7 +83,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "naturalCustomer.nationality",
label: i18n.str`Nationality`,
required: true,
@@ -77,7 +91,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "naturalCustomer.document",
label: i18n.str`Identification document`,
required: true,
@@ -85,7 +99,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "file",
- props: {
+ properties: {
name: "naturalCustomer.documentAttachment",
label: i18n.str`Document attachment`,
required: true,
@@ -96,28 +110,28 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "naturalCustomer.companyName",
label: i18n.str`Company name`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "naturalCustomer.office",
label: i18n.str`Registered office`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "naturalCustomer.companyDocument",
label: i18n.str`Company identification document`,
},
},
{
type: "file",
- props: {
+ properties: {
name: "naturalCustomer.companyDocumentAttachment",
label: i18n.str`Document attachment`,
required: true,
@@ -128,7 +142,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "legalCustomer.companyName",
label: i18n.str`Company name`,
required: true,
@@ -136,7 +150,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "legalCustomer.domicile",
label: i18n.str`Domicile`,
required: true,
@@ -144,28 +158,28 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "legalCustomer.contactPerson",
label: i18n.str`Contact person`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "legalCustomer.telephone",
label: i18n.str`Telephone`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "legalCustomer.email",
label: i18n.str`E-mail`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "legalCustomer.document",
label: i18n.str`Identification document`,
help: i18n.str`Not older than 12 month`,
@@ -173,7 +187,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "file",
- props: {
+ properties: {
name: "legalCustomer.documentAttachment",
label: i18n.str`Document attachment`,
required: true,
@@ -185,14 +199,12 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
],
},
{
- title:
- i18n.str`Information on the natural persons who establish the business relationship for legal entities and partnerships`,
- description:
- i18n.str`For legal entities and partnerships the identity of the natural persons who establish the business relationship must be verified.`,
+ title: i18n.str`Information on the natural persons who establish the business relationship for legal entities and partnerships`,
+ description: i18n.str`For legal entities and partnerships the identity of the natural persons who establish the business relationship must be verified.`,
fields: [
{
type: "array",
- props: {
+ properties: {
name: "businessEstablisher",
label: i18n.str`Persons`,
required: true,
@@ -200,7 +212,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "fullName",
label: i18n.str`Full name`,
required: true,
@@ -208,7 +220,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label: i18n.str`Residential address`,
required: true,
@@ -216,7 +228,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "absoluteTime",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
required: true,
@@ -225,7 +237,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
required: true,
@@ -233,19 +245,17 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "typeOfAuthorization",
- label:
- i18n.str`Type of authorization (signatory of representation)`,
+ label: i18n.str`Type of authorization (signatory of representation)`,
required: true,
},
},
{
type: "file",
- props: {
+ properties: {
name: "documentAttachment",
- label:
- i18n.str`Identification document attachment`,
+ label: i18n.str`Identification document attachment`,
required: true,
maxBites: 2 * 1024 * 1024,
accept: ".png",
@@ -254,7 +264,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "powerOfAttorneyArrangements",
label: i18n.str`Power of attorney arrangements`,
required: true,
@@ -276,7 +286,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "powerOfAttorneyArrangementsOther",
label: i18n.str`Power of attorney arrangements`,
required: true,
@@ -293,7 +303,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "absoluteTime",
- props: {
+ properties: {
name: "acceptance.when",
pattern: "dd/MM/yyyy",
label: i18n.str`Date (conclusion of contract)`,
@@ -302,7 +312,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "acceptance.acceptedBy",
label: i18n.str`Accepted by`,
required: true,
@@ -312,13 +322,11 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
value: "face-to-face",
},
{
- label:
- i18n.str`Correspondence: authenticated copy of identification document obtained`,
+ label: i18n.str`Correspondence: authenticated copy of identification document obtained`,
value: "correspondence-document",
},
{
- label:
- i18n.str`Correspondence: residential address validated`,
+ label: i18n.str`Correspondence: residential address validated`,
value: "correspondence-address",
},
],
@@ -326,7 +334,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
name: "acceptance.typeOfCorrespondence",
label: i18n.str`Type of correspondence service`,
choices: [
@@ -351,7 +359,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "acceptance.thirdPartyFullName",
label: i18n.str`Third party full name`,
required: true,
@@ -359,7 +367,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "acceptance.thirdPartyAddress",
label: i18n.str`Third party address`,
required: true,
@@ -367,16 +375,16 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "selectMultiple",
- props: {
+ properties: {
name: "acceptance.language",
label: i18n.str`Languages`,
- choices: uiForms.currencies(i18n),
+ choices: ["asd"],
unique: true,
},
},
{
type: "textArea",
- props: {
+ properties: {
name: "acceptance.furtherInformation",
label: i18n.str`Further information`,
},
@@ -384,36 +392,30 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
],
},
{
- title:
- i18n.str`Information on the beneficial owner of the assets and/or controlling person`,
- description:
- i18n.str`Establishment of the beneficial owner of the assets and/or controlling person`,
+ title: i18n.str`Information on the beneficial owner of the assets and/or controlling person`,
+ description: i18n.str`Establishment of the beneficial owner of the assets and/or controlling person`,
fields: [
{
type: "choiceStacked",
- props: {
+ properties: {
name: "establishment",
label: i18n.str`The customer is`,
required: true,
choices: [
{
- label:
- i18n.str`a natural person and there are no doubts that this person is the sole beneficial owner of the assets`,
+ label: i18n.str`a natural person and there are no doubts that this person is the sole beneficial owner of the assets`,
value: "natural",
},
{
- label:
- i18n.str`a foundation (or a similar construct; incl. underlying companies)`,
+ label: i18n.str`a foundation (or a similar construct; incl. underlying companies)`,
value: "foundation",
},
{
- label:
- i18n.str`a trust (incl. underlying companies)`,
+ label: i18n.str`a trust (incl. underlying companies)`,
value: "trust",
},
{
- label:
- i18n.str`a life insurance policy with separately managed accounts/securities accounts`,
+ label: i18n.str`a life insurance policy with separately managed accounts/securities accounts`,
value: "insurance-wrapper",
},
{
@@ -426,14 +428,12 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
],
},
{
- title:
- i18n.str`Evaluation with regard to embargo procedures/terrorism lists on establishing the business relationship`,
- description:
- i18n.str`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)`,
+ title: i18n.str`Evaluation with regard to embargo procedures/terrorism lists on establishing the business relationship`,
+ description: i18n.str`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",
- props: {
+ properties: {
name: "embargoEvaluation",
help: i18n.str`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: i18n.str`Evaluation`,
@@ -442,14 +442,12 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
],
},
{
- title:
- i18n.str`In the case of cash transactions/occasional customers: Information on type and purpose of business relationship`,
- description:
- i18n.str`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`,
+ title: i18n.str`In the case of cash transactions/occasional customers: Information on type and purpose of business relationship`,
+ description: i18n.str`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",
- props: {
+ properties: {
name: "cashTransactions.typeOfBusiness",
label: i18n.str`Type of business relationship`,
choices: [
@@ -462,8 +460,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
value: "money-and-asset-transfer",
},
{
- label:
- i18n.str`Other cash transactions. Specify below`,
+ label: i18n.str`Other cash transactions. Specify below`,
value: "other",
},
],
@@ -471,7 +468,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "cashTransactions.otherTypeOfBusiness",
required: true,
label: i18n.str`Specify other cash transactions:`,
@@ -479,108 +476,107 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
name: "cashTransactions.purpose",
- label:
- i18n.str`Purpose of the business relationship (purpose of service requested)`,
+ label: i18n.str`Purpose of the business relationship (purpose of service requested)`,
},
},
],
},
- resolutionSection(current, i18n),
+ resolutionSection(i18n),
],
- behavior: function formBehavior(
- v: Partial<Form902_1.Form>,
- ): FormState<Form902_1.Form> {
- return {
- fullName: {
- disabled: true,
- },
- businessEstablisher: {
- elements: (v.businessEstablisher ?? []).map((be) => {
- return {
- powerOfAttorneyArrangementsOther: {
- hidden: be.powerOfAttorneyArrangements !== "other",
- },
- };
- }),
- },
- acceptance: {
- thirdPartyFullName: {
- hidden: v.acceptance?.typeOfCorrespondence !== "third-party",
- },
- thirdPartyAddress: {
- hidden: v.acceptance?.typeOfCorrespondence !== "third-party",
- },
- },
- cashTransactions: {
- otherTypeOfBusiness: {
- hidden: v.cashTransactions?.typeOfBusiness !== "other",
- },
- },
- naturalCustomer: {
- fullName: {
- hidden: v.customerType !== "natural",
- },
- address: {
- hidden: v.customerType !== "natural",
- },
- telephone: {
- hidden: v.customerType !== "natural",
- },
- email: {
- hidden: v.customerType !== "natural",
- },
- dateOfBirth: {
- hidden: v.customerType !== "natural",
- },
- nationality: {
- hidden: v.customerType !== "natural",
- },
- document: {
- hidden: v.customerType !== "natural",
- },
- companyName: {
- hidden: v.customerType !== "natural",
- },
- office: {
- hidden: v.customerType !== "natural",
- },
- companyDocument: {
- hidden: v.customerType !== "natural",
- },
- companyDocumentAttachment: {
- hidden: v.customerType !== "natural",
- },
- documentAttachment: {
- hidden: v.customerType !== "natural",
- },
- },
- legalCustomer: {
- companyName: {
- hidden: v.customerType !== "legal",
- },
- contactPerson: {
- hidden: v.customerType !== "legal",
- },
- document: {
- hidden: v.customerType !== "legal",
- },
- domicile: {
- hidden: v.customerType !== "legal",
- },
- email: {
- hidden: v.customerType !== "legal",
- },
- telephone: {
- hidden: v.customerType !== "legal",
- },
- documentAttachment: {
- hidden: v.customerType !== "legal",
- },
- },
- };
- },
+ // behavior: function formBehavior(
+ // v: Partial<Form902_1.Form>,
+ // ): FormState<Form902_1.Form> {
+ // return {
+ // fullName: {
+ // disabled: true,
+ // },
+ // businessEstablisher: {
+ // elements: (v.businessEstablisher ?? []).map((be) => {
+ // return {
+ // powerOfAttorneyArrangementsOther: {
+ // hidden: be.powerOfAttorneyArrangements !== "other",
+ // },
+ // };
+ // }),
+ // },
+ // acceptance: {
+ // thirdPartyFullName: {
+ // hidden: v.acceptance?.typeOfCorrespondence !== "third-party",
+ // },
+ // thirdPartyAddress: {
+ // hidden: v.acceptance?.typeOfCorrespondence !== "third-party",
+ // },
+ // },
+ // cashTransactions: {
+ // otherTypeOfBusiness: {
+ // hidden: v.cashTransactions?.typeOfBusiness !== "other",
+ // },
+ // },
+ // naturalCustomer: {
+ // fullName: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // address: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // telephone: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // email: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // dateOfBirth: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // nationality: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // document: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // companyName: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // office: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // companyDocument: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // companyDocumentAttachment: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // documentAttachment: {
+ // hidden: v.customerType !== "natural",
+ // },
+ // },
+ // legalCustomer: {
+ // companyName: {
+ // hidden: v.customerType !== "legal",
+ // },
+ // contactPerson: {
+ // hidden: v.customerType !== "legal",
+ // },
+ // document: {
+ // hidden: v.customerType !== "legal",
+ // },
+ // domicile: {
+ // hidden: v.customerType !== "legal",
+ // },
+ // email: {
+ // hidden: v.customerType !== "legal",
+ // },
+ // telephone: {
+ // hidden: v.customerType !== "legal",
+ // },
+ // documentAttachment: {
+ // hidden: v.customerType !== "legal",
+ // },
+ // },
+ // };
+ // },
});
namespace Form902_1 {
@@ -632,11 +628,11 @@ namespace Form902_1 {
interface BeneficialOwner {
establishment:
- | "natural-person"
- | "foundation"
- | "trust"
- | "insurance-wrapper"
- | "other";
+ | "natural-person"
+ | "foundation"
+ | "trust"
+ | "insurance-wrapper"
+ | "other";
}
interface CashTransactions {
diff --git a/packages/aml-backoffice-ui/src/forms/902_4e.ts b/packages/aml-backoffice-ui/src/forms/902_4e.ts
index b31a8dcba..7a3af8731 100644
--- a/packages/aml-backoffice-ui/src/forms/902_4e.ts
+++ b/packages/aml-backoffice-ui/src/forms/902_4e.ts
@@ -1,11 +1,26 @@
+/*
+ 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 type { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
-import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import type { FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
import { h as create } from "preact";
-import { resolutionSection } from "./simplest.js";
-import { BaseForm } from "./declaration.js";
+import { BaseForm } from "../context/ui-forms.js";
import { ArrowRightIcon, ChevronRightIcon } from "./icons.js";
+import { resolutionSection } from "./simplest.js";
-export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_4.Form> => ({
+export const v1 = (i18n: InternationalizationAPI) => ({
design: [
{
title: i18n.str`Risk Profile AMLA`,
@@ -14,7 +29,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`The member performs additional clarifications if the business relationship or the transaction is classified as increased risk (Art. 56 SRO Regulations)`,
before: create(ArrowRightIcon, { class: "h-6 w-6" }),
@@ -22,7 +37,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "customer",
label: i18n.str`Customer`,
help: i18n.str`Pursuant identification form (VQF doc. Nr. 902.1) numeral 1`,
@@ -36,7 +51,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`This evaluation has to be completed by all members for every business relationship`,
before: create(ArrowRightIcon, { class: "h-6 w-6" }),
@@ -44,7 +59,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Foreign PEP`,
// tooltip:
// i18n.str`Definition see Art. 7 lit. g numeral 1 SRO Regulations`,
@@ -66,7 +81,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
label:
i18n.str`Domestic PEP and PEP of International Organizations`,
// tooltip:
@@ -95,7 +110,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "absoluteTime",
- props: {
+ properties: {
label:
i18n.str`The decision of the Senior executive body on the acceptance of a business relationship with a PEP was obtained on`,
name: "pep.when",
@@ -111,7 +126,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`This evaluation has to be completed by all members for every business relationship`,
before: create(ArrowRightIcon, { class: "h-6 w-6" }),
@@ -119,7 +134,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: '"High risk" or non-cooperative country' as TranslatedString,
help: 'Is the customer, the beneficial owner or the controlling person or authorized representative in a country considered by the FATF "high risk" or non-cooperative and for which FATF requires increased diligence?' as TranslatedString,
name: "highRisk.evaluation",
@@ -139,7 +154,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "absoluteTime",
- props: {
+ properties: {
label:
i18n.str`The decision of the Senior executive body on the acceptance of a business relationship with a PEP was obtained on`,
name: "highRisk.when",
@@ -154,7 +169,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`This evaluation has to be completed by all members who have in total more than 20 customers for every business relationship. At least two risk categories have to be chosen and assessed`,
before: create(ArrowRightIcon, { class: "h-6 w-6" }),
@@ -162,12 +177,12 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "group",
- props: {
+ properties: {
before: i18n.str`a) Country risk (nationality)`,
fields: [
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Domicile/residential address`,
name: "evaluation.nationality.address",
choices: [
@@ -189,7 +204,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Nationality`,
name: "evaluation.nationality.nationality",
choices: [
@@ -207,7 +222,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Risk level`,
name: "evaluation.nationality.risk",
choices: [
@@ -234,12 +249,12 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "group",
- props: {
+ properties: {
before: i18n.str`b) Country risk (business activity)`,
fields: [
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Place of business activity`,
name: "evaluation.business.place",
choices: [
@@ -257,7 +272,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Risk level`,
name: "evaluation.business.risk",
choices: [
@@ -284,19 +299,19 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "group",
- props: {
+ properties: {
before: i18n.str`c) Country risk (payments)`,
fields: [
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`Country of origin and destination of frequent payments (if known)`,
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Risk level`,
name: "evaluation.payments.risk",
choices: [
@@ -323,12 +338,12 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "group",
- props: {
+ properties: {
before: i18n.str`d) Industry risk`,
fields: [
{
type: "choiceStacked",
- props: {
+ properties: {
label:
i18n.str`Nature of customer's business activity`,
name: "evaluation.industry.nature",
@@ -347,7 +362,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Risk level`,
name: "evaluation.payments.risk",
choices: [
@@ -384,19 +399,19 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "group",
- props: {
+ properties: {
before: i18n.str`e) Contact risk`,
fields: [
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`Types of contact to the customer/ beneficial owner of the assets`,
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Risk level`,
name: "evaluation.contact.risk",
choices: [
@@ -423,19 +438,19 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "group",
- props: {
+ properties: {
before: i18n.str`f) Product risk`,
fields: [
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`Nature of services and products requested by the customer`,
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Risk level`,
name: "evaluation.product.risk",
choices: [
@@ -482,19 +497,19 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "group",
- props: {
+ properties: {
before: i18n.str`g) Criteria defined by the member`,
fields: [
{
type: "text",
- props: {
+ properties: {
label: i18n.str`Criteria definition`,
name: "evaluation.custom.definition",
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Risk level`,
name: "evaluation.custom.risk",
choices: [
@@ -518,20 +533,20 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`Overall assessment of the business relationship`,
},
},
{
type: "group",
- props: {
+ properties: {
before:
i18n.str`A business relationship is classified as increased risk if:`,
fields: [
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`Business relationship with PEP pursuant to numeral 1 (no exception possible)`,
before: create(ChevronRightIcon, { class: "h-6 w-6" }),
@@ -539,7 +554,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
label:
'Relationship with a person from a "high risk" or non-cooperative country according to numeral 2 (no exceptions possible)' as TranslatedString,
before: create(ChevronRightIcon, { class: "h-6 w-6" }),
@@ -547,7 +562,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`Min. one criterion pursuant to numeral 3 was assessed with risk 2 or min. two criteria pursuant to numeral 3 were assessed with risk 1 (exception: justification by the member below why the business relationship overall does not have to be classified as increased risk despite the fact that individual risk criteria are increased)`,
before: create(ChevronRightIcon, { class: "h-6 w-6" }),
@@ -558,7 +573,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`Justification for differing risk assessment`,
name: "evaluation.overall.justification",
@@ -566,7 +581,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Risk classified`,
name: "evaluation.overall.risk",
choices: [
@@ -585,7 +600,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "absoluteTime",
- props: {
+ properties: {
label:
i18n.str`The decision of the Senior executive body on the acceptance of a business relationship with a PEP was obtained on`,
name: "evaluation.when",
@@ -601,19 +616,19 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "group",
- props: {
+ properties: {
before: i18n.str`Criteria`,
fields: [
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`Classification as as increased risk is compulsory if`,
},
},
{
type: "caption",
- props: {
+ properties: {
before: create(ChevronRightIcon, { class: "w-6 h-6" }),
label:
i18n.str`Transactions for which assets with an equivalent value of CHF 100'000.- or more are physically introduced at the beginning of the business relationship, either at once or in a staggered manner`,
@@ -621,7 +636,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
before: create(ChevronRightIcon, { class: "w-6 h-6" }),
label:
'Money and asset transfers ("money transfer") whereby a single transaction or multiple transactions which appear to be related reach or exceed the amount of CHF 5,000.-' as TranslatedString,
@@ -629,7 +644,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
before: create(ChevronRightIcon, { class: "w-6 h-6" }),
label:
'Payments from or to a country that is considered to be "high risk" or non-cooperative by the FATF and for which increased diligence is required' as TranslatedString,
@@ -640,13 +655,13 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "group",
- props: {
+ properties: {
before:
i18n.str`Additional criteria defined by the member`,
fields: [
{
type: "caption",
- props: {
+ properties: {
before: create(ArrowRightIcon, { class: "w-6 h-6" }),
label:
i18n.str`All members have to define min. 1 additional criterion for every business relationship to identify unusual transactions`,
@@ -654,20 +669,20 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label: i18n.str`Description`,
name: "criteria.additional",
},
},
{
type: "group",
- props: {
+ properties: {
before:
i18n.str`Possible criteria (Art. 59 para. 2 SRO Regulations)`,
fields: [
{
type: "caption",
- props: {
+ properties: {
before: create(ChevronRightIcon, { class: "w-4 h-4" }),
label:
i18n.str`the amount of inflowing and outflowing assets`,
@@ -675,7 +690,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
before: create(ChevronRightIcon, { class: "w-4 h-4" }),
label:
i18n.str`type, volume and frequency of transactions usual to the business relationship (considerable variance would be unusual)`,
@@ -683,7 +698,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
before: create(ChevronRightIcon, { class: "w-4 h-4" }),
label:
i18n.str`type, volume and frequency of transactions usual to comparable business relationships (considerable variance would be unusual)`,
@@ -691,7 +706,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
before: create(ChevronRightIcon, { class: "w-4 h-4" }),
label:
i18n.str`description of expected transaction patterns which the client notify the member of (considerable variance would be unusual)`,
@@ -699,7 +714,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
before: create(ChevronRightIcon, { class: "w-4 h-4" }),
label:
'The country of origin or destination of payments, especially in the case of payments from or to a country considered by the FATF as "high risk" or non-cooperative' as TranslatedString,
@@ -713,10 +728,10 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
],
},
- resolutionSection(current, i18n),
+ resolutionSection(i18n),
],
behavior: function formBehavior(
- v: Partial<Form902_4.Form>,
+ // v: Partial<Form902_4.Form>,
): FormState<Form902_4.Form> {
return {
};
diff --git a/packages/aml-backoffice-ui/src/forms/902_5e.ts b/packages/aml-backoffice-ui/src/forms/902_5e.ts
index 3af03ed22..e66a4f94d 100644
--- a/packages/aml-backoffice-ui/src/forms/902_5e.ts
+++ b/packages/aml-backoffice-ui/src/forms/902_5e.ts
@@ -1,9 +1,23 @@
-import type { TranslatedString } from "@gnu-taler/taler-util";
-import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
-import { BaseForm, uiForms } from "./declaration.js";
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import type { FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "../context/ui-forms.js";
import { resolutionSection } from "./simplest.js";
-export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_5.Form> => ({
+export const v1 = (i18n: InternationalizationAPI) => ({
design: [
{
title: i18n.str`Customer Profile`,
@@ -12,7 +26,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
name: "customer",
label: i18n.str`Customer`,
help: i18n.str`Pursuant Identification Form (VQF doc. No. 902.1) numeral 1`,
@@ -25,7 +39,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "textArea",
- props: {
+ properties: {
label: i18n.str`Profession, business activities`,
name: "businessActivity",
help: i18n.str`former, current, potentially planned`,
@@ -38,7 +52,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "textArea",
- props: {
+ properties: {
label: i18n.str`Income and assets, liabilities`,
name: "financial",
help: i18n.str`estimated`,
@@ -51,7 +65,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "text",
- props: {
+ properties: {
label: i18n.str`Nature`,
name: "originOfAssets.nature",
help: i18n.str`nature of the involved assets`,
@@ -59,22 +73,22 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "selectOne",
- props: {
+ properties: {
name: "originOfAssets.currency",
label: i18n.str`Currency`,
- choices: uiForms.currencies(i18n),
+ choices: ["change me"],
},
},
{
type: "integer",
- props: {
+ properties: {
label: i18n.str`Amount`,
name: "originOfAssets.amount",
},
},
{
type: "choiceStacked",
- props: {
+ properties: {
label: i18n.str`Category`,
name: "originOfAssets.category",
choices: [
@@ -99,7 +113,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
label: i18n.str`Other category`,
name: "originOfAssets.categoryOther",
required: true,
@@ -107,7 +121,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`Detailed description of the origins/economical background of the assets involved in the business relationship`,
name: "originOfAssets.details",
@@ -121,7 +135,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "textArea",
- props: {
+ properties: {
label: i18n.str`Purpose of the business relationship`,
name: "nature.purpose",
help: i18n.str`nature of the involved assets`,
@@ -129,7 +143,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`Information on the planned development of the business relationship and the assets`,
name: "nature.plan",
@@ -137,7 +151,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`Especially in the case of cash or money and asset transfer transactions with regular customers: Details on usual business volume, Information on the beneficiaries, (Full name, address, bank account)`,
name: "nature.cashOrMoneyTransfer",
@@ -150,7 +164,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`Relation of the customer to the beneficial owner involved in the business relationship`,
name: "relations.beneficialOwners",
@@ -158,7 +172,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`Relation of the customer to the controlling persons involved in the business relationship`,
name: "relations.controllingPersons",
@@ -166,7 +180,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`Relation of the customer to the authorized signatories involved in the business relationship`,
name: "relations.authorizedSignatories",
@@ -174,7 +188,7 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label:
i18n.str`Relation of the customer to other persons involved in the business relationship`,
name: "relations.otherPersons",
@@ -182,14 +196,14 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "textArea",
- props: {
+ properties: {
label: i18n.str`Relation to other AMLA-Files`,
name: "relations.withOtherAmlaFiles",
},
},
{
type: "textArea",
- props: {
+ properties: {
label: i18n.str`Introducer / agents / references`,
name: "relations.references",
},
@@ -201,26 +215,26 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "textArea",
- props: {
+ properties: {
label: i18n.str`Other relevant information`,
name: "furtherInformation",
},
},
],
},
- resolutionSection(current, i18n),
+ resolutionSection(i18n),
],
- behavior: function formBehavior(
- v: Partial<Form902_5.Form>,
- ): FormState<Form902_5.Form> {
- return {
- originOfAssets: {
- categoryOther: {
- hidden: v.originOfAssets?.category !== "other",
- },
- },
- };
- },
+ // behavior: function formBehavior(
+ // v: Partial<Form902_5.Form>,
+ // ): FormState<Form902_5.Form> {
+ // return {
+ // originOfAssets: {
+ // categoryOther: {
+ // hidden: v.originOfAssets?.category !== "other",
+ // },
+ // },
+ // };
+ // },
});
namespace Form902_5 {
diff --git a/packages/aml-backoffice-ui/src/forms/902_9e.ts b/packages/aml-backoffice-ui/src/forms/902_9e.ts
index e0e7a6d65..297ec86b1 100644
--- a/packages/aml-backoffice-ui/src/forms/902_9e.ts
+++ b/packages/aml-backoffice-ui/src/forms/902_9e.ts
@@ -1,9 +1,24 @@
-import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
-import { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+/*
+ 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 } from "@gnu-taler/taler-util";
+import { FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "../context/ui-forms.js";
import { resolutionSection } from "./simplest.js";
-import { BaseForm } from "./declaration.js";
-export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_9.Form> => ({
+export const v1 = (i18n: InternationalizationAPI) =>({
design: [
{
title:
@@ -11,42 +26,42 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
fields: [
{
type: "textArea",
- props: {
+ properties: {
name: "contractingPartner",
label: i18n.str`Contracting partner`,
},
},
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`The contracting partner hereby declares that the person(s) listed below is/are the beneficial owner(s) of the assets involved in the business relationship. If the contracting partner is also the sole beneficial owner of the assets, the contracting partner's detail must be set out below`,
},
},
{
type: "array",
- props: {
+ properties: {
label: i18n.str`Persons`,
labelField: "surname",
name: "persons",
fields: [
{
type: "text",
- props: {
+ properties: {
name: "surname",
label: i18n.str`Surname(s)`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "firstName",
label: i18n.str`First name(s)`,
},
},
{
type: "absoluteTime",
- props: {
+ properties: {
name: "dateOfBirth",
label: i18n.str`Date of birth`,
pattern: "dd/MM/yyyy",
@@ -55,14 +70,14 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "text",
- props: {
+ properties: {
name: "nationality",
label: i18n.str`Nationality`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "address",
label: i18n.str`Actual address of domicile`,
},
@@ -72,31 +87,31 @@ export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): Flexib
},
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`The contracting partner hereby undertakes to inform automatically of any changes to the information contained herein`,
},
},
{
type: "text",
- props: {
+ properties: {
name: "signature",
label: i18n.str`Signature`,
},
},
{
type: "caption",
- props: {
+ properties: {
label:
i18n.str`It is a criminal offense to deliberately provide false information on this form (article 251 of the Swiss Criminal Code, document forgery)`,
},
},
],
},
- resolutionSection(current, i18n),
+ resolutionSection(i18n),
],
behavior: function formBehavior(
- v: Partial<Form902_9.Form>,
+ // v: Partial<Form902_9.Form>,
): FormState<Form902_9.Form> {
return {
};
diff --git a/packages/aml-backoffice-ui/src/forms/declaration.ts b/packages/aml-backoffice-ui/src/forms/declaration.ts
deleted file mode 100644
index ec3bc5189..000000000
--- a/packages/aml-backoffice-ui/src/forms/declaration.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import type { AmountJson, TranslatedString } from "@gnu-taler/taler-util";
-import type { FlexibleForm, InternationalizationAPI } from "@gnu-taler/web-util/browser";
-import { AmlExchangeBackend } from "../utils/types.js";
-
-/**
- * import entry point without hard reference.
- *
- * This file just export types and UI Forms
- * based on what `globalThis` contains.
- *
- * `./index.js` must be imported first before
- * so `globaThis` will have the correct value.
- */
-
-export interface BaseForm {
- state: AmlExchangeBackend.AmlState;
- threshold: AmountJson;
-}
-
-export type FormMetadata<T extends BaseForm> = {
- label: TranslatedString,
- id: string,
- version: number,
- impl: (current: T) => FlexibleForm<T>
-}
-
-interface LabelValue {
- label: TranslatedString;
- value: string,
-}
-
-export interface UiForms {
- currencies: (i18n: InternationalizationAPI) => LabelValue[],
- languages: (i18n: InternationalizationAPI) => LabelValue[],
- forms: (i18n: InternationalizationAPI) => Array<FormMetadata<BaseForm>>,
-}
-
-/**
- * Global settings for the UI.
- */
-const defaultUIForms: UiForms = {
- currencies: () => [],
- languages: () => [],
- forms: () => [],
-};
-
-declare global {
- var amlExchangeBackoffice: UiForms;
-}
-
-export const uiForms: UiForms =
- "amlExchangeBackoffice" in globalThis
- ? (globalThis as any).amlExchangeBackoffice
- : defaultUIForms;
diff --git a/packages/aml-backoffice-ui/src/forms/icons.tsx b/packages/aml-backoffice-ui/src/forms/icons.tsx
index 392790c9c..8bd369c4f 100644
--- a/packages/aml-backoffice-ui/src/forms/icons.tsx
+++ b/packages/aml-backoffice-ui/src/forms/icons.tsx
@@ -1,3 +1,18 @@
+/*
+ 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 { h } from "preact";
export const ChevronRightIcon = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
diff --git a/packages/aml-backoffice-ui/src/forms/index.ts b/packages/aml-backoffice-ui/src/forms/index.ts
index f41122bc7..e89a8fb10 100644
--- a/packages/aml-backoffice-ui/src/forms/index.ts
+++ b/packages/aml-backoffice-ui/src/forms/index.ts
@@ -1,14 +1,20 @@
-import type { InternationalizationAPI } from "@gnu-taler/web-util/browser";
-import { v1 as form_902_11e_v1 } from "./902_11e.js";
-import { v1 as form_902_12e_v1 } from "./902_12e.js";
-import { v1 as form_902_13e_v1 } from "./902_13e.js";
-import { v1 as form_902_15e_v1 } from "./902_15e.js";
-import { v1 as form_902_1e_v1 } from "./902_1e.js";
-import { v1 as form_902_4e_v1 } from "./902_4e.js";
-import { v1 as form_902_5e_v1 } from "./902_5e.js";
-import { v1 as form_902_9e_v1 } from "./902_9e.js";
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import type { FormMetadata, InternationalizationAPI } from "@gnu-taler/web-util/browser";
import { v1 as simplest } from "./simplest.js";
-import { BaseForm, FormMetadata } from "./declaration.js";
const languages = (i18n: InternationalizationAPI) => [
{
@@ -122,52 +128,52 @@ const languages = (i18n: InternationalizationAPI) => [
];
-const forms: (i18n: InternationalizationAPI) => Array<FormMetadata<BaseForm>> = (i18n) => [
+export const preloadedForms: (i18n: InternationalizationAPI) => Array<FormMetadata> = (i18n) => [
{
label: i18n.str`Simple comment`,
- id: "simple_comment",
- version: 1,
- impl: simplest(i18n),
- }, {
- label: i18n.str`Identification form`,
- id: "902.1e",
- version: 1,
- impl: form_902_1e_v1(i18n),
- }, {
- label: i18n.str`Operational legal entity or partnership`,
- id: "902.11e",
- version: 1,
- impl: form_902_11e_v1(i18n),
- }, {
- label: i18n.str`Foundations`,
- id: "902.12e",
- version: 1,
- impl: form_902_12e_v1(i18n),
- }, {
- label: i18n.str`Declaration for trusts`,
- id: "902.13e",
- version: 1,
- impl: form_902_13e_v1(i18n),
- }, {
- label: i18n.str`Information on life insurance policies`,
- id: "902.15e",
- version: 1,
- impl: form_902_15e_v1(i18n),
- }, {
- label: i18n.str`Declaration of beneficial owner`,
- id: "902.9e",
- version: 1,
- impl: form_902_9e_v1(i18n),
- }, {
- label: i18n.str`Customer profile`,
- id: "902.5e",
- version: 1,
- impl: form_902_5e_v1(i18n),
- }, {
- label: i18n.str`Risk profile`,
- id: "902.4e",
+ id: "__simple_comment",
version: 1,
- impl: form_902_4e_v1(i18n),
+ config: simplest(i18n),
+ // }, {
+ // label: i18n.str`Identification form`,
+ // id: "902.1e",
+ // version: 1,
+ // config: form_902_1e_v1(i18n),
+ // }, {
+ // label: i18n.str`Operational legal entity or partnership`,
+ // id: "902.11e",
+ // version: 1,
+ // config: form_902_11e_v1(i18n),
+ // }, {
+ // label: i18n.str`Foundations`,
+ // id: "902.12e",
+ // version: 1,
+ // config: form_902_12e_v1(i18n),
+ // }, {
+ // label: i18n.str`Declaration for trusts`,
+ // id: "902.13e",
+ // version: 1,
+ // config: form_902_13e_v1(i18n),
+ // }, {
+ // label: i18n.str`Information on life insurance policies`,
+ // id: "902.15e",
+ // version: 1,
+ // config: form_902_15e_v1(i18n),
+ // }, {
+ // label: i18n.str`Declaration of beneficial owner`,
+ // id: "902.9e",
+ // version: 1,
+ // config: form_902_9e_v1(i18n),
+ // }, {
+ // label: i18n.str`Customer profile`,
+ // id: "902.5e",
+ // version: 1,
+ // config: form_902_5e_v1(i18n),
+ // }, {
+ // label: i18n.str`Risk profile`,
+ // id: "902.4e",
+ // version: 1,
+ // config: form_902_4e_v1(i18n),
},
];
@@ -199,4 +205,3 @@ const currencies = (i18n: InternationalizationAPI) => [
},
];
-globalThis.amlExchangeBackoffice = { currencies, languages, forms }
diff --git a/packages/aml-backoffice-ui/src/forms/simplest.ts b/packages/aml-backoffice-ui/src/forms/simplest.ts
index 735ca9bfc..4cd781b74 100644
--- a/packages/aml-backoffice-ui/src/forms/simplest.ts
+++ b/packages/aml-backoffice-ui/src/forms/simplest.ts
@@ -1,84 +1,90 @@
-import type {
- TranslatedString
-} from "@gnu-taler/taler-util";
+/*
+ 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 type { DoubleColumnFormSection, FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
-import { BaseForm } from "./declaration.js";
-import { amlStateConverter } from "../utils/converter.js";
-import { AmlExchangeBackend } from "../utils/types.js";
+import type {
+ DoubleColumnForm,
+ DoubleColumnFormSection,
+ InternationalizationAPI,
+ UIHandlerId,
+} from "@gnu-taler/web-util/browser";
-export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Simplest.Form> => ({
+export const v1 = (i18n: InternationalizationAPI): DoubleColumnForm => ({
+ type: "double-column" as const,
design: [
{
title: i18n.str`Simple form`,
fields: [
{
type: "textArea",
- props: {
- name: "comment",
- label: i18n.str`Comments`,
- },
+ id: ".comment" as UIHandlerId,
+ name: "comment",
+ label: i18n.str`Comment`,
},
],
},
- resolutionSection(current, i18n),
+ resolutionSection(i18n),
],
- behavior: function formBehavior(
- v: Partial<Simplest.Form>,
- ): FormState<Simplest.Form> {
- return {
- comment: {
- help: ((v.comment?.length ?? 0) > 100 ? "keep it short" : "") as TranslatedString,
-
- },
- threshold: {
- disabled: v.state === AmlExchangeBackend.AmlState.frozen,
- },
- };
- },
+ // behavior: function formBehavior(
+ // v: Partial<Simplest.Form>,
+ // ): FormState<Simplest.Form> {
+ // return {
+ // comment: {
+ // help: ((v.comment?.length ?? 0) > 100 ? "keep it short" : "") as TranslatedString,
+ // },
+ // threshold: {
+ // disabled: v.state === TalerExchangeApi.AmlState.frozen,
+ // },
+ // };
+ // },
});
-export namespace Simplest {
- export interface Form extends BaseForm {
- comment: string;
- }
-}
-
-export function resolutionSection(current: BaseForm, i18n: InternationalizationAPI): DoubleColumnFormSection {
+export function resolutionSection(
+ i18n: InternationalizationAPI,
+): DoubleColumnFormSection {
return {
title: i18n.str`Resolution`,
- description: `Current state is ${amlStateConverter.toStringUI(
- current.state,
- )} and threshold at ` as TranslatedString,
fields: [
{
type: "choiceHorizontal",
- props: {
- name: "state",
- label: i18n.str`New state`,
- converter: amlStateConverter,
- choices: [
- {
- value: AmlExchangeBackend.AmlState.frozen,
- label: i18n.str`Frozen`,
- },
- {
- value: AmlExchangeBackend.AmlState.pending,
- label: i18n.str`Pending`,
- },
- {
- value: AmlExchangeBackend.AmlState.normal,
- label: i18n.str`Normal`,
- },
- ],
- },
+ id: ".state" as UIHandlerId,
+ name: "state",
+ label: i18n.str`New state`,
+ converterId: "TalerExchangeApi.AmlState",
+ choices: [
+ {
+ value: "frozen",
+ label: i18n.str`Frozen`,
+ },
+ {
+ value: "pending",
+ label: i18n.str`Pending`,
+ },
+ {
+ value: "normal",
+ label: i18n.str`Normal`,
+ },
+ ],
},
{
type: "amount",
- props: {
- name: "threshold",
- label: i18n.str`New threshold`,
- },
+ id: ".threshold" as UIHandlerId,
+ 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
new file mode 100644
index 000000000..70b2db571
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/hooks/form.ts
@@ -0,0 +1,227 @@
+/*
+ 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,
+ TalerExchangeApi,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import {
+ UIFieldHandler,
+ UIFormElementConfig,
+ UIHandlerId,
+} from "@gnu-taler/web-util/browser";
+import { useState } from "preact/hooks";
+import { undefinedIfEmpty } from "../pages/CreateAccount.js";
+
+// export type UIField = {
+// value: string | undefined;
+// onUpdate: (s: string) => void;
+// error: TranslatedString | undefined;
+// };
+
+export type FormHandler<T> = {
+ [k in keyof T]?: T[k] extends string
+ ? UIFieldHandler
+ : T[k] extends AmountJson
+ ? UIFieldHandler
+ : T[k] extends TalerExchangeApi.AmlState
+ ? UIFieldHandler
+ : FormHandler<T[k]>;
+};
+
+export type FormValues<T> = {
+ [k in keyof T]: T[k] extends string ? string | undefined : FormValues<T[k]>;
+};
+
+export type RecursivePartial<T> = {
+ [k in keyof T]?: T[k] extends string
+ ? string
+ : T[k] extends AmountJson
+ ? AmountJson
+ : T[k] extends TalerExchangeApi.AmlState
+ ? TalerExchangeApi.AmlState
+ : RecursivePartial<T[k]>;
+};
+
+export type FormErrors<T> = {
+ [k in keyof T]?: T[k] extends string
+ ? TranslatedString
+ : T[k] extends AmountJson
+ ? TranslatedString
+ : T[k] extends AbsoluteTime
+ ? TranslatedString
+ : T[k] extends TalerExchangeApi.AmlState
+ ? TranslatedString
+ : FormErrors<T[k]>;
+};
+
+export type FormStatus<T> =
+ | {
+ status: "ok";
+ result: T;
+ errors: undefined;
+ }
+ | {
+ status: "fail";
+ result: RecursivePartial<T>;
+ errors: FormErrors<T>;
+ };
+
+function constructFormHandler<T>(
+ shape: Array<UIHandlerId>,
+ form: RecursivePartial<FormValues<T>>,
+ updateForm: (d: RecursivePartial<FormValues<T>>) => void,
+ errors: FormErrors<T> | undefined,
+): FormHandler<T> {
+ const handler = shape.reduce((handleForm, fieldId) => {
+ const path = fieldId.split(".");
+
+ function updater(newValue: unknown) {
+ updateForm(setValueDeeper(form, path, newValue));
+ }
+
+ const currentValue = getValueDeeper<string>(form as any, path, undefined);
+ const currentError = getValueDeeper<TranslatedString>(
+ errors as any,
+ path,
+ undefined,
+ );
+ const field: UIFieldHandler = {
+ error: currentError,
+ value: currentValue,
+ onChange: updater,
+ state: {}, //FIXME: add the state of the field (hidden, )
+ };
+
+ return setValueDeeper(handleForm, path, field);
+ }, {} as FormHandler<T>);
+
+ return handler;
+}
+
+/**
+ * FIXME: Consider sending this to web-utils
+ *
+ *
+ * @param defaultValue
+ * @param check
+ * @returns
+ */
+export function useFormState<T>(
+ shape: Array<UIHandlerId>,
+ defaultValue: RecursivePartial<FormValues<T>>,
+ check: (f: RecursivePartial<FormValues<T>>) => FormStatus<T>,
+): [FormHandler<T>, FormStatus<T>] {
+ const [form, updateForm] =
+ useState<RecursivePartial<FormValues<T>>>(defaultValue);
+
+ const status = check(form);
+ 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: UIFormElementConfig[],
+): Array<UIHandlerId> {
+ const shape: Array<UIHandlerId> = [];
+ fields.forEach((field) => {
+ if ("id" in field) {
+ // FIXME: this should be a validation when loading the form
+ // consistency check
+ if (shape.indexOf(field.id) !== -1) {
+ throw Error(`already present: ${field.id}`);
+ }
+ shape.push(field.id);
+ } else if (field.type === "group") {
+ Array.prototype.push.apply(
+ shape,
+ getShapeFromFields(field.fields),
+ );
+ }
+ });
+ return shape;
+}
+
+export function getRequiredFields(
+ fields: UIFormElementConfig[],
+): Array<UIHandlerId> {
+ const shape: Array<UIHandlerId> = [];
+ fields.forEach((field) => {
+ if ("id" in field) {
+ // FIXME: this should be a validation when loading the form
+ // consistency check
+ if (shape.indexOf(field.id) !== -1) {
+ throw Error(`already present: ${field.id}`);
+ }
+ if (!field.required) {
+ return;
+ }
+ shape.push(field.id);
+ } else if (field.type === "group") {
+ Array.prototype.push.apply(
+ shape,
+ getRequiredFields(field.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/hooks/useOfficer.ts b/packages/aml-backoffice-ui/src/hooks/officer.ts
index 1bf2b308b..1bb73b8fc 100644
--- a/packages/aml-backoffice-ui/src/hooks/useOfficer.ts
+++ b/packages/aml-backoffice-ui/src/hooks/officer.ts
@@ -1,9 +1,25 @@
+/*
+ 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,
Codec,
LockedAccount,
OfficerAccount,
OfficerId,
+ OperationOk,
SigningKey,
buildCodecForObject,
codecForAbsoluteTime,
@@ -11,14 +27,12 @@ import {
createNewOfficerAccount,
decodeCrock,
encodeCrock,
- unlockOfficerAccount
+ opFixedSuccess,
+ unlockOfficerAccount,
} from "@gnu-taler/taler-util";
-import {
- buildStorageKey,
- useLocalStorage
-} from "@gnu-taler/web-util/browser";
+import { buildStorageKey, useExchangeApiContext, useLocalStorage } from "@gnu-taler/web-util/browser";
import { useMemo } from "preact/hooks";
-import { useMaybeExchangeApiContext } from "../context/config.js";
+import { usePreferences } from "./preferences.js";
export interface Officer {
account: LockedAccount;
@@ -28,9 +42,9 @@ export interface Officer {
const codecForLockedAccount = codecForString() as Codec<LockedAccount>;
type OfficerAccountString = {
- id: string,
+ id: string;
strKey: string;
-}
+};
export const codecForOfficerAccount = (): Codec<OfficerAccountString> =>
buildCodecForObject<OfficerAccountString>()
@@ -48,61 +62,68 @@ export type OfficerState = OfficerNotReady | OfficerReady;
export type OfficerNotReady = OfficerNotFound | OfficerLocked;
interface OfficerNotFound {
state: "not-found";
- create: (password: string) => Promise<void>;
+ create: (password: string) => Promise<OperationOk<OfficerId>>;
}
interface OfficerLocked {
state: "locked";
- forget: () => void;
- tryUnlock: (password: string) => Promise<void>;
+ forget: () => OperationOk<void>;
+ tryUnlock: (password: string) => Promise<OperationOk<void>>;
}
interface OfficerReady {
state: "ready";
account: OfficerAccount;
- forget: () => void;
- lock: () => void;
+ forget: () => OperationOk<void>;
+ lock: () => OperationOk<void>;
}
const OFFICER_KEY = buildStorageKey("officer", codecForOfficer());
-const DEV_ACCOUNT_KEY = buildStorageKey("account-dev", codecForOfficerAccount());
+const DEV_ACCOUNT_KEY = buildStorageKey(
+ "account-dev",
+ codecForOfficerAccount(),
+);
export function useOfficer(): OfficerState {
- const exchangeContext = useMaybeExchangeApiContext();
- // dev account, is save when reloaded.
+ const {lib:{exchange: api}} = useExchangeApiContext();
+ const [pref] = usePreferences();
+ pref.keepSessionAfterReload;
+ // dev account, is kept on reloaded.
const accountStorage = useLocalStorage(DEV_ACCOUNT_KEY);
const account = useMemo(() => {
- if (!accountStorage.value) return undefined
+ if (!accountStorage.value) return undefined;
return {
id: accountStorage.value.id as OfficerId,
- signingKey: decodeCrock(accountStorage.value.strKey) as SigningKey
- }
- }, [accountStorage.value?.id, accountStorage.value?.strKey])
+ signingKey: decodeCrock(accountStorage.value.strKey) as SigningKey,
+ };
+ }, [accountStorage.value?.id, accountStorage.value?.strKey]);
const officerStorage = useLocalStorage(OFFICER_KEY);
const officer = useMemo(() => {
- if (!officerStorage.value) return undefined
- return officerStorage.value
- }, [officerStorage.value?.account, officerStorage.value?.when.t_ms])
+ if (!officerStorage.value) return undefined;
+ return officerStorage.value;
+ }, [officerStorage.value?.account, officerStorage.value?.when.t_ms]);
if (officer === undefined) {
return {
state: "not-found",
create: async (pwd: string) => {
- if (!exchangeContext) return;
- const req = await fetch(new URL("seed", exchangeContext.api.baseUrl).href)
- const b = await req.blob()
- const ar = await b.arrayBuffer()
- const uintar = new Uint8Array(ar)
+ const resp = await api.getSeed()
+ const extraEntropy = resp.type === "ok" ? resp.body : new Uint8Array();
- const { id, safe, signingKey } = await createNewOfficerAccount(pwd, uintar);
+ const { id, safe, signingKey } = await createNewOfficerAccount(
+ pwd,
+ extraEntropy,
+ );
officerStorage.update({
account: safe,
when: AbsoluteTime.now(),
});
// accountStorage.update({ id, signingKey });
- const strKey = encodeCrock(signingKey)
- accountStorage.update({ id, strKey })
+ const strKey = encodeCrock(signingKey);
+ accountStorage.update({ id, strKey });
+
+ return opFixedSuccess(id)
},
};
}
@@ -112,11 +133,16 @@ export function useOfficer(): OfficerState {
state: "locked",
forget: () => {
officerStorage.reset();
+ return opFixedSuccess(undefined)
},
tryUnlock: async (pwd: string) => {
const ac = await unlockOfficerAccount(officer.account, pwd);
// accountStorage.update(ac);
- accountStorage.update({ id: ac.id, strKey: encodeCrock(ac.signingKey) })
+ accountStorage.update({
+ id: ac.id,
+ strKey: encodeCrock(ac.signingKey),
+ });
+ return opFixedSuccess(undefined)
},
};
}
@@ -126,10 +152,12 @@ export function useOfficer(): OfficerState {
account,
lock: () => {
accountStorage.reset();
+ return opFixedSuccess(undefined)
},
forget: () => {
officerStorage.reset();
accountStorage.reset();
+ return opFixedSuccess(undefined)
},
};
}
diff --git a/packages/aml-backoffice-ui/src/hooks/useSettings.ts b/packages/aml-backoffice-ui/src/hooks/preferences.ts
index f1610576e..12e85d249 100644
--- a/packages/aml-backoffice-ui/src/hooks/useSettings.ts
+++ b/packages/aml-backoffice-ui/src/hooks/preferences.ts
@@ -1,6 +1,6 @@
/*
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,57 +18,68 @@ import {
Codec,
TranslatedString,
buildCodecForObject,
- codecForBoolean,
- codecForNumber,
- codecForString,
- codecOptional
+ codecForBoolean
} from "@gnu-taler/taler-util";
-import { buildStorageKey, useLocalStorage, useTranslationContext } from "@gnu-taler/web-util/browser";
+import {
+ buildStorageKey,
+ useLocalStorage,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
-interface Settings {
+interface Preferences {
allowInsecurePassword: boolean;
keepSessionAfterReload: boolean;
}
-export function getAllBooleanSettings(): Array<keyof Settings> {
- return ["allowInsecurePassword", "keepSessionAfterReload"]
-}
-
-export function getLabelForSetting(k: keyof Settings, i18n: ReturnType<typeof useTranslationContext>["i18n"]): TranslatedString {
- switch (k) {
- case "allowInsecurePassword": return i18n.str`Allow Insecure password`
- case "keepSessionAfterReload": return i18n.str`Keep session after reload`
- }
-}
-
-export const codecForSettings = (): Codec<Settings> =>
- buildCodecForObject<Settings>()
+export const codecForPreferences = (): Codec<Preferences> =>
+ buildCodecForObject<Preferences>()
.property("allowInsecurePassword", (codecForBoolean()))
.property("keepSessionAfterReload", (codecForBoolean()))
- .build("Settings");
+ .build("Preferences");
-const defaultSettings: Settings = {
+const defaultPreferences: Preferences = {
allowInsecurePassword: false,
keepSessionAfterReload: false,
};
-const EXCHANGE_SETTINGS_KEY = buildStorageKey(
- "exchange-settings",
- codecForSettings(),
+const PREFERENCES_KEY = buildStorageKey(
+ "exchange-preferences",
+ codecForPreferences(),
);
-
-export function useSettings(): [
- Readonly<Settings>,
- <T extends keyof Settings>(key: T, value: Settings[T]) => void,
+/**
+ * User preferences.
+ *
+ * @returns tuple of [state, update()]
+ */
+export function usePreferences(): [
+ Readonly<Preferences>,
+ <T extends keyof Preferences>(key: T, value: Preferences[T]) => void,
] {
const { value, update } = useLocalStorage(
- EXCHANGE_SETTINGS_KEY,
- defaultSettings,
+ PREFERENCES_KEY,
+ defaultPreferences,
);
- function updateField<T extends keyof Settings>(k: T, v: Settings[T]) {
+ function updateField<T extends keyof Preferences>(k: T, v: Preferences[T]) {
const newValue = { ...value, [k]: v };
update(newValue);
}
return [value, updateField];
}
+
+export function getAllBooleanPreferences(): Array<keyof Preferences> {
+ return [
+ "allowInsecurePassword",
+ "keepSessionAfterReload",
+ ];
+}
+
+export function getLabelForPreferences(
+ k: keyof Preferences,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): TranslatedString {
+ switch (k) {
+ case "allowInsecurePassword": return i18n.str`Allow Insecure password`
+ case "keepSessionAfterReload": return i18n.str`Keep session after reload`
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/hooks/useBackend.ts b/packages/aml-backoffice-ui/src/hooks/useBackend.ts
deleted file mode 100644
index 7b55568c8..000000000
--- a/packages/aml-backoffice-ui/src/hooks/useBackend.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
- import { canonicalizeBaseUrl } from "@gnu-taler/taler-util";
-import { uiSettings } from "../settings.js";
-
-
-export function getInitialBackendBaseURL(): string {
- const overrideUrl =
- typeof localStorage !== "undefined"
- ? localStorage.getItem("exchange-base-url")
- : undefined;
-
- let result: string;
-
- if (!overrideUrl) {
- //normal path
- if (!uiSettings.backendBaseURL) {
- console.error(
- "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'",
- );
- result = typeof (window as any) !== "undefined" ? window.origin : "localhost"
- } else {
- result = uiSettings.backendBaseURL;
- }
- } else {
- // testing/development path
- result = overrideUrl
- }
- try {
- return canonicalizeBaseUrl(result)
- } catch (e) {
- //fall back
- return canonicalizeBaseUrl(window.origin)
- }
-}
diff --git a/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts b/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts
index dbc6763ba..78574ada4 100644
--- a/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts
+++ b/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts
@@ -1,16 +1,30 @@
-
+/*
+ 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 { OfficerAccount, PaytoString, TalerExchangeResultByMethod, TalerHttpError } from "@gnu-taler/taler-util";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
-import { AmountString, OfficerAccount, PaytoString, TalerExchangeApi, TalerExchangeResultByMethod, TalerHttpError } from "@gnu-taler/taler-util";
import _useSWR, { SWRHook } from "swr";
-import { useExchangeApiContext } from "../context/config.js";
-import { useOfficer } from "./useOfficer.js";
+import { useOfficer } from "./officer.js";
+import { useExchangeApiContext } from "@gnu-taler/web-util/browser";
const useSWR = _useSWR as unknown as SWRHook;
export function useCaseDetails(paytoHash: string) {
const officer = useOfficer();
const session = officer.state === "ready" ? officer.account : undefined;
- const { api } = useExchangeApiContext();
+ const { lib: {exchange: api} } = useExchangeApiContext();
async function fetcher([officer, account]: [OfficerAccount, PaytoString]) {
return await api.getDecisionDetails(officer, account)
@@ -34,62 +48,4 @@ export function useCaseDetails(paytoHash: string) {
return undefined;
}
-const example1: TalerExchangeApi.AmlDecisionDetails = {
- aml_history: [
- {
- justification: "Lack of documentation",
- decider_pub: "ASDASDASD",
- decision_time: {
- t_s: Date.now() / 1000,
- },
- new_state: 2,
- new_threshold: "USD:0" as AmountString,
- },
- {
- justification: "Doing a transfer of high amount",
- decider_pub: "ASDASDASD",
- decision_time: {
- t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 6,
- },
- new_state: 1,
- new_threshold: "USD:2000" as AmountString,
- },
- {
- justification: "Account is known to the system",
- decider_pub: "ASDASDASD",
- decision_time: {
- t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 9,
- },
- new_state: 0,
- new_threshold: "USD:100" as AmountString,
- },
- ],
- kyc_attributes: [
- {
- collection_time: {
- t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 8,
- },
- expiration_time: {
- t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 4,
- },
- provider_section: "asdasd",
- attributes: {
- name: "Sebastian",
- },
- },
- {
- collection_time: {
- t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 5,
- },
- expiration_time: {
- t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 2,
- },
- provider_section: "asdasd",
- attributes: {
- creditCard: "12312312312",
- },
- },
- ],
-};
-
diff --git a/packages/aml-backoffice-ui/src/hooks/useCases.ts b/packages/aml-backoffice-ui/src/hooks/useCases.ts
index 2bc9b5f0f..d3a1c1018 100644
--- a/packages/aml-backoffice-ui/src/hooks/useCases.ts
+++ b/packages/aml-backoffice-ui/src/hooks/useCases.ts
@@ -1,11 +1,31 @@
+/*
+ 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 { useState } from "preact/hooks";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
-import { OfficerAccount, OfficerId, OperationOk, TalerExchangeResultByMethod, TalerHttpError, decodeCrock, encodeCrock } from "@gnu-taler/taler-util";
+import {
+ OfficerAccount,
+ OperationOk,
+ TalerExchangeApi,
+ TalerExchangeResultByMethod,
+ TalerHttpError,
+} from "@gnu-taler/taler-util";
import _useSWR, { SWRHook } from "swr";
-import { useExchangeApiContext } from "../context/config.js";
-import { AmlExchangeBackend } from "../utils/types.js";
-import { useOfficer } from "./useOfficer.js";
+import { useOfficer } from "./officer.js";
+import { useExchangeApiContext } from "@gnu-taler/web-util/browser";
const useSWR = _useSWR as unknown as SWRHook;
export const PAGINATED_LIST_SIZE = 10;
@@ -19,20 +39,31 @@ export const PAGINATED_LIST_REQUEST = PAGINATED_LIST_SIZE + 1;
* @param args
* @returns
*/
-export function useCases(state: AmlExchangeBackend.AmlState) {
+export function useCases(state: TalerExchangeApi.AmlState) {
const officer = useOfficer();
const session = officer.state === "ready" ? officer.account : undefined;
- const { api } = useExchangeApiContext();
+ const {
+ lib: { exchange: api },
+ } = useExchangeApiContext();
const [offset, setOffset] = useState<string>();
- async function fetcher([officer, state, offset]: [OfficerAccount, AmlExchangeBackend.AmlState, string | undefined]) {
+ async function fetcher([officer, state, offset]: [
+ OfficerAccount,
+ TalerExchangeApi.AmlState,
+ string | undefined,
+ ]) {
return await api.getDecisionsByState(officer, state, {
- order: "asc", offset, limit: PAGINATED_LIST_REQUEST
- })
+ order: "asc",
+ offset,
+ limit: PAGINATED_LIST_REQUEST,
+ });
}
- const { data, error } = useSWR<TalerExchangeResultByMethod<"getDecisionsByState">, TalerHttpError>(
+ const { data, error } = useSWR<
+ TalerExchangeResultByMethod<"getDecisionsByState">,
+ TalerHttpError
+ >(
!session ? undefined : [session, state, offset, "getDecisionsByState"],
fetcher,
);
@@ -41,7 +72,9 @@ export function useCases(state: AmlExchangeBackend.AmlState) {
if (data === undefined) return undefined;
if (data.type !== "ok") return data;
- return buildPaginatedResult(data.body.records, offset, setOffset, (d) => String(d.rowid));
+ return buildPaginatedResult(data.body.records, offset, setOffset, (d) =>
+ String(d.rowid),
+ );
}
type PaginatedResult<T> = OperationOk<T> & {
@@ -49,11 +82,15 @@ type PaginatedResult<T> = OperationOk<T> & {
isFirstPage: boolean;
loadNext(): void;
loadFirst(): void;
-}
+};
//TODO: consider sending this to web-util
-export function buildPaginatedResult<R, OffId>(data: R[], offset: OffId | undefined, setOffset: (o: OffId | undefined) => void, getId: (r: R) => OffId): PaginatedResult<R[]> {
-
+export function buildPaginatedResult<R, OffId>(
+ data: R[],
+ offset: OffId | undefined,
+ setOffset: (o: OffId | undefined) => void,
+ getId: (r: R) => OffId,
+): PaginatedResult<R[]> {
const isLastPage = data.length < PAGINATED_LIST_REQUEST;
const isFirstPage = offset === undefined;
@@ -68,7 +105,7 @@ export function buildPaginatedResult<R, OffId>(data: R[], offset: OffId | undefi
isFirstPage,
loadNext: () => {
if (!result.length) return;
- const id = getId(result[result.length - 1])
+ const id = getId(result[result.length - 1]);
setOffset(id);
},
loadFirst: () => {
@@ -76,11 +113,3 @@ export function buildPaginatedResult<R, OffId>(data: R[], offset: OffId | undefi
},
};
}
-
-
-function removeLastElement<T>(list: Array<T>): Array<T> {
- if (list.length === 0) {
- return list;
- }
- return list.slice(0, -1)
-} \ No newline at end of file
diff --git a/packages/aml-backoffice-ui/src/i18n/bank.pot b/packages/aml-backoffice-ui/src/i18n/bank.pot
index 66e98976f..39f9de5ce 100644
--- a/packages/aml-backoffice-ui/src/i18n/bank.pot
+++ b/packages/aml-backoffice-ui/src/i18n/bank.pot
@@ -1,5 +1,5 @@
# 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
diff --git a/packages/aml-backoffice-ui/src/i18n/fr.po b/packages/aml-backoffice-ui/src/i18n/fr.po
index 203d55343..8148f6a0c 100644
--- a/packages/aml-backoffice-ui/src/i18n/fr.po
+++ b/packages/aml-backoffice-ui/src/i18n/fr.po
@@ -1,5 +1,5 @@
# 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
diff --git a/packages/aml-backoffice-ui/src/i18n/poheader b/packages/aml-backoffice-ui/src/i18n/poheader
index a251e9584..d7a371934 100644
--- a/packages/aml-backoffice-ui/src/i18n/poheader
+++ b/packages/aml-backoffice-ui/src/i18n/poheader
@@ -1,5 +1,5 @@
# 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
diff --git a/packages/aml-backoffice-ui/src/i18n/strings-prelude b/packages/aml-backoffice-ui/src/i18n/strings-prelude
index a0aeb8268..3ab0fd1e5 100644
--- a/packages/aml-backoffice-ui/src/i18n/strings-prelude
+++ b/packages/aml-backoffice-ui/src/i18n/strings-prelude
@@ -1,6 +1,6 @@
/*
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
diff --git a/packages/aml-backoffice-ui/src/i18n/strings.ts b/packages/aml-backoffice-ui/src/i18n/strings.ts
index a779bbc49..4f7419eb4 100644
--- a/packages/aml-backoffice-ui/src/i18n/strings.ts
+++ b/packages/aml-backoffice-ui/src/i18n/strings.ts
@@ -1,6 +1,6 @@
/*
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
diff --git a/packages/aml-backoffice-ui/src/index.html b/packages/aml-backoffice-ui/src/index.html
index c1de73520..0ed2f8178 100644
--- a/packages/aml-backoffice-ui/src/index.html
+++ b/packages/aml-backoffice-ui/src/index.html
@@ -1,6 +1,6 @@
<!--
This file is part of GNU Taler
- (C) 2021--2022 Taler Systems S.A.
+ (C) 2021--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
@@ -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/index.tsx b/packages/aml-backoffice-ui/src/index.tsx
index c2ac4c84b..c6f6b4a8f 100644
--- a/packages/aml-backoffice-ui/src/index.tsx
+++ b/packages/aml-backoffice-ui/src/index.tsx
@@ -1,6 +1,6 @@
/*
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,5 +18,8 @@ import { App } from "./App.js";
import { h, render } from "preact";
const app = document.getElementById("app");
-
-render(<App />, app as any);
+if (!app) {
+ console.error("could not found app element")
+} else {
+ render(<App />, app);
+}
diff --git a/packages/aml-backoffice-ui/src/pages.ts b/packages/aml-backoffice-ui/src/pages.ts
deleted file mode 100644
index 109cd31d0..000000000
--- a/packages/aml-backoffice-ui/src/pages.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { TranslatedString } from "@gnu-taler/taler-util";
-import { AntiMoneyLaunderingForm } from "./pages/AntiMoneyLaunderingForm.js";
-import { CaseDetails } from "./pages/CaseDetails.js";
-import { Cases, HomeIcon, PeopleIcon } from "./pages/Cases.js";
-import { NewFormEntry } from "./pages/NewFormEntry.js";
-import { Officer } from "./pages/Officer.js";
-import { PageEntry, pageDefinition } from "./route.js";
-// import homeLogo from "./assets/home.svg";
-// import peopleLogo from "./assets/people.svg";
-const cases: PageEntry = {
- url: "#/cases",
- view: Cases,
- name: "Cases" as TranslatedString,
- Icon: HomeIcon,
-};
-
-const officer: PageEntry = {
- url: "#/officer",
- view: Officer,
- name: "Officer" as TranslatedString,
- Icon: PeopleIcon,
-};
-
-const account: PageEntry<{ account: string }> = {
- url: pageDefinition("#/account/:account"),
- view: CaseDetails,
- name: "Account" as TranslatedString,
- // icon: () => undefined,
-};
-
-const newFormEntry: PageEntry<{ account?: string; type?: string }> = {
- url: pageDefinition("#/account/:account/new/:type?"),
- view: NewFormEntry,
- name: "New Form" as TranslatedString,
- // icon: () => undefined,
-};
-
-
-export const Pages = {
- cases,
- officer,
- account,
- newFormEntry,
-};
diff --git a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx
deleted file mode 100644
index 0b055f682..000000000
--- a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- *
- * @author Sebastian Javier Marchano (sebasjm)
- */
-
-import * as tests from "@gnu-taler/web-util/testing";
-import {
- AntiMoneyLaunderingForm as TestedComponent,
-} from "./AntiMoneyLaunderingForm.js";
-
-export default {
- title: "aml form",
-};
-
-export const SimpleComment = tests.createExample(TestedComponent, {
- account: "the_account",
- formId: "simple_comment",
- onSubmit: async (justification, newState, newThreshold) => {
- alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
- }
-});
-
-export const Identification = tests.createExample(TestedComponent, {
- account: "the_account",
- formId: "902.1e",
- onSubmit: async (justification, newState, newThreshold) => {
- alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
- }
-});
-
-export const OperationalLegalEntity = tests.createExample(TestedComponent, {
- account: "the_account",
- formId: "902.11e",
-
- onSubmit: async (justification, newState, newThreshold) => {
- alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
- }
-});
-export const Foundations = tests.createExample(TestedComponent, {
- account: "the_account",
- formId: "902.12e",
-
- onSubmit: async (justification, newState, newThreshold) => {
- alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
- }
-});
-export const DelcarationOfTrusts = tests.createExample(TestedComponent, {
- account: "the_account",
- formId: "902.13e",
-
- onSubmit: async (justification, newState, newThreshold) => {
- alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
- }
-});
-
-export const InformationOnLifeInsurance = tests.createExample(TestedComponent, {
- account: "the_account",
- formId: "902.15e",
-
- onSubmit: async (justification, newState, newThreshold) => {
- alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
- }
-});
-export const DeclarationOfBeneficialOwner = tests.createExample(TestedComponent, {
- account: "the_account",
- formId: "902.9e",
-
- onSubmit: async (justification, newState, newThreshold) => {
- alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
- }
-});
-export const CustomerProfile = tests.createExample(TestedComponent, {
- account: "the_account",
- formId: "902.5e",
-
- onSubmit: async (justification, newState, newThreshold) => {
- alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
- }
-});
-export const RiskProfile = tests.createExample(TestedComponent, {
- account: "the_account",
- formId: "902.4e",
-
- onSubmit: async (justification, newState, newThreshold) => {
- alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
- }
-});
-
diff --git a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx
deleted file mode 100644
index c42b1e7af..000000000
--- a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-import {
- AbsoluteTime,
- AmountJson,
- Amounts,
- Codec,
- OperationFail,
- OperationOk,
- OperationResult,
- buildCodecForObject,
- codecForNumber,
- codecForString,
- codecOptional,
-} from "@gnu-taler/taler-util";
-import {
- DefaultForm,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { h } from "preact";
-import { useExchangeApiContext } from "../context/config.js";
-import { FormMetadata, uiForms } from "../forms/declaration.js";
-import { Pages } from "../pages.js";
-import { AmlExchangeBackend } from "../utils/types.js";
-
-export function AntiMoneyLaunderingForm({
- account,
- formId,
- onSubmit,
-}: {
- account: string;
- formId: string;
- onSubmit: (
- justification: Justification,
- state: AmlExchangeBackend.AmlState,
- threshold: AmountJson,
- ) => Promise<void>;
-}) {
- const { i18n } = useTranslationContext();
- const theForm = uiForms.forms(i18n).find((v) => v.id === formId);
- if (!theForm) {
- return <div>form with id {formId} not found</div>;
- }
-
- const { config } = useExchangeApiContext();
-
- const initial = {
- when: AbsoluteTime.now(),
- state: AmlExchangeBackend.AmlState.pending,
- threshold: Amounts.zeroOfCurrency(config.currency),
- };
- return (
- <DefaultForm
- initial={initial}
- form={theForm.impl(initial)}
- onUpdate={() => { }}
- onSubmit={(formValue) => {
- if (formValue.state === undefined || formValue.threshold === undefined)
- return;
- const st = formValue.state;
- const amount = formValue.threshold;
-
- const justification: Justification = {
- id: theForm.id,
- label: theForm.label,
- version: theForm.version,
- value: formValue,
- };
-
- onSubmit(justification, st, amount);
- }}
- >
- <div class="mt-6 flex items-center justify-end gap-x-6">
- <a
- href={Pages.account.url({ account })}
- class="text-sm font-semibold leading-6 text-gray-900"
- >
- <i18n.Translate>Cancel</i18n.Translate>
- </a>
- <button
- type="submit"
- class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- >
- <i18n.Translate>Confirm</i18n.Translate>
- </button>
- </div>
- </DefaultForm>
- );
-}
-
-export type Justification<T = any> = {
- // form values
- value: T;
-} & Omit<Omit<FormMetadata<any>, "icon">, "impl">;
-
-export function stringifyJustification(j: Justification): string {
- return JSON.stringify(j);
-}
-
-type SimpleFormMetadata = {
- version?: number;
- id?: string;
-};
-
-export const codecForSimpleFormMetadata = (): Codec<SimpleFormMetadata> =>
- buildCodecForObject<SimpleFormMetadata>()
- .property("id", codecOptional(codecForString()))
- .property("version", codecOptional(codecForNumber()))
- .build("SimpleFormMetadata");
-
-type ParseJustificationFail =
- | "not-json"
- | "id-not-found"
- | "form-not-found"
- | "version-not-found";
-
-export function parseJustification(
- s: string,
- listOfAllKnownForms: FormMetadata<any>[],
-): OperationOk<{ justification: Justification; metadata: FormMetadata<any> }> | OperationFail<ParseJustificationFail> {
- try {
- const justification = JSON.parse(s);
- const info = codecForSimpleFormMetadata().decode(justification);
- if (!info.id) {
- return {
- type: "fail",
- case: "id-not-found",
- detail: {} as any,
- };
- }
- if (!info.version) {
- return {
- type: "fail",
- case: "version-not-found",
- detail: {} as any,
- };
- }
- const found = listOfAllKnownForms.find((f) => {
- return f.id === info.id && f.version === info.version;
- });
- if (!found) {
- return {
- type: "fail",
- case: "form-not-found",
- detail: {} as any,
- };
- }
- return {
- type: "ok",
- body: {
- justification,
- metadata: found,
- },
- };
- } catch (e) {
- return {
- type: "fail",
- case: "not-json",
- detail: {} as any,
- };
- }
-}
diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
index 0875f047b..bb936cebf 100644
--- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
+++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
@@ -1,41 +1,75 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
import {
AbsoluteTime,
AmountJson,
Amounts,
+ Codec,
HttpStatusCode,
+ OperationFail,
+ OperationOk,
TalerError,
+ TalerErrorDetail,
+ TalerExchangeApi,
TranslatedString,
- assertUnreachable
+ assertUnreachable,
+ buildCodecForObject,
+ codecForNumber,
+ codecForString,
+ codecOptional,
} from "@gnu-taler/taler-util";
-import { DefaultForm, ErrorLoading, InternationalizationAPI, Loading, useTranslationContext } from "@gnu-taler/web-util/browser";
+import {
+ DefaultForm,
+ ErrorLoading,
+ FormMetadata,
+ InternationalizationAPI,
+ Loading,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
import { format } from "date-fns";
-import { Fragment, VNode, h } from "preact";
+import { VNode, h } from "preact";
import { useState } from "preact/hooks";
-import { FormMetadata } from "../forms/declaration.js";
+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 { Pages } from "../pages.js";
-import { Justification, parseJustification } from "./AntiMoneyLaunderingForm.js";
import { ShowConsolidated } from "./ShowConsolidated.js";
-import { AmlExchangeBackend } from "../utils/types.js";
-import { uiForms } from "../forms/declaration.js";
-export type AmlEvent = AmlFormEvent | AmlFormEventError | KycCollectionEvent | KycExpirationEvent;
+export type AmlEvent =
+ | AmlFormEvent
+ | AmlFormEventError
+ | KycCollectionEvent
+ | KycExpirationEvent;
+
type AmlFormEvent = {
type: "aml-form";
when: AbsoluteTime;
title: TranslatedString;
justification: Justification;
- metadata: FormMetadata<any>;
- state: AmlExchangeBackend.AmlState;
+ metadata: FormMetadata;
+ state: TalerExchangeApi.AmlState;
threshold: AmountJson;
};
type AmlFormEventError = {
type: "aml-form-error";
when: AbsoluteTime;
title: TranslatedString;
- justification: undefined,
- metadata: undefined,
- state: AmlExchangeBackend.AmlState;
+ justification: undefined;
+ metadata: undefined;
+ state: TalerExchangeApi.AmlState;
threshold: AmountJson;
};
type KycCollectionEvent = {
@@ -58,29 +92,36 @@ function selectSooner(a: WithTime, b: WithTime) {
return AbsoluteTime.cmp(a.when, b.when);
}
-function titleForJustification(op: ReturnType<typeof parseJustification>, i18n: InternationalizationAPI): TranslatedString {
+function titleForJustification(
+ op: ReturnType<typeof parseJustification>,
+ i18n: InternationalizationAPI,
+): TranslatedString {
if (op.type === "ok") {
return op.body.justification.label as TranslatedString;
}
switch (op.case) {
- case "not-json": return "error: the justification is not a form" as TranslatedString
- case "id-not-found": return "error: justification form's id not found" as TranslatedString
- case "version-not-found": return "error: justification form's version not found" as TranslatedString
- case "form-not-found": return `error: justification form not found` as TranslatedString
+ case "not-json":
+ return i18n.str`error: the justification is not a form`;
+ case "id-not-found":
+ return i18n.str`error: justification form's id not found`;
+ case "version-not-found":
+ return i18n.str`error: justification form's version not found`;
+ case "form-not-found":
+ return i18n.str`error: justification form not found`;
default: {
- assertUnreachable(op.case)
+ assertUnreachable(op.case);
}
}
}
export function getEventsFromAmlHistory(
- aml: AmlExchangeBackend.AmlDecisionDetail[],
- kyc: AmlExchangeBackend.KycDetail[],
+ aml: TalerExchangeApi.AmlDecisionDetail[],
+ kyc: TalerExchangeApi.KycDetail[],
i18n: InternationalizationAPI,
+ forms: FormMetadata[],
): AmlEvent[] {
const ae: AmlEvent[] = aml.map((a) => {
-
- const just = parseJustification(a.justification, uiForms.forms(i18n))
+ const just = parseJustification(a.justification, forms);
return {
type: just.type === "ok" ? "aml-form" : "aml-form-error",
state: a.new_state,
@@ -117,104 +158,120 @@ export function getEventsFromAmlHistory(
export function CaseDetails({ account }: { account: string }) {
const [selected, setSelected] = useState<AbsoluteTime>(AbsoluteTime.now());
- const [showForm, setShowForm] = useState<{ justification: Justification, metadata: FormMetadata<any> }>()
+ const [showForm, setShowForm] = useState<{
+ justification: Justification;
+ metadata: FormMetadata;
+ }>();
const { i18n } = useTranslationContext();
- const details = useCaseDetails(account)
+ const details = useCaseDetails(account);
+ const { forms } = useUiFormsContext();
+
+ const allForms = [...forms, ...preloadedForms(i18n)];
if (!details) {
- return <Loading />
+ return <Loading />;
}
if (details instanceof TalerError) {
- return <ErrorLoading error={details} />
+ return <ErrorLoading error={details} />;
}
if (details.type === "fail") {
switch (details.case) {
case HttpStatusCode.Unauthorized:
case HttpStatusCode.Forbidden:
case HttpStatusCode.NotFound:
- case HttpStatusCode.Conflict: return <div />
- default: assertUnreachable(details)
+ case HttpStatusCode.Conflict:
+ return <div />;
+ default:
+ assertUnreachable(details);
}
}
- const { aml_history, kyc_attributes } = details.body
+ const { aml_history, kyc_attributes } = details.body;
- const events = getEventsFromAmlHistory(aml_history, kyc_attributes, i18n);
+ const events = getEventsFromAmlHistory(
+ aml_history,
+ kyc_attributes,
+ i18n,
+ allForms,
+ );
if (showForm !== undefined) {
- return <DefaultForm
- readOnly={true}
- initial={showForm.justification.value}
- form={showForm.metadata.impl(showForm.justification.value)}
- >
- <div class="mt-6 flex items-center justify-end gap-x-6">
- <button
- class="text-sm font-semibold leading-6 text-gray-900"
- onClick={() => {
- setShowForm(undefined)
- }}
- >
- <i18n.Translate>Cancel</i18n.Translate>
- </button>
- </div>
-
- </DefaultForm>
+ return (
+ <DefaultForm
+ readOnly={true}
+ initial={showForm.justification.value}
+ form={showForm.metadata as any} // FIXME: HERE
+ >
+ <div class="mt-6 flex items-center justify-end gap-x-6">
+ <button
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={() => {
+ setShowForm(undefined);
+ }}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ </div>
+ </DefaultForm>
+ );
}
return (
<div>
<a
- href={Pages.newFormEntry.url({ account })}
+ href={privatePages.caseNew.url({ cid: account })}
class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
>
- <i18n.Translate>
- New AML form
- </i18n.Translate>
+ <i18n.Translate>New AML form</i18n.Translate>
</a>
<header class="flex items-center justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8">
<h1 class="text-base font-semibold leading-7 text-black">
<i18n.Translate>
- Case history for account <span title={account}>{account.substring(0, 16)}...</span>
+ Case history for account{" "}
+ <span title={account}>{account.substring(0, 16)}...</span>
</i18n.Translate>
</h1>
</header>
- <ShowTimeline history={events} onSelect={(e) => {
- switch (e.type) {
- case "aml-form": {
- const { justification, metadata } = e
- setShowForm({ justification, metadata })
- break;
- }
- case "kyc-collection":
- case "kyc-expiration": {
- setSelected(e.when);
- break;
+ <ShowTimeline
+ history={events}
+ onSelect={(e) => {
+ switch (e.type) {
+ case "aml-form": {
+ const { justification, metadata } = e;
+ setShowForm({ justification, metadata });
+ break;
+ }
+ case "kyc-collection":
+ case "kyc-expiration": {
+ setSelected(e.when);
+ break;
+ }
+ case "aml-form-error":
}
- case "aml-form-error":
- }
- }} />
+ }}
+ />
{/* {selected && <ShowEventDetails event={selected} />} */}
{selected && <ShowConsolidated history={events} until={selected} />}
</div>
);
}
-function AmlStateBadge({ state }: { state: AmlExchangeBackend.AmlState }): VNode {
+function AmlStateBadge({ state }: { state: TalerExchangeApi.AmlState }): VNode {
switch (state) {
- case AmlExchangeBackend.AmlState.normal: {
+ case TalerExchangeApi.AmlState.normal: {
return (
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
Normal
</span>
);
}
- case AmlExchangeBackend.AmlState.pending: {
+ case TalerExchangeApi.AmlState.pending: {
return (
<span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20">
Pending
</span>
);
}
- case AmlExchangeBackend.AmlState.frozen: {
+ case TalerExchangeApi.AmlState.frozen: {
return (
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20">
Frozen
@@ -222,93 +279,194 @@ function AmlStateBadge({ state }: { state: AmlExchangeBackend.AmlState }): VNode
);
}
}
- assertUnreachable(state)
+ assertUnreachable(state);
}
-function ShowTimeline({ history, onSelect }: { onSelect: (e: AmlEvent) => void, history: AmlEvent[] }): VNode {
- return <div class="flow-root">
- <ul role="list">
- {history.map((e, idx) => {
- const isLast = history.length - 1 === idx;
- return (
- <li
- data-ok={e.type !== "aml-form-error"}
- class="hover:bg-gray-200 p-2 rounded data-[ok=true]:cursor-pointer"
- onClick={() => {
- onSelect(e);
- }}
- >
- <div class="relative pb-6">
- {!isLast ? (
- <span
- class="absolute left-4 top-4 -ml-px h-full w-1 bg-gray-200"
- aria-hidden="true"
- ></span>
- ) : undefined}
- <div class="relative flex space-x-3">
- {(() => {
- switch (e.type) {
- case "aml-form-error":
- case "aml-form": {
- return <div>
- <AmlStateBadge state={e.state} />
- <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 ">
- {e.threshold.currency}{" "}
- {Amounts.stringifyValue(e.threshold)}
- </span>
- </div>
- }
- case "kyc-collection": {
- return (
- // <ArrowDownCircleIcon class="h-8 w-8 text-green-700" />
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
- <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- );
- }
- case "kyc-expiration": {
- // return <ClockIcon class="h-8 w-8 text-gray-700" />;
- return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
-
+function ShowTimeline({
+ history,
+ onSelect,
+}: {
+ onSelect: (e: AmlEvent) => void;
+ history: AmlEvent[];
+}): VNode {
+ return (
+ <div class="flow-root">
+ <ul role="list">
+ {history.map((e, idx) => {
+ const isLast = history.length - 1 === idx;
+ return (
+ <li
+ key={idx}
+ data-ok={e.type !== "aml-form-error"}
+ class="hover:bg-gray-200 p-2 rounded data-[ok=true]:cursor-pointer"
+ onClick={() => {
+ onSelect(e);
+ }}
+ >
+ <div class="relative pb-6">
+ {!isLast ? (
+ <span
+ class="absolute left-4 top-4 -ml-px h-full w-1 bg-gray-200"
+ aria-hidden="true"
+ ></span>
+ ) : undefined}
+ <div class="relative flex space-x-3">
+ {(() => {
+ switch (e.type) {
+ case "aml-form-error":
+ case "aml-form": {
+ return (
+ <div>
+ <AmlStateBadge state={e.state} />
+ <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 ">
+ {e.threshold.currency}{" "}
+ {Amounts.stringifyValue(e.threshold)}
+ </span>
+ </div>
+ );
+ }
+ case "kyc-collection": {
+ return (
+ // <ArrowDownCircleIcon class="h-8 w-8 text-green-700" />
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ );
+ }
+ case "kyc-expiration": {
+ // return <ClockIcon class="h-8 w-8 text-gray-700" />;
+ return (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ );
+ }
}
- }
- assertUnreachable(e)
- })()}
- <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
- {e.type === "aml-form" ?
- <span
- // href={Pages.newFormEntry.url({ account })}
- class="block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
- >
- {e.title}
- </span>
- :
- <p class="text-sm text-gray-900">{e.title}</p>
- }
- <div class="whitespace-nowrap text-right text-sm text-gray-500">
- {e.when.t_ms === "never" ? (
- "never"
+ assertUnreachable(e);
+ })()}
+ <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
+ {e.type === "aml-form" ? (
+ <span
+ // href={Pages.newFormEntry.url({ account })}
+ class="block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ >
+ {e.title}
+ </span>
) : (
- <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}>
- {format(e.when.t_ms, "dd MMM yyyy")}
- </time>
+ <p class="text-sm text-gray-900">{e.title}</p>
)}
+ <div class="whitespace-nowrap text-right text-sm text-gray-500">
+ {e.when.t_ms === "never" ? (
+ "never"
+ ) : (
+ <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}>
+ {format(e.when.t_ms, "dd MMM yyyy")}
+ </time>
+ )}
+ </div>
</div>
</div>
</div>
- </div>
- </li>
- );
- })}
- </ul>
- </div>
-
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ );
}
-function ShowEventDetails({ event }: { event: AmlEvent }): VNode {
- return <div>type {event.type}</div>;
-}
+export type Justification<T = Record<string, unknown>> = {
+ // form values
+ value: T;
+} & Omit<Omit<FormMetadata, "icon">, "config">;
+
+type SimpleFormMetadata = {
+ version?: number;
+ id?: string;
+};
+
+export const codecForSimpleFormMetadata = (): Codec<SimpleFormMetadata> =>
+ buildCodecForObject<SimpleFormMetadata>()
+ .property("id", codecOptional(codecForString()))
+ .property("version", codecOptional(codecForNumber()))
+ .build("SimpleFormMetadata");
+type ParseJustificationFail =
+ | "not-json"
+ | "id-not-found"
+ | "form-not-found"
+ | "version-not-found";
+function parseJustification(
+ s: string,
+ listOfAllKnownForms: FormMetadata[],
+):
+ | OperationOk<{
+ justification: Justification;
+ metadata: FormMetadata;
+ }>
+ | OperationFail<ParseJustificationFail> {
+ try {
+ const justification = JSON.parse(s);
+ const info = codecForSimpleFormMetadata().decode(justification);
+ if (!info.id) {
+ return {
+ type: "fail",
+ case: "id-not-found",
+ detail: {} as TalerErrorDetail,
+ };
+ }
+ if (!info.version) {
+ return {
+ type: "fail",
+ case: "version-not-found",
+ detail: {} as TalerErrorDetail,
+ };
+ }
+ const found = listOfAllKnownForms.find((f) => {
+ return f.id === info.id && f.version === info.version;
+ });
+ if (!found) {
+ return {
+ type: "fail",
+ case: "form-not-found",
+ detail: {} as TalerErrorDetail,
+ };
+ }
+ return {
+ type: "ok",
+ body: {
+ justification,
+ metadata: found,
+ },
+ };
+ } catch (e) {
+ return {
+ type: "fail",
+ case: "not-json",
+ detail: {} as TalerErrorDetail,
+ };
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx
new file mode 100644
index 000000000..7801625d0
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx
@@ -0,0 +1,284 @@
+/*
+ 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,
+ HttpStatusCode,
+ TalerExchangeApi,
+ TalerProtocolTimestamp,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Button,
+ FormMetadata,
+ InternationalizationAPI,
+ LocalNotificationBanner,
+ RenderAllFieldsByUiConfig,
+ UIHandlerId,
+ convertUiField,
+ getConverterById,
+ useExchangeApiContext,
+ useLocalNotificationHandler,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { privatePages } from "../Routing.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,
+}: {
+ account: string;
+ type: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const officer = useOfficer();
+ const {
+ lib: { exchange: api },
+ } = useExchangeApiContext();
+
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
+ const { config } = useExchangeApiContext();
+ const { forms } = useUiFormsContext();
+ 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 = searchForm(i18n, forms, formId);
+ if (!theForm) {
+ return <div>form with id {formId} not found</div>;
+ }
+
+ 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: "fail",
+ result: st as any,
+ errors,
+ };
+ });
+
+ const validatedForm = state.status !== "ok" ? undefined : state.result;
+
+ const submitHandler =
+ validatedForm === undefined
+ ? undefined
+ : withErrorHandler(
+ () => {
+ const justification: Justification = {
+ id: theForm.id,
+ label: theForm.label,
+ version: theForm.version,
+ value: validatedForm,
+ };
+
+ const decision: Omit<TalerExchangeApi.AmlDecision, "officer_sig"> =
+ {
+ justification: JSON.stringify(justification),
+ decision_time: TalerProtocolTimestamp.now(),
+ h_payto: account,
+ new_state: justification.value
+ .state as TalerExchangeApi.AmlState,
+ new_threshold: Amounts.stringify(
+ justification.value.threshold as AmountJson,
+ ),
+ kyc_requirements: undefined,
+ };
+
+ return api.addDecisionDetails(officer.account, decision);
+ },
+ () => {
+ window.location.href = privatePages.cases.url({});
+ },
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Forbidden:
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Wrong credentials for "${officer.account}"`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Officer or account not found`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`Officer disabled or more recent decision was already submitted.`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ );
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+ <div class="space-y-10 divide-y -mt-5 divide-gray-900/10">
+ {theForm.config.design.map((section, i) => {
+ if (!section) return <Fragment />;
+ return (
+ <div
+ key={i}
+ class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"
+ >
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ {section.title}
+ </h2>
+ {section.description && (
+ <p class="mt-1 text-sm leading-6 text-gray-600">
+ {section.description}
+ </p>
+ )}
+ </div>
+ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2">
+ <div class="p-3">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <RenderAllFieldsByUiConfig
+ key={i}
+ fields={convertUiField(
+ i18n,
+ section.fields,
+ form,
+ getConverterById,
+ )}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+
+ <div class="mt-6 flex items-center justify-end gap-x-6">
+ <a
+ href={privatePages.caseDetails.url({ cid: account })}
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <Button
+ type="submit"
+ handler={submitHandler}
+ disabled={!submitHandler}
+ class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </Button>
+ </div>
+ </Fragment>
+ );
+}
+
+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>
+ {forms.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>
+ );
+ })}
+ {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.stories.tsx b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx
index 3b9c8dacf..22a6d1867 100644
--- a/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx
+++ b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx
@@ -1,6 +1,6 @@
/*
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,23 +20,22 @@
*/
import * as tests from "@gnu-taler/web-util/testing";
-import {
- CasesUI as TestedComponent,
-} from "./Cases.js";
-import { AmountString } from "@gnu-taler/taler-util";
-import { AmlExchangeBackend } from "../utils/types.js";
+import { CasesUI as TestedComponent } from "./Cases.js";
+import { AmountString, TalerExchangeApi } from "@gnu-taler/taler-util";
export default {
title: "cases",
};
export const OneRow = tests.createExample(TestedComponent, {
- filter: AmlExchangeBackend.AmlState.normal,
+ filter: TalerExchangeApi.AmlState.normal,
onChangeFilter: () => null,
- records: [{
- current_state: AmlExchangeBackend.AmlState.normal,
- h_payto: "QWEQWEQWEQWE",
- rowid: 1,
- threshold: "USD:1" as AmountString
- }]
+ records: [
+ {
+ current_state: TalerExchangeApi.AmlState.normal,
+ h_payto: "QWEQWEQWEQWE",
+ rowid: 1,
+ threshold: "USD:1" as AmountString,
+ },
+ ],
});
diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx
index 061286f51..f66eca33f 100644
--- a/packages/aml-backoffice-ui/src/pages/Cases.tsx
+++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx
@@ -1,6 +1,6 @@
/*
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
@@ -17,23 +17,30 @@ import {
HttpStatusCode,
TalerError,
TalerExchangeApi,
- assertUnreachable
+ assertUnreachable,
} from "@gnu-taler/taler-util";
import {
+ Attention,
ErrorLoading,
+ InputChoiceHorizontal,
Loading,
- createNewForm,
+ UIHandlerId,
+ amlStateConverter,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
-import { VNode, h } from "preact";
-import { useState } from "preact/hooks";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
import { useCases } from "../hooks/useCases.js";
-import { Pages } from "../pages.js";
-import { amlStateConverter } from "../utils/converter.js";
-import { AmlExchangeBackend } from "../utils/types.js";
+import { privatePages } from "../Routing.js";
+import { FormErrors, RecursivePartial, useFormState } from "../hooks/form.js";
+import { undefinedIfEmpty } from "./CreateAccount.js";
import { Officer } from "./Officer.js";
+type FormType = {
+ state: TalerExchangeApi.AmlState;
+};
+
export function CasesUI({
records,
filter,
@@ -43,13 +50,46 @@ export function CasesUI({
}: {
onFirstPage?: () => void;
onNext?: () => void;
- filter: AmlExchangeBackend.AmlState;
- onChangeFilter: (f: AmlExchangeBackend.AmlState) => void;
+ filter: TalerExchangeApi.AmlState;
+ onChangeFilter: (f: TalerExchangeApi.AmlState) => void;
records: TalerExchangeApi.AmlRecord[];
}): VNode {
const { i18n } = useTranslationContext();
- const form = createNewForm<{ state: AmlExchangeBackend.AmlState }>();
+ const [form, status] = useFormState<FormType>(
+ [".state"] as Array<UIHandlerId>,
+ {
+ state: filter,
+ },
+ (state) => {
+ const errors = undefinedIfEmpty<FormErrors<FormType>>({
+ state: state.state === undefined ? i18n.str`required` : undefined,
+ });
+ if (errors === undefined) {
+ const result: FormType = {
+ state: state.state!,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<FormType> = {
+ state: state.state,
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ },
+ );
+ useEffect(() => {
+ if (status.status === "ok" && filter !== status.result.state) {
+ onChangeFilter(status.result.state);
+ }
+ }, [form?.state?.value]);
return (
<div>
@@ -65,33 +105,26 @@ export function CasesUI({
</p>
</div>
<div class="px-2">
- <form.Provider
- initial={{ state: filter }}
- onUpdate={(v) => {
- onChangeFilter(v.state ?? filter);
- }}
- onSubmit={(_v) => { }}
- >
- <form.InputChoiceHorizontal
- name="state"
- label={i18n.str`Filter`}
- converter={amlStateConverter}
- choices={[
- {
- label: i18n.str`Pending`,
- value: AmlExchangeBackend.AmlState.pending,
- },
- {
- label: i18n.str`Frozen`,
- value: AmlExchangeBackend.AmlState.frozen,
- },
- {
- label: i18n.str`Normal`,
- value: AmlExchangeBackend.AmlState.normal,
- },
- ]}
- />
- </form.Provider>
+ <InputChoiceHorizontal<FormType, "state">
+ name="state"
+ label={i18n.str`Filter`}
+ handler={form.state}
+ converter={amlStateConverter}
+ choices={[
+ {
+ label: i18n.str`Pending`,
+ value: "pending",
+ },
+ {
+ label: i18n.str`Frozen`,
+ value: "frozen",
+ },
+ {
+ label: i18n.str`Normal`,
+ value: "normal",
+ },
+ ]}
+ />
</div>
</div>
<div class="mt-8 flow-root">
@@ -130,7 +163,9 @@ export function CasesUI({
<td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 ">
<div class="text-gray-900">
<a
- href={Pages.account.url({ account: r.h_payto })}
+ href={privatePages.caseDetails.url({
+ cid: r.h_payto,
+ })}
class="text-indigo-600 hover:text-indigo-900"
>
{r.h_payto.substring(0, 16)}...
@@ -138,23 +173,23 @@ export function CasesUI({
</div>
</td>
<td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500">
- {((state: AmlExchangeBackend.AmlState): VNode => {
+ {((state: TalerExchangeApi.AmlState): VNode => {
switch (state) {
- case AmlExchangeBackend.AmlState.normal: {
+ case TalerExchangeApi.AmlState.normal: {
return (
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
Normal
</span>
);
}
- case AmlExchangeBackend.AmlState.pending: {
+ case TalerExchangeApi.AmlState.pending: {
return (
<span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20">
Pending
</span>
);
}
- case AmlExchangeBackend.AmlState.frozen: {
+ case TalerExchangeApi.AmlState.frozen: {
return (
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20">
Frozen
@@ -182,12 +217,12 @@ export function CasesUI({
}
export function Cases() {
-
const [stateFilter, setStateFilter] = useState(
- AmlExchangeBackend.AmlState.pending,
+ TalerExchangeApi.AmlState.pending,
);
const list = useCases(stateFilter);
+ const { i18n } = useTranslationContext();
if (!list) {
return <Loading />;
@@ -198,8 +233,31 @@ export function Cases() {
if (list.type === "fail") {
switch (list.case) {
- case HttpStatusCode.Unauthorized:
- case HttpStatusCode.Forbidden:
+ case HttpStatusCode.Forbidden: {
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ This account doesn't have access. Request account activation
+ sending your public key.
+ </i18n.Translate>
+ </Attention>
+ <Officer />
+ </Fragment>
+ );
+ }
+ case HttpStatusCode.Unauthorized: {
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ This account is not allowed to perform list the cases.
+ </i18n.Translate>
+ </Attention>
+ <Officer />
+ </Fragment>
+ );
+ }
case HttpStatusCode.NotFound:
case HttpStatusCode.Conflict:
return <Officer />;
@@ -214,7 +272,9 @@ export function Cases() {
onFirstPage={list.isFirstPage ? undefined : list.loadFirst}
onNext={list.isLastPage ? undefined : list.loadNext}
filter={stateFilter}
- onChangeFilter={setStateFilter}
+ onChangeFilter={(d) => {
+ setStateFilter(d);
+ }}
/>
);
}
diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx
index 603813f8e..87310aa27 100644
--- a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx
+++ b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx
@@ -1,26 +1,130 @@
-import { TranslatedString } from "@gnu-taler/taler-util";
+/*
+ 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 {
- createNewForm,
- notifyError,
+ Button,
+ InputLine,
+ InternationalizationAPI,
+ LocalNotificationBanner,
+ UIHandlerId,
+ useLocalNotificationHandler,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
-import { useSettings } from "../hooks/useSettings.js";
+import {
+ FormErrors,
+ FormStatus,
+ FormValues,
+ RecursivePartial,
+ useFormState,
+} from "../hooks/form.js";
+import { useOfficer } from "../hooks/officer.js";
+import { usePreferences } from "../hooks/preferences.js";
+
+type FormType = {
+ password: string;
+ repeat: string;
+};
+function createFormValidator(
+ i18n: InternationalizationAPI,
+ allowInsecurePassword: boolean,
+) {
+ return function check(
+ state: RecursivePartial<FormValues<FormType>>,
+ ): FormStatus<FormType> {
+ const errors = undefinedIfEmpty<FormErrors<FormType>>({
+ password: !state.password
+ ? i18n.str`required`
+ : allowInsecurePassword
+ ? undefined
+ : state.password.length < 8
+ ? i18n.str`should have at least 8 characters`
+ : !state.password.match(/[a-z]/) && state.password.match(/[A-Z]/)
+ ? i18n.str`should have lowercase and uppercase characters`
+ : !state.password.match(/\d/)
+ ? i18n.str`should have numbers`
+ : !state.password.match(/[^a-zA-Z\d]/)
+ ? i18n.str`should have at least one character which is not a number or letter`
+ : undefined,
+
+ repeat: !state.repeat
+ ? i18n.str`required`
+ : state.password !== state.repeat
+ ? i18n.str`doesn't match`
+ : undefined,
+ });
+
+ if (errors === undefined) {
+ const result: FormType = {
+ password: state.password!,
+ repeat: state.repeat!,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<FormType> = {
+ password: state.password,
+ repeat: state.repeat,
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ };
+}
+
+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,
+ )
+ ? obj
+ : undefined;
+}
-export function CreateAccount({
- onNewAccount,
-}: {
- onNewAccount: (password: string) => void;
-}): VNode {
+export function CreateAccount(): VNode {
const { i18n } = useTranslationContext();
- const Form = createNewForm<{
- password: string;
- repeat: string;
- }>();
- const [settings] = useSettings()
+ const [settings] = usePreferences();
+ const officer = useOfficer();
+
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
+ const [form, status] = useFormState<FormType>(
+ [".password", ".repeat"] as Array<UIHandlerId>,
+ {
+ password: undefined,
+ repeat: undefined,
+ },
+ createFormValidator(i18n, settings.allowInsecurePassword),
+ );
+
+ const createAccountHandler =
+ status.status === "fail" || officer.state !== "not-found"
+ ? undefined
+ : withErrorHandler(
+ async () => officer.create(form.password!.value!),
+ () => {},
+ );
return (
<div class="flex min-h-full flex-col ">
+ <LocalNotificationBanner notification={notification} />
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
<i18n.Translate>Create account</i18n.Translate>
@@ -29,78 +133,65 @@ export function CreateAccount({
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] ">
<div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12">
- <Form.Provider
- computeFormState={(v) => {
- return {
- password: {
- error: !v.password
- ? i18n.str`required`
- : settings.allowInsecurePassword
- ? undefined
- : v.password.length < 8
- ? i18n.str`should have at least 8 characters`
- : !v.password.match(/[a-z]/) && v.password.match(/[A-Z]/)
- ? i18n.str`should have lowercase and uppercase characters`
- : !v.password.match(/\d/)
- ? i18n.str`should have numbers`
- : !v.password.match(/[^a-zA-Z\d]/)
- ? i18n.str`should have at least one character which is not a number or letter`
- : undefined,
- },
- repeat: {
- error: !v.repeat
- ? i18n.str`required`
- : v.repeat !== v.password
- ? i18n.str`doesn't match`
- : undefined,
- },
- };
- }}
- onSubmit={async (v, s) => {
- const error = s?.password?.error ?? s?.repeat?.error;
- if (error) {
- notifyError(
- i18n.str`Can't create account`,
- error as TranslatedString,
- );
- } else {
- onNewAccount(v.password!);
- }
+ <form
+ class="space-y-6"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
}}
+ autoCapitalize="none"
+ autoCorrect="off"
>
- <div class="mb-4">
- <Form.InputLine
+ <div class="mt-2">
+ <InputLine<FormType, "password">
label={i18n.str`Password`}
name="password"
type="password"
- help={
- settings.allowInsecurePassword
- ? i18n.str`short password are insecure, turn off insecure password in settings`
- : i18n.str`lower and upper case letters, number and special character`
- }
required
+ handler={form.password}
/>
</div>
- <div class="mb-4">
- <Form.InputLine
+
+ <div class="mt-2">
+ <InputLine<FormType, "repeat">
label={i18n.str`Repeat password`}
name="repeat"
type="password"
required
+ handler={form.repeat}
/>
</div>
<div class="mt-8">
- <button
+ <Button
type="submit"
- class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!createAccountHandler}
+ class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ handler={createAccountHandler}
>
<i18n.Translate>Create</i18n.Translate>
- </button>
+ </Button>
</div>
- </Form.Provider>
+ </form>
</div>
</div>
</div>
);
}
+
+/**
+ * Show the element when the load ended
+ * @param element
+ */
+export function doAutoFocus(element: HTMLElement | null) {
+ if (element) {
+ setTimeout(() => {
+ element.focus({ preventScroll: true });
+ element.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "center",
+ });
+ }, 100);
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx b/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx
index ff800ebdc..3d6e14f22 100644
--- a/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx
+++ b/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx
@@ -1,8 +1,23 @@
+/*
+ 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 { assertUnreachable } from "@gnu-taler/taler-util";
import { VNode, h } from "preact";
-import { OfficerNotReady } from "../hooks/useOfficer.js";
+import { OfficerNotReady } from "../hooks/officer.js";
import { CreateAccount } from "./CreateAccount.js";
import { UnlockAccount } from "./UnlockAccount.js";
-import { assertUnreachable } from "@gnu-taler/taler-util";
export function HandleAccountNotReady({
officer,
@@ -10,26 +25,11 @@ export function HandleAccountNotReady({
officer: OfficerNotReady;
}): VNode {
if (officer.state === "not-found") {
- return (
- <CreateAccount
- onNewAccount={(password) => {
- officer.create(password);
- }}
- />
- );
+ return <CreateAccount />;
}
if (officer.state === "locked") {
- return (
- <UnlockAccount
- onRemoveAccount={() => {
- officer.forget();
- }}
- onAccountUnlocked={async (pwd) => {
- await officer.tryUnlock(pwd);
- }}
- />
- );
+ return <UnlockAccount />;
}
- assertUnreachable(officer)
+ assertUnreachable(officer);
}
diff --git a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx
deleted file mode 100644
index df97cc3a4..000000000
--- a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { AbsoluteTime, Amounts, HttpStatusCode, TalerExchangeApi, TalerProtocolTimestamp, TranslatedString } from "@gnu-taler/taler-util";
-import { LocalNotificationBanner, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser";
-import { Fragment, VNode, h } from "preact";
-import { useExchangeApiContext } from "../context/config.js";
-import { useOfficer } from "../hooks/useOfficer.js";
-import { Pages } from "../pages.js";
-import { AntiMoneyLaunderingForm } from "./AntiMoneyLaunderingForm.js";
-import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
-import { uiForms } from "../forms/declaration.js";
-
-export function NewFormEntry({
- account,
- type,
-}: {
- account?: string;
- type?: string;
-}): VNode {
- const { i18n } = useTranslationContext()
- const officer = useOfficer();
- const { api } = useExchangeApiContext()
- const [notification, notify, handleError] = useLocalNotification()
-
- if (!account) {
- return <div>no account</div>;
- }
- if (!type) {
- return <SelectForm account={account} />;
- }
- if (officer.state !== "ready") {
- return <HandleAccountNotReady officer={officer} />;
- }
-
- return (
- <Fragment>
- <LocalNotificationBanner notification={notification} />
-
- <AntiMoneyLaunderingForm
- account={account}
- formId={type}
- onSubmit={async (justification, new_state, new_threshold) => {
-
- const decision: Omit<TalerExchangeApi.AmlDecision, "officer_sig"> = {
- justification: JSON.stringify(justification),
- decision_time: TalerProtocolTimestamp.now(),
- h_payto: account,
- new_state,
- new_threshold: Amounts.stringify(new_threshold),
- kyc_requirements: undefined
- }
- await handleError(async () => {
- const resp = await api.addDecisionDetails(officer.account, decision);
- if (resp.type === "ok") {
- window.location.href = Pages.cases.url;
- return;
- }
- switch (resp.case) {
- case HttpStatusCode.Forbidden:
- case HttpStatusCode.Unauthorized: return notify({
- type: "error",
- title: i18n.str`Wrong credentials for "${officer.account}"`,
- description: resp.detail.hint as TranslatedString,
- debug: resp.detail,
- when: AbsoluteTime.now(),
- })
- case HttpStatusCode.NotFound: return notify({
- type: "error",
- title: i18n.str`Officer or account not found`,
- description: resp.detail.hint as TranslatedString,
- debug: resp.detail,
- when: AbsoluteTime.now(),
- })
- case HttpStatusCode.Conflict: return notify({
- type: "error",
- title: i18n.str`Officer disabled or more recent decision was already submitted.`,
- description: resp.detail.hint as TranslatedString,
- debug: resp.detail,
- when: AbsoluteTime.now(),
- })
- }
- })
- }}
- />
- </Fragment>
- );
-}
-
-function SelectForm({ account }: { account: string }) {
- const { i18n } = useTranslationContext()
- return (
- <div>
- <pre>New form for account: {account.substring(0, 16)}...</pre>
- {uiForms.forms(i18n).map((form, idx) => {
- return (
- <a
- href={Pages.newFormEntry.url({ 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/Officer.tsx b/packages/aml-backoffice-ui/src/pages/Officer.tsx
index ec8327814..39359cd5e 100644
--- a/packages/aml-backoffice-ui/src/pages/Officer.tsx
+++ b/packages/aml-backoffice-ui/src/pages/Officer.tsx
@@ -1,19 +1,39 @@
-import { Fragment, h } from "preact";
-import { useOfficer } from "../hooks/useOfficer.js";
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ useExchangeApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { h } from "preact";
+import { useOfficer } from "../hooks/officer.js";
import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
-import { useTranslationContext } from "@gnu-taler/web-util/browser";
-import { uiSettings } from "../settings.js";
-import { getInitialBackendBaseURL } from "../hooks/useBackend.js";
+import { useUiSettingsContext } from "../context/ui-settings.js";
export function Officer() {
const officer = useOfficer();
- const { i18n } = useTranslationContext()
+ const settings = useUiSettingsContext();
+ const { lib } = useExchangeApiContext();
+
+ const { i18n } = useTranslationContext();
if (officer.state !== "ready") {
return <HandleAccountNotReady officer={officer} />;
}
- const url = new URL(getInitialBackendBaseURL())
- const signupEmail = uiSettings.signupEmail ?? `aml-signup@${url.hostname}`
+ const url = new URL("./", lib.exchange.baseUrl);
+ const signupEmail = settings.signupEmail ?? `aml-signup@${url.hostname}`;
return (
<div>
@@ -25,7 +45,11 @@ export function Officer() {
</div>
<p>
<a
- href={`mailto:${signupEmail}?subject=${encodeURIComponent("Request AML signup")}&body=${encodeURIComponent(`I want my AML account\n\n\nPubKey: ${officer.account.id}`)}`}
+ href={`mailto:${signupEmail}?subject=${encodeURIComponent(
+ "Request AML signup",
+ )}&body=${encodeURIComponent(
+ `I want my AML account\n\n\nPubKey: ${officer.account.id}`,
+ )}`}
target="_blank"
rel="noreferrer"
class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx
index f985e6ff5..714bf6580 100644
--- a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx
+++ b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx
@@ -1,6 +1,6 @@
/*
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
@@ -19,99 +19,114 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { addDays } from "date-fns";
import {
- ShowConsolidated as TestedComponent,
-} from "./ShowConsolidated.js";
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { InternationalizationAPI } from "@gnu-taler/web-util/browser";
import * as tests from "@gnu-taler/web-util/testing";
import { getEventsFromAmlHistory } from "./CaseDetails.js";
-import { AbsoluteTime, Duration } from "@gnu-taler/taler-util";
-import { InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { ShowConsolidated as TestedComponent } from "./ShowConsolidated.js";
export default {
title: "show consolidated",
};
const nullTranslator: InternationalizationAPI = {
- str: (str: any) => str,
- singular: (str: any) => str,
- translate: (str: any) => str,
- Translate: (str: any) => str,
-}
+ str: (str: TemplateStringsArray) => str.join() as TranslatedString,
+ singular: (str: TemplateStringsArray) => str.join() as TranslatedString,
+ translate: (str: TemplateStringsArray) => [str.join()] as TranslatedString[],
+ Translate: () => undefined as unknown,
+};
export const WithEmptyHistory = tests.createExample(TestedComponent, {
- history: getEventsFromAmlHistory([], [], nullTranslator),
- until: AbsoluteTime.now()
+ history: getEventsFromAmlHistory([], [], nullTranslator, []),
+ until: AbsoluteTime.now(),
});
export const WithSomeEvents = tests.createExample(TestedComponent, {
- history: getEventsFromAmlHistory([
- {
- "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
- "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}",
- "new_threshold": "STATER:0",
- "new_state": 1,
- "decision_time": {
- "t_s": 1700208199
- }
- },
- {
- "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
- "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}",
- "new_threshold": "STATER:0",
- "new_state": 1,
- "decision_time": {
- "t_s": 1700208211
- }
- },
- {
- "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
- "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}",
- "new_threshold": "STATER:0",
- "new_state": 1,
- "decision_time": {
- "t_s": 1700208220
- }
- },
- {
- "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
- "justification": "{\"index\":4,\"name\":\"Declaration for trusts (902.13e)\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700208362854},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"contractingPartner\":\"f\",\"knownAs\":\"a\",\"trust\":{\"name\":\"b\",\"type\":\"discretionary\",\"revocability\":\"irrevocable\"}}}",
- "new_threshold": "STATER:0",
- "new_state": 1,
- "decision_time": {
- "t_s": 1700208385
- }
- },
- {
- "decider_pub": "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG",
- "justification": "{\"id\":\"simple_comment\",\"label\":\"Simple comment\",\"version\":1,\"value\":{\"when\":{\"t_ms\":1700488420810},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"qwe\"}}",
- "new_threshold": "STATER:0",
- "new_state": 1,
- "decision_time": {
- "t_s": 1700488423
- }
- },
- {
- "decider_pub": "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG",
- "justification": "{\"id\":\"simple_comment\",\"label\":\"Simple comment\",\"version\":1,\"value\":{\"when\":{\"t_ms\":1700488671251},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"asd asd asd \"}}",
- "new_threshold": "STATER:0",
- "new_state": 1,
- "decision_time": {
- "t_s": 1700488677
- }
- }
- ], [{
- collection_time: AbsoluteTime.toProtocolTimestamp(
- AbsoluteTime.subtractDuraction(AbsoluteTime.now(), Duration.fromPrettyString("1d"))
- ),
- expiration_time: { t_s: "never" },
- provider_section: "asd",
- attributes: {
- email: "sebasjm@qwdde.com"
- }
- }], nullTranslator),
- until: AbsoluteTime.now()
+ history: getEventsFromAmlHistory(
+ [
+ {
+ decider_pub: "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
+ justification:
+ '{"index":0,"name":"Simple comment","value":{"fullName":"loggedIn_user_fullname","when":{"t_ms":1700207199558},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"comment":"test"}}',
+ new_threshold: "STATER:0" as AmountString,
+ new_state: 1,
+ decision_time: {
+ t_s: 1700208199,
+ },
+ },
+ {
+ decider_pub: "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
+ justification:
+ '{"index":0,"name":"Simple comment","value":{"fullName":"loggedIn_user_fullname","when":{"t_ms":1700207199558},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"comment":"test"}}',
+ new_threshold: "STATER:0" as AmountString,
+ new_state: 1,
+ decision_time: {
+ t_s: 1700208211,
+ },
+ },
+ {
+ decider_pub: "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
+ justification:
+ '{"index":0,"name":"Simple comment","value":{"fullName":"loggedIn_user_fullname","when":{"t_ms":1700207199558},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"comment":"test"}}',
+ new_threshold: "STATER:0" as AmountString,
+ new_state: 1,
+ decision_time: {
+ t_s: 1700208220,
+ },
+ },
+ {
+ decider_pub: "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
+ justification:
+ '{"index":4,"name":"Declaration for trusts (902.13e)","value":{"fullName":"loggedIn_user_fullname","when":{"t_ms":1700208362854},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"contractingPartner":"f","knownAs":"a","trust":{"name":"b","type":"discretionary","revocability":"irrevocable"}}}',
+ new_threshold: "STATER:0" as AmountString,
+ new_state: 1,
+ decision_time: {
+ t_s: 1700208385,
+ },
+ },
+ {
+ decider_pub: "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG",
+ justification:
+ '{"id":"simple_comment","label":"Simple comment","version":1,"value":{"when":{"t_ms":1700488420810},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"comment":"qwe"}}',
+ new_threshold: "STATER:0" as AmountString,
+ new_state: 1,
+ decision_time: {
+ t_s: 1700488423,
+ },
+ },
+ {
+ decider_pub: "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG",
+ justification:
+ '{"id":"simple_comment","label":"Simple comment","version":1,"value":{"when":{"t_ms":1700488671251},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"comment":"asd asd asd "}}',
+ new_threshold: "STATER:0" as AmountString,
+ new_state: 1,
+ decision_time: {
+ t_s: 1700488677,
+ },
+ },
+ ],
+ [
+ {
+ collection_time: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.subtractDuraction(
+ AbsoluteTime.now(),
+ Duration.fromPrettyString("1d"),
+ ),
+ ),
+ expiration_time: { t_s: "never" },
+ provider_section: "asd",
+ attributes: {
+ email: "sebasjm@qwdde.com",
+ },
+ },
+ ],
+ nullTranslator,
+ [],
+ ),
+ until: AbsoluteTime.now(),
});
-
-
-
diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx
index ad350c0e6..cdc5d0bc1 100644
--- a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx
+++ b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx
@@ -1,10 +1,34 @@
-import { AbsoluteTime, AmountJson, TranslatedString } from "@gnu-taler/taler-util";
+/*
+ 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,
+ TalerExchangeApi,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import {
+ DefaultForm,
+ FormConfiguration,
+ UIFormElementConfig,
+ UIHandlerId,
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
import { format } from "date-fns";
import { Fragment, VNode, h } from "preact";
import { AmlEvent } from "./CaseDetails.js";
-import { DefaultForm, FlexibleForm, UIFormField, useTranslationContext } from "@gnu-taler/web-util/browser";
-import { amlStateConverter } from "../utils/converter.js";
-import { AmlExchangeBackend } from "../utils/types.js";
export function ShowConsolidated({
history,
@@ -17,91 +41,76 @@ 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: FormConfiguration = {
+ type: "double-column",
design: [
{
title: i18n.str`AML`,
fields: [
{
type: "amount",
- props: {
- label: i18n.str`Threshold`,
- name: "aml.threshold",
- },
+ id: ".aml.threshold" as UIHandlerId,
+ currency: "NETZBON",
+ label: i18n.str`Threshold`,
+ name: "aml.threshold",
},
{
type: "choiceHorizontal",
- props: {
- label: i18n.str`State`,
- name: "aml.state",
- converter: amlStateConverter,
- choices: [
- {
- label: i18n.str`Frozen`,
- value: AmlExchangeBackend.AmlState.frozen,
- },
- {
- label: i18n.str`Pending`,
- value: AmlExchangeBackend.AmlState.pending,
- },
- {
- label: i18n.str`Normal`,
- value: AmlExchangeBackend.AmlState.normal,
- },
- ],
- },
+ label: i18n.str`State`,
+ name: "aml.state",
+ id: ".aml.state" as UIHandlerId,
+ choices: [
+ {
+ label: i18n.str`Frozen`,
+ value: "frozen",
+ },
+ {
+ label: i18n.str`Pending`,
+ value: "pending",
+ },
+ {
+ label: i18n.str`Normal`,
+ value: "normal",
+ },
+ ],
},
],
},
Object.entries(cons.kyc).length > 0
? {
- title: i18n.str`KYC`,
- fields: Object.entries(cons.kyc).map(([key, field]) => {
- const result: UIFormField = {
- type: "text",
- props: {
+ title: i18n.str`KYC`,
+ fields: Object.entries(cons.kyc).map(([key, field]) => {
+ const result: UIFormElementConfig = {
+ type: "text",
label: key as TranslatedString,
+ id: `kyc.${key}.value` as UIHandlerId,
name: `kyc.${key}.value`,
- help: `${field.provider} since ${field.since.t_ms === "never"
- ? "never"
- : format(field.since.t_ms, "dd/MM/yyyy")
- }` as TranslatedString,
- },
- };
- return result;
- }),
- }
- : undefined,
+ help: `${field.provider} since ${
+ field.since.t_ms === "never"
+ ? "never"
+ : format(field.since.t_ms, "dd/MM/yyyy")
+ }` as TranslatedString,
+ };
+ return result;
+ }),
+ }
+ : undefined!,
],
};
return (
<Fragment>
<h1 class="text-base font-semibold leading-7 text-black">
- Consolidated information {until.t_ms === "never"
+ Consolidated information{" "}
+ {until.t_ms === "never"
? ""
: `after ${format(until.t_ms, "dd MMMM yyyy")}`}
</h1>
<DefaultForm
key={`${String(Date.now())}`}
- form={form}
+ form={form as any}
initial={cons}
readOnly
- onUpdate={() => { }}
+ onUpdate={() => {}}
/>
</Fragment>
);
@@ -109,13 +118,13 @@ export function ShowConsolidated({
interface Consolidated {
aml: {
- state: AmlExchangeBackend.AmlState;
+ state: TalerExchangeApi.AmlState;
threshold: AmountJson;
since: AbsoluteTime;
};
kyc: {
[field: string]: {
- value: any;
+ value: unknown;
provider: string;
since: AbsoluteTime;
};
@@ -128,13 +137,13 @@ function getConsolidated(
): Consolidated {
const initial: Consolidated = {
aml: {
- state: AmlExchangeBackend.AmlState.normal,
+ state: TalerExchangeApi.AmlState.normal,
threshold: {
currency: "ARS",
value: 1000,
fraction: 0,
},
- since: AbsoluteTime.never()
+ since: AbsoluteTime.never(),
},
kyc: {},
};
@@ -153,14 +162,15 @@ function getConsolidated(
prev.aml = {
since: cur.when,
state: cur.state,
- threshold: cur.threshold
- }
+ threshold: cur.threshold,
+ };
break;
}
case "kyc-collection": {
Object.keys(cur.values).forEach((field) => {
+ const value = (cur.values as Record<string, unknown>)[field];
prev.kyc[field] = {
- value: (cur.values as any)[field],
+ value,
provider: cur.provider,
since: cur.when,
};
@@ -170,4 +180,4 @@ function getConsolidated(
}
return prev;
}, initial);
-} \ No newline at end of file
+}
diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx
index 1b0342b12..084e639bf 100644
--- a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx
+++ b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx
@@ -1,78 +1,130 @@
-import { TranslatedString, UnwrapKeyError } from "@gnu-taler/taler-util";
-import { createNewForm, notifyError, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
+/*
+ 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 {
+ Button,
+ InputLine,
+ LocalNotificationBanner,
+ UIHandlerId,
+ useLocalNotificationHandler,
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
+import { FormErrors, useFormState } from "../hooks/form.js";
+import { useOfficer } from "../hooks/officer.js";
+import { undefinedIfEmpty } from "./CreateAccount.js";
+
+type FormType = {
+ password: string;
+};
+
+export function UnlockAccount(): VNode {
+ const { i18n } = useTranslationContext();
+
+ const officer = useOfficer();
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
-export function UnlockAccount({
- onAccountUnlocked,
- onRemoveAccount,
-}: {
- onAccountUnlocked: (password: string) => Promise<void>;
- onRemoveAccount: () => void;
-}): VNode {
- const { i18n } = useTranslationContext()
- const Form = createNewForm<{
- password: string;
- }>();
+ const [form, status] = useFormState<FormType>(
+ [".password"] as Array<UIHandlerId>,
+ {
+ password: undefined,
+ },
+ (state) => {
+ const errors = undefinedIfEmpty<FormErrors<FormType>>({
+ password: !state.password ? i18n.str`required` : undefined,
+ });
+ if (errors === undefined) {
+ return {
+ status: "ok",
+ result: state as FormType,
+ errors,
+ };
+ }
+ return {
+ status: "fail",
+ result: state,
+ errors,
+ };
+ },
+ );
+
+ const unlockHandler =
+ status.status === "fail" || officer.state !== "locked"
+ ? undefined
+ : withErrorHandler(
+ async () => officer.tryUnlock(form.password!.value!),
+ () => {},
+ );
+
+ const forgetHandler =
+ status.status === "fail" || officer.state !== "locked"
+ ? undefined
+ : withErrorHandler(
+ async () => officer.forget(),
+ () => {},
+ );
return (
<div class="flex min-h-full flex-col ">
+ <LocalNotificationBanner notification={notification} />
+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h1 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
<i18n.Translate>Account locked</i18n.Translate>
</h1>
<p class="mt-6 text-lg leading-8 text-gray-600">
- <i18n.Translate>Your account is normally locked anytime you reload. To unlock type
- your password again.</i18n.Translate>
+ <i18n.Translate>
+ Your account is normally locked anytime you reload. To unlock type
+ your password again.
+ </i18n.Translate>
</p>
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] ">
<div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12">
- <Form.Provider
- onSubmit={async (v) => {
- try {
- await onAccountUnlocked(v.password!);
- notifyInfo(i18n.str`Account unlocked`);
- } catch (e) {
- if (e instanceof UnwrapKeyError) {
- notifyError(
- "Could not unlock account" as any,
- e.message as any,
- );
- } else {
- throw e;
- }
- }
- }}
- >
- <div class="mb-4">
- <Form.InputLine
- label={i18n.str`Password`}
- name="password"
- type="password"
- required
- />
- </div>
- <div class="mt-8">
- <button
- type="submit"
- class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
- >
- <i18n.Translate>Unlock</i18n.Translate>
- </button>
- </div>
- </Form.Provider>
+ <div class="mb-4">
+ <InputLine<FormType, "password">
+ label={i18n.str`Password`}
+ name="password"
+ type="password"
+ required
+ handler={form.password}
+ />
+ </div>
+
+ <div class="mt-8">
+ <Button
+ type="submit"
+ handler={unlockHandler}
+ disabled={!unlockHandler}
+ class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Unlock</i18n.Translate>
+ </Button>
+ </div>
+
</div>
- <button
+ <Button
type="button"
- onClick={() => {
- onRemoveAccount();
- }}
- class="m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
+ handler={forgetHandler}
+ disabled={!forgetHandler}
+ class="disabled:opacity-50 disabled:cursor-default m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
>
<i18n.Translate>Forget account</i18n.Translate>
- </button>
+ </Button>
</div>
</div>
);
diff --git a/packages/aml-backoffice-ui/src/pages/index.stories.ts b/packages/aml-backoffice-ui/src/pages/index.stories.ts
index afe73227a..f11028de8 100644
--- a/packages/aml-backoffice-ui/src/pages/index.stories.ts
+++ b/packages/aml-backoffice-ui/src/pages/index.stories.ts
@@ -1,3 +1,17 @@
+/*
+ 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 * as a1 from "./ShowConsolidated.stories.js";
-export * as a2 from "./AntiMoneyLaunderingForm.stories.js";
export * as a3 from "./Cases.stories.js";
diff --git a/packages/aml-backoffice-ui/src/route.ts b/packages/aml-backoffice-ui/src/route.ts
deleted file mode 100644
index f515a590a..000000000
--- a/packages/aml-backoffice-ui/src/route.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { TranslatedString } from "@gnu-taler/taler-util";
-import { createHashHistory } from "history";
-import { ComponentChildren, h as create, createContext, VNode } from "preact";
-import { useContext, useEffect, useState } from "preact/hooks";
-
-type ContextType = {
- onChange: (listener: () => void) => VoidFunction
-}
-const nullChangeListener = { onChange: () => () => { } }
-const Context = createContext<ContextType>(nullChangeListener);
-
-export const usePathChangeContext = (): ContextType => useContext(Context);
-
-export function HashPathProvider({ children }: { children: ComponentChildren }): VNode {
- const history = createHashHistory();
- return create(Context.Provider, { value: { onChange: history.listen }, children }, children)
-}
-
-type PageDefinition<DynamicPart extends Record<string, string>> = {
- pattern: string;
- (params: DynamicPart): string;
-};
-
-function replaceAll(
- pattern: string,
- vars: Record<string, string>,
- values: Record<string, string>,
-): string {
- let result = pattern;
- for (const v in vars) {
- result = result.replace(vars[v], !values[v] ? "" : values[v]);
- }
- return result;
-}
-
-export function pageDefinition<T extends Record<string, string>>(
- pattern: string,
-): PageDefinition<T> {
- const patternParams = pattern.match(/(:[\w?]*)/g);
- if (!patternParams)
- throw Error(
- `page definition pattern ${pattern} doesn't have any parameter`,
- );
-
- const vars = patternParams.reduce((prev, cur) => {
- const pName = cur.match(/(\w+)/g);
-
- //skip things like :? in the path pattern
- if (!pName || !pName[0]) return prev;
- const name = pName[0];
- return { ...prev, [name]: cur };
- }, {} as Record<string, string>);
-
- const f = (values: T): string => replaceAll(pattern, vars, values);
- f.pattern = pattern;
- return f;
-}
-
-export type PageEntry<T = unknown> = T extends Record<string, string>
- ? {
- url: PageDefinition<T>;
- view: (props: T) => VNode;
- name: TranslatedString,
- Icon?: () => VNode,
- }
- : T extends unknown
- ? {
- url: string;
- view: (props: {}) => VNode;
- name: TranslatedString,
- Icon?: () => VNode,
- }
- : never;
-
-export function Router({
- pageList,
- onNotFound,
-}: {
- pageList: Array<PageEntry<any>>;
- onNotFound: () => VNode;
-}): VNode {
- const current = useCurrentLocation(pageList);
- if (current !== undefined) {
- return create(current.page.view, current.values);
- }
- return onNotFound();
-}
-
-type Location = {
- page: PageEntry<any>;
- path: string;
- values: Record<string, string>;
-};
-export function useCurrentLocation(pageList: Array<PageEntry<any>>): Location | undefined {
- const [currentLocation, setCurrentLocation] = useState<Location | null | undefined>(null);
- const path = usePathChangeContext();
- useEffect(() => {
- return path.onChange(() => {
- const result = doSync(window.location.hash, new URLSearchParams(window.location.search), pageList);
- setCurrentLocation(result);
- });
- }, []);
- if (currentLocation === null) {
- return doSync(window.location.hash, new URLSearchParams(window.location.search), pageList);
- }
- return currentLocation;
-}
-
-export function useChangeLocation() {
- const [location, setLocation] = useState(window.location.hash)
- const path = usePathChangeContext()
- useEffect(() => {
- return path.onChange(() => {
- setLocation(window.location.hash)
- });
- }, []);
- return location;
-}
-
-/**
- * Search path in the pageList
- * get the values from the path found
- * add params from searchParams
- *
- * @param path
- * @param params
- */
-export function doSync(path: string, params: URLSearchParams, pageList: Array<PageEntry<any>>): Location | undefined {
- for (let idx = 0; idx < pageList.length; idx++) {
- const page = pageList[idx];
- if (typeof page.url === "string") {
- if (page.url === path) {
- const values: Record<string, string> = {};
- params.forEach((v, k) => {
- values[k] = v;
- });
- return { page, values, path };
- }
- } else {
- const values = doestUrlMatchToRoute(path, page.url.pattern);
- if (values !== undefined) {
- params.forEach((v, k) => {
- values[k] = v;
- });
- return { page, values, path };
- }
- }
- }
- return undefined;
-}
-
-function doestUrlMatchToRoute(
- url: string,
- route: string,
-): undefined | Record<string, string> {
- const paramsPattern = /(?:\?([^#]*))?$/;
- // const paramsPattern = /(?:\?([^#]*))?(#.*)?$/;
- const params = url.match(paramsPattern);
- const urlWithoutParams = url.replace(paramsPattern, "");
-
- const result: Record<string, string> = {};
- if (params && params[1]) {
- const paramList = params[1].split("&");
- for (let i = 0; i < paramList.length; i++) {
- const idx = paramList[i].indexOf("=");
- const name = paramList[i].substring(0, idx);
- const value = paramList[i].substring(idx + 1);
- result[decodeURIComponent(name)] = decodeURIComponent(value);
- }
- }
- const urlSeg = urlWithoutParams.split("/");
- const routeSeg = route.split("/");
- let max = Math.max(urlSeg.length, routeSeg.length);
- for (let i = 0; i < max; i++) {
- if (routeSeg[i] && routeSeg[i].charAt(0) === ":") {
- const param = routeSeg[i].replace(/(^:|[+*?]+$)/g, "");
-
- const flags = (routeSeg[i].match(/[+*?]+$/) || EMPTY)[0] || "";
- const plus = ~flags.indexOf("+");
- const star = ~flags.indexOf("*");
- const val = urlSeg[i] || "";
-
- if (!val && !star && (flags.indexOf("?") < 0 || plus)) {
- return undefined;
- }
- result[param] = decodeURIComponent(val);
- if (plus || star) {
- result[param] = urlSeg.slice(i).map(decodeURIComponent).join("/");
- break;
- }
- } else if (routeSeg[i] !== urlSeg[i]) {
- return undefined;
- }
- }
- return result;
-}
-const EMPTY: Record<string, string> = {};
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 eca66cb18..265a2165b 100644
--- a/packages/aml-backoffice-ui/src/stories.test.ts
+++ b/packages/aml-backoffice-ui/src/stories.test.ts
@@ -1,6 +1,6 @@
/*
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
@@ -19,15 +19,17 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import { TalerExchangeApi, setupI18n } from "@gnu-taler/taler-util";
-import { parseGroupImport } from "@gnu-taler/web-util/browser";
+import {
+ ExchangeApiProviderTesting,
+ ExchangeContextType,
+ parseGroupImport,
+} from "@gnu-taler/web-util/browser";
import * as tests from "@gnu-taler/web-util/testing";
// import * as components from "./components/index.examples.js";
import * as pages from "./pages/index.stories.js";
-import { ComponentChildren, Fragment, VNode, h as create } from "preact";
-import { ExchangeApiContextTesting } from "./context/config.js";
-// import { BackendStateProviderTesting } from "./context/backend.js";
+import { ComponentChildren, VNode, h as create } from "preact";
setupI18n("en", { en: {} });
@@ -48,7 +50,6 @@ describe("All the examples:", () => {
});
});
-
function DefaultTestingContext({
children,
}: {
@@ -61,11 +62,22 @@ function DefaultTestingContext({
name: "ARS",
num_fractional_input_digits: 2,
num_fractional_normal_digits: 2,
- num_fractional_trailing_zero_digits: 2
+ num_fractional_trailing_zero_digits: 2,
},
name: "taler-exchange",
supported_kyc_requirements: [],
version: "asd",
- }
- return create(ExchangeApiContextTesting, { config, children });
+ };
+ const value: ExchangeContextType = {
+ cancelRequest: () => null,
+ config,
+ url: new URL("/", "http://localhost"),
+ hints: [],
+ lib: {
+ exchange: undefined!, //FIXME: mock
+ },
+ onActivity: () => null!,
+ };
+
+ return create(ExchangeApiProviderTesting, { value, children });
}
diff --git a/packages/aml-backoffice-ui/src/stories.tsx b/packages/aml-backoffice-ui/src/stories.tsx
index 1aa6a44ac..9a23d82fa 100644
--- a/packages/aml-backoffice-ui/src/stories.tsx
+++ b/packages/aml-backoffice-ui/src/stories.tsx
@@ -1,6 +1,6 @@
/*
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
@@ -22,41 +22,57 @@ import { strings } from "./i18n/strings.js";
import * as pages from "./pages/index.stories.js";
-import { renderStories } from "@gnu-taler/web-util/browser";
+import {
+ ExchangeApiProviderTesting,
+ ExchangeContextType,
+ renderStories,
+} from "@gnu-taler/web-util/browser";
+import { TalerExchangeApi } from "@gnu-taler/taler-util";
+import { ComponentChildren, FunctionComponent, VNode, h } from "preact";
import "./scss/main.css";
-import { h, ComponentChildren, FunctionComponent, VNode } from "preact";
-import { ExchangeApiContextTesting } from "./context/config.js";
function main(): void {
renderStories(
{ pages },
{
strings,
- getWrapperForGroup
+ getWrapperForGroup,
},
);
}
function getWrapperForGroup(): FunctionComponent {
return function All({ children }: { children?: ComponentChildren }): VNode {
- return <ExchangeApiContextTesting
- config={{
- currency: "ARS",
- currency_specification: {
- alt_unit_names: {},
- name: "ARS",
- num_fractional_input_digits: 2,
- num_fractional_normal_digits: 2,
- num_fractional_trailing_zero_digits: 2
- },
- name: "taler-exchange",
- supported_kyc_requirements: [],
- version: "asd",
- }}>
- {children}
- </ExchangeApiContextTesting>
- }
+ const config: TalerExchangeApi.ExchangeVersionResponse = {
+ currency: "ARS",
+ currency_specification: {
+ alt_unit_names: {},
+ name: "ARS",
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ },
+ name: "taler-exchange",
+ supported_kyc_requirements: [],
+ version: "asd",
+ };
+ const value: ExchangeContextType = {
+ cancelRequest: () => null,
+ config,
+ url: new URL("/", "http://localhost"),
+ hints: [],
+ lib: {
+ exchange: undefined!, //FIXME: mock
+ },
+ onActivity: () => null!,
+ };
+ return (
+ <ExchangeApiProviderTesting value={value}>
+ {children}
+ </ExchangeApiProviderTesting>
+ );
+ };
}
if (document.readyState === "loading") {
diff --git a/packages/aml-backoffice-ui/src/utils/QR.tsx b/packages/aml-backoffice-ui/src/utils/QR.tsx
index 1dc1712b7..b382348a3 100644
--- a/packages/aml-backoffice-ui/src/utils/QR.tsx
+++ b/packages/aml-backoffice-ui/src/utils/QR.tsx
@@ -1,6 +1,6 @@
/*
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
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 d2f05ed84..000000000
--- a/packages/aml-backoffice-ui/src/utils/converter.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { AmlExchangeBackend } from "./types.js";
-
-export const amlStateConverter = {
- toStringUI: stringifyAmlState,
- fromStringUI: parseAmlState,
-};
-
-function stringifyAmlState(s: AmlExchangeBackend.AmlState | undefined): string {
- if (s === undefined) return "";
- switch (s) {
- case AmlExchangeBackend.AmlState.normal:
- return "normal";
- case AmlExchangeBackend.AmlState.pending:
- return "pending";
- case AmlExchangeBackend.AmlState.frozen:
- return "frozen";
- }
-}
-
-function parseAmlState(s: string | undefined): AmlExchangeBackend.AmlState {
- switch (s) {
- case "normal":
- return AmlExchangeBackend.AmlState.normal;
- case "pending":
- return AmlExchangeBackend.AmlState.pending;
- case "frozen":
- return AmlExchangeBackend.AmlState.frozen;
- default:
- throw Error(`unknown AML state: ${s}`);
- }
-}
diff --git a/packages/aml-backoffice-ui/src/utils/types.ts b/packages/aml-backoffice-ui/src/utils/types.ts
deleted file mode 100644
index fd70d4e4d..000000000
--- a/packages/aml-backoffice-ui/src/utils/types.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-export namespace AmlExchangeBackend {
- // FIXME: placeholder
- export interface AmlError {
- code: number;
- hint: string;
- }
- export interface AmlDecisionDetails {
- // Array of AML decisions made for this account. Possibly
- // contains only the most recent decision if "history" was
- // not set to 'true'.
- aml_history: AmlDecisionDetail[];
-
- // Array of KYC attributes obtained for this account.
- kyc_attributes: KycDetail[];
- }
-
- type AmlOfficerPublicKeyP = string;
-
- export interface AmlDecisionDetail {
- // What was the justification given?
- justification: string;
-
- // What is the new AML state.
- new_state: Integer;
-
- // When was this decision made?
- decision_time: Timestamp;
-
- // What is the new AML decision threshold (in monthly transaction volume)?
- new_threshold: Amount;
-
- // Who made the decision?
- decider_pub: AmlOfficerPublicKeyP;
- }
- export interface KycDetail {
- // Name of the configuration section that specifies the provider
- // which was used to collect the KYC details
- provider_section: string;
-
- // The collected KYC data. NULL if the attribute data could not
- // be decrypted (internal error of the exchange, likely the
- // attribute key was changed).
- attributes?: Object;
-
- // Time when the KYC data was collected
- collection_time: Timestamp;
-
- // Time when the validity of the KYC data will expire
- expiration_time: Timestamp;
- }
-
- interface Timestamp {
- // Seconds since epoch, or the special
- // value "never" to represent an event that will
- // never happen.
- t_s: number | "never";
- }
-
- type PaytoHash = string;
- type Integer = number;
- type Amount = string;
- // EdDSA signatures are transmitted as 64-bytes base32
- // binary-encoded objects with just the R and S values (base32_ binary-only).
- type EddsaSignature = string;
-
- export interface AmlRecords {
- // Array of AML records matching the query.
- records: AmlRecord[];
- }
-
- interface AmlRecord {
- // Which payto-address is this record about.
- // Identifies a GNU Taler wallet or an affected bank account.
- h_payto: PaytoHash;
-
- // What is the current AML state.
- current_state: AmlState;
-
- // Monthly transaction threshold before a review will be triggered
- threshold: Amount;
-
- // RowID of the record.
- rowid: Integer;
- }
-
- export enum AmlState {
- normal = 0,
- pending = 1,
- frozen = 2,
- }
-
-
- export interface AmlDecision {
-
- // Human-readable justification for the decision.
- justification: string;
-
- // At what monthly transaction volume should the
- // decision be automatically reviewed?
- new_threshold: Amount;
-
- // Which payto-address is the decision about?
- // Identifies a GNU Taler wallet or an affected bank account.
- h_payto: PaytoHash;
-
- // What is the new AML state (e.g. frozen, unfrozen, etc.)
- // Numerical values are defined in AmlDecisionState.
- new_state: Integer;
-
- // Signature by the AML officer over a
- // TALER_MasterAmlOfficerStatusPS.
- // Must have purpose TALER_SIGNATURE_MASTER_AML_KEY.
- officer_sig: EddsaSignature;
-
- // When was the decision made?
- decision_time: Timestamp;
-
- // Optional argument to impose new KYC requirements
- // that the customer has to satisfy to unblock transactions.
- kyc_requirements?: string[];
- }
-
-
-}
diff --git a/packages/anastasis-cli/package.json b/packages/anastasis-cli/package.json
index b0e26fae3..5a9d6abea 100644
--- a/packages/anastasis-cli/package.json
+++ b/packages/anastasis-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/anastasis-cli",
- "version": "0.0.1",
+ "version": "0.10.7",
"description": "",
"engines": {
"node": ">=0.18.0"
diff --git a/packages/anastasis-core/package.json b/packages/anastasis-core/package.json
index f551a41f8..576acc988 100644
--- a/packages/anastasis-core/package.json
+++ b/packages/anastasis-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/anastasis-core",
- "version": "0.0.2",
+ "version": "0.10.7",
"description": "",
"main": "./lib/index.js",
"module": "./lib/index.js",
diff --git a/packages/anastasis-core/src/anastasis-data.ts b/packages/anastasis-core/src/anastasis-data.ts
index d69bb319b..9cbf5f594 100644
--- a/packages/anastasis-core/src/anastasis-data.ts
+++ b/packages/anastasis-core/src/anastasis-data.ts
@@ -11,14 +11,10 @@ export const anastasisData = {
url: "https://v1.anastasis.taler.net/",
name: "Bern University of Applied Sciences, Switzerland",
},
- {
- url: "https://v1.anastasis.codeblau.de/",
- name: "Codeblau GmbH, Germany",
- },
- // {
- // url: "https://v1.anastasis.openw3b.org/",
- // name: "Openw3b Foundation, India",
- // },
+// {
+// url: "https://v1.anastasis.codeblau.de/",
+// name: "Codeblau GmbH, Germany",
+// },
{
url: "https://v1.anastasis.lu/",
name: "Anastasis SARL, Luxembourg",
diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts
index 9a774d0ff..05fa4a49f 100644
--- a/packages/anastasis-core/src/index.ts
+++ b/packages/anastasis-core/src/index.ts
@@ -43,7 +43,7 @@ import {
URL,
j2s,
} from "@gnu-taler/taler-util";
-import { HttpResponse, createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import { HttpResponse } from "@gnu-taler/taler-util/http";
import { anastasisData } from "./anastasis-data.js";
import {
codecForChallengeInstructionMessage,
@@ -137,10 +137,6 @@ export * from "./reducer-types.js";
export * as validators from "./validators.js";
export * from "./challenge-feedback-types.js";
-const httpLib = createPlatformHttpLib({
- enableThrottling: false,
-});
-
const logger = new Logger("anastasis-core:index.ts");
const ANASTASIS_HTTP_HEADER_POLICY_META_DATA = "Anastasis-Policy-Meta-Data";
@@ -283,17 +279,22 @@ async function getProviderInfo(
providerBaseUrl: string,
): Promise<AuthenticationProviderStatus> {
// FIXME: Use a reasonable timeout here.
- let resp: HttpResponse;
+ let resp: Response;
try {
- resp = await httpLib.fetch(new URL("config", providerBaseUrl).href);
+ resp = await fetch(new URL("config", providerBaseUrl).href);
} catch (e) {
+ console.warn(
+ "Encountered an HTTP error whilst trying to get the provider's config: ",
+ e,
+ );
return {
status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
- hint: "request to provider failed",
+ hint: "request to anastasis provider failed",
};
}
- if (resp.status !== 200) {
+ if (!resp.ok) {
+ console.warn("Got bad response code whilst getting provider config", resp);
return {
status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
@@ -555,7 +556,7 @@ async function uploadSecret(
// FIXME: Get this from the params
reqUrl.searchParams.set("timeout_ms", "500");
}
- const resp = await httpLib.fetch(reqUrl.href, {
+ const resp = await fetch(reqUrl.href, {
method: "POST",
headers: {
"content-type": "application/json",
@@ -645,11 +646,11 @@ async function uploadSecret(
reqUrl.searchParams.set("timeout_ms", "500");
}
logger.info(`uploading policy to ${prov.provider_url}`);
- const resp = await httpLib.fetch(reqUrl.href, {
+ const resp = await fetch(reqUrl.href, {
method: "POST",
headers: {
"Anastasis-Policy-Signature": encodeCrock(sig),
- "If-None-Match": encodeCrock(bodyHash),
+ "If-None-Match": JSON.stringify(encodeCrock(bodyHash)),
[ANASTASIS_HTTP_HEADER_POLICY_META_DATA]: metadataEnc,
...(paySecret
? {
@@ -756,14 +757,14 @@ async function downloadPolicyFromProvider(
const acctKeypair = accountKeypairDerive(userId);
const reqUrl = new URL(`policy/${acctKeypair.pub}`, providerUrl);
reqUrl.searchParams.set("version", `${version}`);
- const resp = await httpLib.fetch(reqUrl.href);
+ const resp = await fetch(reqUrl.href);
if (resp.status !== 200) {
logger.info(
`Could not download policy from provider ${providerUrl}, status ${resp.status}`,
);
return undefined;
}
- const body = await resp.bytes();
+ const body = await resp.arrayBuffer();
const bodyDecrypted = await decryptRecoveryDocument(
userId,
encodeCrock(body),
@@ -980,10 +981,10 @@ async function requestTruth(
const hresp = await getResponseHash(truth, solveRequest);
- let resp: HttpResponse;
+ let resp: Response;
try {
- resp = await httpLib.fetch(url.href, {
+ resp = await fetch(url.href, {
method: "POST",
headers: {
Accept: "application/json",
@@ -1021,7 +1022,7 @@ async function requestTruth(
truth.provider_salt,
);
- const respBody = new Uint8Array(await resp.bytes());
+ const respBody = new Uint8Array(await resp.arrayBuffer());
const keyShare = await decryptKeyShare(
encodeCrock(respBody),
userId,
@@ -1137,10 +1138,10 @@ async function selectChallenge(
}
}
- let resp: HttpResponse;
+ let resp: Response;
try {
- resp = await httpLib.fetch(url.href, {
+ resp = await fetch(url.href, {
method: "POST",
headers: {
Accept: "application/json",
@@ -1858,7 +1859,7 @@ export async function discoverPolicies(
);
const acctKeypair = accountKeypairDerive(userId);
const reqUrl = new URL(`policy/${acctKeypair.pub}/meta`, providerUrl);
- const resp = await httpLib.fetch(reqUrl.href);
+ const resp = await fetch(reqUrl.href);
if (resp.status !== 200) {
logger.warn(`Could not fetch policy metadate from ${reqUrl.href}`);
continue;
diff --git a/packages/anastasis-core/tsconfig.json b/packages/anastasis-core/tsconfig.json
index a12f2e641..e463201e7 100644
--- a/packages/anastasis-core/tsconfig.json
+++ b/packages/anastasis-core/tsconfig.json
@@ -6,7 +6,7 @@
"module": "Node16",
"moduleResolution": "Node16",
"sourceMap": true,
- "lib": ["ES2020"],
+ "lib": ["ES2020", "DOM"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json
index c1c2925a2..108b1476e 100644
--- a/packages/anastasis-webui/package.json
+++ b/packages/anastasis-webui/package.json
@@ -1,7 +1,7 @@
{
"private": true,
"name": "@gnu-taler/anastasis-webui",
- "version": "0.2.99",
+ "version": "0.10.7",
"license": "MIT",
"type": "module",
"scripts": {
diff --git a/packages/anastasis-webui/src/components/menu/SideBar.tsx b/packages/anastasis-webui/src/components/menu/SideBar.tsx
index 3dac73e04..31bc3c7a7 100644
--- a/packages/anastasis-webui/src/components/menu/SideBar.tsx
+++ b/packages/anastasis-webui/src/components/menu/SideBar.tsx
@@ -29,7 +29,10 @@ interface Props {
}
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
-const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
+const GIT_HASH =
+ typeof __GIT_HASH__ !== "undefined"
+ ? __GIT_HASH__.substring(0, 7)
+ : undefined;
const VERSION_WITH_HASH = GIT_HASH ? `${VERSION}-${GIT_HASH}` : VERSION;
export function Sidebar({ mobile }: Props): VNode {
diff --git a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts
index fc8c4cf6c..fcc380775 100644
--- a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts
+++ b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts
@@ -303,7 +303,7 @@ export function useAnastasisReducer(): AnastasisReducerApi {
},
});
} catch (e) {
- throw Error("could not restore the state");
+ throw new Error("could not restore the state");
}
},
async discoverStart(): Promise<void> {
@@ -399,7 +399,7 @@ export function useAnastasisReducer(): AnastasisReducerApi {
}
class ReducerTxImpl implements ReducerTransactionHandle {
- constructor(public transactionState: ReducerState) { }
+ constructor(public transactionState: ReducerState) {}
async transition(action: string, args: any): Promise<ReducerState> {
let s: ReducerState;
if (remoteReducer) {
@@ -410,7 +410,7 @@ class ReducerTxImpl implements ReducerTransactionHandle {
this.transactionState = s;
// Abort transaction as soon as we transition into an error state.
if (this.transactionState.reducer_type === "error") {
- throw Error("transition resulted in error");
+ throw new Error("transition resulted in error");
}
return this.transactionState;
}
diff --git a/packages/anastasis-webui/src/index.ts b/packages/anastasis-webui/src/index.ts
index d7b2164ab..f614e4f54 100644
--- a/packages/anastasis-webui/src/index.ts
+++ b/packages/anastasis-webui/src/index.ts
@@ -22,7 +22,7 @@ function main(): void {
try {
const container = document.getElementById("container");
if (!container) {
- throw Error("container not found, can't mount page contents");
+ throw new Error("container not found, can't mount page contents");
}
render(h(App, {}), container);
} catch (e) {
diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts
index 0ab275f54..ed8301d65 100644
--- a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts
+++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts
@@ -24,7 +24,7 @@ import { WithoutProviderType, WithProviderType } from "./views.js";
export type AuthProvByStatusMap = Record<
AuthenticationProviderStatus["status"],
(AuthenticationProviderStatus & { url: string })[]
->
+>;
export type State = NoReducer | InvalidState | WithType | WithoutType;
@@ -63,42 +63,69 @@ const map: StateViewMap<State> = {
"without-type": WithoutProviderType,
};
-export default compose("AddingProviderScreen", useComponentState, map)
-
+export default compose("AddingProviderScreen", useComponentState, map);
+const providerResponseCache = new Map<string, any>(); // `any` is the return type of res.json()
export async function testProvider(
url: string,
expectedMethodType?: string,
): Promise<void> {
+ const testFatalPrefix = `Encountered a fatal error whilst testing the provider ${url}`;
+ let configUrl = "";
try {
- const response = await fetch(new URL("config", url).href);
- const json = await response.json().catch((d) => ({}));
- if (!("methods" in json) || !Array.isArray(json.methods)) {
- throw Error(
- "This provider doesn't have authentication method. Check the provider URL",
- );
- }
- if (!expectedMethodType) {
- return;
- }
- let found = false;
- for (let i = 0; i < json.methods.length && !found; i++) {
- found = json.methods[i].type === expectedMethodType;
- }
- if (!found) {
- throw Error(
- `This provider does not support authentication method ${expectedMethodType}`,
- );
- }
+ configUrl = new URL("config", url).href;
+ } catch (error) {
+ throw new Error(`${testFatalPrefix}: Invalid Provider URL: ${url}
+Error: ${error}`);
+ }
+ // TODO: look into using core.getProviderInfo :)
+ const providerHasUrl = providerResponseCache.has(url);
+ const json = providerHasUrl
+ ? providerResponseCache.get(url)
+ : await fetch(configUrl)
+ .catch((error) => {
+ throw new Error(`${testFatalPrefix}: Could not connect: ${error}
+Please check the URL.`);
+ })
+ .then(async (response) => {
+ if (!response.ok)
+ throw new Error(
+ `${testFatalPrefix}: The server ${response.url} responded with a non-2xx response.`,
+ );
+ try {
+ return await response.json();
+ } catch (error) {
+ throw new Error(
+ `${testFatalPrefix}: The server responded with malformed JSON.\nError: ${error}`,
+ );
+ }
+ });
+ if (typeof json !== "object")
+ throw new Error(
+ `${testFatalPrefix}: Did not get an object after decoding.`,
+ );
+ if (!("name" in json) || json.name !== "anastasis") {
+ throw new Error(
+ `${testFatalPrefix}: The provider does not appear to be an Anastasis provider. Please check the provider's URL.`,
+ );
+ }
+ if (!("methods" in json) || !Array.isArray(json.methods)) {
+ throw new Error(
+ "This provider doesn't have authentication method. Please check the provider's URL and ensure it is properly configured.",
+ );
+ }
+ if (!providerHasUrl) providerResponseCache.set(url, json);
+ if (!expectedMethodType) {
return;
- } catch (e) {
- console.log("ERROR testProvider", e);
- const error =
- e instanceof Error
- ? Error(
- `There was an error testing this provider, try another one. ${e.message}`,
- )
- : Error(`There was an error testing this provider, try another one.`);
- throw error;
}
+ let found = false;
+ for (let i = 0; i < json.methods.length && !found; i++) {
+ found = json.methods[i].type === expectedMethodType;
+ }
+ if (!found) {
+ throw new Error(
+ `${testFatalPrefix}: This provider does not support authentication method ${expectedMethodType}`,
+ );
+ }
+ return;
}
diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts
index f80f1c464..30e4d750d 100644
--- a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts
+++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts
@@ -76,14 +76,23 @@ export default function useComponentState({
useEffect(() => {
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(async () => {
- const url = providerURL.endsWith("/") ? providerURL : providerURL + "/";
- if (!providerURL || authProviders.includes(url)) return;
+ let url = providerURL;
+ if (!url || authProviders.includes(url)) return;
+ if (url && !url.match(/^(https?:)\/\/.+\/(?:config)?$/iu))
+ return setError(
+ "Malformed URL: Must be an HTTP(S) URL ending with a /",
+ );
+ if (url.endsWith("/config")) url = url.substring(0, url.length - 6);
try {
setTesting(true);
await testProvider(url, providerType);
setError("");
} catch (e) {
if (e instanceof Error) setError(e.message);
+ else
+ throw new Error(
+ `Unexpected Error Type: ${typeof e} - Cannot handle. Error: ${e}`,
+ );
}
setTesting(false);
}, 200);
@@ -114,11 +123,12 @@ export default function useComponentState({
let errors = !providerURL ? "Add provider URL" : undefined;
let url: string | undefined;
- try {
- url = new URL("", providerURL).href;
- } catch {
- errors = "Check the URL";
- }
+ // We'll validate it in testProvider & via a regex above - there's no need in this :)
+ // try {
+ // url = new URL("", providerURL).href;
+ // } catch {
+ // errors = "Check the URL";
+ // }
const _url = url;
if (!!error && !errors) {
diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx
index 19557a12f..00a42a949 100644
--- a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx
+++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx
@@ -121,13 +121,13 @@ export function WithoutProviderType(props: WithoutType): VNode {
<div class="container">
<TextInput
label="Provider URL"
- placeholder="https://provider.com"
+ placeholder="https://provider.com/"
grabFocus
error={props.errors}
bind={[props.providerURL, props.setProviderURL]}
/>
</div>
- <p class="block">Example: https://kudos.demo.anastasis.lu</p>
+ <p class="block">Example: https://kudos.demo.anastasis.lu/</p>
{props.testing && <p class="has-text-info">Testing</p>}
<div
diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx
index 228186a2d..1f8cea7aa 100644
--- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx
@@ -97,13 +97,11 @@ export function AttributeEntryScreen(): VNode {
function saveAsPDF(): void {
const printWindow = window.open("", "", "height=400,width=800");
const divContents = document.getElementById("printThis");
- const styleContents = document.getElementById("style-id");
- if (!printWindow || !divContents || !styleContents) return;
+ if (!printWindow || !divContents) return;
printWindow.document.write(
- "<html><head><title>Anastasis Recovery Document</title><style>",
+ `<html><head><link rel="stylesheet" href="index.css" /><title>Anastasis Recovery Document</title><style>`,
);
- printWindow.document.write(styleContents.innerHTML);
printWindow.document.write("</style></head><body>&nbsp;</body></html>");
printWindow.document.close();
printWindow.document.body.appendChild(divContents.cloneNode(true));
@@ -132,6 +130,7 @@ export function AttributeEntryScreen(): VNode {
secret will be safely stored. If you forget what you have entered or
if there is a misspell you will be unable to recover your secret.
<p>
+ {/* TODO: make this actually work reliably cross-browser lol (opens about:blank for me) */}
<a onClick={saveAsPDF}>Save the personal information as PDF</a>
</p>
</ConfirmModal>
diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx
index 62ac410a2..f528bc207 100644
--- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx
@@ -58,9 +58,14 @@ export function RecoveryFinishedScreen(): VNode {
const secret = bytesToString(decodeCrock(encodedSecret.value));
const plainText =
encodedSecret.value.length < 1000 && encodedSecret.mime === "text/plain";
- const contentURI = !plainText
- ? secret
- : `data:${encodedSecret.mime},${secret}`;
+
+ let [uri, setUri] = useState(`data:${encodedSecret.mime},${secret}`);
+ fetch(`data:${encodedSecret.mime},${secret}`) // TODO: look into using new Blob
+ .then((v) => v.blob())
+ .then((blob) => URL.createObjectURL(blob))
+ .then((newUri) => {
+ setUri(newUri);
+ });
return (
<AnastasisClientFrame title="Recovery Success" hideNav>
<h2 class="subtitle">Your secret was recovered</h2>
@@ -87,7 +92,7 @@ export function RecoveryFinishedScreen(): VNode {
download={
encodedSecret.filename ? encodedSecret.filename : "secret.file"
}
- href={contentURI}
+ href={uri}
>
<div class="icon is-small ">
<i class="mdi mdi-download" />
diff --git a/packages/auditor-backoffice-ui/package.json b/packages/auditor-backoffice-ui/package.json
index 33d397f93..776c179b4 100644
--- a/packages/auditor-backoffice-ui/package.json
+++ b/packages/auditor-backoffice-ui/package.json
@@ -1,7 +1,7 @@
{
"private": true,
"name": "@gnu-taler/auditor-backoffice-ui",
- "version": "0.9.3-dev.27",
+ "version": "0.10.7",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
@@ -47,7 +47,7 @@
},
"devDependencies": {
"@creativebulma/bulma-tooltip": "^1.2.0",
- "@gnu-taler/pogen": "^0.0.5",
+ "@gnu-taler/pogen": "workspace:*",
"@types/chai": "^4.3.0",
"@types/history": "^4.7.8",
"@types/mocha": "^8.2.3",
diff --git a/packages/bank-ui/package.json b/packages/bank-ui/package.json
index c25decf8d..f06905a93 100644
--- a/packages/bank-ui/package.json
+++ b/packages/bank-ui/package.json
@@ -1,7 +1,7 @@
{
"private": true,
"name": "@gnu-taler/bank-ui",
- "version": "0.9.3-dev.29",
+ "version": "0.10.7",
"license": "AGPL-3.0-OR-LATER",
"type": "module",
"scripts": {
@@ -31,7 +31,7 @@
"@typescript-eslint/parser": "^6.19.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.33.2",
- "@gnu-taler/pogen": "^0.0.5",
+ "@gnu-taler/pogen": "workspace:*",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@types/chai": "^4.3.0",
diff --git a/packages/bank-ui/postcss.config.js b/packages/bank-ui/postcss.config.js
index 2e7af2b7f..c9a60a43c 100644
--- a/packages/bank-ui/postcss.config.js
+++ b/packages/bank-ui/postcss.config.js
@@ -1,3 +1,18 @@
+/*
+ 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 default {
plugins: {
tailwindcss: {},
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/app.tsx b/packages/bank-ui/src/app.tsx
index a2aa6ec37..1ea8c69ca 100644
--- a/packages/bank-ui/src/app.tsx
+++ b/packages/bank-ui/src/app.tsx
@@ -34,13 +34,10 @@ import { h } from "preact";
import { useEffect, useState } from "preact/hooks";
import { SWRConfig } from "swr";
import { Routing } from "./Routing.js";
-// import { BankCoreApiProvider } from "./context/config.js";
-// import { BrowserHashNavigationProvider } from "./context/navigation.js";
import { SettingsProvider } from "./context/settings.js";
-// import { TalerWalletIntegrationBrowserProvider } from "./context/wallet-integration.js";
import { strings } from "./i18n/strings.js";
import { BankFrame } from "./pages/BankFrame.js";
-import { BankUiSettings, fetchSettings } from "./settings.js";
+import { UiSettings, fetchSettings } from "./settings.js";
import {
revalidateAccountDetails,
revalidatePublicAccounts,
@@ -54,7 +51,7 @@ import {
const WITH_LOCAL_STORAGE_CACHE = false;
export function App() {
- const [settings, setSettings] = useState<BankUiSettings>();
+ const [settings, setSettings] = useState<UiSettings>();
useEffect(() => {
fetchSettings(setSettings);
}, []);
diff --git a/packages/bank-ui/src/context/config.ts b/packages/bank-ui/src/context/config.ts
deleted file mode 100644
index 342a65c4f..000000000
--- a/packages/bank-ui/src/context/config.ts
+++ /dev/null
@@ -1,320 +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 {
- LibtoolVersion,
- ObservableHttpClientLibrary,
- TalerAuthenticationHttpClient,
- TalerBankConversionCacheEviction,
- TalerBankConversionHttpClient,
- TalerCoreBankCacheEviction,
- TalerCoreBankHttpClient,
- TalerCorebankApi,
- TalerError,
- assertUnreachable,
- CacheEvictor,
- ObservabilityEvent,
-} from "@gnu-taler/taler-util";
-import {
- BrowserFetchHttpLib,
- ErrorLoading,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import {
- ComponentChildren,
- FunctionComponent,
- VNode,
- createContext,
- h,
-} from "preact";
-import { useContext, useEffect, useState } from "preact/hooks";
-import {
- revalidateAccountDetails,
- revalidatePublicAccounts,
- revalidateTransactions,
-} from "../hooks/account.js";
-import {
- revalidateBusinessAccounts,
- revalidateCashouts,
- revalidateConversionInfo,
-} from "../hooks/regional.js";
-
-/**
- *
- * @author Sebastian Javier Marchano (sebasjm)
- */
-
-export type Type = {
- url: URL;
- config: TalerCorebankApi.Config;
- bank: TalerCoreBankHttpClient;
- conversion: TalerBankConversionHttpClient;
- authenticator: (user: string) => TalerAuthenticationHttpClient;
- hints: VersionHint[];
- onBackendActivity: (fn: Listener) => Unsuscriber;
- cancelRequest: (eventId: string) => void;
-};
-
-// FIXME: below
-// @ts-expect-error default value to undefined, should it be another thing?
-const Context = createContext<Type>(undefined);
-
-export const useBankCoreApiContext = (): Type => useContext(Context);
-
-export enum VersionHint {
- /**
- * when this flag is on, server is running an old version with cashout before implementing 2fa API
- */
- CASHOUT_BEFORE_2FA,
-}
-
-const observers = new Array<(e: ObservabilityEvent) => void>();
-type Listener = (e: ObservabilityEvent) => void;
-type Unsuscriber = () => void;
-
-const activity = Object.freeze({
- notify: (data: ObservabilityEvent) =>
- observers.forEach((observer) => observer(data)),
- subscribe: (func: Listener): Unsuscriber => {
- observers.push(func);
- return () => {
- observers.forEach((observer, index) => {
- if (observer === func) {
- observers.splice(index, 1);
- }
- });
- };
- },
-});
-
-export type ConfigResult =
- | undefined
- | { type: "ok"; config: TalerCorebankApi.Config; hints: VersionHint[] }
- | { type: "incompatible"; result: TalerCorebankApi.Config; supported: string }
- | { type: "error"; error: TalerError };
-
-export const BankCoreApiProvider = ({
- baseUrl,
- children,
- frameOnError,
-}: {
- baseUrl: string;
- children: ComponentChildren;
- frameOnError: FunctionComponent<{ children: ComponentChildren }>;
-}): VNode => {
- const [checked, setChecked] = useState<ConfigResult>();
- const { i18n } = useTranslationContext();
-
- const { bankClient, conversionClient, authClient, cancelRequest } =
- buildApiClient(new URL(baseUrl));
-
- useEffect(() => {
- bankClient
- .getConfig()
- .then((resp) => {
- if (resp.type === "fail") {
- setChecked({ type: "error", error: TalerError.fromUncheckedDetail(resp.detail) });
- } else if (bankClient.isCompatible(resp.body.version)) {
- setChecked({ type: "ok", config: resp.body, hints: [] });
- } else {
- // this API supports version 3.0.3
- const compare = LibtoolVersion.compare("3:0:3", resp.body.version);
- if (compare?.compatible ?? false) {
- setChecked({
- type: "ok",
- config: resp.body,
- hints: [VersionHint.CASHOUT_BEFORE_2FA],
- });
- } else {
- setChecked({
- type: "incompatible",
- result: resp.body,
- supported: bankClient.PROTOCOL_VERSION,
- });
- }
- }
- })
- .catch((error: unknown) => {
- if (error instanceof TalerError) {
- setChecked({ type: "error", error });
- }
- });
- }, []);
-
- if (checked === undefined) {
- return h(frameOnError, { children: h("div", {}, "loading...") });
- }
- if (checked.type === "error") {
- return h(frameOnError, {
- children: h(ErrorLoading, { error: checked.error, showDetail: true }),
- });
- }
- if (checked.type === "incompatible") {
- return h(frameOnError, {
- children: h(
- "div",
- {},
- i18n.str`The bank backend is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`,
- ),
- });
- }
- const value: Type = {
- url: new URL(bankClient.baseUrl),
- config: checked.config,
- bank: bankClient,
- onBackendActivity: activity.subscribe,
- conversion: conversionClient,
- authenticator: authClient,
- cancelRequest,
- hints: checked.hints,
- };
- return h(Context.Provider, {
- value,
- children,
- });
-};
-
-/**
- * build http client with cache breaker due to SWR
- * @param url
- * @returns
- */
-function buildApiClient(url: URL) {
- const httpFetch = new BrowserFetchHttpLib({
- enableThrottling: true,
- requireTls: false,
- });
- const httpLib = new ObservableHttpClientLibrary(httpFetch, {
- observe(ev) {
- activity.notify(ev);
- },
- });
-
- function cancelRequest(id: string) {
- httpLib.cancelRequest(id);
- }
-
- const bankClient = new TalerCoreBankHttpClient(
- url.href,
- httpLib,
- evictBankSwrCache,
- );
- const conversionClient = new TalerBankConversionHttpClient(
- bankClient.getConversionInfoAPI().href,
- httpLib,
- evictConversionSwrCache,
- );
- const authClient = (user: string) =>
- new TalerAuthenticationHttpClient(
- bankClient.getAuthenticationAPI(user).href,
- httpLib,
- );
-
- return { bankClient, conversionClient, authClient, cancelRequest };
-}
-
-export const BankCoreApiProviderTesting = ({
- children,
- state,
- url,
-}: {
- children: ComponentChildren;
- state: TalerCorebankApi.Config;
- url: string;
-}): VNode => {
- const value: Type = {
- url: new URL(url),
- config: state,
- // @ts-expect-error this API is not being used, not really needed
- bank: undefined,
- hints: [],
- };
-
- return h(Context.Provider, {
- value,
- children,
- });
-};
-
-const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = {
- async notifySuccess(op) {
- switch (op) {
- case TalerCoreBankCacheEviction.DELETE_ACCOUNT: {
- await Promise.all([
- revalidatePublicAccounts(),
- revalidateBusinessAccounts(),
- ]);
- return;
- }
- case TalerCoreBankCacheEviction.CREATE_ACCOUNT: {
- // admin balance change on new account
- await Promise.all([
- revalidateAccountDetails(),
- revalidateTransactions(),
- revalidatePublicAccounts(),
- revalidateBusinessAccounts(),
- ]);
- return;
- }
- case TalerCoreBankCacheEviction.UPDATE_ACCOUNT: {
- await Promise.all([revalidateAccountDetails()]);
- return;
- }
- case TalerCoreBankCacheEviction.CREATE_TRANSACTION: {
- await Promise.all([
- revalidateAccountDetails(),
- revalidateTransactions(),
- ]);
- return;
- }
- case TalerCoreBankCacheEviction.CONFIRM_WITHDRAWAL: {
- await Promise.all([
- revalidateAccountDetails(),
- revalidateTransactions(),
- ]);
- return;
- }
- case TalerCoreBankCacheEviction.CREATE_CASHOUT: {
- await Promise.all([
- revalidateAccountDetails(),
- revalidateCashouts(),
- revalidateTransactions(),
- ]);
- return;
- }
- case TalerCoreBankCacheEviction.UPDATE_PASSWORD:
- case TalerCoreBankCacheEviction.ABORT_WITHDRAWAL:
- case TalerCoreBankCacheEviction.CREATE_WITHDRAWAL:
- return;
- default:
- assertUnreachable(op);
- }
- },
-};
-
-const evictConversionSwrCache: CacheEvictor<TalerBankConversionCacheEviction> =
- {
- async notifySuccess(op) {
- switch (op) {
- case TalerBankConversionCacheEviction.UPDATE_RATE: {
- await revalidateConversionInfo();
- return;
- }
- default:
- assertUnreachable(op);
- }
- },
- };
diff --git a/packages/bank-ui/src/context/navigation.ts b/packages/bank-ui/src/context/navigation.ts
deleted file mode 100644
index 9552bf899..000000000
--- a/packages/bank-ui/src/context/navigation.ts
+++ /dev/null
@@ -1,92 +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 { ComponentChildren, createContext, h, VNode } from "preact";
-import { useContext, useEffect, useState } from "preact/hooks";
-import { AppLocation } from "../route.js";
-
-/**
- *
- * @author Sebastian Javier Marchano (sebasjm)
- */
-
-export type Type = {
- path: string;
- params: Record<string, string>;
- navigateTo: (path: AppLocation) => void;
- // addNavigationListener: (listener: (path: string, params: Record<string, string>) => void) => (() => void);
-};
-
-// @ts-expect-error should not be used without provider
-const Context = createContext<Type>(undefined);
-
-export const useNavigationContext = (): Type => useContext(Context);
-
-function getPathAndParamsFromWindow() {
- const path =
- typeof window !== "undefined" ? window.location.hash.substring(1) : "/";
- const params: Record<string, string> = {};
- if (typeof window !== "undefined") {
- for (const [key, value] of new URLSearchParams(window.location.search)) {
- params[key] = value;
- }
- }
- return { path, params };
-}
-
-const { path: initialPath, params: initialParams } =
- getPathAndParamsFromWindow();
-
-// there is a possibility that if the browser does a redirection
-// (which doesn't go through navigatTo function) and that executed
-// too early (before addEventListener runs) it won't be taking
-// into account
-const PopStateEventType = "popstate";
-
-export const BrowserHashNavigationProvider = ({
- children,
-}: {
- children: ComponentChildren;
-}): VNode => {
- const [{ path, params }, setState] = useState({
- path: initialPath,
- params: initialParams,
- });
- if (typeof window === "undefined") {
- throw Error(
- "Can't use BrowserHashNavigationProvider if there is no window object",
- );
- }
- function navigateTo(path: string) {
- const { params } = getPathAndParamsFromWindow();
- setState({ path, params });
- window.location.href = path;
- }
-
- useEffect(() => {
- function eventListener() {
- setState(getPathAndParamsFromWindow());
- }
- window.addEventListener(PopStateEventType, eventListener);
- return () => {
- window.removeEventListener(PopStateEventType, eventListener);
- };
- }, []);
- return h(Context.Provider, {
- value: { path, params, navigateTo },
- children,
- });
-};
diff --git a/packages/bank-ui/src/context/settings.ts b/packages/bank-ui/src/context/settings.ts
index 053fcbd12..6c61a7b4a 100644
--- a/packages/bank-ui/src/context/settings.ts
+++ b/packages/bank-ui/src/context/settings.ts
@@ -16,16 +16,16 @@
import { ComponentChildren, createContext, h, VNode } from "preact";
import { useContext } from "preact/hooks";
-import { BankUiSettings } from "../settings.js";
+import { UiSettings } from "../settings.js";
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
-export type Type = BankUiSettings;
+export type Type = UiSettings;
-const initial: BankUiSettings = {};
+const initial: UiSettings = {};
const Context = createContext<Type>(initial);
export const useSettingsContext = (): Type => useContext(Context);
@@ -34,7 +34,7 @@ export const SettingsProvider = ({
children,
value,
}: {
- value: BankUiSettings;
+ value: UiSettings;
children: ComponentChildren;
}): VNode => {
return h(Context.Provider, {
diff --git a/packages/bank-ui/src/hooks/form.ts b/packages/bank-ui/src/hooks/form.ts
index afa4912eb..fae11c05c 100644
--- a/packages/bank-ui/src/hooks/form.ts
+++ b/packages/bank-ui/src/hooks/form.ts
@@ -72,6 +72,7 @@ function constructFormHandler<T>(
updateForm: (d: FormValues<T>) => void,
errors: FormErrors<T> | undefined,
): FormHandler<T> {
+
const keys = Object.keys(form) as Array<keyof T>;
const handler = keys.reduce((prev, fieldName) => {
@@ -102,6 +103,14 @@ function constructFormHandler<T>(
return handler;
}
+/**
+ * FIXME: Consider sending this to web-utils
+ *
+ *
+ * @param defaultValue
+ * @param check
+ * @returns
+ */
export function useFormState<T>(
defaultValue: FormValues<T>,
check: (f: FormValues<T>) => FormStatus<T>,
diff --git a/packages/bank-ui/src/i18n/bank.pot b/packages/bank-ui/src/i18n/bank.pot
index 1f11b8f10..1d8595d17 100644
--- a/packages/bank-ui/src/i18n/bank.pot
+++ b/packages/bank-ui/src/i18n/bank.pot
@@ -263,11 +263,13 @@ msgstr ""
#: src/pages/PaytoWireTransferForm.tsx:457
#, c-format
+msgctxt "wire_transfer"
msgid "Cancel"
msgstr ""
#: src/pages/PaytoWireTransferForm.tsx:471
#, c-format
+msgctxt "wire_transfer"
msgid "Send"
msgstr ""
diff --git a/packages/bank-ui/src/i18n/de.po b/packages/bank-ui/src/i18n/de.po
index ccbbc8208..54fda4377 100644
--- a/packages/bank-ui/src/i18n/de.po
+++ b/packages/bank-ui/src/i18n/de.po
@@ -14,7 +14,7 @@ msgstr ""
"Project-Id-Version: Taler Wallet\n"
"Report-Msgid-Bugs-To: taler@gnu.org\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
-"PO-Revision-Date: 2024-03-21 21:39+0000\n"
+"PO-Revision-Date: 2024-05-05 09:32+0000\n"
"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
"Language-Team: German <https://weblate.taler.net/projects/gnu-taler/"
"taler-bank-spa/de/>\n"
@@ -23,7 +23,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
-"X-Generator: Weblate 5.2.1\n"
+"X-Generator: Weblate 5.4.3\n"
#: src/utils.ts:137
#, c-format
@@ -268,7 +268,7 @@ msgstr ""
#: src/pages/PaytoWireTransferForm.tsx:457
#, c-format
msgid "Cancel"
-msgstr ""
+msgstr "Zurück"
#: src/pages/PaytoWireTransferForm.tsx:471
#, c-format
@@ -490,7 +490,7 @@ msgstr ""
#: src/pages/OperationState/views.tsx:319
#, c-format
msgid "Close"
-msgstr ""
+msgstr "Schließen"
#: src/pages/OperationState/views.tsx:399
#, c-format
@@ -558,7 +558,7 @@ msgstr ""
#: src/pages/WalletWithdrawForm.tsx:253
#, c-format
msgid "Continue"
-msgstr ""
+msgstr "Weiter"
#: src/pages/WalletWithdrawForm.tsx:282
#, c-format
diff --git a/packages/bank-ui/src/i18n/es.po b/packages/bank-ui/src/i18n/es.po
index fb69822c5..39527f1dd 100644
--- a/packages/bank-ui/src/i18n/es.po
+++ b/packages/bank-ui/src/i18n/es.po
@@ -271,11 +271,13 @@ msgstr "payto://iban/[iban-destinatario]?message=[asunto]&amount=[%1$s:X.Y]"
#: src/pages/PaytoWireTransferForm.tsx:457
#, c-format
+msgctxt "wire_transfer"
msgid "Cancel"
msgstr "Cancelar"
#: src/pages/PaytoWireTransferForm.tsx:471
#, c-format
+msgctxt "wire_transfer"
msgid "Send"
msgstr "Envíar"
diff --git a/packages/bank-ui/src/i18n/en.po b/packages/bank-ui/src/i18n/ru.po
index a9657bd32..8cd1eec53 100644
--- a/packages/bank-ui/src/i18n/en.po
+++ b/packages/bank-ui/src/i18n/ru.po
@@ -1,395 +1,406 @@
-# This file is part of GNU Taler
-# (C) 2021 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/>
+# 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/>
#
msgid ""
msgstr ""
-"Project-Id-Version: Taler Wallet\n"
+"Project-Id-Version: Taler Bank\n"
"Report-Msgid-Bugs-To: taler@gnu.org\n"
-"POT-Creation-Date: 2016-11-23 00:00+0100\n"
-"PO-Revision-Date: 2022-01-08 09:57+0100\n"
-"Last-Translator: <translate@taler.net>\n"
-"Language-Team: English\n"
-"Language: en\n"
+"PO-Revision-Date: 2024-05-10 00:13+0000\n"
+"Last-Translator: Lily Ponomareva <lilyponomareva2017@gmail.com>\n"
+"Language-Team: Russian <https://weblate.taler.net/projects/gnu-taler/"
+"taler-bank-spa/ru/>\n"
+"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
+"X-Generator: Weblate 5.4.3\n"
#: src/utils.ts:137
#, c-format
msgid "Operation failed, please report"
-msgstr ""
+msgstr "Не удалось выполнить операцию, сообщите об этом"
#: src/utils.ts:156
#, c-format
msgid "Request timeout"
-msgstr ""
+msgstr "Тайм-аут запроса"
#: src/utils.ts:165
#, c-format
msgid "Request throttled"
-msgstr ""
+msgstr "Запрос замедлен"
#: src/utils.ts:174
#, c-format
msgid "Malformed response"
-msgstr ""
+msgstr "Неправильный ответ"
#: src/utils.ts:183
#, c-format
msgid "Network error"
-msgstr ""
+msgstr "Ошибка сети"
#: src/utils.ts:192
#, c-format
msgid "Unexpected request error"
-msgstr ""
+msgstr "Неожиданная ошибка запроса"
#: src/utils.ts:201
#, c-format
msgid "Unexpected error"
-msgstr ""
+msgstr "Непредвиденная ошибка"
#: src/utils.ts:377
#, c-format
msgid "IBAN numbers usually have more that 4 digits"
-msgstr ""
+msgstr "Номера IBAN обычно содержат более 4 цифр"
#: src/utils.ts:379
#, c-format
msgid "IBAN numbers usually have less that 34 digits"
-msgstr ""
+msgstr "Номера IBAN обычно содержат менее 34 цифр"
#: src/utils.ts:387
#, c-format
msgid "IBAN country code not found"
-msgstr ""
+msgstr "Код страны IBAN не найден"
#: src/utils.ts:401
#, c-format
msgid "IBAN number is not valid, checksum is wrong"
-msgstr ""
+msgstr "Номер IBAN недействителен, контрольная сумма неверна"
#: src/context/config.ts:136
#, c-format
msgid ""
-"the bank backend is not supported. supported version \"%1$s\", server "
-"version \"%2$s\""
+"the bank backend is not supported. supported version \"%1$s\", server version "
+"\"%2$s\""
msgstr ""
+"Бэкенд банка не поддерживается. Поддерживаемая версия \"%1$s\" а версия "
+"сервера \"%2$s\""
#: src/hooks/preferences.ts:55
-#, fuzzy, c-format
+#, c-format
msgid "Max withdrawal amount"
-msgstr ""
+msgstr "Максимальная сумма вывода"
#: src/hooks/preferences.ts:57
#, c-format
msgid "Show withdrawal confirmation"
-msgstr ""
+msgstr "Показать подтверждение вывода средств"
#: src/hooks/preferences.ts:59
#, c-format
msgid "Show demo description"
-msgstr ""
+msgstr "Показать описание демо"
#: src/hooks/preferences.ts:61
#, c-format
msgid "Show install wallet first"
-msgstr ""
+msgstr "Сначала показать как установить кошелёк"
#: src/hooks/preferences.ts:63
-#, fuzzy, c-format
+#, c-format
msgid "Use fast withdrawal form"
-msgstr ""
+msgstr "Используйте форму быстрого вывода средств"
#: src/hooks/preferences.ts:65
#, c-format
msgid "Show debug info"
-msgstr ""
+msgstr "Показать информацию для отладки"
#: src/pages/PaytoWireTransferForm.tsx:90
#, c-format
msgid "required"
-msgstr ""
+msgstr "обязательно"
#: src/pages/PaytoWireTransferForm.tsx:92
#, c-format
msgid "IBAN should have just uppercased letters and numbers"
-msgstr ""
+msgstr "IBAN должен состоять только из прописных букв и цифр"
#: src/pages/PaytoWireTransferForm.tsx:98
#, c-format
msgid "not valid"
-msgstr ""
+msgstr "недопустимый"
#: src/pages/PaytoWireTransferForm.tsx:100
#, c-format
msgid "should be greater than 0"
-msgstr ""
+msgstr "должно быть больше 0"
#: src/pages/PaytoWireTransferForm.tsx:102
#, c-format
msgid "balance is not enough"
-msgstr ""
+msgstr "Недостаточно средств на балансе"
#: src/pages/PaytoWireTransferForm.tsx:112
#, c-format
msgid "does not follow the pattern"
-msgstr ""
+msgstr "не следует шаблону"
#: src/pages/PaytoWireTransferForm.tsx:114
#, c-format
msgid "only \"IBAN\" target are supported"
-msgstr ""
+msgstr "поддерживаются только \"IBAN\""
#: src/pages/PaytoWireTransferForm.tsx:116
#, c-format
msgid "use the \"amount\" parameter to specify the amount to be transferred"
-msgstr ""
+msgstr "Используйте параметр \"Сумма\" для указания суммы перевода"
#: src/pages/PaytoWireTransferForm.tsx:118
#, c-format
msgid "the amount is not valid"
-msgstr ""
+msgstr "сумма не является действительной"
#: src/pages/PaytoWireTransferForm.tsx:120
#, c-format
-msgid ""
-"use the \"message\" parameter to specify a reference text for the transfer"
-msgstr ""
+msgid "use the \"message\" parameter to specify a reference text for the transfer"
+msgstr "используйте параметр \"message\" для текста причины перевода"
#: src/pages/PaytoWireTransferForm.tsx:160
#, c-format
msgid "The request was invalid or the payto://-URI used unacceptable features."
msgstr ""
+"Запрос был неверным или payto://-URI использовал недопустимую "
+"функциональность."
#: src/pages/PaytoWireTransferForm.tsx:167
#, c-format
msgid "Not enough permission to complete the operation."
-msgstr ""
+msgstr "Не хватает разрешения для завершения операции."
#: src/pages/PaytoWireTransferForm.tsx:174
#, c-format
msgid "The destination account \"%1$s\" was not found."
-msgstr ""
+msgstr "Целевой счет \"%1$s\" не найден."
#: src/pages/PaytoWireTransferForm.tsx:181
#, c-format
msgid "The origin and the destination of the transfer can't be the same."
-msgstr ""
+msgstr "Пункт отправления и пункт назначения перевода не могут совпадать."
#: src/pages/PaytoWireTransferForm.tsx:188
#, c-format
msgid "Your balance is not enough."
-msgstr ""
+msgstr "Вашего баланса недостаточно."
#: src/pages/PaytoWireTransferForm.tsx:195
#, c-format
msgid "The origin account \"%1$s\" was not found."
-msgstr ""
+msgstr "Исходный аккаунт \"%1$s\" не найден."
#: src/pages/PaytoWireTransferForm.tsx:212
#, c-format
msgid "Wire transfer created!"
-msgstr ""
+msgstr "Банковский перевод создан!"
#: src/pages/PaytoWireTransferForm.tsx:270
#, c-format
msgid "Using a form"
-msgstr ""
+msgstr "Используя форму"
#: src/pages/PaytoWireTransferForm.tsx:310
#, c-format
msgid "Import payto:// URI"
-msgstr ""
+msgstr "Импорт payto:// URI"
#: src/pages/PaytoWireTransferForm.tsx:335
#, c-format
msgid "Recipient"
-msgstr ""
+msgstr "Получатель"
#: src/pages/PaytoWireTransferForm.tsx:359
#, c-format
msgid "IBAN of the recipient's account"
-msgstr ""
+msgstr "IBAN счета получателя"
#: src/pages/PaytoWireTransferForm.tsx:369
#, c-format
msgid "Transfer subject"
-msgstr ""
+msgstr "Причина перевода"
#: src/pages/PaytoWireTransferForm.tsx:377
#, c-format
msgid "subject"
-msgstr ""
+msgstr "причина"
#: src/pages/PaytoWireTransferForm.tsx:390
#, c-format
msgid "some text to identify the transfer"
-msgstr ""
+msgstr "какой-то текст для идентификации перевода"
#: src/pages/PaytoWireTransferForm.tsx:400
#, c-format
msgid "Amount"
-msgstr ""
+msgstr "Сумма"
#: src/pages/PaytoWireTransferForm.tsx:415
-#, fuzzy, c-format
+#, c-format
msgid "amount to transfer"
-msgstr ""
+msgstr "сумма для перевода"
#: src/pages/PaytoWireTransferForm.tsx:425
#, c-format
msgid "payto URI:"
-msgstr ""
+msgstr "payto URI:"
#: src/pages/PaytoWireTransferForm.tsx:436
#, c-format
msgid "uniform resource identifier of the target account"
-msgstr ""
+msgstr "унифицированный идентификатор ресурса целевой учетной записи"
#: src/pages/PaytoWireTransferForm.tsx:437
#, c-format
msgid "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]"
msgstr ""
+"payto://iban/[iban_получателя]?message=[причина_платежа]&amount=[%1$s:X.Y]"
#: src/pages/PaytoWireTransferForm.tsx:457
#, c-format
+msgctxt "wire_transfer"
msgid "Cancel"
-msgstr ""
+msgstr "Отмена"
#: src/pages/PaytoWireTransferForm.tsx:471
#, c-format
+msgctxt "wire_transfer"
msgid "Send"
-msgstr ""
+msgstr "Отправить"
#: src/pages/LoginForm.tsx:71
#, c-format
msgid "Missing username"
-msgstr ""
+msgstr "Отсутствует имя пользователя"
#: src/pages/LoginForm.tsx:75
#, c-format
msgid "Missing password"
-msgstr ""
+msgstr "Отсутствует пароль"
#: src/pages/LoginForm.tsx:104
#, c-format
msgid "Wrong credentials for \"%1$s\""
-msgstr ""
+msgstr "Неверные учетные данные для «%1$s» ‎"
#: src/pages/LoginForm.tsx:111
#, c-format
msgid "Account not found"
-msgstr ""
+msgstr "Учётная запись не найдена"
#: src/pages/LoginForm.tsx:142
#, c-format
msgid "Username"
-msgstr ""
+msgstr "Имя пользователя"
#: src/pages/LoginForm.tsx:156
#, c-format
msgid "username of the account"
-msgstr ""
+msgstr "имя пользователя счёта"
#: src/pages/LoginForm.tsx:175
#, c-format
msgid "Password"
-msgstr ""
+msgstr "Пароль"
#: src/pages/LoginForm.tsx:188
#, c-format
msgid "password of the account"
-msgstr ""
+msgstr "пароль от счёта"
#: src/pages/LoginForm.tsx:223
#, c-format
msgid "Check"
-msgstr ""
+msgstr "Проверить"
#: src/pages/LoginForm.tsx:237
#, c-format
msgid "Log in"
-msgstr ""
+msgstr "Войти"
#: src/pages/LoginForm.tsx:249
#, c-format
msgid "Register"
-msgstr ""
+msgstr "Регистрация"
#: src/components/Transactions/views.tsx:52
#, c-format
msgid "Latest transactions"
-msgstr ""
+msgstr "Последние транзакции"
#: src/components/Transactions/views.tsx:63
#, c-format
msgid "Date"
-msgstr ""
+msgstr "Дата"
#: src/components/Transactions/views.tsx:71
#, c-format
msgid "Counterpart"
-msgstr ""
+msgstr "Контрагент"
#: src/components/Transactions/views.tsx:75
#, c-format
msgid "Subject"
-msgstr ""
+msgstr "Причина"
#: src/components/Transactions/views.tsx:111
#, c-format
msgid "sent"
-msgstr ""
+msgstr "отправлено"
#: src/components/Transactions/views.tsx:112
#, c-format
msgid "received"
-msgstr ""
+msgstr "получено"
#: src/components/Transactions/views.tsx:127
#, c-format
msgid "invalid value"
-msgstr ""
+msgstr "Недопустимое значение"
#: src/components/Transactions/views.tsx:136
#, c-format
msgid "to"
-msgstr ""
+msgstr "к"
#: src/components/Transactions/views.tsx:136
#, c-format
msgid "from"
-msgstr ""
+msgstr "от"
#: src/components/Transactions/views.tsx:202
#, c-format
msgid "First page"
-msgstr ""
+msgstr "Первая страница"
#: src/components/Transactions/views.tsx:209
#, c-format
msgid "Next"
-msgstr ""
+msgstr "Далее"
#: src/pages/WithdrawalConfirmationQuestion.tsx:86
#, c-format
msgid "Wire transfer completed!"
-msgstr ""
+msgstr "Отправка перевода завершена!"
#: src/pages/WithdrawalConfirmationQuestion.tsx:93
#, c-format
msgid "The withdrawal has been aborted previously and can't be confirmed"
-msgstr ""
+msgstr "Вывод средств был прерван ранее и не может быть подтвержден"
#: src/pages/WithdrawalConfirmationQuestion.tsx:100
#, c-format
@@ -397,62 +408,63 @@ msgid ""
"The withdrawal operation can't be confirmed before a wallet accepted the "
"transaction."
msgstr ""
+"Операция по выводу средств не может быть подтверждена до того как кошёлек "
+"примет транзакцию."
#: src/pages/WithdrawalConfirmationQuestion.tsx:107
#, c-format
msgid "The operation id is invalid."
-msgstr ""
+msgstr "Идентификатор операции недействителен."
#: src/pages/WithdrawalConfirmationQuestion.tsx:114
#, c-format
msgid "The operation was not found."
-msgstr ""
+msgstr "Операция не найдена."
#: src/pages/WithdrawalConfirmationQuestion.tsx:121
#, c-format
msgid "Your balance is not enough for the operation."
-msgstr ""
+msgstr "Вашего баланса недостаточно для проведения операции."
#: src/pages/WithdrawalConfirmationQuestion.tsx:155
#, c-format
-msgid ""
-"The reserve operation has been confirmed previously and can't be aborted"
-msgstr ""
+msgid "The reserve operation has been confirmed previously and can't be aborted"
+msgstr "Резервная операция была подтверждена ранее и не может быть прервана"
#: src/pages/WithdrawalConfirmationQuestion.tsx:186
-#, fuzzy, c-format
+#, c-format
msgid "Confirm the withdrawal operation"
-msgstr ""
+msgstr "Подтвердите операцию вывода"
#: src/pages/WithdrawalConfirmationQuestion.tsx:203
#, c-format
msgid "Wire transfer details"
-msgstr ""
+msgstr "Детали банковского перевода"
#: src/pages/WithdrawalConfirmationQuestion.tsx:217
#, c-format
msgid "Taler Exchange operator's account"
-msgstr ""
+msgstr "Счет оператора Обменника Taler"
#: src/pages/WithdrawalConfirmationQuestion.tsx:228
#, c-format
msgid "Taler Exchange operator's name"
-msgstr ""
+msgstr "Название оператора Обменника Taler"
#: src/pages/WithdrawalConfirmationQuestion.tsx:317
#, c-format
msgid "Transfer"
-msgstr ""
+msgstr "Перевести"
#: src/pages/WithdrawalConfirmationQuestion.tsx:342
#, c-format
msgid "Authentication required"
-msgstr ""
+msgstr "Требуется аутентификация"
#: src/pages/WithdrawalConfirmationQuestion.tsx:352
#, c-format
msgid "This operation was created with other username"
-msgstr ""
+msgstr "Эта операция была создана с другим именем пользователя"
#: src/pages/OperationState/views.tsx:209
#, c-format
@@ -460,16 +472,18 @@ msgid ""
"Unauthorized to make the operation, maybe the session has expired or the "
"password changed."
msgstr ""
+"Неавторизированное выполнение операции, возможно истек сеанс или изменён "
+"пароль."
#: src/pages/OperationState/views.tsx:218
#, c-format
msgid "The operation was rejected due to insufficient funds."
-msgstr ""
+msgstr "Операция отклонена из-за нехватки средств."
#: src/pages/OperationState/views.tsx:268
#, c-format
msgid "Withdrawal confirmed"
-msgstr ""
+msgstr "Вывод подтверждён"
#: src/pages/OperationState/views.tsx:272
#, c-format
@@ -477,524 +491,544 @@ msgid ""
"The wire transfer to the Taler operator has been initiated. You will soon "
"receive the requested amount in your Taler wallet."
msgstr ""
+"Инициирован банковский перевод оператору Taler. Вскоре вы получите "
+"запрошенную сумму на свой кошелёк Taler."
#: src/pages/OperationState/views.tsx:287
#, c-format
msgid "Do not show this again"
-msgstr ""
+msgstr "Не показывать снова"
#: src/pages/OperationState/views.tsx:319
#, c-format
msgid "Close"
-msgstr ""
+msgstr "Закрыть"
#: src/pages/OperationState/views.tsx:399
#, c-format
msgid "On this device"
-msgstr ""
+msgstr "На этом устройстве"
#: src/pages/OperationState/views.tsx:404
#, c-format
msgid ""
-"If you are using a web browser on desktop you should access your wallet with "
-"the GNU Taler WebExtension now or click the link if your WebExtension have "
-"the \"Inject Taler support\" option enabled."
+"If you are using a web browser on desktop you should access your wallet with the "
+"GNU Taler WebExtension now or click the link if your WebExtension have the "
+"\"Inject Taler support\" option enabled."
msgstr ""
+"Если вы используете веб-браузер на рабочем столе, вы можете получить доступ "
+"к своему кошельку с помощью расширения браузера GNU Taler прямо сейчас или "
+"нажать на ссылку если в вашем расширении браузера включена опция «Встронная "
+"поддержка Taler»."
#: src/pages/OperationState/views.tsx:417
#, c-format
msgid "Start"
-msgstr ""
+msgstr "Старт"
#: src/pages/OperationState/views.tsx:426
#, c-format
msgid "On a mobile phone"
-msgstr ""
+msgstr "На мобильном телефоне"
#: src/pages/OperationState/views.tsx:431
#, c-format
msgid "Scan the QR code with your mobile device."
-msgstr ""
+msgstr "Отсканируйте QR-код с помощью мобильного устройства."
#: src/pages/WalletWithdrawForm.tsx:73
#, c-format
msgid "There is an operation already"
-msgstr ""
+msgstr "Операция уже идет"
#: src/pages/WalletWithdrawForm.tsx:75
-#, fuzzy, c-format
+#, c-format
msgid "Complete or cancel the operation in"
-msgstr ""
+msgstr "Завершите или отмените операцию в"
#: src/pages/WalletWithdrawForm.tsx:84
#, c-format
msgid "this page"
-msgstr ""
+msgstr "этой странице"
#: src/pages/WalletWithdrawForm.tsx:101
#, c-format
msgid "invalid"
-msgstr ""
+msgstr "недействительно"
#: src/pages/WalletWithdrawForm.tsx:116
#, c-format
msgid "Server responded with an invalid withdraw URI"
-msgstr ""
+msgstr "Сервер ответил с недопустимым URI вывода"
#: src/pages/WalletWithdrawForm.tsx:117
-#, fuzzy, c-format
+#, c-format
msgid "Withdraw URI: %1$s"
-msgstr ""
+msgstr "URI вывода: %1$s"
#: src/pages/WalletWithdrawForm.tsx:132
#, c-format
msgid "The operation was rejected due to insufficient funds"
-msgstr ""
+msgstr "Операция отклонена из-за нехватки средств."
#: src/pages/WalletWithdrawForm.tsx:253
#, c-format
msgid "Continue"
-msgstr ""
+msgstr "Продолжить"
#: src/pages/WalletWithdrawForm.tsx:282
#, c-format
msgid "Prepare your wallet"
-msgstr ""
+msgstr "Подготовьте свой кошелёк"
#: src/pages/WalletWithdrawForm.tsx:285
#, c-format
msgid ""
-"After using your wallet you will need to confirm or cancel the operation on "
-"this site."
+"After using your wallet you will need to confirm or cancel the operation on this "
+"site."
msgstr ""
+"После использования кошелька вам нужно будет подтвердить или отменить "
+"операцию на этом сайте."
#: src/pages/WalletWithdrawForm.tsx:295
-#, fuzzy, c-format
+#, c-format
msgid "You need a GNU Taler Wallet"
-msgstr ""
+msgstr "Вам нужен кошелёк Taler"
#: src/pages/WalletWithdrawForm.tsx:300
#, c-format
msgid "If you don't have one yet you can follow the instruction in"
-msgstr ""
+msgstr "Если у вас его еще нет, вы можете следовать инструкциям на"
#: src/pages/PaymentOptions.tsx:55
#, c-format
msgid "Send money"
-msgstr ""
+msgstr "Отправить деньги"
#: src/pages/PaymentOptions.tsx:73
#, c-format
msgid "to a %1$s wallet"
-msgstr ""
+msgstr "на кошелёк %1$s"
#: src/pages/PaymentOptions.tsx:95
#, c-format
msgid "Withdraw digital money into your mobile wallet or browser extension"
msgstr ""
+"Выводите цифровые деньги на свой мобильный кошелёк или расширение для "
+"браузера"
#: src/pages/PaymentOptions.tsx:109
#, c-format
msgid "operation ready"
-msgstr ""
+msgstr "операция готова"
#: src/pages/PaymentOptions.tsx:129
#, c-format
msgid "to another bank account"
-msgstr ""
+msgstr "на другой банковский счет"
#: src/pages/PaymentOptions.tsx:149
#, c-format
msgid "Make a wire transfer to an account with known bank account number."
msgstr ""
+"Сделайте банковский перевод на счет с известным номером банковского счета."
#: src/pages/PaymentOptions.tsx:171
-#, fuzzy, c-format
+#, c-format
msgid "Transfer details"
-msgstr ""
+msgstr "Подробности перевода"
#: src/pages/AccountPage/views.tsx:41
#, c-format
msgid "This is a demo bank"
-msgstr ""
+msgstr "Это демо-банк"
#: src/pages/AccountPage/views.tsx:46
#, c-format
msgid ""
-"This part of the demo shows how a bank that supports Taler directly would "
-"work. In addition to using your own bank account, you can also see the "
-"transaction history of some %1$s."
+"This part of the demo shows how a bank that supports Taler directly would work. "
+"In addition to using your own bank account, you can also see the transaction "
+"history of some %1$s."
msgstr ""
+"В этой части демонстрации показано как будет работать банк поддерживающий "
+"Taler напрямую. Помимо использования собственного банковского счёта, вы "
+"также можете просмотреть историю транзакций некоторых %1$s."
#: src/pages/AccountPage/views.tsx:53
#, c-format
-msgid ""
-"This part of the demo shows how a bank that supports Taler directly would "
-"work."
+msgid "This part of the demo shows how a bank that supports Taler directly would work."
msgstr ""
+"В этой части демонстрации показано как будет работать банк поддерживающий "
+"Taler напрямую."
#: src/pages/AccountPage/views.tsx:70
#, c-format
msgid "Pending account delete operation"
-msgstr ""
+msgstr "Ожидание операции удаления счёта"
#: src/pages/AccountPage/views.tsx:72
#, c-format
msgid "Pending account update operation"
-msgstr ""
+msgstr "Ожидание операции обновления счёта"
#: src/pages/AccountPage/views.tsx:74
#, c-format
msgid "Pending password update operation"
-msgstr ""
+msgstr "Ожидание операции обновления пароля"
#: src/pages/AccountPage/views.tsx:76
#, c-format
msgid "Pending transaction operation"
-msgstr ""
+msgstr "Ожидание операции транзакции"
#: src/pages/AccountPage/views.tsx:78
#, c-format
msgid "Pending withdrawal operation"
-msgstr ""
+msgstr "Ожидание операции вывода средств"
#: src/pages/AccountPage/views.tsx:80
#, c-format
msgid "Pending cashout operation"
-msgstr ""
+msgstr "Ожидание операции обналички"
#: src/pages/AccountPage/views.tsx:91
#, c-format
msgid "You can complete or cancel the operation in"
-msgstr ""
+msgstr "Завершить или отменить операцию можно в"
#: src/pages/BankFrame.tsx:64
#, c-format
msgid "Internal error, please report."
-msgstr ""
+msgstr "Внутренняя ошибка, пожалуйста, сообщите."
#: src/pages/BankFrame.tsx:100
#, c-format
msgid "Preferences"
-msgstr ""
+msgstr "Настройки"
#: src/pages/BankFrame.tsx:184
#, c-format
msgid "Welcome, %1$s"
-msgstr ""
+msgstr "Добро пожаловать, %1$s"
#: src/pages/WireTransfer.tsx:79
#, c-format
msgid "Make a wire transfer"
-msgstr ""
+msgstr "Сделать банковский перевод"
#: src/pages/admin/AccountList.tsx:72
#, c-format
msgid "Accounts"
-msgstr ""
+msgstr "Счета"
#: src/pages/admin/AccountList.tsx:75
#, c-format
msgid "A list of all business account in the bank."
-msgstr ""
+msgstr "Список всех бизнес-счетов в банке."
#: src/pages/admin/AccountList.tsx:86
#, c-format
msgid "Create account"
-msgstr ""
+msgstr "Создать учётную запись"
#: src/pages/admin/AccountList.tsx:106
#, c-format
msgid "Name"
-msgstr ""
+msgstr "Название"
#: src/pages/admin/AccountList.tsx:110
#, c-format
msgid "Balance"
-msgstr ""
+msgstr "Баланс"
#: src/pages/admin/AccountList.tsx:112
#, c-format
msgid "Actions"
-msgstr ""
+msgstr "Действия"
#: src/pages/admin/AccountList.tsx:151
#, c-format
msgid "unknown"
-msgstr ""
+msgstr "неизвестно"
#: src/pages/admin/AccountList.tsx:170
#, c-format
msgid "change password"
-msgstr ""
+msgstr "изменить пароль"
#: src/pages/admin/AccountList.tsx:179
#, c-format
msgid "cashouts"
-msgstr ""
+msgstr "выплаты"
#: src/pages/admin/AccountList.tsx:189
#, c-format
msgid "remove"
-msgstr ""
+msgstr "удалить"
#: src/pages/admin/AdminHome.tsx:168
#, c-format
msgid "Cashout not implemented"
-msgstr ""
+msgstr "Обналичка не реализована"
#: src/pages/admin/AdminHome.tsx:184
#, c-format
msgid "Select a section"
-msgstr ""
+msgstr "Выберите раздел"
#: src/pages/admin/AdminHome.tsx:202
#, c-format
msgid "Last hour"
-msgstr ""
+msgstr "Последний час"
#: src/pages/admin/AdminHome.tsx:208
#, c-format
msgid "Last day"
-msgstr ""
+msgstr "Последний день"
#: src/pages/admin/AdminHome.tsx:216
#, c-format
msgid "Last month"
-msgstr ""
+msgstr "Последний месяц"
#: src/pages/admin/AdminHome.tsx:222
#, c-format
msgid "Last year"
-msgstr ""
+msgstr "Последний год"
#: src/pages/admin/AdminHome.tsx:310
#, c-format
msgid "Last Year"
-msgstr ""
+msgstr "Прошлый год"
#: src/pages/admin/AdminHome.tsx:325
#, c-format
msgid "Trading volume on %1$s compared to %2$s"
-msgstr ""
+msgstr "Объем торгов на %1$s по сравнению с %2$s"
#: src/pages/admin/AdminHome.tsx:342
#, c-format
msgid "Cashin"
-msgstr ""
+msgstr "Внесения"
#: src/pages/admin/AdminHome.tsx:352
#, c-format
msgid "Cashout"
-msgstr ""
+msgstr "Выплата"
#: src/pages/admin/AdminHome.tsx:364
#, c-format
msgid "Payin"
-msgstr ""
+msgstr "Отплата"
#: src/pages/admin/AdminHome.tsx:374
#, c-format
msgid "Payout"
-msgstr ""
+msgstr "Выплата"
#: src/pages/admin/AdminHome.tsx:388
#, c-format
msgid "download stats as CSV"
-msgstr ""
+msgstr "скачать статистику в формате CSV"
#: src/pages/admin/AdminHome.tsx:494
#, c-format
msgid "Decreased by"
-msgstr ""
+msgstr "Уменьшилось на"
#: src/pages/admin/AdminHome.tsx:498
#, c-format
msgid "Increased by"
-msgstr ""
+msgstr "Увеличение на"
#: src/pages/DownloadStats.tsx:89
#, c-format
msgid "Download bank stats"
-msgstr ""
+msgstr "Скачивать статистику банка"
#: src/pages/DownloadStats.tsx:110
#, c-format
msgid "Include hour metric"
-msgstr ""
+msgstr "Включить часовую метрику"
#: src/pages/DownloadStats.tsx:143
#, c-format
msgid "Include day metric"
-msgstr ""
+msgstr "Включить дневную метрику"
#: src/pages/DownloadStats.tsx:173
#, c-format
msgid "Include month metric"
-msgstr ""
+msgstr "Включить месячную метрику"
#: src/pages/DownloadStats.tsx:206
#, c-format
msgid "Include year metric"
-msgstr ""
+msgstr "Включить годовую метрику"
#: src/pages/DownloadStats.tsx:239
#, c-format
msgid "Include table header"
-msgstr ""
+msgstr "Включить заголовок таблицы"
#: src/pages/DownloadStats.tsx:272
#, c-format
msgid "Add previous metric for compare"
-msgstr ""
+msgstr "Добавить предыдущую метрику для сравнения"
#: src/pages/DownloadStats.tsx:307
#, c-format
msgid "Fail on first error"
-msgstr ""
+msgstr "Сбой при первой ошибке"
#: src/pages/DownloadStats.tsx:364
#, c-format
msgid "Download"
-msgstr ""
+msgstr "Скачивать"
#: src/pages/DownloadStats.tsx:381
#, c-format
msgid "downloading... %1$s"
-msgstr ""
+msgstr "скачивание... %1$s"
#: src/pages/DownloadStats.tsx:399
#, c-format
msgid "Download completed"
-msgstr ""
+msgstr "Скачивание завершено"
#: src/pages/DownloadStats.tsx:400
#, c-format
msgid "click here to save the file in your computer"
-msgstr ""
+msgstr "Нажмите здесь, чтобы сохранить файл на своем компьютере"
#: src/pages/PublicHistoriesPage.tsx:78
#, c-format
msgid "History of public accounts"
-msgstr ""
+msgstr "История публичных счетов"
#: src/pages/RegistrationPage.tsx:48
#, c-format
msgid "Currently, the bank is not accepting new registrations!"
-msgstr ""
+msgstr "В настоящее время банк не принимает новые регистрации!"
#: src/pages/RegistrationPage.tsx:87
#, c-format
msgid "Missing name"
-msgstr ""
+msgstr "Отсутствует имя"
#: src/pages/RegistrationPage.tsx:91
#, c-format
msgid "Use letters and numbers only, and start with a lowercase letter"
-msgstr ""
+msgstr "Используйте только буквы и цифры и начинайте со строчной буквы"
#: src/pages/RegistrationPage.tsx:107
#, c-format
msgid "Passwords don't match"
-msgstr ""
+msgstr "Пароли не совпадают"
#: src/pages/RegistrationPage.tsx:130
#, c-format
msgid "Server replied with invalid phone or email."
-msgstr ""
+msgstr "Сервер ответил что телефон или электронной почта недействительны."
#: src/pages/RegistrationPage.tsx:137
#, c-format
msgid "Registration is disabled because the bank ran out of bonus credit."
-msgstr ""
+msgstr "Регистрация отключена, так как в банке закончился бонусный кредит."
#: src/pages/RegistrationPage.tsx:144
#, c-format
msgid "No enough permission to create that account."
-msgstr ""
+msgstr "Недостаточно разрешений для создания этого счёта."
#: src/pages/RegistrationPage.tsx:151
#, c-format
msgid "That account id is already taken."
-msgstr ""
+msgstr "Этот идентификатор счёта уже занят."
#: src/pages/RegistrationPage.tsx:158
#, c-format
msgid "That username is already taken."
-msgstr ""
+msgstr "Это имя пользователя уже используется."
#: src/pages/RegistrationPage.tsx:165
#, c-format
msgid "That username can't be used because is reserved."
msgstr ""
+"Это имя пользователя не может быть использовано, так как оно зарезервировано."
#: src/pages/RegistrationPage.tsx:172
#, c-format
msgid "Only admin is allow to set debt limit."
-msgstr ""
+msgstr "Только администратор может установить лимит задолженности."
#: src/pages/RegistrationPage.tsx:179
#, c-format
msgid "No information for the selected authentication channel."
-msgstr ""
+msgstr "Нет информации о выбранном канале аутентификации."
#: src/pages/RegistrationPage.tsx:186
#, c-format
msgid "Authentication channel is not supported."
-msgstr ""
+msgstr "Канал аутентификации не поддерживается."
#: src/pages/RegistrationPage.tsx:193
#, c-format
msgid "Only admin can create accounts with second factor authentication."
msgstr ""
+"Только администратор может создавать учетные записи со второй "
+"аутентификацией."
#: src/pages/RegistrationPage.tsx:233
#, c-format
msgid "Account registration"
-msgstr ""
+msgstr "Регистрация счёта"
#: src/pages/RegistrationPage.tsx:315
#, c-format
msgid "Repeat password"
-msgstr ""
+msgstr "Повторите Пароль"
#: src/pages/RegistrationPage.tsx:457
#, c-format
msgid "Create a random temporary user"
-msgstr ""
+msgstr "Создать случайного временного пользователя"
#: src/pages/QrCodeSection.tsx:110
#, c-format
msgid "If you have a Taler wallet installed in this device"
-msgstr ""
+msgstr "Если в этом устройстве установлен кошелёк Taler"
#: src/pages/QrCodeSection.tsx:116
#, c-format
msgid ""
-"You will see the details of the operation in your wallet including the fees "
-"(if applies). If you still don't have one you can install it following "
-"instructions in"
+"You will see the details of the operation in your wallet including the fees (if "
+"applies). If you still don't have one you can install it following instructions "
+"in"
msgstr ""
+"Вы увидите подробности операции в своем кошельке, включая комиссию (если "
+"применимо). Если у вас его еще нет, вы можете установить его следуя "
+"инструкциям на"
#: src/pages/QrCodeSection.tsx:143
-#, fuzzy, c-format
+#, c-format
msgid "Withdraw"
-msgstr ""
+msgstr "Снять средства"
#: src/pages/QrCodeSection.tsx:152
#, c-format
msgid "Or if you have the wallet in another device"
-msgstr ""
+msgstr "Или если у вас есть кошелёк в другом устройстве"
#: src/pages/QrCodeSection.tsx:157
-#, fuzzy, c-format
+#, c-format
msgid "Scan the QR below to start the withdrawal."
-msgstr ""
+msgstr "Отсканируйте QR-код ниже чтобы начать вывод средств."
#: src/pages/WithdrawalQRCode.tsx:79
#, c-format
msgid "Operation aborted"
-msgstr ""
+msgstr "Операция прервана"
#: src/pages/WithdrawalQRCode.tsx:82
#, c-format
@@ -1002,32 +1036,34 @@ msgid ""
"The wire transfer to the Taler Exchange operator's account was aborted, your "
"balance was not affected."
msgstr ""
+"Банковский перевод на счет оператора Обменника Taler был прерван, ваш баланс "
+"не пострадал."
#: src/pages/WithdrawalQRCode.tsx:88
#, c-format
msgid "You can close this page now or continue to the account page."
-msgstr ""
+msgstr "Теперь вы можете закрыть эту страницу или перейти на страницу счёта."
#: src/pages/WithdrawalQRCode.tsx:147
#, c-format
msgid "Done"
-msgstr ""
+msgstr "Готово"
#: src/pages/WithdrawalQRCode.tsx:158
#, c-format
msgid "Operation canceled"
-msgstr ""
+msgstr "Операция отменена"
#: src/pages/WithdrawalQRCode.tsx:173
#, c-format
-msgid ""
-"The operation is marked as 'selected' but some step in the withdrawal failed"
+msgid "The operation is marked as 'selected' but some step in the withdrawal failed"
msgstr ""
+"Операция помечена как «выбранная», но какой-то шаг в выводе средств не удался"
#: src/pages/WithdrawalQRCode.tsx:175
#, c-format
msgid "The account is selected but no withdrawal identification found."
-msgstr ""
+msgstr "Счёт выбран, но идентификатор вывода средств не найден."
#: src/pages/WithdrawalQRCode.tsx:188
#, c-format
@@ -1035,12 +1071,14 @@ msgid ""
"There is a withdrawal identification but no account has been selected or the "
"selected account is invalid."
msgstr ""
+"Есть идентификатор вывода средств, но счёт не был выбран или выбранный счёт "
+"недействителен."
#: src/pages/WithdrawalQRCode.tsx:202
#, c-format
msgid ""
-"No withdrawal ID found and no account has been selected or the selected "
-"account is invalid."
+"No withdrawal ID found and no account has been selected or the selected account "
+"is invalid."
msgstr ""
#: src/pages/WithdrawalQRCode.tsx:259
@@ -1103,12 +1141,12 @@ msgstr ""
#: src/pages/SolveChallengePage.tsx:224
#, c-format
msgid "Account delete"
-msgstr ""
+msgstr "Удаление счёта"
#: src/pages/SolveChallengePage.tsx:226
#, c-format
msgid "Account update"
-msgstr ""
+msgstr "Обновление счёта"
#: src/pages/SolveChallengePage.tsx:228
#, c-format
@@ -1118,158 +1156,159 @@ msgstr ""
#: src/pages/SolveChallengePage.tsx:230
#, c-format
msgid "Wire transfer"
-msgstr ""
+msgstr "Перевод"
#: src/pages/SolveChallengePage.tsx:232
-#, fuzzy, c-format
+#, c-format
msgid "Withdrawal"
-msgstr ""
+msgstr "Вывод"
#: src/pages/SolveChallengePage.tsx:248
-#, fuzzy, c-format
+#, c-format
msgid "Confirm the operation"
-msgstr ""
+msgstr "Подтвердить операцию"
#: src/pages/SolveChallengePage.tsx:271
#, c-format
msgid "Enter the confirmation code"
-msgstr ""
+msgstr "Введите код подтверждения"
#: src/pages/SolveChallengePage.tsx:313
#, c-format
msgid "Confirm"
-msgstr ""
+msgstr "Подтвердить"
#: src/pages/SolveChallengePage.tsx:348
#, c-format
msgid "Send again"
-msgstr ""
+msgstr "Отправить ещё раз"
#: src/pages/SolveChallengePage.tsx:359
#, c-format
msgid "Send code"
-msgstr ""
+msgstr "Отправить код"
#: src/pages/SolveChallengePage.tsx:369
#, c-format
msgid "Operation details"
-msgstr ""
+msgstr "Сведения об операции"
#: src/pages/SolveChallengePage.tsx:529
#, c-format
msgid "Challenge details"
-msgstr ""
+msgstr "Детали подтверждения"
#: src/pages/SolveChallengePage.tsx:536
#, c-format
msgid "Sent at"
-msgstr ""
+msgstr "Время отправления"
#: src/pages/SolveChallengePage.tsx:551
#, c-format
msgid "To phone"
-msgstr ""
+msgstr "На телефон"
#: src/pages/SolveChallengePage.tsx:553
#, c-format
msgid "To email"
-msgstr ""
+msgstr "На email"
#: src/pages/WithdrawalOperationPage.tsx:49
#, c-format
msgid "The Withdrawal URI is not valid"
-msgstr ""
+msgstr "URI вывода недействителен"
#: src/components/Cashouts/views.tsx:100
#, c-format
msgid "Latest cashouts"
-msgstr ""
+msgstr "Последние обналички"
#: src/components/Cashouts/views.tsx:111
#, c-format
msgid "Created"
-msgstr ""
+msgstr "Создано"
#: src/components/Cashouts/views.tsx:115
#, c-format
msgid "Total debit"
-msgstr ""
+msgstr "Всего дебет"
#: src/components/Cashouts/views.tsx:119
#, c-format
msgid "Total credit"
-msgstr ""
+msgstr "Итого кредит"
#: src/pages/ProfileNavigation.tsx:70
#, c-format
msgid "Details"
-msgstr ""
+msgstr "Подробности"
#: src/pages/ProfileNavigation.tsx:74
#, c-format
msgid "Delete"
-msgstr ""
+msgstr "Удалить"
#: src/pages/ProfileNavigation.tsx:78
#, c-format
msgid "Credentials"
-msgstr ""
+msgstr "Учетные данные"
#: src/pages/ProfileNavigation.tsx:82
#, c-format
msgid "Cashouts"
-msgstr ""
+msgstr "Выплаты"
#: src/pages/business/CreateCashout.tsx:95
#, c-format
msgid "Unable to create a cashout"
-msgstr ""
+msgstr "Не удается создать выплату"
#: src/pages/business/CreateCashout.tsx:96
#, c-format
msgid "The bank configuration does not support cashout operations."
-msgstr ""
+msgstr "Конфигурация банка не поддерживает операции выплаты."
#: src/pages/business/CreateCashout.tsx:223
#, c-format
msgid "need to be higher due to fees"
-msgstr ""
+msgstr "должна быть выше из-за комиссий"
#: src/pages/business/CreateCashout.tsx:225
#, c-format
msgid "the total transfer at destination will be zero"
-msgstr ""
+msgstr "общая сумма перевода в назначенее будет равна нулю"
#: src/pages/business/CreateCashout.tsx:250
#, c-format
msgid "Cashout created"
-msgstr ""
+msgstr "Выплата создана"
#: src/pages/business/CreateCashout.tsx:272
#, c-format
-msgid ""
-"Duplicated request detected, check if the operation succeeded or try again."
+msgid "Duplicated request detected, check if the operation succeeded or try again."
msgstr ""
+"Обнаружен дубликат запроса, проверьте, успешно ли выполнена операция, или "
+"повторите попытку."
#: src/pages/business/CreateCashout.tsx:279
#, c-format
msgid "The conversion rate was incorrectly applied"
-msgstr ""
+msgstr "Неправильно применен курс конвертации"
#: src/pages/business/CreateCashout.tsx:286
#, c-format
msgid "The account does not have sufficient funds"
-msgstr ""
+msgstr "На счете недостаточно средств"
#: src/pages/business/CreateCashout.tsx:293
#, c-format
msgid "Cashouts are not supported"
-msgstr ""
+msgstr "Выплаты не поддерживаются"
#: src/pages/business/CreateCashout.tsx:300
#, c-format
msgid "Missing cashout URI in the profile"
-msgstr ""
+msgstr "Отсутствующий URI вылат в профиле"
#: src/pages/business/CreateCashout.tsx:307
#, c-format
@@ -1277,237 +1316,242 @@ msgid ""
"Sending the confirmation message failed, retry later or contact the "
"administrator."
msgstr ""
+"Не удалось отправить сообщение с подтверждением, повторите попытку позже или "
+"обратитесь к администратору."
#: src/pages/business/CreateCashout.tsx:339
#, c-format
msgid "Conversion rate"
-msgstr ""
+msgstr "Обменный курс"
#: src/pages/business/CreateCashout.tsx:360
#, c-format
msgid "Fee"
-msgstr ""
+msgstr "Комиссия"
#: src/pages/business/CreateCashout.tsx:374
#, c-format
msgid "To account"
-msgstr ""
+msgstr "На счёт"
#: src/pages/business/CreateCashout.tsx:381
#, c-format
msgid "No cashout account"
-msgstr ""
+msgstr "Нет счёта для выплат"
#: src/pages/business/CreateCashout.tsx:382
#, c-format
msgid "Before doing a cashout you need to complete your profile"
-msgstr ""
+msgstr "Перед тем, как сделать выплату, вам необходимо заполнить свой профиль"
#: src/pages/business/CreateCashout.tsx:440
-#, fuzzy, c-format
+#, c-format
msgid "Amount to send"
-msgstr ""
+msgstr "Сумма к отправке"
#: src/pages/business/CreateCashout.tsx:441
-#, fuzzy, c-format
+#, c-format
msgid "Amount to receive"
-msgstr ""
+msgstr "Сумма к получению"
#: src/pages/business/CreateCashout.tsx:490
#, c-format
msgid "Total cost"
-msgstr ""
+msgstr "Общая стоимость"
#: src/pages/business/CreateCashout.tsx:505
#, c-format
msgid "Balance left"
-msgstr ""
+msgstr "Остаток баланса"
#: src/pages/business/CreateCashout.tsx:520
#, c-format
msgid "Before fee"
-msgstr ""
+msgstr "Комиссия до"
#: src/pages/business/CreateCashout.tsx:533
#, c-format
msgid "Total cashout transfer"
-msgstr ""
+msgstr "Общий сумма перевода выплаты"
#: src/pages/business/CreateCashout.tsx:553
#, c-format
msgid "No cashout channel available"
-msgstr ""
+msgstr "Канал вывода средств недоступен"
#: src/pages/business/CreateCashout.tsx:555
#, c-format
msgid ""
-"Before doing a cashout the server need to provide an second channel to "
-"confirm the operation"
+"Before doing a cashout the server need to provide an second channel to confirm "
+"the operation"
msgstr ""
+"Перед тем, как сделать кэшаут, серверу необходимо предоставить второй канал "
+"для подтверждения операции"
#: src/pages/business/CreateCashout.tsx:567
#, c-format
msgid "Second factor authentication"
-msgstr ""
+msgstr "Двухфакторная аутентификация"
#: src/pages/business/CreateCashout.tsx:598
#, c-format
msgid "Email"
-msgstr ""
+msgstr "Email"
#: src/pages/business/CreateCashout.tsx:600
#, c-format
msgid "add a email in your profile to enable this option"
msgstr ""
+"Добавьте адрес электронной почты в свой профиль, чтобы включить эту опцию"
#: src/pages/business/CreateCashout.tsx:646
#, c-format
msgid "SMS"
-msgstr ""
+msgstr "SMS"
#: src/pages/business/CreateCashout.tsx:648
#, c-format
msgid "add a phone number in your profile to enable this option"
-msgstr ""
+msgstr "Добавьте номер телефона в свой профиль, чтобы включить эту опцию"
#: src/pages/account/CashoutListForAccount.tsx:52
#, c-format
msgid "Cashout for account %1$s"
-msgstr ""
+msgstr "Выплата для аккаунта %1$s"
#: src/pages/admin/AccountForm.tsx:165
#, c-format
msgid "it doesn't have the pattern of an IBAN number"
-msgstr ""
+msgstr "у него нет шаблона номера IBAN"
#: src/pages/admin/AccountForm.tsx:185
#, c-format
msgid "it doesn't have the pattern of an email"
-msgstr ""
+msgstr "У него нет шаблона электронного письма"
#: src/pages/admin/AccountForm.tsx:190
#, c-format
msgid "should start with +"
-msgstr ""
+msgstr "должен начинаться с +"
#: src/pages/admin/AccountForm.tsx:192
#, c-format
msgid "phone number can't have other than numbers"
-msgstr ""
+msgstr "Номер телефона не может иметь ничего, кроме цифр"
#: src/pages/admin/AccountForm.tsx:329
#, c-format
msgid "account identification in the bank"
-msgstr ""
+msgstr "Идентификация счета в банке"
#: src/pages/admin/AccountForm.tsx:365
#, c-format
msgid "name of the person owner the account"
-msgstr ""
+msgstr "имя владельца счёта"
#: src/pages/admin/AccountForm.tsx:374
#, c-format
msgid "Internal IBAN"
-msgstr ""
+msgstr "Внутренний IBAN"
#: src/pages/admin/AccountForm.tsx:377
#, c-format
msgid "if empty a random account number will be assigned"
-msgstr ""
+msgstr "Если пусто, будет присвоен случайный номер счета"
#: src/pages/admin/AccountForm.tsx:378
#, c-format
msgid "account identification for bank transfer"
-msgstr ""
+msgstr "Идентификация счета для банковского перевода"
#: src/pages/admin/AccountForm.tsx:423
#, c-format
msgid "Phone"
-msgstr ""
+msgstr "Телефон"
#: src/pages/admin/AccountForm.tsx:451
#, c-format
msgid "Cashout IBAN"
-msgstr ""
+msgstr "IBAN выплаты"
#: src/pages/admin/AccountForm.tsx:452
#, c-format
msgid "account number where the money is going to be sent when doing cashouts"
-msgstr ""
+msgstr "номер счета, на который будут отправлены деньги при выводе средств"
#: src/pages/admin/AccountForm.tsx:470
#, c-format
msgid "Max debt"
-msgstr ""
+msgstr "Максимальная задолженность"
#: src/pages/admin/AccountForm.tsx:494
#, c-format
msgid "how much is user able to transfer after zero balance"
-msgstr ""
+msgstr "Какую сумму пользователь может перевести после нулевого баланса"
#: src/pages/admin/AccountForm.tsx:508
#, c-format
msgid "Is this a Taler Exchange?"
-msgstr ""
+msgstr "Это Обменник Taler?"
#: src/pages/admin/AccountForm.tsx:549
#, c-format
msgid "This server doesn't support second factor authentication."
-msgstr ""
+msgstr "Этот сервер не поддерживает двухфакторную аутентификацию."
#: src/pages/admin/AccountForm.tsx:560
#, c-format
msgid "Enable second factor authentication"
-msgstr ""
+msgstr "Включите двухфакторную аутентификацию"
#: src/pages/admin/AccountForm.tsx:596
#, c-format
msgid "Using email"
-msgstr ""
+msgstr "Используя email"
#: src/pages/admin/AccountForm.tsx:654
#, c-format
msgid "Using SMS"
-msgstr ""
+msgstr "Используя SMS"
#: src/pages/admin/AccountForm.tsx:691
#, c-format
msgid "Is this account public?"
-msgstr ""
+msgstr "Является ли этот счёт общедоступным?"
#: src/pages/admin/AccountForm.tsx:719
#, c-format
msgid "public accounts have their balance publicly accessible"
-msgstr ""
+msgstr "Баланс публичных счётов находится в открытом доступе"
#: src/pages/account/ShowAccountDetails.tsx:100
#, c-format
msgid "Account updated"
-msgstr ""
+msgstr "Счёт обновлён"
#: src/pages/account/ShowAccountDetails.tsx:107
#, c-format
msgid "The rights to change the account are not sufficient"
-msgstr ""
+msgstr "Недостаточно прав на изменение счёта"
#: src/pages/account/ShowAccountDetails.tsx:114
#, c-format
msgid "The username was not found"
-msgstr ""
+msgstr "Имя пользователя не найдено"
#: src/pages/account/ShowAccountDetails.tsx:121
#, c-format
-msgid ""
-"You can't change the legal name, please contact the your account "
-"administrator."
+msgid "You can't change the legal name, please contact the your account administrator."
msgstr ""
+"Вы не можете изменить официальное имя, обратитесь к администратору вашей "
+"учетной записи."
#: src/pages/account/ShowAccountDetails.tsx:128
#, c-format
-msgid ""
-"You can't change the debt limit, please contact the your account "
-"administrator."
+msgid "You can't change the debt limit, please contact the your account administrator."
msgstr ""
+"Вы не можете изменить лимит задолженности, обратитесь к администратору "
+"аккаунта."
#: src/pages/account/ShowAccountDetails.tsx:135
#, c-format
@@ -1515,31 +1559,33 @@ msgid ""
"You can't change the cashout address, please contact the your account "
"administrator."
msgstr ""
+"Вы не можете изменить адрес для вывода средств, пожалуйста, свяжитесь с "
+"администратором вашего аккаунта."
#: src/pages/account/ShowAccountDetails.tsx:177
#, c-format
msgid "Account \"%1$s\""
-msgstr ""
+msgstr "Счет \"%1$s\""
#: src/pages/account/ShowAccountDetails.tsx:190
#, c-format
msgid "Change details"
-msgstr ""
+msgstr "Изменение реквизитов"
#: src/pages/account/ShowAccountDetails.tsx:235
#, c-format
msgid "Update"
-msgstr ""
+msgstr "Обновить"
#: src/pages/account/UpdateAccountPassword.tsx:78
#, c-format
msgid "password doesn't match"
-msgstr ""
+msgstr "пароль не совпадает"
#: src/pages/account/UpdateAccountPassword.tsx:95
#, c-format
msgid "Password changed"
-msgstr ""
+msgstr "Пароль изменен"
#: src/pages/account/UpdateAccountPassword.tsx:102
#, c-format
@@ -1549,8 +1595,8 @@ msgstr ""
#: src/pages/account/UpdateAccountPassword.tsx:112
#, c-format
msgid ""
-"You need to provide the old password. If you don't have it contact your "
-"account administrator."
+"You need to provide the old password. If you don't have it contact your account "
+"administrator."
msgstr ""
#: src/pages/account/UpdateAccountPassword.tsx:117
@@ -1561,27 +1607,27 @@ msgstr ""
#: src/pages/account/UpdateAccountPassword.tsx:149
#, c-format
msgid "Update password"
-msgstr ""
+msgstr "Обновить пароль"
#: src/pages/account/UpdateAccountPassword.tsx:167
#, c-format
msgid "New password"
-msgstr ""
+msgstr "Новый пароль"
#: src/pages/account/UpdateAccountPassword.tsx:195
#, c-format
msgid "Type it again"
-msgstr ""
+msgstr "Введите его ещё раз"
#: src/pages/account/UpdateAccountPassword.tsx:217
#, c-format
msgid "repeat the same password"
-msgstr ""
+msgstr "повторите этот же пароль"
#: src/pages/account/UpdateAccountPassword.tsx:227
#, c-format
msgid "Current password"
-msgstr ""
+msgstr "Текущий пароль"
#: src/pages/account/UpdateAccountPassword.tsx:248
#, c-format
@@ -1591,13 +1637,13 @@ msgstr ""
#: src/pages/account/UpdateAccountPassword.tsx:272
#, c-format
msgid "Change"
-msgstr ""
+msgstr "Изменить"
#: src/pages/admin/CreateNewAccount.tsx:74
#, c-format
msgid ""
-"Account created with password \"%1$s\". The user must change the password on "
-"the next login."
+"Account created with password \"%1$s\". The user must change the password on the "
+"next login."
msgstr ""
#: src/pages/admin/CreateNewAccount.tsx:83
@@ -1643,12 +1689,12 @@ msgstr ""
#: src/pages/admin/CreateNewAccount.tsx:183
#, c-format
msgid "New business account"
-msgstr ""
+msgstr "Новый бизнес счёт"
#: src/pages/admin/CreateNewAccount.tsx:209
#, c-format
msgid "Create"
-msgstr ""
+msgstr "Создать"
#: src/pages/admin/RemoveAccount.tsx:94
#, c-format
@@ -1658,8 +1704,8 @@ msgstr ""
#: src/pages/admin/RemoveAccount.tsx:95
#, c-format
msgid ""
-"The account can't be delete while still holding some balance. First make "
-"sure that the owner make a complete cashout."
+"The account can't be delete while still holding some balance. First make sure "
+"that the owner make a complete cashout."
msgstr ""
#: src/pages/admin/RemoveAccount.tsx:117
@@ -1705,12 +1751,12 @@ msgstr ""
#: src/pages/admin/RemoveAccount.tsx:188
#, c-format
msgid "Deleting account \"%1$s\""
-msgstr ""
+msgstr "Удаление счёта \"%1$s\""
#: src/pages/admin/RemoveAccount.tsx:206
#, c-format
msgid "Verification"
-msgstr ""
+msgstr "Проверка"
#: src/pages/admin/RemoveAccount.tsx:231
#, c-format
@@ -1730,55 +1776,19 @@ msgstr ""
#: src/pages/business/ShowCashoutDetails.tsx:106
#, c-format
msgid "Cashout detail"
-msgstr ""
+msgstr "Подробности обналичивания"
#: src/pages/business/ShowCashoutDetails.tsx:139
#, c-format
msgid "Debited"
-msgstr ""
+msgstr "Дебетировано"
#: src/pages/business/ShowCashoutDetails.tsx:154
#, c-format
msgid "Credited"
-msgstr ""
+msgstr "Кредитировано"
#: src/Routing.tsx:140
#, c-format
msgid "Welcome to %1$s!"
-msgstr ""
-
-#, c-format
-#~ msgid "days"
-#~ msgstr ""
-
-#, c-format
-#~ msgid "hours"
-#~ msgstr ""
-
-#, c-format
-#~ msgid "minutes"
-#~ msgstr ""
-
-#, c-format
-#~ msgid "seconds"
-#~ msgstr ""
-
-#~ msgid "Go back"
-#~ msgstr ""
-
-#, fuzzy
-#~ msgid "Withdraw Money into a Taler wallet"
-#~ msgstr ""
-
-#~ msgid "Page has a problem: logged in but backend state is lost."
-#~ msgstr ""
-
-#, fuzzy
-#~ msgid "Welcome to the euFin bank!"
-#~ msgstr ""
-
-#~ msgid "Page has a problem:"
-#~ msgstr ""
-
-#~ msgid "Sign in"
-#~ msgstr ""
+msgstr "Добро пожаловать в %1$s!"
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 41956b84b..3bf891504 100644
--- a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
+++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
@@ -24,6 +24,7 @@ import {
HttpStatusCode,
PaytoString,
PaytoUri,
+ TalerCorebankApi,
TalerErrorCode,
TranslatedString,
assertUnreachable,
@@ -34,18 +35,19 @@ import {
import {
InternationalizationAPI,
LocalNotificationBanner,
+ RouteDefinition,
ShowInputErrorLabel,
notifyInfo,
+ useBankCoreApiContext,
useLocalNotification,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { ComponentChildren, Fragment, Ref, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { mutate } from "swr";
-import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { IdempotencyRetry } from "../../../taler-util/lib/http-client/utils.js";
import { useBankState } from "../hooks/bank-state.js";
import { useSessionState } from "../hooks/session.js";
-import { RouteDefinition } from "@gnu-taler/web-util/browser";
import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js";
interface Props {
@@ -182,12 +184,13 @@ export function PaytoWireTransferForm({
const puri = payto_uri;
const sAmount = sendingAmount;
- await handleError(async () => {
- const request = {
+ await handleError(async function createTransactionHandleError() {
+ const request: TalerCorebankApi.CreateTransactionRequest = {
payto_uri: puri,
amount: sAmount,
};
- const resp = await api.createTransaction(credentials, request);
+ const check = IdempotencyRetry.tryFiveTimes();
+ const resp = await api.createTransaction(credentials, request, check);
mutate(() => true);
if (resp.type === "fail") {
switch (resp.case) {
@@ -249,6 +252,15 @@ export function PaytoWireTransferForm({
debug: resp.detail,
when: AbsoluteTime.now(),
});
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: {
+ return notify({
+ type: "error",
+ title: i18n.str`Tried to create the transaction ${check.maxTries} times with different UID but failed.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
case HttpStatusCode.Accepted: {
updateBankState("currentChallenge", {
operation: "create-transaction",
@@ -278,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 18f926e00..61939c3d6 100644
--- a/packages/bank-ui/src/pages/RegistrationPage.tsx
+++ b/packages/bank-ui/src/pages/RegistrationPage.tsx
@@ -13,22 +13,18 @@
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 {
- AccessToken,
- HttpStatusCode,
- TalerErrorCode,
-} from "@gnu-taler/taler-util";
+import { HttpStatusCode, TalerErrorCode } from "@gnu-taler/taler-util";
import {
LocalNotificationBanner,
+ RouteDefinition,
ShowInputErrorLabel,
+ useBankCoreApiContext,
useLocalNotification,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
-import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
import { useSettingsContext } from "../context/settings.js";
-import { RouteDefinition } from "@gnu-taler/web-util/browser";
import { undefinedIfEmpty } from "../utils.js";
import { getRandomPassword, getRandomUsername } from "./rnd.js";
@@ -144,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/SolveChallengePage.tsx b/packages/bank-ui/src/pages/SolveChallengePage.tsx
index eae5bfb5f..624890468 100644
--- a/packages/bank-ui/src/pages/SolveChallengePage.tsx
+++ b/packages/bank-ui/src/pages/SolveChallengePage.tsx
@@ -48,6 +48,7 @@ import { RouteDefinition } from "@gnu-taler/web-util/browser";
import { undefinedIfEmpty } from "../utils.js";
import { RenderAmount } from "./PaytoWireTransferForm.js";
import { OperationNotFound } from "./WithdrawalQRCode.js";
+import { IdempotencyRetry } from "../../../taler-util/lib/http-client/utils.js";
const TAN_PREFIX = "T-";
const TAN_REGEX = /^([Tt](-)?)?[0-9]*$/;
@@ -205,7 +206,7 @@ export function SolveChallengePage({
case "update-password":
return await api.updatePassword(creds, ch.request, ch.id);
case "create-transaction":
- return await api.createTransaction(creds, ch.request, ch.id);
+ return await api.createTransaction(creds, ch.request, undefined, ch.id);
case "confirm-withdrawal":
return await api.confirmWithdrawalById(creds, ch.request, ch.id);
case "create-cashout":
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/bank-ui/src/route.ts b/packages/bank-ui/src/route.ts
deleted file mode 100644
index 11f13d140..000000000
--- a/packages/bank-ui/src/route.ts
+++ /dev/null
@@ -1,139 +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 { useNavigationContext } from "./context/navigation.js";
-
-declare const __location: unique symbol;
-/**
- * special string that defined a location in the application
- *
- * this help to prevent wrong path
- */
-export type AppLocation = string & {
- [__location]: true;
-};
-export type EmptyObject = Record<string, never>;
-
-export function urlPattern<
- T extends Record<string, string | undefined> = EmptyObject,
->(pattern: RegExp, reverse: (p: T) => string): RouteDefinition<T> {
- const url = reverse as (p: T) => AppLocation;
- return {
- pattern: new RegExp(pattern),
- url,
- };
-}
-
-/**
- * defines a location in the app
- *
- * pattern: how a string will trigger this location
- * url(): how a state serialize to a location
- */
-
-export type ObjectOf<T> = Record<string, T> | EmptyObject;
-
-export type RouteDefinition<
- T extends ObjectOf<string | undefined> = EmptyObject,
-> = {
- pattern: RegExp;
- url: (p: T) => AppLocation;
-};
-
-const nullRountDef = {
- pattern: new RegExp(/.*/),
- url: () => "" as AppLocation,
-};
-export function buildNullRoutDefinition<
- T extends ObjectOf<string>,
->(): RouteDefinition<T> {
- return nullRountDef;
-}
-
-/**
- * Search path in the pageList
- * get the values from the path found
- * add params from searchParams
- *
- * @param path
- * @param params
- */
-function findMatch<T extends ObjectOf<RouteDefinition>>(
- pagesMap: T,
- pageList: Array<keyof T>,
- path: string,
- params: Record<string, string>,
-): Location<T> | undefined {
- for (let idx = 0; idx < pageList.length; idx++) {
- const name = pageList[idx];
- const found = pagesMap[name].pattern.exec(path);
- if (found !== null) {
- const values = {} as Record<string, unknown>;
-
- Object.entries(params).forEach(([key, value]) => {
- values[key] = value;
- });
-
- if (found.groups !== undefined) {
- Object.entries(found.groups).forEach(([key, value]) => {
- values[key] = value;
- });
- }
-
- // @ts-expect-error values is a map string which is equivalent to the RouteParamsType
- return { name, parent: pagesMap, values };
- }
- }
- return undefined;
-}
-
-/**
- * get the type of the params of a location
- *
- */
-type RouteParamsType<
- RouteType,
- Key extends keyof RouteType,
-> = RouteType[Key] extends RouteDefinition<infer ParamType> ? ParamType : never;
-
-/**
- * Helps to create a map of a type with the key
- */
-type MapKeyValue<Type> = {
- [Key in keyof Type]: Key extends string
- ? {
- parent: Type;
- name: Key;
- values: RouteParamsType<Type, Key>;
- }
- : never;
-};
-
-/**
- * create a enumeration of value of a mapped type
- */
-type EnumerationOf<T> = T[keyof T];
-
-type Location<T> = EnumerationOf<MapKeyValue<T>>;
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>(
- pagesMap: T,
-): Location<T> | undefined {
- const pageList = Object.keys(pagesMap as object) as Array<keyof T>;
- const { path, params } = useNavigationContext();
-
- return findMatch(pagesMap, pageList, path, params);
-}
diff --git a/packages/bank-ui/src/settings.ts b/packages/bank-ui/src/settings.ts
index 968fe6248..c085c7cd8 100644
--- a/packages/bank-ui/src/settings.ts
+++ b/packages/bank-ui/src/settings.ts
@@ -24,7 +24,7 @@ import {
codecOptional,
} from "@gnu-taler/taler-util";
-export interface BankUiSettings {
+export interface UiSettings {
// Where libeufin backend is localted
// default: window.origin without "webui/"
backendBaseURL?: string;
@@ -50,7 +50,7 @@ export interface BankUiSettings {
/**
* Global settings for the bank UI.
*/
-const defaultSettings: BankUiSettings = {
+const defaultSettings: UiSettings = {
backendBaseURL: buildDefaultBackendBaseURL(),
iconLinkURL: undefined,
simplePasswordForRandomAccounts: false,
@@ -58,8 +58,8 @@ const defaultSettings: BankUiSettings = {
topNavSites: {},
};
-const codecForBankUISettings = (): Codec<BankUiSettings> =>
- buildCodecForObject<BankUiSettings>()
+const codecForUISettings = (): Codec<UiSettings> =>
+ buildCodecForObject<UiSettings>()
.property("backendBaseURL", codecOptional(codecForString()))
.property("allowRandomAccountCreation", codecOptional(codecForBoolean()))
.property(
@@ -68,7 +68,7 @@ const codecForBankUISettings = (): Codec<BankUiSettings> =>
)
.property("iconLinkURL", codecOptional(codecForString()))
.property("topNavSites", codecOptional(codecForMap(codecForString())))
- .build("BankUiSettings");
+ .build("UiSettings");
function removeUndefineField<T extends object>(obj: T): T {
const keys = Object.keys(obj) as Array<keyof T>;
@@ -80,10 +80,10 @@ function removeUndefineField<T extends object>(obj: T): T {
}, obj);
}
-export function fetchSettings(listener: (s: BankUiSettings) => void): void {
+export function fetchSettings(listener: (s: UiSettings) => void): void {
fetch("./settings.json")
.then((resp) => resp.json())
- .then((json) => codecForBankUISettings().decode(json))
+ .then((json) => codecForUISettings().decode(json))
.then((result) =>
listener({
...defaultSettings,
diff --git a/packages/bank-ui/tailwind.config.js b/packages/bank-ui/tailwind.config.js
index ec51dfbb8..d384690e2 100644
--- a/packages/bank-ui/tailwind.config.js
+++ b/packages/bank-ui/tailwind.config.js
@@ -1,4 +1,18 @@
-/** @type {import('tailwindcss').Config} */
+/*
+ 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 default {
content: {
relative: true,
diff --git a/packages/challenger-ui/build.mjs b/packages/challenger-ui/build.mjs
index 95088628c..166647f79 100755
--- a/packages/challenger-ui/build.mjs
+++ b/packages/challenger-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,10 +20,11 @@ import { build } from "@gnu-taler/web-util/build";
await build({
type: "production",
source: {
- js: ["src/main.js"],
+ js: ["src/main.js","src/index.tsx"],
assets: [{
base: "src",
files: [
+ "src/index.html",
"src/attempts-exhausted.html",
"src/enter-address-form.html",
"src/enter-email-form.html",
diff --git a/packages/challenger-ui/copyleft-header.js b/packages/challenger-ui/copyleft-header.js
index 2635717c5..7fa276bea 100644
--- a/packages/challenger-ui/copyleft-header.js
+++ b/packages/challenger-ui/copyleft-header.js
@@ -1,6 +1,6 @@
/*
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
diff --git a/packages/challenger-ui/dev.mjs b/packages/challenger-ui/dev.mjs
index 41f6b4210..595c3e99e 100755
--- a/packages/challenger-ui/dev.mjs
+++ b/packages/challenger-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,16 @@
import { serve } from "@gnu-taler/web-util/node";
import { initializeDev } from "@gnu-taler/web-util/build";
+const devEntryPoints = ["src/index.tsx", "src/main.js"];
+
const build = initializeDev({
type: "development",
source: {
- js: ["src/main.js"],
+ js: devEntryPoints,
assets: [{
base: "src",
files: [
+ "src/index.html",
"src/attempts-exhausted.html",
"src/enter-address-form.html",
"src/enter-email-form.html",
@@ -39,6 +42,7 @@ const build = initializeDev({
}],
},
destination: "./dist/dev",
+ public: "/app",
css: "postcss",
});
diff --git a/packages/challenger-ui/package.json b/packages/challenger-ui/package.json
index 64201346a..8234e2385 100644
--- a/packages/challenger-ui/package.json
+++ b/packages/challenger-ui/package.json
@@ -1,7 +1,7 @@
{
"private": true,
"name": "@gnu-taler/challenger-ui",
- "version": "0.1.0",
+ "version": "0.10.7",
"author": "sebasjm",
"license": "AGPL-3.0-OR-LATER",
"description": "UI for GNU Challenger.",
@@ -9,11 +9,12 @@
"scripts": {
"build": "./build.mjs && ./create_must.sh",
"check": "tsc",
- "clean": "rm -rf dist lib",
- "i18n:extract": "pogen extract",
- "i18n:merge": "pogen merge",
- "i18n:emit": "pogen emit",
- "i18n": "pnpm i18n:extract && pnpm i18n:merge && pnpm i18n:emit",
+ "compile": "tsc && ./build.mjs",
+ "test": "./test.mjs && mocha --require source-map-support/register 'dist/test/**/*.test.js' 'dist/test/**/test.js'",
+ "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo",
+ "i18n:strings": "pogen extract && pogen merge",
+ "i18n:translations": "pogen emit",
"pretty": "prettier --write src"
},
"eslintConfig": {
@@ -31,18 +32,36 @@
]
},
"devDependencies": {
- "@gnu-taler/pogen": "^0.0.5",
- "@gnu-taler/web-util": "workspace:*",
+ "eslint": "^8.56.0",
+ "@typescript-eslint/eslint-plugin": "^6.19.0",
+ "@typescript-eslint/parser": "^6.19.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-react": "^7.33.2",
+ "@gnu-taler/pogen": "workspace:*",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
+ "@types/chai": "^4.3.0",
+ "@types/history": "^4.7.8",
+ "@types/mocha": "^10.0.1",
+ "@types/node": "^18.11.17",
"autoprefixer": "^10.4.14",
+ "chai": "^4.3.6",
"esbuild": "^0.19.9",
+ "mocha": "9.2.0",
"po2json": "^0.4.5",
- "postcss": "^8.4.23",
- "postcss-cli": "^10.1.0",
- "tailwindcss": "^3.3.2"
+ "tailwindcss": "^3.3.2",
+ "typescript": "5.3.3"
},
"pogen": {
- "domain": "aml-backoffice"
+ "domain": "challenger-ui"
+ },
+ "dependencies": {
+ "swr": "2.0.3",
+ "@gnu-taler/taler-util": "workspace:*",
+ "@gnu-taler/web-util": "workspace:*",
+ "date-fns": "2.29.3",
+ "jed": "1.1.1",
+ "qrcode-generator": "^1.4.4",
+ "preact": "10.11.3"
}
}
diff --git a/packages/challenger-ui/postcss.config.js b/packages/challenger-ui/postcss.config.js
index 2e7af2b7f..c9a60a43c 100644
--- a/packages/challenger-ui/postcss.config.js
+++ b/packages/challenger-ui/postcss.config.js
@@ -1,3 +1,18 @@
+/*
+ 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 default {
plugins: {
tailwindcss: {},
diff --git a/packages/challenger-ui/src/Routing.tsx b/packages/challenger-ui/src/Routing.tsx
new file mode 100644
index 000000000..6166f159a
--- /dev/null
+++ b/packages/challenger-ui/src/Routing.tsx
@@ -0,0 +1,270 @@
+/*
+ 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 {
+ Loading,
+ urlPattern,
+ useCurrentLocation,
+ useNavigationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+
+import { assertUnreachable } from "@gnu-taler/taler-util";
+import { CheckChallengeIsUpToDate } from "./components/CheckChallengeIsUpToDate.js";
+import { SessionId, useSessionState } from "./hooks/session.js";
+import { AnswerChallenge } from "./pages/AnswerChallenge.js";
+import { AskChallenge } from "./pages/AskChallenge.js";
+import { CallengeCompleted } from "./pages/CallengeCompleted.js";
+import { Frame } from "./pages/Frame.js";
+import { MissingParams } from "./pages/MissingParams.js";
+import { NonceNotFound } from "./pages/NonceNotFound.js";
+import { Setup } from "./pages/Setup.js";
+
+export function Routing(): VNode {
+ // check session and defined if this is
+ // public routing or private
+ return (
+ <Frame>
+ <PublicRounting />
+ </Frame>
+ );
+}
+
+const publicPages = {
+ noinfo: urlPattern<{ nonce: string }>(
+ /\/noinfo\/(?<nonce>[a-zA-Z0-9]+)/,
+ ({ nonce }) => `#/noinfo/${nonce}`,
+ ),
+ authorize: urlPattern<{ nonce: string }>(
+ /\/authorize\/(?<nonce>[a-zA-Z0-9]+)/,
+ ({ nonce }) => `#/authorize/${nonce}`,
+ ),
+ ask: urlPattern<{ nonce: string }>(
+ /\/ask\/(?<nonce>[a-zA-Z0-9]+)/,
+ ({ nonce }) => `#/ask/${nonce}`,
+ ),
+ answer: urlPattern<{ nonce: string }>(
+ /\/answer\/(?<nonce>[a-zA-Z0-9]+)/,
+ ({ nonce }) => `#/answer/${nonce}`,
+ ),
+ completed: urlPattern<{ nonce: string }>(
+ /\/completed\/(?<nonce>[a-zA-Z0-9]+)/,
+ ({ nonce }) => `#/completed/${nonce}`,
+ ),
+ setup: urlPattern<{ client: string }>(
+ /\/setup\/(?<client>[0-9]+)/,
+ ({ client }) => `#/setup/${client}`,
+ ),
+};
+
+function safeGetParam(
+ ps: Record<string, string[]>,
+ n: string,
+): string | undefined {
+ if (!ps[n] || ps[n].length == 0) return undefined;
+ return ps[n][0];
+}
+
+function safeToURL(s: string | undefined): URL | undefined {
+ if (s === undefined) return undefined;
+ try {
+ return new URL(s);
+ } catch (e) {
+ return undefined;
+ }
+}
+
+function PublicRounting(): VNode {
+ const location = useCurrentLocation(publicPages);
+ const { navigateTo } = useNavigationContext();
+ const { start } = useSessionState();
+
+ if (location === undefined) {
+ return <NonceNotFound />;
+ }
+
+ switch (location.name) {
+ case "noinfo": {
+ return <div>no info</div>;
+ }
+ case "setup": {
+ return (
+ <Setup
+ clientId={location.values.client}
+ onCreated={(nonce) => {
+ navigateTo(publicPages.ask.url({ nonce }));
+ //response_type=code
+ //client_id=1
+ //redirect_uri=http://exchange.taler.test:1180/kyc-proof/kyc-provider-wallet
+ //state=123
+ }}
+ />
+ );
+ }
+ case "authorize": {
+ const responseType = safeGetParam(location.params, "response_type");
+ const clientId = safeGetParam(location.params, "client_id");
+ const redirectURL = safeToURL(
+ safeGetParam(location.params, "redirect_uri"),
+ );
+ const state = safeGetParam(location.params, "state");
+ // http://localhost:8080/app/#/authorize/ASDASD123?response_type=code&client_id=1&redirect_uri=goog.ecom&state=123
+ //
+
+ // http://localhost:8080/app/?response_type=code&client_id=1&redirect_uri=http://exchange.taler.test:1180/kyc-proof/kyc-provider-wallet&state=123#/authorize/X9668AR2CFC26X55H0M87GJZXGM45VD4SZE05C5SNS5FADPWN220
+
+ if (
+ !responseType ||
+ !clientId ||
+ !redirectURL ||
+ !state ||
+ responseType !== "code"
+ ) {
+ return <MissingParams />;
+ }
+ const sessionId: SessionId = {
+ clientId,
+ redirectURL: redirectURL.href,
+ state,
+ };
+ return (
+ <CheckChallengeIsUpToDate
+ sessionId={sessionId}
+ nonce={location.values.nonce}
+ onNoInfo={() => {
+ navigateTo(
+ publicPages.noinfo.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ onCompleted={() => {
+ start(sessionId);
+ navigateTo(
+ publicPages.completed.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ onChangeLeft={() => {
+ start(sessionId);
+ navigateTo(
+ publicPages.ask.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ onNoMoreChanges={() => {
+ start(sessionId);
+ navigateTo(
+ publicPages.ask.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ >
+ <Loading />
+ </CheckChallengeIsUpToDate>
+ );
+ }
+ case "ask": {
+ return (
+ <CheckChallengeIsUpToDate
+ nonce={location.values.nonce}
+ onNoInfo={() => {
+ navigateTo(
+ publicPages.noinfo.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ onCompleted={() => {
+ navigateTo(
+ publicPages.completed.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ >
+ <AskChallenge
+ focus
+ nonce={location.values.nonce}
+ routeSolveChallenge={publicPages.answer}
+ onSendSuccesful={() => {
+ navigateTo(
+ publicPages.answer.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ />
+ </CheckChallengeIsUpToDate>
+ );
+ }
+ case "answer": {
+ return (
+ <CheckChallengeIsUpToDate
+ nonce={location.values.nonce}
+ onNoInfo={() => {
+ navigateTo(
+ publicPages.noinfo.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ onCompleted={() => {
+ navigateTo(
+ publicPages.completed.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ >
+ <AnswerChallenge
+ focus
+ nonce={location.values.nonce}
+ routeAsk={publicPages.ask}
+ onComplete={() => {
+ navigateTo(
+ publicPages.completed.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ />
+ </CheckChallengeIsUpToDate>
+ );
+ }
+ case "completed": {
+ return (
+ <CheckChallengeIsUpToDate
+ nonce={location.values.nonce}
+ onNoInfo={() => {
+ navigateTo(
+ publicPages.noinfo.url({
+ nonce: location.values.nonce,
+ }),
+ );
+ }}
+ >
+ <CallengeCompleted nonce={location.values.nonce} />
+ </CheckChallengeIsUpToDate>
+ );
+ }
+ default:
+ assertUnreachable(location);
+ }
+}
diff --git a/packages/challenger-ui/src/app.tsx b/packages/challenger-ui/src/app.tsx
new file mode 100644
index 000000000..2b5c5c815
--- /dev/null
+++ b/packages/challenger-ui/src/app.tsx
@@ -0,0 +1,168 @@
+/*
+ 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 {
+ CacheEvictor,
+ ChallengerCacheEviction,
+ assertUnreachable,
+ canonicalizeBaseUrl,
+ getGlobalLogLevel,
+ setGlobalLogLevelFromString,
+} from "@gnu-taler/taler-util";
+import {
+ BrowserHashNavigationProvider,
+ ChallengerApiProvider,
+ Loading,
+ TalerWalletIntegrationBrowserProvider,
+ TranslationProvider,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { SWRConfig } from "swr";
+import { Routing } from "./Routing.js";
+// import { BankCoreApiProvider } from "./context/config.js";
+// import { BrowserHashNavigationProvider } from "./context/navigation.js";
+import { SettingsProvider } from "./context/settings.js";
+// import { TalerWalletIntegrationBrowserProvider } from "./context/wallet-integration.js";
+import { VNode, h } from "preact";
+import { strings } from "./i18n/strings.js";
+import { ChallengerUiSettings, fetchSettings } from "./settings.js";
+import { Frame } from "./pages/Frame.js";
+import { revalidateChallengeSession } from "./hooks/challenge.js";
+const WITH_LOCAL_STORAGE_CACHE = false;
+
+const evictBankSwrCache: CacheEvictor<ChallengerCacheEviction> = {
+ async notifySuccess(op) {
+ switch (op) {
+ case ChallengerCacheEviction.CREATE_CHALLENGE: {
+ await Promise.all([revalidateChallengeSession()]);
+ return;
+ }
+ default: {
+ assertUnreachable(op);
+ }
+ }
+ },
+};
+
+export function App(): VNode {
+ const [settings, setSettings] = useState<ChallengerUiSettings>();
+ useEffect(() => {
+ fetchSettings(setSettings);
+ }, []);
+ if (!settings) return <Loading />;
+
+ const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL);
+ return (
+ <SettingsProvider value={settings}>
+ <TranslationProvider
+ source={strings}
+ forceLang="en"
+ completeness={{
+ es: strings["es"].completeness,
+ de: strings["de"].completeness,
+ }}
+ >
+ <ChallengerApiProvider
+ baseUrl={new URL("/", baseUrl)}
+ frameOnError={Frame}
+ evictors={{
+ challenger: evictBankSwrCache,
+ }}
+ >
+ <SWRConfig
+ value={{
+ provider: WITH_LOCAL_STORAGE_CACHE
+ ? localStorageProvider
+ : undefined,
+ // normally, do not revalidate
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ revalidateIfStale: false,
+ revalidateOnMount: undefined,
+ focusThrottleInterval: undefined,
+
+ // normally, do not refresh
+ refreshInterval: undefined,
+ dedupingInterval: 2000,
+ refreshWhenHidden: false,
+ refreshWhenOffline: false,
+
+ // ignore errors
+ shouldRetryOnError: false,
+ errorRetryCount: 0,
+ errorRetryInterval: undefined,
+
+ // do not go to loading again if already has data
+ keepPreviousData: true,
+ }}
+ >
+ <TalerWalletIntegrationBrowserProvider>
+ <BrowserHashNavigationProvider>
+ <Routing />
+ </BrowserHashNavigationProvider>
+ </TalerWalletIntegrationBrowserProvider>
+ </SWRConfig>
+ </ChallengerApiProvider>
+ </TranslationProvider>
+ </SettingsProvider>
+ );
+}
+
+// @ts-expect-error creating a new property for window object
+window.setGlobalLogLevelFromString = setGlobalLogLevelFromString;
+// @ts-expect-error creating a new property for window object
+window.getGlobalLevel = getGlobalLogLevel;
+
+function localStorageProvider(): Map<unknown, unknown> {
+ const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]"));
+
+ window.addEventListener("beforeunload", () => {
+ const appCache = JSON.stringify(Array.from(map.entries()));
+ localStorage.setItem("app-cache", appCache);
+ });
+ return map;
+}
+
+function getInitialBackendBaseURL(
+ backendFromSettings: string | undefined,
+): string {
+ const overrideUrl =
+ typeof localStorage !== "undefined"
+ ? localStorage.getItem("challenger-base-url")
+ : undefined;
+ let result: string;
+
+ if (!overrideUrl) {
+ // normal path
+ if (!backendFromSettings) {
+ console.error(
+ "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'",
+ );
+ result = window.origin;
+ } else {
+ result = backendFromSettings;
+ }
+ } else {
+ // testing/development path
+ result = overrideUrl;
+ }
+ try {
+ return canonicalizeBaseUrl(result);
+ } catch (e) {
+ // fall back
+ return canonicalizeBaseUrl(window.origin);
+ }
+}
diff --git a/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx b/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx
new file mode 100644
index 000000000..70e41bf1e
--- /dev/null
+++ b/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx
@@ -0,0 +1,132 @@
+/*
+ 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 {
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { useChallengeSession } from "../hooks/challenge.js";
+import { SessionId, useSessionState } from "../hooks/session.js";
+
+interface Props {
+ nonce: string;
+ children: ComponentChildren;
+ sessionId?: SessionId;
+ onCompleted?: () => void;
+ onChangeLeft?: () => void;
+ onNoMoreChanges?: () => void;
+ onNoInfo: () => void;
+}
+export function CheckChallengeIsUpToDate({
+ sessionId: sessionFromParam,
+ nonce,
+ children,
+ onCompleted,
+ onChangeLeft,
+ onNoMoreChanges,
+ onNoInfo,
+}: Props): VNode {
+ const { state, updateStatus } = useSessionState();
+ const { i18n } = useTranslationContext();
+
+ const sessionId = sessionFromParam
+ ? sessionFromParam
+ : !state
+ ? undefined
+ : {
+ clientId: state.clientId,
+ redirectURL: state.redirectURL,
+ state: state.state,
+ };
+
+ const result = useChallengeSession(nonce, sessionId);
+ console.log("asd");
+ if (!sessionId) {
+ onNoInfo();
+ return <Loading />;
+ }
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <pre>{JSON.stringify(result, undefined, 2)}</pre>;
+ }
+
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.BadRequest: {
+ return (
+ <Attention type="danger" title={i18n.str`Bad request`}>
+ <i18n.Translate>
+ Could not start the challenge, check configuration.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ case HttpStatusCode.NotFound: {
+ return (
+ <Attention type="danger" title={i18n.str`Not found`}>
+ <i18n.Translate>Nonce not found</i18n.Translate>
+ </Attention>
+ );
+ }
+ case HttpStatusCode.NotAcceptable: {
+ return (
+ <Attention type="danger" title={i18n.str`Not acceptable`}>
+ <i18n.Translate>
+ Server has wrong template configuration
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ case HttpStatusCode.InternalServerError: {
+ return (
+ <Attention type="danger" title={i18n.str`Internal error`}>
+ <i18n.Translate>Check logs</i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(result);
+ }
+ }
+
+ updateStatus(result.body);
+
+ if (onCompleted && "redirectURL" in result.body) {
+ onCompleted();
+ return <Loading />;
+ }
+
+ if (onNoMoreChanges && !result.body.changes_left) {
+ onNoMoreChanges();
+ return <Loading />;
+ }
+
+ if (onChangeLeft && !result.body.changes_left) {
+ onChangeLeft();
+ return <Loading />;
+ }
+
+ return <Fragment>{children}</Fragment>;
+}
diff --git a/packages/challenger-ui/src/context/settings.ts b/packages/challenger-ui/src/context/settings.ts
new file mode 100644
index 000000000..679359200
--- /dev/null
+++ b/packages/challenger-ui/src/context/settings.ts
@@ -0,0 +1,44 @@
+/*
+ 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 { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+import { ChallengerUiSettings } from "../settings.js";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type Type = ChallengerUiSettings;
+
+const initial: ChallengerUiSettings = {};
+const Context = createContext<Type>(initial);
+
+export const useSettingsContext = (): Type => useContext(Context);
+
+export const SettingsProvider = ({
+ children,
+ value,
+}: {
+ value: ChallengerUiSettings;
+ children: ComponentChildren;
+}): VNode => {
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/challenger-ui/src/hooks/challenge.ts b/packages/challenger-ui/src/hooks/challenge.ts
new file mode 100644
index 000000000..846242816
--- /dev/null
+++ b/packages/challenger-ui/src/hooks/challenge.ts
@@ -0,0 +1,58 @@
+/*
+ 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 {
+ ChallengerResultByMethod,
+ TalerHttpError,
+} from "@gnu-taler/taler-util";
+import { useChallengerApiContext } from "@gnu-taler/web-util/browser";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { SessionId } from "./session.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function revalidateChallengeSession() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "login",
+ undefined,
+ { revalidate: true },
+ );
+}
+
+export function useChallengeSession(
+ nonce: string,
+ session: SessionId | undefined,
+) {
+ const {
+ lib: { challenger: api },
+ } = useChallengerApiContext();
+
+ async function fetcher([n, c, r, s]: [string, string, string, string]) {
+ return await api.login(n, c, r, s);
+ }
+ const { data, error } = useSWR<
+ ChallengerResultByMethod<"login">,
+ TalerHttpError
+ >(
+ !session
+ ? undefined
+ : [nonce, session.clientId, session.redirectURL, session.state, "login"],
+ fetcher,
+ {},
+ );
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
diff --git a/packages/challenger-ui/src/hooks/session.ts b/packages/challenger-ui/src/hooks/session.ts
new file mode 100644
index 000000000..ed7ea8986
--- /dev/null
+++ b/packages/challenger-ui/src/hooks/session.ts
@@ -0,0 +1,143 @@
+/*
+ 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 {
+ ChallengerApi,
+ Codec,
+ buildCodecForObject,
+ codecForBoolean,
+ codecForChallengeStatus,
+ codecForNumber,
+ codecForString,
+ codecForStringURL,
+ codecOptional,
+} from "@gnu-taler/taler-util";
+import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
+import { mutate } from "swr";
+
+/**
+ * Has the information to reach and
+ * authenticate at the bank's backend.
+ */
+export type SessionId = {
+ clientId: string;
+ redirectURL: string;
+ state: string;
+};
+
+export type LastChallengeResponse = {
+ attemptsLeft: number;
+ nextSend: string;
+ transmitted: boolean;
+};
+
+export type SessionState = SessionId & {
+ lastTry: LastChallengeResponse | undefined;
+ lastStatus: ChallengerApi.ChallengeStatus | undefined;
+ completedURL: string | undefined;
+};
+export const codecForLastChallengeResponse = (): Codec<LastChallengeResponse> =>
+ buildCodecForObject<LastChallengeResponse>()
+ .property("attemptsLeft", codecForNumber())
+ .property("nextSend", codecForString())
+ .property("transmitted", codecForBoolean())
+ .build("LastChallengeResponse");
+
+export const codecForSessionState = (): Codec<SessionState> =>
+ buildCodecForObject<SessionState>()
+ .property("clientId", codecForString())
+ .property("redirectURL", codecForStringURL())
+ .property("completedURL", codecOptional(codecForStringURL()))
+ .property("state", codecForString())
+ .property("lastStatus", codecOptional(codecForChallengeStatus()))
+ .property("lastTry", codecOptional(codecForLastChallengeResponse()))
+ .build("SessionState");
+
+export interface SessionStateHandler {
+ state: SessionState | undefined;
+ start(s: SessionId): void;
+ accepted(l: LastChallengeResponse): void;
+ completed(e: URL): void;
+ updateStatus(s: ChallengerApi.ChallengeStatus): void;
+}
+
+const SESSION_STATE_KEY = buildStorageKey(
+ "challenger-session",
+ codecForSessionState(),
+);
+
+/**
+ * Return getters and setters for
+ * login credentials and backend's
+ * base URL.
+ */
+export function useSessionState(): SessionStateHandler {
+ const { value: state, update } = useLocalStorage(SESSION_STATE_KEY);
+
+ return {
+ state,
+ start(info) {
+ update({
+ ...info,
+ lastTry: undefined,
+ completedURL: undefined,
+ lastStatus: undefined,
+ });
+ cleanAllCache();
+ },
+ accepted(lastTry) {
+ if (!state) return;
+ update({
+ ...state,
+ lastTry,
+ });
+ },
+ completed(url) {
+ if (!state) return;
+ update({
+ ...state,
+ completedURL: url.href,
+ });
+ },
+ updateStatus(st: ChallengerApi.ChallengeStatus) {
+ if (!state) return;
+ if (!state.lastStatus) {
+ update({
+ ...state,
+ lastStatus: st,
+ });
+ return;
+ }
+ // current status
+ const ls = state.lastStatus;
+ if (
+ ls.changes_left !== st.changes_left ||
+ ls.fix_address !== st.fix_address ||
+ ls.last_address !== st.last_address
+ ) {
+ update({
+ ...state,
+ lastStatus: st,
+ });
+ return;
+ }
+ },
+ };
+}
+
+function cleanAllCache(): void {
+ mutate(() => true, undefined, { revalidate: false });
+}
diff --git a/packages/challenger-ui/src/i18n/challenger-ui.pot b/packages/challenger-ui/src/i18n/challenger-ui.pot
new file mode 100644
index 000000000..5d2497acf
--- /dev/null
+++ b/packages/challenger-ui/src/i18n/challenger-ui.pot
@@ -0,0 +1,199 @@
+# 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/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Bank\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+#: src/pages/AnswerChallenge.tsx:55
+#, c-format
+msgid "Can't be empty"
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:81
+#, c-format
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:108
+#, c-format
+msgid "Invalid request"
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:111
+#, c-format
+msgid "Invalid pin"
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:142
+#, c-format
+msgid "Please enter the TAN you received to authenticate."
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:147
+#, c-format
+msgid "A TAN was sent to your address &quot;%1$s&quot;."
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:151
+#, c-format
+msgid ""
+"We recently already sent a TAN to your address &quot; %1$s&quot;. A new TAN will "
+"not be transmitted again before %2$s."
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:161
+#, c-format
+msgid "You can try another PIN but just %1$s times more."
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:181
+#, c-format
+msgid "TAN code"
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:204
+#, c-format
+msgid "You have %1$s attempts left."
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:217
+#, c-format
+msgid "Check"
+msgstr ""
+
+#: src/pages/AnswerChallenge.tsx:227
+#, c-format
+msgid "Send again"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:76
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:84
+#, c-format
+msgid "invalid email"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:86
+#, c-format
+msgid "emails don't match"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:130
+#, c-format
+msgid "Enter contact details"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:133
+#, c-format
+msgid ""
+"You will receive an email with a TAN code that must be provided on the next "
+"page."
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:152
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:180
+#, c-format
+msgid "Repeat email"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:198
+#, c-format
+msgid "You can change your email address another %1$s times."
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:211
+#, c-format
+msgid "Send email"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:237
+#, c-format
+msgid "Bad request"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:238
+#, c-format
+msgid "Could not start the challenge, check configuration."
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:246
+#, c-format
+msgid "Not found"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:247
+#, c-format
+msgid "Nonce not found"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:253
+#, c-format
+msgid "Not acceptable"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:254
+#, c-format
+msgid "Server has wrong template configuration"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:262
+#, c-format
+msgid "Internal error"
+msgstr ""
+
+#: src/pages/AskChallenge.tsx:263
+#, c-format
+msgid "Check logs"
+msgstr ""
+
+#: src/pages/NonceNotFound.tsx:33
+#, c-format
+msgid "The URL is wrong"
+msgstr ""
+
+#: src/pages/NonceNotFound.tsx:36
+#, c-format
+msgid "Maybe the validation check expired."
+msgstr ""
+
+#: src/pages/Setup.tsx:53
+#, c-format
+msgid "Client doesn't exist."
+msgstr ""
+
+#: src/pages/Setup.tsx:65
+#, c-format
+msgid "Setup new challenge with client ID: &quot;%1$s&quot;"
+msgstr ""
+
+#: src/pages/Setup.tsx:76
+#, c-format
+msgid "Start"
+msgstr ""
+
diff --git a/packages/challenger-ui/src/i18n/poheader b/packages/challenger-ui/src/i18n/poheader
new file mode 100644
index 000000000..f2c9d10dd
--- /dev/null
+++ b/packages/challenger-ui/src/i18n/poheader
@@ -0,0 +1,26 @@
+# 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/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Challenger UI\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
diff --git a/packages/challenger-ui/src/i18n/strings.ts b/packages/challenger-ui/src/i18n/strings.ts
new file mode 100644
index 000000000..ea13fed2e
--- /dev/null
+++ b/packages/challenger-ui/src/i18n/strings.ts
@@ -0,0 +1,90 @@
+/*
+ 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 interface StringsType {
+ // X-Domain or 'messages'
+ domain: string;
+ lang: string;
+ completeness: number;
+ plural_forms: string;
+ locale_data: {
+ messages: Record<string, unknown>;
+ };
+}
+export const strings: Record<string, StringsType> = {};
+
+strings["it"] = {
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "it",
+ },
+ }
+ },
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "it",
+ completeness: 14,
+};
+
+strings["es"] = {
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "es",
+ },
+ }
+ },
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "es",
+ completeness: 100,
+};
+
+strings["en"] = {
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "en",
+ },
+ }
+ },
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=(n != 1);",
+ lang: "en",
+ completeness: 100,
+};
+
+strings["de"] = {
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "de",
+ },
+ }
+ },
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "de",
+ completeness: 4,
+};
diff --git a/packages/challenger-ui/src/index.html b/packages/challenger-ui/src/index.html
new file mode 100644
index 000000000..18f472045
--- /dev/null
+++ b/packages/challenger-ui/src/index.html
@@ -0,0 +1,41 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!doctype html>
+<html lang="en" class="h-full bg-gray-100">
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri,api" />
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link
+ rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
+ />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <title>Challenger</title>
+ <!-- Entry point for the SPA. -->
+ <script type="module" src="index.js"></script>
+ <link rel="stylesheet" href="index.css" />
+ </head>
+
+ <body class="h-full">
+ <div id="app"></div>
+ </body>
+</html>
diff --git a/packages/aml-backoffice-ui/src/settings.ts b/packages/challenger-ui/src/index.tsx
index 68f44b4df..f559288a3 100644
--- a/packages/aml-backoffice-ui/src/settings.ts
+++ b/packages/challenger-ui/src/index.tsx
@@ -1,6 +1,6 @@
/*
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
@@ -14,18 +14,14 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-export interface UiSettings {
- backendBaseURL?: string;
- signupEmail?: string;
-}
+import { App } from "./app.js";
+import { h, render } from "preact";
+import "./scss/main.css";
-/**
- * Global settings for the UI.
- */
-const defaultSettings: UiSettings = {
-};
+const app = document.getElementById("app");
-export const uiSettings: UiSettings =
- "talerExchangeAmlSettings" in globalThis
- ? (globalThis as any).talerExchangeAmlSettings
- : defaultSettings;
+if (app) {
+ render(<App />, app);
+} else {
+ console.error("HTML element with id 'app' not found.");
+}
diff --git a/packages/challenger-ui/src/pages/AnswerChallenge.tsx b/packages/challenger-ui/src/pages/AnswerChallenge.tsx
new file mode 100644
index 000000000..73a79c51f
--- /dev/null
+++ b/packages/challenger-ui/src/pages/AnswerChallenge.tsx
@@ -0,0 +1,279 @@
+/*
+ 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 {
+ ChallengerApi,
+ HttpStatusCode,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Button,
+ LocalNotificationBanner,
+ RouteDefinition,
+ ShowInputErrorLabel,
+ useChallengerApiContext,
+ useLocalNotificationHandler,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useSessionState } from "../hooks/session.js";
+
+export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/;
+
+type Props = {
+ nonce: string;
+ focus?: boolean;
+ onComplete: () => void;
+ routeAsk: RouteDefinition<{ nonce: string }>;
+};
+
+export function AnswerChallenge({ focus, nonce, onComplete, routeAsk }: Props): VNode {
+ const { lib } = useChallengerApiContext();
+ const { i18n } = useTranslationContext();
+ const { state, accepted, completed } = useSessionState();
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
+ const [pin, setPin] = useState<string | undefined>();
+ const [lastTryError, setLastTryError] =
+ useState<ChallengerApi.InvalidPinResponse>();
+ const errors = undefinedIfEmpty({
+ pin: !pin ? i18n.str`Can't be empty` : undefined,
+ });
+
+ const lastEmail = !state
+ ? undefined
+ : !state.lastStatus
+ ? undefined
+ : !state.lastStatus.last_address
+ ? undefined
+ : state.lastStatus.last_address["email"];
+
+ const onSendAgain =
+ !state || lastEmail === undefined
+ ? undefined
+ : withErrorHandler(
+ async () => {
+ if (!lastEmail) return;
+ return await lib.challenger.challenge(nonce, { email: lastEmail });
+ },
+ (ok) => {
+ if ("redirectURL" in ok.body) {
+ completed(ok.body.redirectURL);
+ } else {
+ accepted({
+ attemptsLeft: ok.body.attempts_left,
+ nextSend: ok.body.next_tx_time,
+ transmitted: ok.body.transmitted,
+ });
+ }
+ return undefined;
+ },
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str``;
+ case HttpStatusCode.NotFound:
+ return i18n.str``;
+ case HttpStatusCode.NotAcceptable:
+ return i18n.str``;
+ case HttpStatusCode.TooManyRequests:
+ return i18n.str``;
+ case HttpStatusCode.InternalServerError:
+ return i18n.str``;
+ }
+ },
+ );
+
+ const onCheck =
+ errors !== undefined || (lastTryError && lastTryError.exhausted)
+ ? undefined
+ : withErrorHandler(
+ async () => {
+ return lib.challenger.solve(nonce, { pin: pin! });
+ },
+ (ok) => {
+ completed(ok.body.redirectURL as URL);
+ onComplete();
+ },
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`Invalid request`;
+ case HttpStatusCode.Forbidden: {
+ setLastTryError(fail.body);
+ return i18n.str`Invalid pin`;
+ }
+ case HttpStatusCode.NotFound:
+ return i18n.str``;
+ case HttpStatusCode.NotAcceptable:
+ return i18n.str``;
+ case HttpStatusCode.TooManyRequests:
+ return i18n.str``;
+ case HttpStatusCode.InternalServerError:
+ return i18n.str``;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ );
+
+ if (!state) {
+ return <div>no state</div>;
+ }
+
+ if (!state.lastTry) {
+ return <div>you should do a challenge first</div>;
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="isolate bg-white px-6 py-12">
+ <div class="mx-auto max-w-2xl text-center">
+ <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
+ <i18n.Translate>
+ Enter the TAN you received to authenticate.
+ </i18n.Translate>
+ </h2>
+ <p class="mt-2 text-lg leading-8 text-gray-600">
+ {state.lastTry.transmitted ? (
+ <i18n.Translate>
+ A TAN was sent to your address &quot;{lastEmail}&quot;.
+ </i18n.Translate>
+ ) : (
+ <Attention title={i18n.str`Resend failed`} type="warning">
+ <i18n.Translate>
+ We recently already sent a TAN to your address &quot;
+ {lastEmail}&quot;. A new TAN will not be transmitted again
+ before &quot;{state.lastTry.nextSend}&quot;.
+ </i18n.Translate>
+ </Attention>
+ )}
+ </p>
+ {!lastTryError ? undefined : (
+ <p class="mt-2 text-lg leading-8 text-gray-600">
+ <i18n.Translate>
+ You can try another PIN but just{" "}
+ {lastTryError.auth_attempts_left} times more.
+ </i18n.Translate>
+ </p>
+ )}
+ </div>
+ <form
+ method="POST"
+ class="mx-auto mt-16 max-w-xl sm:mt-20"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="grid grid-cols-1 gap-x-8 gap-y-6">
+ <div class="sm:col-span-2">
+ <label
+ for="pin"
+ class="block text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>TAN code</i18n.Translate>
+ </label>
+ <div class="mt-2.5">
+ <input
+ autoFocus
+ ref={focus ? doAutoFocus : undefined}
+ type="number"
+ name="pin"
+ id="pin"
+ maxLength={64}
+ value={pin}
+ onChange={(e) => {
+ setPin(e.currentTarget.value);
+ }}
+ placeholder="12345678"
+ class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ />
+ <ShowInputErrorLabel
+ message={errors?.pin}
+ isDirty={pin !== undefined}
+ />
+ </div>
+ </div>
+
+ <p class="mt-3 text-sm leading-6 text-gray-400">
+ <i18n.Translate>
+ You have {state.lastTry.attemptsLeft} attempts left.
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="mt-10">
+ <Button
+ type="submit"
+ class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center 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={!onCheck}
+ handler={onCheck}
+ >
+ <i18n.Translate>Check</i18n.Translate>
+ </Button>
+ </div>
+ <div class="mt-10 flex justify-between">
+ <div>
+ <a
+ href={routeAsk.url({ nonce })}
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ >
+ <i18n.Translate>Change email</i18n.Translate>
+ </a>
+ </div>
+ <div>
+ <Button
+ type="submit"
+ disabled={!onSendAgain}
+ class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center 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"
+ handler={onSendAgain}
+ >
+ <i18n.Translate>Send code again</i18n.Translate>
+ </Button>
+ </div>
+ </div>
+ </form>
+ </div>
+ </Fragment>
+ );
+}
+
+/**
+ * Show the element when the load ended
+ * @param element
+ */
+export function doAutoFocus(element: HTMLElement | null): void {
+ if (element) {
+ setTimeout(() => {
+ element.focus({ preventScroll: true });
+ element.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "center",
+ });
+ }, 100);
+ }
+}
+
+export function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
+ return Object.keys(obj).some(
+ (k) => (obj as Record<string, T>)[k] !== undefined,
+ )
+ ? obj
+ : undefined;
+}
diff --git a/packages/challenger-ui/src/pages/AskChallenge.tsx b/packages/challenger-ui/src/pages/AskChallenge.tsx
new file mode 100644
index 000000000..30b50d707
--- /dev/null
+++ b/packages/challenger-ui/src/pages/AskChallenge.tsx
@@ -0,0 +1,263 @@
+/*
+ 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 { HttpStatusCode } from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Button,
+ LocalNotificationBanner,
+ RouteDefinition,
+ ShowInputErrorLabel,
+ useChallengerApiContext,
+ useLocalNotificationHandler,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useSessionState } from "../hooks/session.js";
+import { doAutoFocus } from "./AnswerChallenge.js";
+
+type Form = {
+ email: string;
+};
+export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/;
+
+type Props = {
+ nonce: string;
+ onSendSuccesful: () => void;
+ routeSolveChallenge: RouteDefinition<{ nonce: string }>;
+ focus?: boolean;
+};
+
+export function AskChallenge({
+ nonce,
+ onSendSuccesful,
+ routeSolveChallenge,
+ focus,
+}: Props): VNode {
+ const { state, accepted, completed } = useSessionState();
+ const status = state?.lastStatus;
+ const prevEmail =
+ !status || !status.last_address ? undefined : status.last_address["email"];
+ const regexEmail =
+ !status || !status.restrictions ? undefined : status.restrictions["email"];
+
+ const { lib } = useChallengerApiContext();
+ const { i18n } = useTranslationContext();
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
+ const [email, setEmail] = useState<string | undefined>();
+ const [repeat, setRepeat] = useState<string | undefined>();
+
+ const regexTest =
+ regexEmail && regexEmail.regex ? new RegExp(regexEmail.regex) : EMAIL_REGEX;
+ const regexHint =
+ regexEmail && regexEmail.hint ? regexEmail.hint : i18n.str`invalid email`;
+
+ const errors = undefinedIfEmpty({
+ email: !email
+ ? i18n.str`required`
+ : !regexTest.test(email)
+ ? regexHint
+ : prevEmail !== undefined && email === prevEmail
+ ? i18n.str`email should be different`
+ : undefined,
+ repeat: !repeat
+ ? i18n.str`required`
+ : email !== repeat
+ ? i18n.str`emails doesn't match`
+ : undefined,
+ });
+
+ const onSend = errors
+ ? undefined
+ : withErrorHandler(
+ async () => {
+ return lib.challenger.challenge(nonce, { email: email! });
+ },
+ (ok) => {
+ if ("redirectURL" in ok.body) {
+ completed(ok.body.redirectURL);
+ } else {
+ accepted({
+ attemptsLeft: ok.body.attempts_left,
+ nextSend: ok.body.next_tx_time,
+ transmitted: ok.body.transmitted,
+ });
+ }
+ onSendSuccesful();
+ },
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str``;
+ case HttpStatusCode.NotFound:
+ return i18n.str``;
+ case HttpStatusCode.NotAcceptable:
+ return i18n.str``;
+ case HttpStatusCode.TooManyRequests:
+ return i18n.str``;
+ case HttpStatusCode.InternalServerError:
+ return i18n.str``;
+ }
+ },
+ );
+
+ if (!status) {
+ return <div>no status loaded</div>;
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="isolate bg-white px-6 py-12">
+ <div class="mx-auto max-w-2xl text-center">
+ <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
+ <i18n.Translate>Enter contact details</i18n.Translate>
+ </h2>
+ <p class="mt-2 text-lg leading-8 text-gray-600">
+ <i18n.Translate>
+ You will receive an email with a TAN code that must be provided on
+ the next page.
+ </i18n.Translate>
+ </p>
+ </div>
+ {state.lastTry && (
+ <Fragment>
+ <Attention title={i18n.str`A code has been sent to ${prevEmail}`}>
+ <i18n.Translate>
+ <a href={routeSolveChallenge.url({ nonce })} class="underline">
+ <i18n.Translate>Complete the challenge here.</i18n.Translate>
+ </a>
+ </i18n.Translate>
+ </Attention>
+ </Fragment>
+ )}
+ <form
+ method="POST"
+ class="mx-auto mt-16 max-w-xl sm:mt-20"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="grid grid-cols-1 gap-x-8 gap-y-6">
+ <div class="sm:col-span-2">
+ <label
+ for="email"
+ class="block text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Email</i18n.Translate>
+ </label>
+ <div class="mt-2.5">
+ <input
+ type="email"
+ name="email"
+ id="email"
+ ref={focus ? doAutoFocus : undefined}
+ maxLength={512}
+ autocomplete="email"
+ value={email}
+ onChange={(e) => {
+ setEmail(e.currentTarget.value);
+ }}
+ placeholder={prevEmail}
+ readOnly={status.fix_address}
+ class="block w-full read-only:bg-slate-200 rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ />
+ <ShowInputErrorLabel
+ message={errors?.email}
+ isDirty={email !== undefined}
+ />
+ </div>
+ </div>
+
+ {status.fix_address ? undefined : (
+ <div class="sm:col-span-2">
+ <label
+ for="repeat-email"
+ class="block text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Repeat email</i18n.Translate>
+ </label>
+ <div class="mt-2.5">
+ <input
+ type="email"
+ name="repeat-email"
+ id="repeat-email"
+ value={repeat}
+ onChange={(e) => {
+ setRepeat(e.currentTarget.value);
+ }}
+ autocomplete="email"
+ class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ />
+ <ShowInputErrorLabel
+ message={errors?.repeat}
+ isDirty={repeat !== undefined}
+ />
+ </div>
+ </div>
+ )}
+
+ {!status.changes_left ? (
+ <p class="mt-3 text-sm leading-6 text-gray-400">
+ <i18n.Translate>No more changes left</i18n.Translate>
+ </p>
+ ) : (
+ <p class="mt-3 text-sm leading-6 text-gray-400">
+ <i18n.Translate>
+ You can change your email address another{" "}
+ {status.changes_left} times.
+ </i18n.Translate>
+ </p>
+ )}
+ </div>
+
+ {!prevEmail ? (
+ <div class="mt-10">
+ <Button
+ type="submit"
+ disabled={!onSend}
+ class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center 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"
+ handler={onSend}
+ >
+ <i18n.Translate>Send email</i18n.Translate>
+ </Button>
+ </div>
+ ) : (
+ <div class="mt-10">
+ <Button
+ type="submit"
+ disabled={!onSend}
+ class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center 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"
+ handler={onSend}
+ >
+ <i18n.Translate>Change email</i18n.Translate>
+ </Button>
+ </div>
+ )}
+ </form>
+ </div>
+ </Fragment>
+ );
+}
+
+export function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
+ return Object.keys(obj).some(
+ (k) => (obj as Record<string, T>)[k] !== undefined,
+ )
+ ? obj
+ : undefined;
+}
diff --git a/packages/challenger-ui/src/pages/CallengeCompleted.tsx b/packages/challenger-ui/src/pages/CallengeCompleted.tsx
new file mode 100644
index 000000000..f8cd7ce60
--- /dev/null
+++ b/packages/challenger-ui/src/pages/CallengeCompleted.tsx
@@ -0,0 +1,26 @@
+/*
+ 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 { VNode, h } from "preact";
+
+type Props = {
+ nonce: string;
+}
+export function CallengeCompleted({nonce}:Props):VNode {
+
+ return <div>
+ completed {nonce}
+ </div>
+} \ No newline at end of file
diff --git a/packages/challenger-ui/src/pages/Frame.tsx b/packages/challenger-ui/src/pages/Frame.tsx
new file mode 100644
index 000000000..612eced0b
--- /dev/null
+++ b/packages/challenger-ui/src/pages/Frame.tsx
@@ -0,0 +1,69 @@
+/*
+ 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 { ComponentChildren, Fragment, h, VNode } from "preact";
+
+export function Frame({ children }: { children: ComponentChildren }): VNode {
+ return (
+ <Fragment>
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg">
+ <a href="#">
+ <img
+ class="h-8 w-auto"
+ src='data:image/svg+xml,<?xml version="1.0" encoding="UTF-8" standalone="no"?>%0A<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">%0A <g fill="%230042b3" fill-rule="evenodd" stroke-width=".3">%0A <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />%0A <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />%0A <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />%0A </g>%0A <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />%0A</svg>'
+ alt="GNU Taler"
+ style="height: 1.5rem; margin: 0.5rem;"
+ />
+ </a>
+ </div>
+ <span class="flex items-center text-white text-lg font-bold ml-4">
+ Challenger
+ </span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end"></div>
+ </div>
+ </header>
+
+ <main class="flex-1">{children}</main>
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">
+ Learn more about{" "}
+ <a
+ target="_blank"
+ rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400"
+ href="https://taler.net"
+ >
+ GNU Taler
+ </a>
+ </p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">
+ Copyright © 2014—2023 Taler Systems SA.{" "}
+ </p>
+ </div>
+ </footer>
+ </Fragment>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/forms.ts b/packages/challenger-ui/src/pages/MissingParams.tsx
index cc9e4c7e8..5eb1e434e 100644
--- a/packages/aml-backoffice-ui/src/forms.ts
+++ b/packages/challenger-ui/src/pages/MissingParams.tsx
@@ -1,6 +1,6 @@
/*
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
@@ -13,12 +13,10 @@
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 { VNode, h } from "preact";
-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.
- */
+export function MissingParams() :VNode {
+ return <div>
+ missing params: {window.location.href}
+ </div>
+} \ No newline at end of file
diff --git a/packages/challenger-ui/src/pages/NonceNotFound.tsx b/packages/challenger-ui/src/pages/NonceNotFound.tsx
new file mode 100644
index 000000000..16b3d90ef
--- /dev/null
+++ b/packages/challenger-ui/src/pages/NonceNotFound.tsx
@@ -0,0 +1,42 @@
+/*
+ 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 {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+
+type Form = {
+ email: string;
+};
+
+export function NonceNotFound(): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <div class="isolate bg-white px-6 py-12">
+ <div class="mx-auto max-w-2xl text-center">
+ <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
+ <i18n.Translate>The URL is wrong</i18n.Translate>
+ </h2>
+ <p class="mt-2 text-lg leading-8 text-gray-600">
+ <i18n.Translate>Maybe the validation check expired.</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/challenger-ui/src/pages/Setup.tsx b/packages/challenger-ui/src/pages/Setup.tsx
new file mode 100644
index 000000000..f431835aa
--- /dev/null
+++ b/packages/challenger-ui/src/pages/Setup.tsx
@@ -0,0 +1,82 @@
+/*
+ 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 { AccessToken, HttpStatusCode, encodeCrock, randomBytes } from "@gnu-taler/taler-util";
+import {
+ Button,
+ LocalNotificationBanner,
+ useChallengerApiContext,
+ useLocalNotificationHandler,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useSessionState } from "../hooks/session.js";
+
+type Props = {
+ clientId: string;
+ onCreated: (nonce:string) => void;
+};
+export function Setup({ clientId, onCreated }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
+ const { lib } = useChallengerApiContext();
+ const { start } = useSessionState();
+
+ const onStart = withErrorHandler(
+ async () => {
+ return lib.challenger.setup(clientId, "secret-token:chal-secret" as AccessToken);
+ },
+ (ok) => {
+ start({
+ clientId,
+ redirectURL: "http://exchange.taler.test:1180/kyc-proof/kyc-provider-wallet",
+ state: encodeCrock(randomBytes(32)),
+ });
+
+ onCreated(ok.body.nonce);
+ },
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.NotFound:
+ return i18n.str`Client doesn't exist.`;
+ }
+ },
+ );
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="isolate bg-white px-6 py-12">
+ <div class="mx-auto max-w-2xl text-center">
+ <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
+ <i18n.Translate>
+ Setup new challenge with client ID: &quot;{clientId}&quot;
+ </i18n.Translate>
+ </h2>
+ </div>
+ <div class="mt-10">
+ <Button
+ type="submit"
+ class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center 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"
+ handler={onStart}
+ >
+ <i18n.Translate>Start</i18n.Translate>
+ </Button>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/challenger-ui/src/settings.json b/packages/challenger-ui/src/settings.json
new file mode 100644
index 000000000..b3d0476aa
--- /dev/null
+++ b/packages/challenger-ui/src/settings.json
@@ -0,0 +1,3 @@
+{
+ "backendBaseURL": "http://challenger.taler.test:1180/"
+}
diff --git a/packages/challenger-ui/src/settings.ts b/packages/challenger-ui/src/settings.ts
new file mode 100644
index 000000000..61d2117fa
--- /dev/null
+++ b/packages/challenger-ui/src/settings.ts
@@ -0,0 +1,83 @@
+/*
+ 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 {
+ Codec,
+ buildCodecForObject,
+ canonicalizeBaseUrl,
+ codecForString,
+ codecOptional
+} from "@gnu-taler/taler-util";
+
+export interface ChallengerUiSettings {
+ // Where challenger backend is localted
+ // default: window.origin without "webui/"
+ backendBaseURL?: string;
+
+}
+
+/**
+ * Global settings for the bank UI.
+ */
+const defaultSettings: ChallengerUiSettings = {
+ backendBaseURL: buildDefaultBackendBaseURL(),
+};
+
+const codecForChallengerUISettings = (): Codec<ChallengerUiSettings> =>
+ buildCodecForObject<ChallengerUiSettings>()
+ .property("backendBaseURL", codecOptional(codecForString()))
+ .build("codecForChallengerUISettings");
+
+function removeUndefineField<T extends object>(obj: T): T {
+ const keys = Object.keys(obj) as Array<keyof T>;
+ return keys.reduce((prev, cur) => {
+ if (typeof prev[cur] === "undefined") {
+ delete prev[cur];
+ }
+ return prev;
+ }, obj);
+}
+
+export function fetchSettings(listener: (s: ChallengerUiSettings) => void): void {
+ fetch("./settings.json")
+ .then((resp) => resp.json())
+ .then((json) => codecForChallengerUISettings().decode(json))
+ .then((result) =>
+ listener({
+ ...defaultSettings,
+ ...removeUndefineField(result),
+ }),
+ )
+ .catch((e) => {
+ console.log("failed to fetch settings", e);
+ listener(defaultSettings);
+ });
+}
+
+function buildDefaultBackendBaseURL(): string | undefined {
+ if (typeof window !== "undefined") {
+ const currentLocation = new URL(
+ window.location.pathname,
+ window.location.origin,
+ ).href;
+ /**
+ * By default, bank backend serves the html content
+ * from the /webui root.
+ */
+ return canonicalizeBaseUrl(currentLocation.replace("/webui", ""));
+ }
+ throw Error("No default URL");
+}
diff --git a/packages/challenger-ui/tailwind.config.js b/packages/challenger-ui/tailwind.config.js
index ec51dfbb8..d384690e2 100644
--- a/packages/challenger-ui/tailwind.config.js
+++ b/packages/challenger-ui/tailwind.config.js
@@ -1,4 +1,18 @@
-/** @type {import('tailwindcss').Config} */
+/*
+ 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 default {
content: {
relative: true,
diff --git a/packages/challenger-ui/tsconfig.json b/packages/challenger-ui/tsconfig.json
new file mode 100644
index 000000000..9826fac07
--- /dev/null
+++ b/packages/challenger-ui/tsconfig.json
@@ -0,0 +1,46 @@
+{
+ "compilerOptions": {
+ /* Basic Options */
+ "target": "ES2020",
+ "module": "Node16",
+ "lib": ["DOM", "ES2020"],
+ "allowJs": true /* Allow javascript files to be compiled. */,
+ // "checkJs": true, /* Report errors in .js files. */
+ "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
+ "jsxFactory": "h",
+ "jsxFragmentFactory": "Fragment",
+ "noEmit": true /* Do not emit outputs. */,
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+ /* Strict Type-Checking Options */
+ "strict": true /* Enable all strict type-checking options. */,
+ "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
+ /* Additional Checks */
+ // "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+ /* Module Resolution Options */
+ "moduleResolution": "Node16" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
+ "esModuleInterop": true /* */,
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+ /* Source Map Options */
+ // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+ /* Advanced Options */
+ "skipLibCheck": true /* Skip type checking of declaration files. */
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/idb-bridge/package.json b/packages/idb-bridge/package.json
index 3a9049f90..376265c0f 100644
--- a/packages/idb-bridge/package.json
+++ b/packages/idb-bridge/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/idb-bridge",
- "version": "0.0.16",
+ "version": "0.10.7",
"description": "IndexedDB implementation that uses SQLite3 as storage",
"main": "./dist/idb-bridge.js",
"module": "./lib/index.js",
diff --git a/packages/merchant-backend-ui/package.json b/packages/merchant-backend-ui/package.json
index f7306baf8..bd16317f5 100644
--- a/packages/merchant-backend-ui/package.json
+++ b/packages/merchant-backend-ui/package.json
@@ -1,7 +1,7 @@
{
"private": true,
"name": "@gnu-taler/merchant-backend-ui",
- "version": "0.0.5",
+ "version": "0.10.7",
"license": "AGPL-3.0-or-later",
"scripts": {
"compile": "tsc && ./build.mjs",
@@ -42,7 +42,7 @@
},
"devDependencies": {
"@babel/core": "7.18.9",
- "@gnu-taler/pogen": "^0.0.5",
+ "@gnu-taler/pogen": "workspace:*",
"@linaria/babel-preset": "3.0.0-beta.22",
"@linaria/core": "3.0.0-beta.22",
"@linaria/react": "3.0.0-beta.22",
@@ -66,4 +66,4 @@
"tslib": "2.6.2",
"typescript": "5.3.3"
}
-} \ No newline at end of file
+}
diff --git a/packages/merchant-backoffice-ui/package.json b/packages/merchant-backoffice-ui/package.json
index b00133251..e80604777 100644
--- a/packages/merchant-backoffice-ui/package.json
+++ b/packages/merchant-backoffice-ui/package.json
@@ -1,7 +1,7 @@
{
"private": true,
"name": "@gnu-taler/merchant-backoffice-ui",
- "version": "0.9.3-dev.27",
+ "version": "0.10.7",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
@@ -37,9 +37,8 @@
"@typescript-eslint/parser": "^6.19.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.33.2",
-
"@creativebulma/bulma-tooltip": "^1.2.0",
- "@gnu-taler/pogen": "^0.0.5",
+ "@gnu-taler/pogen": "workspace:*",
"@types/chai": "^4.3.0",
"@types/history": "^4.7.8",
"@types/mocha": "^8.2.3",
diff --git a/packages/merchant-backoffice-ui/src/Application.tsx b/packages/merchant-backoffice-ui/src/Application.tsx
index d5a05e821..5be21ff8f 100644
--- a/packages/merchant-backoffice-ui/src/Application.tsx
+++ b/packages/merchant-backoffice-ui/src/Application.tsx
@@ -19,14 +19,21 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { CacheEvictor, TalerMerchantApi, TalerMerchantInstanceCacheEviction, TalerMerchantManagementCacheEviction, assertUnreachable, canonicalizeBaseUrl } from "@gnu-taler/taler-util";
+import {
+ CacheEvictor,
+ TalerMerchantApi,
+ TalerMerchantInstanceCacheEviction,
+ TalerMerchantManagementCacheEviction,
+ assertUnreachable,
+ canonicalizeBaseUrl,
+} from "@gnu-taler/taler-util";
import {
BrowserHashNavigationProvider,
ConfigResultFail,
MerchantApiProvider,
TalerWalletIntegrationBrowserProvider,
TranslationProvider,
- useTranslationContext
+ useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { useEffect, useState } from "preact/hooks";
@@ -35,16 +42,43 @@ import { Routing } from "./Routing.js";
import { Loading } from "./components/exception/loading.js";
import { NotificationCard } from "./components/menu/index.js";
import { SettingsProvider } from "./context/settings.js";
-import { revalidateBankAccountDetails, revalidateInstanceBankAccounts } from "./hooks/bank.js";
-import { revalidateBackendInstances, revalidateInstanceDetails, revalidateManagedInstanceDetails } from "./hooks/instance.js";
-import { revalidateInstanceOtpDevices, revalidateOtpDeviceDetails } from "./hooks/otp.js";
-import { revalidateInstanceProducts, revalidateProductDetails } from "./hooks/product.js";
-import { revalidateInstanceTemplates, revalidateTemplateDetails } from "./hooks/templates.js";
+import {
+ revalidateBankAccountDetails,
+ revalidateInstanceBankAccounts,
+} from "./hooks/bank.js";
+import {
+ revalidateBackendInstances,
+ revalidateInstanceDetails,
+ revalidateManagedInstanceDetails,
+} from "./hooks/instance.js";
+import {
+ revalidateInstanceOtpDevices,
+ revalidateOtpDeviceDetails,
+} from "./hooks/otp.js";
+import {
+ revalidateInstanceProducts,
+ revalidateProductDetails,
+} from "./hooks/product.js";
+import {
+ revalidateInstanceTemplates,
+ revalidateTemplateDetails,
+} from "./hooks/templates.js";
import { revalidateInstanceTransfers } from "./hooks/transfer.js";
-import { revalidateInstanceWebhooks, revalidateWebhookDetails } from "./hooks/webhooks.js";
+import {
+ revalidateInstanceWebhooks,
+ revalidateWebhookDetails,
+} from "./hooks/webhooks.js";
import { strings } from "./i18n/strings.js";
-import { MerchantUiSettings, buildDefaultBackendBaseURL, fetchSettings } from "./settings.js";
-import { revalidateInstanceOrders, revalidateOrderDetails } from "./hooks/order.js";
+import {
+ MerchantUiSettings,
+ buildDefaultBackendBaseURL,
+ fetchSettings,
+} from "./settings.js";
+import {
+ revalidateInstanceOrders,
+ revalidateOrderDetails,
+} from "./hooks/order.js";
+import { SessionContextProvider } from "./context/session.js";
const WITH_LOCAL_STORAGE_CACHE = false;
export function Application(): VNode {
@@ -64,42 +98,48 @@ export function Application(): VNode {
de: strings["de"].completeness,
}}
>
- <MerchantApiProvider baseUrl={new URL("./", baseUrl)} frameOnError={OnConfigError} evictors={{
- management: swrCacheEvictor
- }}>
- <SWRConfig
- value={{
- provider: WITH_LOCAL_STORAGE_CACHE
- ? localStorageProvider
- : undefined,
- // normally, do not revalidate
- revalidateOnFocus: false,
- revalidateOnReconnect: false,
- revalidateIfStale: false,
- revalidateOnMount: undefined,
- focusThrottleInterval: undefined,
+ <MerchantApiProvider
+ baseUrl={new URL("./", baseUrl)}
+ frameOnError={OnConfigError}
+ evictors={{
+ management: swrCacheEvictor,
+ }}
+ >
+ <SessionContextProvider>
+ <SWRConfig
+ value={{
+ provider: WITH_LOCAL_STORAGE_CACHE
+ ? localStorageProvider
+ : undefined,
+ // normally, do not revalidate
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ revalidateIfStale: false,
+ revalidateOnMount: undefined,
+ focusThrottleInterval: undefined,
- // normally, do not refresh
- refreshInterval: undefined,
- dedupingInterval: 2000,
- refreshWhenHidden: false,
- refreshWhenOffline: false,
+ // normally, do not refresh
+ refreshInterval: undefined,
+ dedupingInterval: 2000,
+ refreshWhenHidden: false,
+ refreshWhenOffline: false,
- // ignore errors
- shouldRetryOnError: false,
- errorRetryCount: 0,
- errorRetryInterval: undefined,
+ // ignore errors
+ shouldRetryOnError: false,
+ errorRetryCount: 0,
+ errorRetryInterval: undefined,
- // do not go to loading again if already has data
- keepPreviousData: true,
- }}
- >
- <TalerWalletIntegrationBrowserProvider>
- <BrowserHashNavigationProvider>
- <Routing />
- </BrowserHashNavigationProvider>
- </TalerWalletIntegrationBrowserProvider>
- </SWRConfig>
+ // do not go to loading again if already has data
+ keepPreviousData: true,
+ }}
+ >
+ <TalerWalletIntegrationBrowserProvider>
+ <BrowserHashNavigationProvider>
+ <Routing />
+ </BrowserHashNavigationProvider>
+ </TalerWalletIntegrationBrowserProvider>
+ </SWRConfig>
+ </SessionContextProvider>
</MerchantApiProvider>
</TranslationProvider>
</SettingsProvider>
@@ -150,187 +190,160 @@ function localStorageProvider(): Map<unknown, unknown> {
return map;
}
-function OnConfigError({ state }: { state: ConfigResultFail<TalerMerchantApi.VersionResponse> | undefined }): VNode {
+function OnConfigError({
+ state,
+}: {
+ state: ConfigResultFail<TalerMerchantApi.VersionResponse> | undefined;
+}): VNode {
const { i18n } = useTranslationContext();
if (!state) {
- return <i18n.Translate>checking compatibility with server...</i18n.Translate>
+ return (
+ <i18n.Translate>checking compatibility with server...</i18n.Translate>
+ );
}
switch (state.type) {
case "error": {
- return <NotificationCard
- notification={{
- message: i18n.str`Contacting the server failed`,
- description: state.error.message,
- details: JSON.stringify(state.error.errorDetail, undefined, 2),
- type: "ERROR",
- }}
- />
+ return (
+ <NotificationCard
+ notification={{
+ message: i18n.str`Contacting the server failed`,
+ description: state.error.message,
+ details: JSON.stringify(state.error.errorDetail, undefined, 2),
+ type: "ERROR",
+ }}
+ />
+ );
}
case "incompatible": {
- return <NotificationCard
- notification={{
- message: i18n.str`The server version is not supported`,
- description: i18n.str`Supported version "${state.supported}", server version "${state.result.version}".`,
- type: "WARN",
- }}
- />
+ return (
+ <NotificationCard
+ notification={{
+ message: i18n.str`The server version is not supported`,
+ description: i18n.str`Supported version "${state.supported}", server version "${state.result.version}".`,
+ type: "WARN",
+ }}
+ />
+ );
}
- default: assertUnreachable(state)
+ default:
+ assertUnreachable(state);
}
}
-const swrCacheEvictor= new class implements CacheEvictor<TalerMerchantManagementCacheEviction | TalerMerchantInstanceCacheEviction> {
- async notifySuccess(op: TalerMerchantManagementCacheEviction | TalerMerchantInstanceCacheEviction) {
- switch(op) {
+const swrCacheEvictor = new (class
+ implements
+ CacheEvictor<
+ TalerMerchantManagementCacheEviction | TalerMerchantInstanceCacheEviction
+ >
+{
+ async notifySuccess(
+ op:
+ | TalerMerchantManagementCacheEviction
+ | TalerMerchantInstanceCacheEviction,
+ ) {
+ switch (op) {
case TalerMerchantManagementCacheEviction.CREATE_INSTANCE: {
- await Promise.all([
- revalidateBackendInstances()
- ])
- return
+ await Promise.all([revalidateBackendInstances()]);
+ return;
}
case TalerMerchantManagementCacheEviction.UPDATE_INSTANCE: {
- await Promise.all([
- revalidateManagedInstanceDetails()
- ])
- return
+ await Promise.all([revalidateManagedInstanceDetails()]);
+ return;
}
- case TalerMerchantManagementCacheEviction.DELETE_INSTANCE:{
- await Promise.all([
- revalidateBackendInstances()
- ])
- return
+ case TalerMerchantManagementCacheEviction.DELETE_INSTANCE: {
+ await Promise.all([revalidateBackendInstances()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.UPDATE_CURRENT_INSTANCE:{
- await Promise.all([
- revalidateInstanceDetails()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.UPDATE_CURRENT_INSTANCE: {
+ await Promise.all([revalidateInstanceDetails()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.DELETE_CURRENT_INSTANCE:{
- await Promise.all([
- revalidateInstanceDetails()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.DELETE_CURRENT_INSTANCE: {
+ await Promise.all([revalidateInstanceDetails()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.CREATE_BANK_ACCOUNT:{
- await Promise.all([
- revalidateInstanceBankAccounts()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.CREATE_BANK_ACCOUNT: {
+ await Promise.all([revalidateInstanceBankAccounts()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.UPDATE_BANK_ACCOUNT:{
- await Promise.all([
- revalidateBankAccountDetails()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.UPDATE_BANK_ACCOUNT: {
+ await Promise.all([revalidateBankAccountDetails()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.DELETE_BANK_ACCOUNT:{
- await Promise.all([
- revalidateInstanceBankAccounts()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.DELETE_BANK_ACCOUNT: {
+ await Promise.all([revalidateInstanceBankAccounts()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.CREATE_PRODUCT:{
- await Promise.all([
- revalidateInstanceProducts()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.CREATE_PRODUCT: {
+ await Promise.all([revalidateInstanceProducts()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.UPDATE_PRODUCT:{
+ case TalerMerchantInstanceCacheEviction.UPDATE_PRODUCT: {
await Promise.all([
- revalidateProductDetails()
- ])
- return
+ revalidateProductDetails(),
+ revalidateInstanceProducts(),
+ ]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.DELETE_PRODUCT:{
- await Promise.all([
- revalidateInstanceProducts()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.DELETE_PRODUCT: {
+ await Promise.all([revalidateInstanceProducts()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.CREATE_TRANSFER:{
- await Promise.all([
- revalidateInstanceTransfers()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.CREATE_TRANSFER: {
+ await Promise.all([revalidateInstanceTransfers()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.DELETE_TRANSFER:{
- await Promise.all([
- revalidateInstanceTransfers()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.DELETE_TRANSFER: {
+ await Promise.all([revalidateInstanceTransfers()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.CREATE_DEVICE:{
- await Promise.all([
- revalidateInstanceOtpDevices()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.CREATE_DEVICE: {
+ await Promise.all([revalidateInstanceOtpDevices()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.UPDATE_DEVICE:{
- await Promise.all([
- revalidateOtpDeviceDetails()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.UPDATE_DEVICE: {
+ await Promise.all([revalidateOtpDeviceDetails()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.DELETE_DEVICE:{
- await Promise.all([
- revalidateInstanceOtpDevices()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.DELETE_DEVICE: {
+ await Promise.all([revalidateInstanceOtpDevices()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.CREATE_TEMPLATE:{
- await Promise.all([
- revalidateInstanceTemplates()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.CREATE_TEMPLATE: {
+ await Promise.all([revalidateInstanceTemplates()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.UPDATE_TEMPLATE:{
- await Promise.all([
- revalidateTemplateDetails()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.UPDATE_TEMPLATE: {
+ await Promise.all([revalidateTemplateDetails()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.DELETE_TEMPLATE:{
- await Promise.all([
- revalidateInstanceTemplates()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.DELETE_TEMPLATE: {
+ await Promise.all([revalidateInstanceTemplates()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.CREATE_WEBHOOK:{
- await Promise.all([
- revalidateInstanceWebhooks()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.CREATE_WEBHOOK: {
+ await Promise.all([revalidateInstanceWebhooks()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.UPDATE_WEBHOOK:{
- await Promise.all([
- revalidateWebhookDetails()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.UPDATE_WEBHOOK: {
+ await Promise.all([revalidateWebhookDetails()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.DELETE_WEBHOOK:{
- await Promise.all([
- revalidateInstanceWebhooks()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.DELETE_WEBHOOK: {
+ await Promise.all([revalidateInstanceWebhooks()]);
+ return;
}
- case TalerMerchantInstanceCacheEviction.CREATE_ORDER:{
- await Promise.all([
- revalidateInstanceOrders()
- ])
- return
+ case TalerMerchantInstanceCacheEviction.CREATE_ORDER: {
+ await Promise.all([revalidateInstanceOrders()]);
+ return;
}
case TalerMerchantInstanceCacheEviction.UPDATE_ORDER: {
- await Promise.all([
- revalidateOrderDetails()
- ])
- return
+ await Promise.all([revalidateOrderDetails()]);
+ return;
}
case TalerMerchantInstanceCacheEviction.DELETE_ORDER: {
- await Promise.all([
- revalidateInstanceOrders()
- ])
- return
+ await Promise.all([revalidateInstanceOrders()]);
+ return;
}
case TalerMerchantInstanceCacheEviction.LAST:
// case TalerMerchantInstanceCacheEviction.CREATE_TOKENFAMILY:{
@@ -351,5 +364,4 @@ const swrCacheEvictor= new class implements CacheEvictor<TalerMerchantManagement
// }
}
}
-
-}
+})();
diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx
index 66f352118..665137415 100644
--- a/packages/merchant-backoffice-ui/src/Routing.tsx
+++ b/packages/merchant-backoffice-ui/src/Routing.tsx
@@ -22,12 +22,9 @@
import {
AbsoluteTime,
TalerError,
- TranslatedString
+ TranslatedString,
} from "@gnu-taler/taler-util";
-import {
- urlPattern,
- useTranslationContext
-} from "@gnu-taler/web-util/browser";
+import { urlPattern, useTranslationContext } from "@gnu-taler/web-util/browser";
import { createHashHistory } from "history";
import { Fragment, VNode, h } from "preact";
import { Route, Router, route } from "preact-router";
@@ -168,8 +165,7 @@ export function Routing(_p: Props): VNode {
(AbsoluteTime.isNever(preference.hideMissingAccountUntil) ||
AbsoluteTime.cmp(now, preference.hideMissingAccountUntil) > 1);
- const shouldLogin =
- state.status === "loggedOut" || state.status === "expired";
+ const shouldLogin = state.status === "loggedOut";
// function ServerErrorRedirectTo(to: InstancePaths | AdminPaths) {
// return function ServerErrorRedirectToImpl(
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx
index 76d38db84..11396b88e 100644
--- a/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx
+++ b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx
@@ -23,6 +23,7 @@ import { ComponentChildren, h, VNode } from "preact";
import { InputWithAddon } from "./InputWithAddon.js";
import { InputProps } from "./useField.js";
import { AmountString } from "@gnu-taler/taler-util";
+import { useSessionContext } from "../../context/session.js";
export interface Props<T> extends InputProps<T> {
expand?: boolean;
@@ -43,7 +44,7 @@ export function InputCurrency<T>({
children,
side,
}: Props<keyof T>): VNode {
- const { config } = useMerchantApiContext();
+ const { config } = useSessionContext();
return (
<InputWithAddon<T>
name={name}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx b/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx
index 10b28cd93..38444b85d 100644
--- a/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx
+++ b/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx
@@ -53,8 +53,9 @@ export function InputNumber<T>({
help={help}
tooltip={tooltip}
inputExtra={{ min: 0 }}
- children={children}
side={side}
- />
+ >
+ {children}
+ </InputWithAddon>
);
}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
index 3337e5f57..a0c15c77c 100644
--- a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
+++ b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
@@ -18,7 +18,11 @@
*
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { parsePaytoUri, PaytoUriGeneric, stringifyPaytoUri } from "@gnu-taler/taler-util";
+import {
+ parsePaytoUri,
+ PaytoUriGeneric,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
import { COUNTRY_TABLE } from "../../utils/constants.js";
@@ -71,7 +75,7 @@ function checkAddressChecksum(address: string) {
return true;
}
-function validateBitcoin(
+function validateBitcoin_path1(
addr: string,
i18n: ReturnType<typeof useTranslationContext>["i18n"],
): string | undefined {
@@ -84,7 +88,7 @@ function validateBitcoin(
return i18n.str`This is not a valid bitcoin address.`;
}
-function validateEthereum(
+function validateEthereum_path1(
addr: string,
i18n: ReturnType<typeof useTranslationContext>["i18n"],
): string | undefined {
@@ -98,6 +102,29 @@ function validateEthereum(
}
/**
+ * validates
+ * bank.com/
+ * bank.com
+ * bank.com/path
+ * bank.com/path/subpath/
+ */
+const DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+(\/[a-zA-Z0-9-.]+)*\/?$/
+
+function validateTalerBank_path1(
+ addr: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ console.log(addr, DOMAIN_REGEX.test(addr))
+ try {
+ const valid = DOMAIN_REGEX.test(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid host.`;
+}
+
+/**
* An IBAN is validated by converting it into an integer and performing a
* basic mod-97 operation (as described in ISO 7064) on it.
* If the IBAN is valid, the remainder equals 1.
@@ -111,7 +138,7 @@ function validateEthereum(
* If the remainder is 1, the check digit test is passed and the IBAN might be valid.
*
*/
-function validateIBAN(
+function validateIBAN_path1(
iban: string,
i18n: ReturnType<typeof useTranslationContext>["i18n"],
): string | undefined {
@@ -178,34 +205,36 @@ export function InputPaytoForm<T>({
}: Props<keyof T>): VNode {
const { value: initialValueStr, onChange } = useField<T>(name);
- const initialPayto = parsePaytoUri(initialValueStr ?? "")
- const paths = !initialPayto ? [] : initialPayto.targetPath.split("/")
+ const initialPayto = parsePaytoUri(initialValueStr ?? "");
+ const paths = !initialPayto ? [] : initialPayto.targetPath.split("/");
const initialPath1 = paths.length >= 1 ? paths[0] : undefined;
const initialPath2 = paths.length >= 2 ? paths[1] : undefined;
- const initial: Entity = initialPayto === undefined ? defaultTarget : {
- target: initialPayto.targetType,
- params: initialPayto.params,
- path1: initialPath1,
- path2: initialPath2,
- }
- const [value, setValue] = useState<Partial<Entity>>(initial)
+ const initial: Entity =
+ initialPayto === undefined
+ ? defaultTarget
+ : {
+ target: initialPayto.targetType,
+ params: initialPayto.params,
+ path1: initialPath1,
+ path2: initialPath2,
+ };
+ const [value, setValue] = useState<Partial<Entity>>(initial);
const { i18n } = useTranslationContext();
const errors: FormErrors<Entity> = {
- target:
- value.target === noTargetValue
- ? i18n.str`required`
- : undefined,
+ target: value.target === noTargetValue ? i18n.str`required` : undefined,
path1: !value.path1
? i18n.str`required`
: value.target === "iban"
- ? validateIBAN(value.path1, i18n)
+ ? validateIBAN_path1(value.path1, i18n)
: value.target === "bitcoin"
- ? validateBitcoin(value.path1, i18n)
+ ? validateBitcoin_path1(value.path1, i18n)
: value.target === "ethereum"
- ? validateEthereum(value.path1, i18n)
- : undefined,
+ ? validateEthereum_path1(value.path1, i18n)
+ : value.target === "x-taler-bank"
+ ? validateTalerBank_path1(value.path1, i18n)
+ : undefined,
path2:
value.target === "x-taler-bank"
? !value.path2
@@ -222,15 +251,22 @@ export function InputPaytoForm<T>({
const hasErrors = Object.keys(errors).some(
(k) => (errors as any)[k] !== undefined,
);
- const str = hasErrors || !value.target ? undefined : stringifyPaytoUri({
- targetType: value.target,
- targetPath: value.path2 ? `${value.path1}/${value.path2}` : (value.path1 ?? ""),
- params: value.params ?? {} as any,
- isKnown: false,
- })
+
+ const path1WithSlash = value.path1 && !value.path1.endsWith("/") ? value.path1 + "/" : value.path1
+ const str =
+ hasErrors || !value.target
+ ? undefined
+ : stringifyPaytoUri({
+ targetType: value.target,
+ targetPath: value.path2
+ ? `${path1WithSlash}${value.path2}`
+ : value.path1 ?? "",
+ params: value.params ?? ({} as any),
+ isKnown: false,
+ });
useEffect(() => {
- onChange(str as any)
- }, [str])
+ onChange(str as any);
+ }, [str]);
// const submit = useCallback((): void => {
// // const accounts: TalerMerchantApi.AccountAddDetails[] = paytos;
@@ -365,7 +401,23 @@ export function InputPaytoForm<T>({
name="path1"
readonly={readonly}
label={i18n.str`Host`}
+ fromStr={(v) => {
+ if (v.startsWith("http")) {
+ try {
+ const url = new URL(v);
+ return url.host + url.pathname;
+ } catch {
+ return v;
+ }
+ }
+ return v;
+ }}
tooltip={i18n.str`Bank host.`}
+ help={<Fragment>
+ <div><i18n.Translate>Without scheme and may include subpath:</i18n.Translate></div>
+ <div>bank.com/</div>
+ <div>bank.com/path/subpath/</div>
+ </Fragment>}
/>
<Input<Entity>
name="path2"
@@ -389,9 +441,7 @@ export function InputPaytoForm<T>({
/>
</Fragment>
)}
-
</FormProvider>
</InputGroup>
);
}
-
diff --git a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx
index 2a24dfbe2..864d09f48 100644
--- a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx
+++ b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx
@@ -19,11 +19,9 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import {
- useMerchantApiContext,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
+import { useSessionContext } from "../../context/session.js";
import { Entity } from "../../paths/admin/create/CreatePage.js";
import { Input } from "../form/Input.js";
import { InputDuration } from "../form/InputDuration.js";
@@ -33,6 +31,7 @@ import { InputLocation } from "../form/InputLocation.js";
import { InputSelector } from "../form/InputSelector.js";
import { InputToggle } from "../form/InputToggle.js";
import { InputWithAddon } from "../form/InputWithAddon.js";
+import { TextField } from "../form/TextField.js";
export function DefaultInstanceFormFields({
readonlyId,
@@ -42,13 +41,13 @@ export function DefaultInstanceFormFields({
showId: boolean;
}): VNode {
const { i18n } = useTranslationContext();
- const { url: backendUrl } = useMerchantApiContext();
+ const { state } = useSessionContext();
return (
<Fragment>
{showId && (
<InputWithAddon<Entity>
name="id"
- addonBefore={new URL("instances/", backendUrl.href).href}
+ addonBefore={new URL("instances/", state.backendUrl.href).href}
readonly={readonlyId}
label={i18n.str`Identifier`}
tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`}
@@ -61,11 +60,20 @@ export function DefaultInstanceFormFields({
tooltip={i18n.str`Legal name of the business represented by this instance.`}
/>
+ <TextField name="asdasd" label="">
+ <i18n.Translate>
+ Choose individual if you don't have or are not required to have legal business permission.
+ </i18n.Translate>
+ </TextField>
+
<InputSelector<Entity>
name="user_type"
- label={i18n.str`Type`}
+ label={i18n.str`Selling as`}
tooltip={i18n.str`Different type of account can have different rules and requirements.`}
values={["business", "individual"]}
+ toStr={(d: string) => {
+ return d.toUpperCase();
+ }}
/>
<Input<Entity>
@@ -86,12 +94,6 @@ export function DefaultInstanceFormFields({
tooltip={i18n.str`Logo image.`}
/>
- <InputToggle<Entity>
- name="use_stefan"
- label={i18n.str`Pay transaction fee`}
- tooltip={i18n.str`Assume the cost of the transaction of let the user pay for it.`}
- />
-
<InputGroup
name="address"
label={i18n.str`Address`}
@@ -108,6 +110,12 @@ export function DefaultInstanceFormFields({
<InputLocation name="jurisdiction" />
</InputGroup>
+ <InputToggle<Entity>
+ name="use_stefan"
+ label={i18n.str`Pay transaction fee`}
+ tooltip={i18n.str`Assume the cost of the transaction of let the user pay for it.`}
+ />
+
<InputDuration<Entity>
name="default_pay_delay"
label={i18n.str`Default payment delay`}
diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx
index 9819c1911..2090704d9 100644
--- a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx
+++ b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx
@@ -19,15 +19,12 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import {
- useMerchantApiContext,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
+import { TalerError } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useSessionContext } from "../../context/session.js";
import { useInstanceKYCDetails } from "../../hooks/instance.js";
import { LangSelector } from "./LangSelector.js";
-import { TalerError } from "@gnu-taler/taler-util";
// const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
@@ -38,6 +35,7 @@ interface Props {
export function Sidebar({ mobile }: Props): VNode {
const { i18n } = useTranslationContext();
+ const { state, logOut, config } = useSessionContext();
const kycStatus = useInstanceKYCDetails();
const needKYC =
@@ -45,11 +43,9 @@ export function Sidebar({ mobile }: Props): VNode {
!(kycStatus instanceof TalerError) &&
kycStatus.type === "ok" &&
!!kycStatus.body;
- const { state, logOut } = useSessionContext();
const isLoggedIn = state.status === "loggedIn";
const hasToken = isLoggedIn && state.token !== undefined;
- const { config, url: backendURL } = useMerchantApiContext();
-
+
return (
<aside
class="aside is-placed-left is-expanded"
@@ -195,10 +191,7 @@ export function Sidebar({ mobile }: Props): VNode {
</p>
<ul class="menu-list">
<li>
- <a
- class="has-icon is-state-info is-hoverable"
- href="/interface"
- >
+ <a class="has-icon is-state-info is-hoverable" href="/interface">
<span class="icon">
<i class="mdi mdi-newspaper" />
</span>
@@ -212,9 +205,7 @@ export function Sidebar({ mobile }: Props): VNode {
<span style={{ width: "3rem" }} class="icon">
<i class="mdi mdi-web" />
</span>
- <span class="menu-item-label">
- {backendURL.hostname}
- </span>
+ <span class="menu-item-label">{state.backendUrl.hostname}</span>
</div>
</li>
<li>
diff --git a/packages/merchant-backoffice-ui/src/components/menu/index.tsx b/packages/merchant-backoffice-ui/src/components/menu/index.tsx
index aa955db4e..a35c07ace 100644
--- a/packages/merchant-backoffice-ui/src/components/menu/index.tsx
+++ b/packages/merchant-backoffice-ui/src/components/menu/index.tsx
@@ -104,7 +104,7 @@ export function Menu(_p: MenuProps): VNode {
? getInstanceTitle(path, state.instance)
: getAdminTitle(path, state.instance);
- const isLoggedIn =state.status === "loggedIn";
+ const isLoggedIn = state.status === "loggedIn";
return (
<WithTitle title={titleWithSubtitle}>
@@ -117,11 +117,9 @@ export function Menu(_p: MenuProps): VNode {
title={titleWithSubtitle}
/>
- {isLoggedIn && (
- <Sidebar mobile={mobileOpen} />
- )}
+ {isLoggedIn && <Sidebar mobile={mobileOpen} />}
- {state.status !== "loggedOut" && state.impersonate !== undefined && (
+ {state.status !== "loggedOut" && state.impersonated && (
<nav
class="level"
style={{
@@ -137,9 +135,8 @@ export function Menu(_p: MenuProps): VNode {
.{" "}
<a
href="#/instances"
- onClick={(e) => {
+ onClick={() => {
deImpersonate();
- e.preventDefault();
}}
>
go back
@@ -228,7 +225,7 @@ export function NotYetReadyAppMenu({ title }: NotYetReadyAppMenuProps): VNode {
useEffect(() => {
document.title = `Taler Backoffice: ${title}`;
}, [title]);
-
+
const isLoggedIn = state.status === "loggedIn";
return (
diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx
index 781d2de2c..dede0008f 100644
--- a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx
+++ b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx
@@ -20,13 +20,11 @@
*/
import { AmountString, TalerMerchantApi } from "@gnu-taler/taler-util";
-import {
- useMerchantApiContext,
- useTranslationContext
-} from "@gnu-taler/web-util/browser";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { h } from "preact";
import { useCallback, useEffect, useState } from "preact/hooks";
import * as yup from "yup";
+import { useSessionContext } from "../../context/session.js";
import {
ProductCreateSchema as createSchema,
ProductUpdateSchema as updateSchema,
@@ -88,7 +86,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
);
const submit = useCallback((): Entity | undefined => {
- const stock = (value).stock;
+ const stock = value.stock;
if (!stock) {
value.total_stock = -1;
@@ -116,9 +114,8 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
onSubscribe(hasErrors ? undefined : submit);
}, [submit, hasErrors]);
- const { url: backendUrl } = useMerchantApiContext();
const { i18n } = useTranslationContext();
-
+ const { state } = useSessionContext();
return (
<div>
<FormProvider<Entity>
@@ -130,7 +127,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
{alreadyExist ? undefined : (
<InputWithAddon<Entity>
name="product_id"
- addonBefore={new URL("product/", backendUrl.href).href}
+ addonBefore={new URL("product/", state.backendUrl.href).href}
label={i18n.str`ID`}
tooltip={i18n.str`product identification to use in URLs (for internal use only)`}
/>
@@ -150,13 +147,13 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
name="minimum_age"
label={i18n.str`Age restriction`}
tooltip={i18n.str`is this product restricted for customer below certain age?`}
- help={i18n.str`minimum age of the buyer`}
+ help={i18n.str`minimum age of the customer`}
/>
<Input<Entity>
name="unit"
label={i18n.str`Unit name`}
tooltip={i18n.str`unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers`}
- help={i18n.str`exajmple: kg, items or liters`}
+ help={i18n.str`example: kg, items or liters`}
/>
<InputCurrency<Entity>
name="price"
diff --git a/packages/merchant-backoffice-ui/src/context/session.ts b/packages/merchant-backoffice-ui/src/context/session.ts
index f3349bf83..fa5e14ab3 100644
--- a/packages/merchant-backoffice-ui/src/context/session.ts
+++ b/packages/merchant-backoffice-ui/src/context/session.ts
@@ -17,11 +17,10 @@
import {
AccessToken,
Codec,
+ TalerMerchantApi,
buildCodecForObject,
- buildCodecForUnion,
- codecForBoolean,
- codecForConstString,
codecForString,
+ codecForURL,
codecOptional,
} from "@gnu-taler/taler-util";
import {
@@ -29,99 +28,79 @@ import {
useLocalStorage,
useMerchantApiContext,
} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, VNode, createContext, h } from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
import { mutate } from "swr";
+import { MerchantLib } from "../../../web-util/lib/context/activity.js";
/**
* Has the information to reach and
* authenticate at the bank's backend.
*/
-export type SessionState = LoggedIn | LoggedOut | Expired;
+export type SessionState = LoggedIn | LoggedOut;
interface LoggedIn {
status: "loggedIn";
+
+ // is this instance admin? usually "default" name
isAdmin: boolean;
+
+ // url where all the request will be made
+ // usually this is from where the SPA was loaded
+ // unless it's using impersonate feature
+ backendUrl: URL;
+
+ // instance name
instance: string;
+
+ // session is not the same from where it was loaded
+ impersonated: boolean;
+
+ //instance access token
token: AccessToken | undefined;
- impersonate: Impersonate | undefined;
-}
-interface Impersonate {
- originalInstance: string;
- originalToken: AccessToken | undefined;
- originalBackendUrl: string;
-}
-interface Expired {
- status: "expired";
- isAdmin: boolean;
- instance: string;
- token?: undefined;
- impersonate: Impersonate | undefined;
}
+
interface LoggedOut {
status: "loggedOut";
+ backendUrl: URL;
instance: string;
isAdmin: boolean;
- token?: undefined;
+ token: AccessToken | undefined;
}
-export const codecForSessionStateLoggedIn = (): Codec<LoggedIn> =>
- buildCodecForObject<LoggedIn>()
- .property("status", codecForConstString("loggedIn"))
- .property("instance", codecForString())
- .property("impersonate", codecOptional(codecForImpresonate()))
+interface SavedSession {
+ backendUrl: URL;
+ token: AccessToken | undefined;
+ prevToken: AccessToken | undefined;
+}
+
+export const codecForSessionState = (): Codec<SavedSession> =>
+ buildCodecForObject<SavedSession>()
+ .property("backendUrl", codecForURL())
.property("token", codecOptional(codecForString() as Codec<AccessToken>))
- .property("isAdmin", codecForBoolean())
- .build("SessionState.LoggedIn");
-
-export const codecForSessionStateExpired = (): Codec<Expired> =>
- buildCodecForObject<Expired>()
- .property("status", codecForConstString("expired"))
- .property("instance", codecForString())
- .property("impersonate", codecOptional(codecForImpresonate()))
- .property("isAdmin", codecForBoolean())
- .build("SessionState.Expired");
-
-export const codecForSessionStateLoggedOut = (): Codec<LoggedOut> =>
- buildCodecForObject<LoggedOut>()
- .property("status", codecForConstString("loggedOut"))
- .property("instance", codecForString())
- .property("isAdmin", codecForBoolean())
- .build("SessionState.LoggedOut");
-
-export const codecForImpresonate = (): Codec<Impersonate> =>
- buildCodecForObject<Impersonate>()
- .property("originalInstance", codecForString())
.property(
- "originalToken",
+ "prevToken",
codecOptional(codecForString() as Codec<AccessToken>),
)
- .property("originalBackendUrl", codecForString())
- .build("SessionState.Impersonate");
-
-export const codecForSessionState = (): Codec<SessionState> =>
- buildCodecForUnion<SessionState>()
- .discriminateOn("status")
- .alternative("loggedIn", codecForSessionStateLoggedIn())
- .alternative("loggedOut", codecForSessionStateLoggedOut())
- .alternative("expired", codecForSessionStateExpired())
- .build("SessionState");
+ .build("SavedSession");
function inferInstanceName(url: URL) {
const match = INSTANCE_ID_LOOKUP.exec(url.href);
return !match || !match[1] ? DEFAULT_ADMIN_USERNAME : match[1];
}
-export const defaultState = (url: URL): SessionState => {
- const instance = inferInstanceName(url);
+export const defaultState = (url: URL): SavedSession => {
return {
- status: "loggedIn",
- instance,
- isAdmin: instance === DEFAULT_ADMIN_USERNAME,
+ backendUrl: url,
token: undefined,
- impersonate: undefined,
+ prevToken: undefined,
};
};
export interface SessionStateHandler {
+ lib: MerchantLib;
+ config: TalerMerchantApi.VersionResponse;
+
state: SessionState;
/**
* from every state to logout state
@@ -132,19 +111,15 @@ export interface SessionStateHandler {
*/
deImpersonate(): void;
/**
- * from non-loggedOut state to expired
- */
- expired(): void;
- /**
* from any to loggedIn
* @param info
*/
- logIn(info: { token?: AccessToken }): void;
+ logIn(token: AccessToken | undefined): void;
/**
* from loggedIn to impersonate
* @param info
*/
- impersonate(info: { instance: string; baseUrl: URL, token?: AccessToken }): void;
+ impersonate(baseUrl: URL): void;
}
const SESSION_STATE_KEY = buildStorageKey(
@@ -156,95 +131,125 @@ export const DEFAULT_ADMIN_USERNAME = "default";
export const INSTANCE_ID_LOOKUP = /\/instances\/([^/]*)\/?$/;
+export function cleanAllCache(): void {
+ mutate(() => true, undefined, { revalidate: false });
+}
+
+const Context = createContext<SessionStateHandler>(undefined!);
+
+export const useSessionContext = (): SessionStateHandler => useContext(Context);
+
/**
- * Return getters and setters for
- * login credentials and backend's
- * base URL.
+ * Creates the session in loggedIn state.
+ * Infer the instance name based on the URL.
+ * Create the instance of the merchant api http rest.
+ * Returns API that handle impersonation.
+ *
+ * @param param0
+ * @returns
*/
-export function useSessionContext(): SessionStateHandler {
- const { url: merchantUrl, changeBackend } = useMerchantApiContext();
-
+export const SessionContextProvider = ({
+ children,
+ // value,
+}: {
+ // value: MerchantUiSettings;
+ children: ComponentChildren;
+}): VNode => {
+ const {
+ lib: rootLib,
+ config: rootConfig,
+ url: merchantUrl,
+ } = useMerchantApiContext();
+ const [status, setStatus] = useState<"loggedIn" | "loggedOut">("loggedIn");
+ const [currentConfig, setCurrentConfig] =
+ useState<TalerMerchantApi.VersionResponse>();
const { value: state, update } = useLocalStorage(
SESSION_STATE_KEY,
defaultState(merchantUrl),
);
- return {
- state,
+ const currentInstance = inferInstanceName(state.backendUrl);
+
+ let lib: MerchantLib;
+ let config: TalerMerchantApi.VersionResponse;
+ const doingImpersonation = state.backendUrl.href !== merchantUrl.href;
+ if (doingImpersonation) {
+ /**
+ * FIXME: can't impersonate other than local instances
+ */
+ lib = rootLib.subInstanceApi(inferInstanceName(state.backendUrl));
+
+ config = currentConfig ?? rootConfig;
+ } else {
+ lib = rootLib;
+ config = rootConfig;
+ }
+
+ useEffect(() => {
+ // FIXME: handle what happen if the subinstance /config
+ // fails
+ if (!doingImpersonation) return;
+ lib.instance.getConfig().then((resp) => {
+ if (resp.type === "ok") {
+ setCurrentConfig(resp.body);
+ }
+ });
+ }, [state.backendUrl.href]);
+
+ const value: SessionStateHandler = {
+ state: {
+ backendUrl: state.backendUrl,
+ token: state.token,
+ impersonated: doingImpersonation,
+ instance: currentInstance,
+ isAdmin: currentInstance === DEFAULT_ADMIN_USERNAME,
+ status: status,
+ },
+ lib,
+ config,
logOut() {
- const instance = inferInstanceName(merchantUrl);
- const nextState: SessionState = {
- status: "loggedOut",
- instance,
- isAdmin: instance === DEFAULT_ADMIN_USERNAME,
- };
- update(nextState);
+ setStatus("loggedOut");
+ update({
+ backendUrl: merchantUrl,
+ token: undefined,
+ prevToken: undefined,
+ });
+ cleanAllCache();
},
deImpersonate() {
- if (state.status === "loggedOut" || state.status === "expired") {
- // can't impersonate if not loggedin
- return;
- }
- if (state.impersonate === undefined) {
- return;
- }
- const newURL = new URL(`./`, state.impersonate.originalBackendUrl);
- changeBackend(newURL);
- const nextState: SessionState = {
- status: "loggedIn",
- isAdmin: state.impersonate.originalInstance === DEFAULT_ADMIN_USERNAME,
- instance: state.impersonate.originalInstance,
- token: state.impersonate.originalToken,
- impersonate: undefined,
- };
- update(nextState);
- },
- impersonate(info) {
- if (state.status === "loggedOut" || state.status === "expired") {
- // can't impersonate if not loggedin
- return;
- }
- changeBackend(info.baseUrl);
- const nextState: SessionState = {
- status: "loggedIn",
- isAdmin: info.instance === DEFAULT_ADMIN_USERNAME,
- instance: info.instance,
- // FIXME: bank and merchant should have consistent behavior
- token: info.token?.substring("secret-token:".length) as AccessToken,
- impersonate: {
- originalBackendUrl: merchantUrl.href,
- originalToken: state.token,
- originalInstance: state.instance,
- },
- };
- update(nextState);
+ cleanAllCache();
+ update({
+ backendUrl: merchantUrl,
+ token: state.prevToken,
+ prevToken: undefined,
+ });
+ setStatus("loggedIn");
},
- expired() {
- if (state.status === "loggedOut") return;
-
- const nextState: SessionState = {
- ...state,
- status: "expired",
+ impersonate(baseUrl) {
+ /**
+ * FIXME: can't impersonate other than local instances
+ */
+ update({
+ backendUrl: baseUrl,
token: undefined,
- };
- update(nextState);
+ prevToken: state.token,
+ });
+ setStatus("loggedIn");
+ cleanAllCache();
},
- logIn(info) {
- // admin is defined by the username
- const nextState: SessionState = {
- impersonate: undefined,
- ...state,
- status: "loggedIn",
- // FIXME: bank and merchant should have consistent behavior
- token: info.token?.substring("secret-token:".length) as AccessToken,
- // token: info.token,
- };
- update(nextState);
+ logIn(token) {
cleanAllCache();
+ setStatus("loggedIn");
+ update({
+ backendUrl: state.backendUrl,
+ token: token,
+ prevToken: state.prevToken,
+ });
},
};
-}
-function cleanAllCache(): void {
- mutate(() => true, undefined, { revalidate: false });
-}
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/merchant-backoffice-ui/src/hooks/bank.ts b/packages/merchant-backoffice-ui/src/hooks/bank.ts
index abfaecf68..8857ad839 100644
--- a/packages/merchant-backoffice-ui/src/hooks/bank.ts
+++ b/packages/merchant-backoffice-ui/src/hooks/bank.ts
@@ -16,14 +16,11 @@
import {
useMerchantApiContext
} from "@gnu-taler/web-util/browser";
-import { useState } from "preact/hooks";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
import _useSWR, { SWRHook, mutate } from "swr";
import { useSessionContext } from "../context/session.js";
-import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
-import { buildPaginatedResult } from "./webhooks.js";
const useSWR = _useSWR as unknown as SWRHook;
export interface InstanceBankAccountFilter {
@@ -38,11 +35,11 @@ export function revalidateInstanceBankAccounts() {
}
export function useInstanceBankAccounts() {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
// const [offset, setOffset] = useState<string | undefined>();
- async function fetcher([token, bid]: [AccessToken, string]) {
+ async function fetcher([token, _bid]: [AccessToken, string]) {
return await instance.listBankAccounts(token, {
// limit: PAGINATED_LIST_REQUEST,
// offset: bid,
@@ -72,7 +69,7 @@ export function revalidateBankAccountDetails() {
}
export function useBankAccountDetails(h_wire: string) {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
async function fetcher([token, wireId]: [AccessToken, string]) {
return await instance.getBankAccountDetails(token, wireId);
diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts
index 1fa84c9d9..f5f8893cd 100644
--- a/packages/merchant-backoffice-ui/src/hooks/instance.ts
+++ b/packages/merchant-backoffice-ui/src/hooks/instance.ts
@@ -13,9 +13,6 @@
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 {
- useMerchantApiContext
-} from "@gnu-taler/web-util/browser";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
@@ -33,7 +30,7 @@ export function revalidateInstanceDetails() {
}
export function useInstanceDetails() {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
async function fetcher([token]: [AccessToken]) {
return await instance.getCurrentInstanceDetails(token);
@@ -58,7 +55,7 @@ export function revalidateInstanceKYCDetails() {
}
export function useInstanceKYCDetails() {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
async function fetcher([token]: [AccessToken]) {
return await instance.getCurrentIntanceKycStatus(token, {});
@@ -85,7 +82,7 @@ export function revalidateManagedInstanceDetails() {
}
export function useManagedInstanceDetails(instanceId: string) {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
async function fetcher([token, instanceId]: [AccessToken, string]) {
return await instance.getInstanceDetails(token, instanceId);
@@ -110,7 +107,7 @@ export function revalidateBackendInstances() {
}
export function useBackendInstances() {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
async function fetcher([token]: [AccessToken]) {
return await instance.listInstances(token);
diff --git a/packages/merchant-backoffice-ui/src/hooks/order.ts b/packages/merchant-backoffice-ui/src/hooks/order.ts
index 79f970ec2..d0513dc40 100644
--- a/packages/merchant-backoffice-ui/src/hooks/order.ts
+++ b/packages/merchant-backoffice-ui/src/hooks/order.ts
@@ -13,9 +13,6 @@
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 {
- useMerchantApiContext
-} from "@gnu-taler/web-util/browser";
import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
@@ -36,7 +33,7 @@ export function revalidateOrderDetails() {
}
export function useOrderDetails(oderId: string) {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
async function fetcher([dId, token]: [string, AccessToken]) {
return await instance.getOrderDetails(token, dId);
@@ -72,7 +69,7 @@ export function useInstanceOrders(
updatePosition: (d: string | undefined) => void = () => { },
) {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
// const [offset, setOffset] = useState<string | undefined>(args?.position);
diff --git a/packages/merchant-backoffice-ui/src/hooks/otp.ts b/packages/merchant-backoffice-ui/src/hooks/otp.ts
index 8438a46b3..41ed89f70 100644
--- a/packages/merchant-backoffice-ui/src/hooks/otp.ts
+++ b/packages/merchant-backoffice-ui/src/hooks/otp.ts
@@ -13,9 +13,6 @@
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 {
- useMerchantApiContext
-} from "@gnu-taler/web-util/browser";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
@@ -32,11 +29,11 @@ export function revalidateInstanceOtpDevices() {
}
export function useInstanceOtpDevices() {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
// const [offset, setOffset] = useState<string | undefined>();
- async function fetcher([token, bid]: [AccessToken, string]) {
+ async function fetcher([token, _bid]: [AccessToken, string]) {
return await instance.listOtpDevices(token, {
// limit: PAGINATED_LIST_REQUEST,
// offset: bid,
@@ -66,7 +63,7 @@ export function revalidateOtpDeviceDetails() {
}
export function useOtpDeviceDetails(deviceId: string) {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
async function fetcher([dId, token]: [string, AccessToken]) {
return await instance.getOtpDeviceDetails(token, dId);
diff --git a/packages/merchant-backoffice-ui/src/hooks/product.ts b/packages/merchant-backoffice-ui/src/hooks/product.ts
index 7f3504c64..defda5552 100644
--- a/packages/merchant-backoffice-ui/src/hooks/product.ts
+++ b/packages/merchant-backoffice-ui/src/hooks/product.ts
@@ -13,9 +13,6 @@
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 {
- useMerchantApiContext
-} from "@gnu-taler/web-util/browser";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
import { AccessToken, OperationOk, TalerHttpError, TalerMerchantApi, TalerMerchantManagementErrorsByMethod, TalerMerchantManagementResultByMethod, opFixedSuccess } from "@gnu-taler/taler-util";
@@ -40,7 +37,7 @@ export function revalidateInstanceProducts() {
}
export function useInstanceProducts() {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
const [offset, setOffset] = useState<number | undefined>();
@@ -89,7 +86,7 @@ export function revalidateProductDetails() {
}
export function useProductDetails(productId: string) {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
async function fetcher([pid, token]: [string, AccessToken]) {
return await instance.getProductDetails(token, pid);
diff --git a/packages/merchant-backoffice-ui/src/hooks/templates.ts b/packages/merchant-backoffice-ui/src/hooks/templates.ts
index e0065e284..12d99f3fc 100644
--- a/packages/merchant-backoffice-ui/src/hooks/templates.ts
+++ b/packages/merchant-backoffice-ui/src/hooks/templates.ts
@@ -13,9 +13,6 @@
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 {
- useMerchantApiContext
-} from "@gnu-taler/web-util/browser";
import { useState } from "preact/hooks";
import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
@@ -39,7 +36,7 @@ export function revalidateInstanceTemplates() {
}
export function useInstanceTemplates() {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
const [offset, setOffset] = useState<string | undefined>();
@@ -73,7 +70,7 @@ export function revalidateTemplateDetails() {
}
export function useTemplateDetails(templateId: string) {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
async function fetcher([tid, token]: [string, AccessToken]) {
return await instance.getTemplateDetails(token, tid);
diff --git a/packages/merchant-backoffice-ui/src/hooks/transfer.ts b/packages/merchant-backoffice-ui/src/hooks/transfer.ts
index 6c2fc1d75..6f77369c2 100644
--- a/packages/merchant-backoffice-ui/src/hooks/transfer.ts
+++ b/packages/merchant-backoffice-ui/src/hooks/transfer.ts
@@ -13,9 +13,6 @@
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 {
- useMerchantApiContext
-} from "@gnu-taler/web-util/browser";
import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
@@ -43,7 +40,7 @@ export function useInstanceTransfers(
updatePosition: (id: string | undefined) => void = (() => { }),
) {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
// const [offset, setOffset] = useState<string | undefined>(args?.position);
diff --git a/packages/merchant-backoffice-ui/src/hooks/webhooks.ts b/packages/merchant-backoffice-ui/src/hooks/webhooks.ts
index df53c06bc..fe37162aa 100644
--- a/packages/merchant-backoffice-ui/src/hooks/webhooks.ts
+++ b/packages/merchant-backoffice-ui/src/hooks/webhooks.ts
@@ -13,9 +13,6 @@
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 {
- useMerchantApiContext
-} from "@gnu-taler/web-util/browser";
import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
@@ -36,11 +33,11 @@ export function revalidateInstanceWebhooks() {
}
export function useInstanceWebhooks() {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
// const [offset, setOffset] = useState<string | undefined>();
- async function fetcher([token, bid]: [AccessToken, string]) {
+ async function fetcher([token, _bid]: [AccessToken, string]) {
return await instance.listWebhooks(token, {
// limit: PAGINATED_LIST_REQUEST,
// offset: bid,
@@ -104,7 +101,7 @@ export function revalidateWebhookDetails() {
}
export function useWebhookDetails(webhookId: string) {
const { state: session } = useSessionContext();
- const { lib: { instance } } = useMerchantApiContext();
+ const { lib: { instance } } = useSessionContext();
async function fetcher([hookId, token]: [string, AccessToken]) {
return await instance.getWebhookDetails(token, hookId);
diff --git a/packages/merchant-backoffice-ui/src/i18n/de.po b/packages/merchant-backoffice-ui/src/i18n/de.po
index f34d5dd20..66d654f64 100644
--- a/packages/merchant-backoffice-ui/src/i18n/de.po
+++ b/packages/merchant-backoffice-ui/src/i18n/de.po
@@ -17,7 +17,7 @@ msgstr ""
"Project-Id-Version: Taler Wallet\n"
"Report-Msgid-Bugs-To: taler@gnu.org\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
-"PO-Revision-Date: 2024-03-21 21:39+0000\n"
+"PO-Revision-Date: 2024-05-07 14:32+0000\n"
"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
"Language-Team: German <https://weblate.taler.net/projects/gnu-taler/"
"merchant-backoffice/de/>\n"
@@ -26,27 +26,27 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
-"X-Generator: Weblate 5.2.1\n"
+"X-Generator: Weblate 5.4.3\n"
#: src/components/modal/index.tsx:71
#, c-format
msgid "Cancel"
-msgstr ""
+msgstr "Zurück"
#: src/components/modal/index.tsx:79
#, c-format
msgid "%1$s"
-msgstr ""
+msgstr "%1$s"
#: src/components/modal/index.tsx:84
#, c-format
msgid "Close"
-msgstr ""
+msgstr "Schließen"
#: src/components/modal/index.tsx:124
#, c-format
msgid "Continue"
-msgstr ""
+msgstr "Weiter"
#: src/components/modal/index.tsx:178
#, c-format
@@ -66,12 +66,12 @@ msgstr ""
#: src/components/modal/index.tsx:299
#, c-format
msgid "cannot be empty"
-msgstr ""
+msgstr "darf nicht leer sein"
#: src/components/modal/index.tsx:301
#, c-format
msgid "cannot be the same as the old token"
-msgstr ""
+msgstr "muss sich vom alten Token unterscheiden"
#: src/components/modal/index.tsx:305
#, c-format
@@ -563,57 +563,59 @@ msgstr ""
#: src/paths/instance/orders/create/CreatePage.tsx:164
#, c-format
msgid "not a valid json"
-msgstr ""
+msgstr "kein gültiges JSON-Format"
#: src/paths/instance/orders/create/CreatePage.tsx:170
#, c-format
msgid "should be in the future"
-msgstr ""
+msgstr "sollte in der Zukunft liegen"
#: src/paths/instance/orders/create/CreatePage.tsx:173
#, c-format
msgid "refund deadline cannot be before pay deadline"
-msgstr ""
+msgstr "Die Rückerstattungsfrist kann nicht vor der Zahlungsfrist liegen"
#: src/paths/instance/orders/create/CreatePage.tsx:179
#, c-format
msgid "wire transfer deadline cannot be before refund deadline"
-msgstr ""
+msgstr "Die Überweisungsfrist kann nicht vor der Rückerstattungsfrist liegen"
#: src/paths/instance/orders/create/CreatePage.tsx:190
#, c-format
msgid "wire transfer deadline cannot be before pay deadline"
-msgstr ""
+msgstr "Die Überweisungsfrist kann nicht vor der Zahlungsfrist liegen"
#: src/paths/instance/orders/create/CreatePage.tsx:197
#, c-format
msgid "should have a refund deadline"
-msgstr ""
+msgstr "sollte eine Rückerstattungsfrist haben"
#: src/paths/instance/orders/create/CreatePage.tsx:202
#, c-format
msgid "auto refund cannot be after refund deadline"
msgstr ""
+"Die automatische Rückerstattung kann nicht nach der Rückerstattungsfrist "
+"erfolgen"
#: src/paths/instance/orders/create/CreatePage.tsx:360
#, c-format
msgid "Manage products in order"
-msgstr ""
+msgstr "Artikel in der Bestellung verwalten"
#: src/paths/instance/orders/create/CreatePage.tsx:369
#, c-format
msgid "Manage list of products in the order."
-msgstr ""
+msgstr "Liste der Artikel in der Bestellung verwalten."
#: src/paths/instance/orders/create/CreatePage.tsx:391
#, c-format
msgid "Remove this product from the order."
-msgstr ""
+msgstr "Diesen Artikel aus der Bestellung entfernen."
#: src/paths/instance/orders/create/CreatePage.tsx:415
#, c-format
msgid "Total price"
-msgstr ""
+msgstr "Gesamtpreis"
#: src/paths/instance/orders/create/CreatePage.tsx:417
#, c-format
@@ -623,12 +625,12 @@ msgstr ""
#: src/paths/instance/orders/create/CreatePage.tsx:430
#, c-format
msgid "Amount to be paid by the customer"
-msgstr ""
+msgstr "Zu zahlender Betrag"
#: src/paths/instance/orders/create/CreatePage.tsx:436
#, c-format
msgid "Order price"
-msgstr ""
+msgstr "Bestellsumme"
#: src/paths/instance/orders/create/CreatePage.tsx:437
#, c-format
@@ -638,12 +640,12 @@ msgstr ""
#: src/paths/instance/orders/create/CreatePage.tsx:444
#, c-format
msgid "Summary"
-msgstr ""
+msgstr "Zusammenfassung"
#: src/paths/instance/orders/create/CreatePage.tsx:445
#, c-format
msgid "Title of the order to be shown to the customer"
-msgstr ""
+msgstr "Bezeichnung der Bestellung, die den Kunden angezeigt wird"
#: src/paths/instance/orders/create/CreatePage.tsx:450
#, c-format
@@ -653,12 +655,12 @@ msgstr ""
#: src/paths/instance/orders/create/CreatePage.tsx:455
#, c-format
msgid "Delivery date"
-msgstr ""
+msgstr "Lieferdatum"
#: src/paths/instance/orders/create/CreatePage.tsx:456
#, c-format
msgid "Deadline for physical delivery assured by the merchant."
-msgstr ""
+msgstr "Vom Händler zugesicherte Zustellfrist."
#: src/paths/instance/orders/create/CreatePage.tsx:461
#, c-format
@@ -668,22 +670,22 @@ msgstr ""
#: src/paths/instance/orders/create/CreatePage.tsx:462
#, c-format
msgid "address where the products will be delivered"
-msgstr ""
+msgstr "Zustelladresse der Artikel"
#: src/paths/instance/orders/create/CreatePage.tsx:469
#, c-format
msgid "Fulfillment URL"
-msgstr ""
+msgstr "Adresse digitaler Dienstleistung (Fulfillment-URL)"
#: src/paths/instance/orders/create/CreatePage.tsx:470
#, c-format
msgid "URL to which the user will be redirected after successful payment."
-msgstr ""
+msgstr "URL der von Kunden zu besuchenden Adresse nach erfolgter Bezahlung."
#: src/paths/instance/orders/create/CreatePage.tsx:476
#, c-format
msgid "Taler payment options"
-msgstr ""
+msgstr "Taler-Zahlungsoptionen"
#: src/paths/instance/orders/create/CreatePage.tsx:477
#, c-format
@@ -693,7 +695,7 @@ msgstr ""
#: src/paths/instance/orders/create/CreatePage.tsx:481
#, c-format
msgid "Payment deadline"
-msgstr ""
+msgstr "Zahlungsfrist"
#: src/paths/instance/orders/create/CreatePage.tsx:482
#, c-format
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 731ea8939..a28992a2f 100644
--- a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx
@@ -19,7 +19,11 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { Duration, TalerMerchantApi } from "@gnu-taler/taler-util";
+import {
+ Duration,
+ TalerMerchantApi,
+ createRFC8959AccessTokenPlain,
+} from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact";
import { useState } from "preact/hooks";
@@ -33,10 +37,13 @@ import { SetTokenNewInstanceModal } from "../../../components/modal/index.js";
import { INSTANCE_ID_REGEX } from "../../../utils/constants.js";
import { undefinedIfEmpty } from "../../../utils/table.js";
-export type Entity = Omit<Omit<TalerMerchantApi.InstanceConfigurationMessage, "default_pay_delay">, "default_wire_transfer_delay"> & {
+export type Entity = Omit<
+ Omit<TalerMerchantApi.InstanceConfigurationMessage, "default_pay_delay">,
+ "default_wire_transfer_delay"
+> & {
auth_token?: string;
- default_pay_delay: Duration,
- default_wire_transfer_delay: Duration,
+ default_pay_delay: Duration;
+ default_wire_transfer_delay: Duration;
};
interface Props {
@@ -90,10 +97,11 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
default_pay_delay: !value.default_pay_delay
? i18n.str`required`
: !!value.default_wire_transfer_delay &&
- value.default_wire_transfer_delay.d_ms !== "forever" &&
- value.default_pay_delay.d_ms !== "forever" &&
- value.default_pay_delay.d_ms > value.default_wire_transfer_delay.d_ms ?
- i18n.str`pay delay can't be greater than wire transfer delay` : undefined,
+ value.default_wire_transfer_delay.d_ms !== "forever" &&
+ value.default_pay_delay.d_ms !== "forever" &&
+ value.default_pay_delay.d_ms > value.default_wire_transfer_delay.d_ms
+ ? i18n.str`pay delay can't be greater than wire transfer delay`
+ : undefined,
default_wire_transfer_delay: !value.default_wire_transfer_delay
? i18n.str`required`
: undefined,
@@ -112,7 +120,7 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
};
const hasErrors = Object.keys(errors).some(
- (k) => (errors as any)[k] !== undefined,
+ (k) => (errors as Record<string, unknown>)[k] !== undefined,
);
const submit = (): Promise<void> => {
@@ -121,19 +129,26 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
const newToken = newValue.auth_token;
newValue.auth_token = undefined;
- newValue.auth = newToken === null || newToken === undefined
- ? { method: "external" }
- : { method: "token", token: `secret-token:${newToken}` };
+ newValue.auth =
+ newToken === null || newToken === undefined
+ ? { method: "external" }
+ : { method: "token", token: createRFC8959AccessTokenPlain(newToken) };
if (!newValue.address) newValue.address = {};
if (!newValue.jurisdiction) newValue.jurisdiction = {};
// remove above use conversion
// schema.validateSync(value, { abortEarly: false })
- newValue.default_pay_delay = Duration.toTalerProtocolDuration(newValue.default_pay_delay!) as any
- newValue.default_wire_transfer_delay = Duration.toTalerProtocolDuration(newValue.default_wire_transfer_delay!) as any
+ newValue.default_pay_delay = Duration.toTalerProtocolDuration(
+ newValue.default_pay_delay!,
+ ) as any;
+ newValue.default_wire_transfer_delay = Duration.toTalerProtocolDuration(
+ newValue.default_wire_transfer_delay!,
+ ) as any;
// delete value.default_pay_delay;
// delete value.default_wire_transfer_delay;
- return onCreate(newValue as any as TalerMerchantApi.InstanceConfigurationMessage);
+ return onCreate(
+ newValue as any as TalerMerchantApi.InstanceConfigurationMessage,
+ );
};
function updateToken(token: string | null) {
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx
index 8ee8608a3..b00cfbe7d 100644
--- a/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx
@@ -19,8 +19,7 @@
*/
import { TalerMerchantApi } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
@@ -39,7 +38,7 @@ export type Entity = TalerMerchantApi.InstanceConfigurationMessage;
export default function Create({ onBack, onConfirm, forceId }: Props): VNode {
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state, logIn } = useSessionContext();
return (
@@ -69,7 +68,7 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode {
);
if (result.type === "ok") {
const { token } = result.body;
- logIn({ token });
+ logIn(token);
}
}
onConfirm();
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx
index 2455913c2..cff3c5a02 100644
--- a/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx
@@ -25,6 +25,7 @@ import {
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { StateUpdater, useEffect, useState } from "preact/hooks";
+import { useSessionContext } from "../../../context/session.js";
interface Props {
instances: TalerMerchantApi.Instance[];
@@ -150,8 +151,8 @@ function Table({
onPurge,
}: TableProps): VNode {
const { i18n } = useTranslationContext();
- // const { lib } = useMerchantApiContext();
- // const { impersonate } = useSessionContext();
+ const { lib } = useSessionContext();
+ const { impersonate } = useSessionContext();
return (
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
@@ -199,22 +200,17 @@ function Table({
</label>
</td>
<td>
- {/* TODO uncommented to enable impersonate #8604 */}
- {/* <a
+ <a
href={`#/orders`}
- onClick={async (e) => {
- e.preventDefault();
+ onClick={async (_e) => {
+ // e.preventDefault();
const newInstanceApi = lib.subInstanceApi(i.id);
//not checking /config since this comes from instance list
- impersonate({
- instance: i.id,
- baseUrl: new URL(newInstanceApi.instance.baseUrl),
- token: undefined,
- });
+ impersonate(new URL(newInstanceApi.instance.baseUrl));
}}
- > */}
+ >
{i.id}
- {/* </a> */}
+ </a>
</td>
<td>{i.name}</td>
<td class="is-actions-cell right-sticky">
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx
index 7bf64cdbb..5b492e45c 100644
--- a/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx
@@ -21,7 +21,6 @@
import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -33,8 +32,8 @@ import { DeleteModal, PurgeModal } from "../../../components/modal/index.js";
import { useSessionContext } from "../../../context/session.js";
import { useBackendInstances } from "../../../hooks/instance.js";
import { Notification } from "../../../utils/types.js";
-import { View } from "./View.js";
import { LoginPage } from "../../login/index.js";
+import { View } from "./View.js";
interface Props {
onCreate: () => void;
@@ -53,7 +52,7 @@ export default function Instances({
useState<TalerMerchantApi.Instance | null>(null);
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
if (!result) return <Loading />
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx
index fb50ab995..9bab33f6f 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx
@@ -32,15 +32,14 @@ import {
} from "@gnu-taler/taler-util";
import {
BrowserFetchHttpLib,
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
+import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
import { Notification } from "../../../../utils/types.js";
import { CreatePage } from "./CreatePage.js";
-import { useSessionContext } from "../../../../context/session.js";
export type Entity = TalerMerchantApi.AccountAddDetails;
interface Props {
@@ -49,7 +48,7 @@ interface Props {
}
export default function CreateValidator({ onConfirm, onBack }: Props): VNode {
- const { lib: api } = useMerchantApiContext();
+ const { lib: api } = useSessionContext();
const { state } = useSessionContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx
index 613cb9614..1eda7382d 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx
@@ -21,7 +21,6 @@
import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -32,9 +31,9 @@ import { NotificationCard } from "../../../../components/menu/index.js";
import { useSessionContext } from "../../../../context/session.js";
import { useInstanceBankAccounts } from "../../../../hooks/bank.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { ListPage } from "./ListPage.js";
-import { LoginPage } from "../../../login/index.js";
interface Props {
onCreate: () => void;
@@ -47,7 +46,7 @@ export default function ListOtpDevices({
}: Props): VNode {
const { i18n } = useTranslationContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
- const { lib: api } = useMerchantApiContext();
+ const { lib: api } = useSessionContext();
const { state } = useSessionContext();
const result = useInstanceBankAccounts();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx
index 519c9f56a..70942fd55 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx
@@ -21,7 +21,6 @@
import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -34,8 +33,8 @@ import { useBankAccountDetails } from "../../../../hooks/bank.js";
import { Notification } from "../../../../utils/types.js";
import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
-import { UpdatePage } from "./UpdatePage.js";
import { TestRevenueErrorType, testRevenueAPI } from "../create/index.js";
+import { UpdatePage } from "./UpdatePage.js";
export type Entity = TalerMerchantApi.AccountPatchDetails & WithId;
@@ -49,7 +48,7 @@ export default function UpdateValidator({
onConfirm,
onBack,
}: Props): VNode {
- const { lib: api } = useMerchantApiContext();
+ const { lib: api } = useSessionContext();
const { state } = useSessionContext();
const result = useBankAccountDetails(bid);
const [notif, setNotif] = useState<Notification | undefined>(undefined);
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx
index 76e3bf878..e1a7f87f0 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx
@@ -14,7 +14,6 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { HttpStatusCode, TalerError, assertUnreachable } from "@gnu-taler/taler-util";
-import { useMerchantApiContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js";
@@ -23,8 +22,8 @@ import { DeleteModal } from "../../../components/modal/index.js";
import { useSessionContext } from "../../../context/session.js";
import { useInstanceDetails } from "../../../hooks/instance.js";
import { LoginPage } from "../../login/index.js";
-import { DetailPage } from "./DetailPage.js";
import { NotFoundPageOrAdminCreate } from "../../notfound/index.js";
+import { DetailPage } from "./DetailPage.js";
interface Props {
onUpdate: () => void;
@@ -40,7 +39,7 @@ export default function Detail({
const [deleting, setDeleting] = useState<boolean>(false);
// const { deleteInstance } = useInstanceAPI();
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
if (!result) return <Loading />
if (result instanceof TalerError) {
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx
index 041ec73e7..7be3d23f6 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx
@@ -28,8 +28,7 @@ import {
TalerProtocolDuration,
} from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { format, isFuture } from "date-fns";
import { Fragment, VNode, h } from "preact";
@@ -49,6 +48,7 @@ import { InputToggle } from "../../../../components/form/InputToggle.js";
import { InventoryProductForm } from "../../../../components/product/InventoryProductForm.js";
import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm.js";
import { ProductList } from "../../../../components/product/ProductList.js";
+import { useSessionContext } from "../../../../context/session.js";
import { usePreference } from "../../../../hooks/preference.js";
import { rate } from "../../../../utils/amount.js";
import { undefinedIfEmpty } from "../../../../utils/table.js";
@@ -134,7 +134,7 @@ export function CreatePage({
instanceConfig,
instanceInventory,
}: Props): VNode {
- const { config } = useMerchantApiContext();
+ const { config } = useSessionContext();
const instance_default = with_defaults(instanceConfig, config.currency);
const [value, valueHandler] = useState(instance_default);
const zero = Amounts.zeroOfCurrency(config.currency);
@@ -679,7 +679,6 @@ export function CreatePage({
value.extra &&
value.extra[key] !== undefined
) {
- console.log(value.extra);
delete value.extra[key];
}
valueHandler({
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx
index 849711df6..861114014 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx
@@ -20,7 +20,6 @@
*/
import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
-import { useMerchantApiContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
@@ -30,9 +29,9 @@ import { useSessionContext } from "../../../../context/session.js";
import { useInstanceDetails } from "../../../../hooks/instance.js";
import { useInstanceProducts } from "../../../../hooks/product.js";
import { Notification } from "../../../../utils/types.js";
-import { CreatePage } from "./CreatePage.js";
-import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { CreatePage } from "./CreatePage.js";
export type Entity = {
request: TalerMerchantApi.PostOrderRequest;
@@ -46,7 +45,7 @@ export default function OrderCreate({
onConfirm,
onBack,
}: Props): VNode {
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { state } = useSessionContext();
const detailsResult = useInstanceDetails();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
index 4aed0cc42..498ea83e3 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
@@ -26,8 +26,7 @@ import {
stringifyRefundUri,
} from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { format, formatDistance } from "date-fns";
import { Fragment, VNode, h } from "preact";
@@ -41,6 +40,7 @@ import { InputGroup } from "../../../../components/form/InputGroup.js";
import { InputLocation } from "../../../../components/form/InputLocation.js";
import { TextField } from "../../../../components/form/TextField.js";
import { ProductList } from "../../../../components/product/ProductList.js";
+import { useSessionContext } from "../../../../context/session.js";
import {
datetimeFormatForSettings,
usePreference,
@@ -430,10 +430,10 @@ function PaidPage({
});
const [value, valueHandler] = useState<Partial<Paid>>(order);
- const { url: backendUrl } = useMerchantApiContext();
+ const { state } = useSessionContext();
const refundurl = stringifyRefundUri({
- merchantBaseUrl: backendUrl.href,
+ merchantBaseUrl: state.backendUrl.href,
orderId: order.contract_terms.order_id,
});
const { i18n } = useTranslationContext();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx
index 4785c795d..b28e59b29 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx
@@ -19,8 +19,7 @@ import {
assertUnreachable,
} from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
@@ -30,9 +29,9 @@ import { NotificationCard } from "../../../../components/menu/index.js";
import { useSessionContext } from "../../../../context/session.js";
import { useOrderDetails } from "../../../../hooks/order.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { DetailPage } from "./DetailPage.js";
-import { LoginPage } from "../../../login/index.js";
export interface Props {
oid: string;
@@ -42,7 +41,7 @@ export interface Props {
export default function Update({ oid, onBack }: Props): VNode {
const result = useOrderDetails(oid);
const [notif, setNotif] = useState<Notification | undefined>(undefined);
- const { lib: api } = useMerchantApiContext();
+ const { lib: api } = useSessionContext();
const { state } = useSessionContext();
const { i18n } = useTranslationContext();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx
index a9314d005..5ece34409 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx
@@ -21,8 +21,7 @@
import { Amounts, TalerMerchantApi } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { format } from "date-fns";
import { VNode, h } from "preact";
@@ -36,6 +35,7 @@ import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputGroup } from "../../../../components/form/InputGroup.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
import { ConfirmModal } from "../../../../components/modal/index.js";
+import { useSessionContext } from "../../../../context/session.js";
import {
datetimeFormatForSettings,
usePreference,
@@ -258,7 +258,7 @@ export function RefundModal({
order.order_status === "paid" ? order.refund_details : []
).reduce(mergeRefunds, []);
- const { config } = useMerchantApiContext();
+ const { config } = useSessionContext();
const totalRefunded = refunds
.map((r) => r.amount)
.reduce(
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx
index af1ffbcc6..8a1f85b1c 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx
@@ -27,8 +27,7 @@ import {
assertUnreachable,
} from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { useState } from "preact/hooks";
@@ -43,10 +42,10 @@ import {
useOrderDetails,
} from "../../../../hooks/order.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { ListPage } from "./ListPage.js";
import { RefundModal } from "./Table.js";
-import { LoginPage } from "../../../login/index.js";
interface Props {
onSelect: (id: string) => void;
@@ -65,7 +64,7 @@ export default function OrderList({ onCreate, onSelect }: Props): VNode {
const result = useInstanceOrders(filter, (d) =>
setFilter({ ...filter, position: d }),
);
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx
index 982132057..7723bec81 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx
@@ -15,7 +15,7 @@
*/
import { TalerMerchantApi } from "@gnu-taler/taler-util";
-import { useMerchantApiContext, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { QR } from "../../../../components/exception/QR.js";
import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
@@ -33,9 +33,8 @@ export function CreatedSuccessfully({
onConfirm,
}: Props): VNode {
const { i18n } = useTranslationContext();
- const { url: backendUrl } = useMerchantApiContext();
const { state } = useSessionContext();
- const issuer = backendUrl.href;
+ const issuer = state.backendUrl.href;
const qrText = `otpauth://totp/${state.instance}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`;
const qrTextSafe = `otpauth://totp/${state.instance}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`;
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx
index 864190c9f..8ab0e1f26 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx
@@ -20,14 +20,14 @@
*/
import { TalerMerchantApi } from "@gnu-taler/taler-util";
-import { useMerchantApiContext, useTranslationContext } from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
import { Notification } from "../../../../utils/types.js";
-import { CreatedSuccessfully } from "./CreatedSuccessfully.js";
import { CreatePage } from "./CreatePage.js";
-import { useSessionContext } from "../../../../context/session.js";
+import { CreatedSuccessfully } from "./CreatedSuccessfully.js";
export type Entity = TalerMerchantApi.OtpDeviceAddDetails;
interface Props {
@@ -36,7 +36,7 @@ interface Props {
}
export default function CreateValidator({ onConfirm, onBack }: Props): VNode {
- const { lib: api } = useMerchantApiContext();
+ const { lib: api } = useSessionContext();
const { state } = useSessionContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx
index 776823a95..b6a077863 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx
@@ -26,7 +26,6 @@ import {
assertUnreachable
} from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -37,9 +36,9 @@ import { NotificationCard } from "../../../../components/menu/index.js";
import { useSessionContext } from "../../../../context/session.js";
import { useInstanceOtpDevices } from "../../../../hooks/otp.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { ListPage } from "./ListPage.js";
-import { LoginPage } from "../../../login/index.js";
interface Props {
onCreate: () => void;
@@ -50,7 +49,7 @@ export default function ListOtpDevices({ onCreate, onSelect }: Props): VNode {
// const [position, setPosition] = useState<string | undefined>(undefined);
const { i18n } = useTranslationContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
const result = useInstanceOtpDevices();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx
index 5e34e4c8a..99edb95c3 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx
@@ -26,7 +26,6 @@ import {
assertUnreachable
} from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -37,10 +36,10 @@ import { NotificationCard } from "../../../../components/menu/index.js";
import { useSessionContext } from "../../../../context/session.js";
import { useOtpDeviceDetails } from "../../../../hooks/otp.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { CreatedSuccessfully } from "../create/CreatedSuccessfully.js";
import { UpdatePage } from "./UpdatePage.js";
-import { LoginPage } from "../../../login/index.js";
export type Entity = TalerMerchantApi.OtpDevicePatchDetails & WithId;
@@ -58,7 +57,7 @@ export default function UpdateValidator({
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const [keyUpdated, setKeyUpdated] =
useState<TalerMerchantApi.OtpDeviceAddDetails | null>(null);
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
const { i18n } = useTranslationContext();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx
index e1e3c846a..9de5cae78 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx
@@ -20,13 +20,13 @@
*/
import { TalerMerchantApi } from "@gnu-taler/taler-util";
-import { useMerchantApiContext, useTranslationContext } from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
import { Notification } from "../../../../utils/types.js";
import { CreatePage } from "./CreatePage.js";
-import { useSessionContext } from "../../../../context/session.js";
export type Entity = TalerMerchantApi.ProductAddDetail;
interface Props {
@@ -34,7 +34,7 @@ interface Props {
onConfirm: () => void;
}
export default function CreateProduct({ onConfirm, onBack }: Props): VNode {
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx
index 9d5701fa7..39e2fd0c7 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx
@@ -506,6 +506,7 @@ function difference(price: string, tax: number) {
ps[1] = `${p - tax}`;
return ps.join(":");
}
-function sum(taxes: TalerMerchantApi.Tax[]) {
+function sum(taxes: TalerMerchantApi.Tax[] | undefined) {
+ if (taxes === undefined) return 0;
return taxes.reduce((p, c) => p + parseInt(c.tax.split(":")[1], 10), 0);
}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx
index db6cf5376..6ad0d4598 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx
@@ -21,7 +21,6 @@
import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
@@ -36,9 +35,9 @@ import {
useInstanceProducts
} from "../../../../hooks/product.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { CardTable } from "./Table.js";
-import { LoginPage } from "../../../login/index.js";
interface Props {
onCreate: () => void;
@@ -49,7 +48,7 @@ export default function ProductList({
onSelect,
}: Props): VNode {
const result = useInstanceProducts();
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
const [deleting, setDeleting] =
useState<TalerMerchantApi.ProductDetail & WithId | null>(null);
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx
index 06f813b14..5e3e58d80 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx
@@ -21,7 +21,6 @@
import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -32,9 +31,9 @@ import { NotificationCard } from "../../../../components/menu/index.js";
import { useSessionContext } from "../../../../context/session.js";
import { useProductDetails } from "../../../../hooks/product.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { UpdatePage } from "./UpdatePage.js";
-import { LoginPage } from "../../../login/index.js";
export type Entity = TalerMerchantApi.ProductAddDetail;
interface Props {
@@ -49,7 +48,7 @@ export default function UpdateProduct({
}: Props): VNode {
const result = useProductDetails(pid);
const [notif, setNotif] = useState<Notification | undefined>(undefined);
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
const { i18n } = useTranslationContext();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx
index 139ee7aa3..0e9b5a284 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx
@@ -28,8 +28,7 @@ import {
TranslatedString,
} from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
@@ -46,6 +45,7 @@ import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputToggle } from "../../../../components/form/InputToggle.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { TextField } from "../../../../components/form/TextField.js";
+import { useSessionContext } from "../../../../context/session.js";
import { useInstanceOtpDevices } from "../../../../hooks/otp.js";
// type Entity = TalerMerchantApi.TemplateAddDetails & { type: Steps };
@@ -69,7 +69,8 @@ interface Props {
export function CreatePage({ onCreate, onBack }: Props): VNode {
const { i18n } = useTranslationContext();
- const { url: backendUrl, config } = useMerchantApiContext();
+ const { config } = useSessionContext();
+ const {state:session} = useSessionContext();
const devices = useInstanceOtpDevices();
const [state, setState] = useState<Partial<Entity>>({
@@ -139,6 +140,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
? undefined
: config.currency,
},
+ required_currency: config.currency,
editable_defaults: {
amount: !state.amount_editable ? undefined : state.amount,
summary: !state.summary_editable ? undefined : state.summary,
@@ -175,7 +177,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
<InputWithAddon<Entity>
name="id"
help={
- new URL(`templates/${state.id ?? ""}`, backendUrl.href).href
+ new URL(`templates/${state.id ?? ""}`, session.backendUrl.href).href
}
label={i18n.str`Identifier`}
tooltip={i18n.str`Name of the template in URLs.`}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx
index f71ca4794..499c7c859 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx
@@ -20,7 +20,7 @@
*/
import { TalerMerchantApi } from "@gnu-taler/taler-util";
-import { useMerchantApiContext, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { NotificationCard } from "../../../../components/menu/index.js";
@@ -35,7 +35,7 @@ interface Props {
}
export default function CreateTransfer({ onConfirm, onBack }: Props): VNode {
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx
index f9ab6678b..9e59609c7 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx
@@ -21,7 +21,6 @@
import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
@@ -36,9 +35,9 @@ import {
useInstanceTemplates
} from "../../../../hooks/templates.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { ListPage } from "./ListPage.js";
-import { LoginPage } from "../../../login/index.js";
interface Props {
onCreate: () => void;
@@ -55,7 +54,7 @@ export default function ListTemplates({
}: Props): VNode {
const { i18n } = useTranslationContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const result = useInstanceTemplates();
const [deleting, setDeleting] =
useState<TalerMerchantApi.TemplateEntry | null>(null);
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx
index cd6b8b45c..7322ca169 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx
@@ -19,22 +19,18 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { TalerMerchantApi, stringifyPayTemplateUri } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ TalerMerchantApi,
+ stringifyPayTemplateUri
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
-import { useState } from "preact/hooks";
import { QR } from "../../../../components/exception/QR.js";
-import {
- FormErrors,
- FormProvider,
-} from "../../../../components/form/FormProvider.js";
-import { Input } from "../../../../components/form/Input.js";
-import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { useSessionContext } from "../../../../context/session.js";
-type Entity = TalerMerchantApi.UsingTemplateDetails;
+// type Entity = TalerMerchantApi.UsingTemplateDetails;
interface Props {
contract: TalerMerchantApi.TemplateContractDetails;
@@ -42,9 +38,9 @@ interface Props {
onBack?: () => void;
}
-export function QrPage({ contract, id: templateId, onBack }: Props): VNode {
+export function QrPage({ id: templateId, onBack }: Props): VNode {
const { i18n } = useTranslationContext();
- const { config, url: backendUrl } = useMerchantApiContext();
+ const { state } = useSessionContext();
// const [state, setState] = useState<Partial<Entity>>({
// amount: contract.amount,
@@ -69,7 +65,7 @@ export function QrPage({ contract, id: templateId, onBack }: Props): VNode {
// templateParams.summary = state.summary ?? "";
// }
- const merchantBaseUrl = backendUrl.href;
+ const merchantBaseUrl = state.backendUrl.href;
const payTemplateUri = stringifyPayTemplateUri({
merchantBaseUrl,
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx
index a4813c8e9..197049486 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx
@@ -28,8 +28,7 @@ import {
TranslatedString,
} from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
@@ -45,6 +44,7 @@ import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputToggle } from "../../../../components/form/InputToggle.js";
import { TextField } from "../../../../components/form/TextField.js";
+import { useSessionContext } from "../../../../context/session.js";
import { useInstanceOtpDevices } from "../../../../hooks/otp.js";
type Entity = {
@@ -67,7 +67,8 @@ interface Props {
export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
const { i18n } = useTranslationContext();
- const { url: backendUrl, config } = useMerchantApiContext();
+ const { config } = useSessionContext();
+ const {state:session} = useSessionContext();
const [state, setState] = useState<Partial<Entity>>({
description: template.template_description,
@@ -155,6 +156,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
? undefined
: config.currency,
},
+ required_currency: config.currency,
editable_defaults: {
amount: !state.amount_editable ? undefined : state.amount,
summary: !state.summary_editable ? undefined : state.summary,
@@ -176,7 +178,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
<div class="level-left">
<div class="level-item">
<span class="is-size-4">
- {new URL(`templates/${template.id}`, backendUrl.href).href}
+ {new URL(`templates/${template.id}`, session.backendUrl.href).href}
</span>
</div>
</div>
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx
index 9e5099947..6185bd2a9 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx
@@ -21,7 +21,6 @@
import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -34,9 +33,9 @@ import {
useTemplateDetails,
} from "../../../../hooks/templates.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { UpdatePage } from "./UpdatePage.js";
-import { LoginPage } from "../../../login/index.js";
export type Entity = TalerMerchantApi.TemplatePatchDetails & WithId;
@@ -50,7 +49,7 @@ export default function UpdateTemplate({
onConfirm,
onBack,
}: Props): VNode {
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
const result = useTemplateDetails(tid);
const [notif, setNotif] = useState<Notification | undefined>(undefined);
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx
index 46d4da8d7..00cb2b827 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx
@@ -21,7 +21,6 @@
import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -33,9 +32,10 @@ import {
useTemplateDetails
} from "../../../../hooks/templates.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { UsePage } from "./UsePage.js";
-import { LoginPage } from "../../../login/index.js";
+import { useSessionContext } from "../../../../context/session.js";
export type Entity = TalerMerchantApi.TransferInformation;
interface Props {
@@ -49,7 +49,7 @@ export default function TemplateUsePage({
onOrderCreated,
onBack,
}: Props): VNode {
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const result = useTemplateDetails(tid);
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
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 0274d6caa..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 } from "@gnu-taler/taler-util";
+import { AccessToken, createRFC8959AccessTokenPlain } from "@gnu-taler/taler-util";
interface Props {
hasToken: boolean | undefined;
@@ -67,7 +67,7 @@ export function DetailPage({
};
const hasErrors = Object.keys(errors).some(
- (k) => (errors as any)[k] !== undefined,
+ (k) => (errors as Record<string, unknown>)[k] !== undefined,
);
const { state } = useSessionContext();
@@ -76,11 +76,12 @@ export function DetailPage({
async function submitForm() {
if (hasErrors) return;
- const oldToken = hasToken
- ? (form.old_token as AccessToken)
- : undefined;
- const newToken = form.new_token as AccessToken;
- onNewToken(oldToken, `secret-token:${newToken}` as AccessToken);
+ const oldToken =
+ form.old_token !== undefined && hasToken
+ ? createRFC8959AccessTokenPlain(form.old_token)
+ : undefined;
+ const newToken = createRFC8959AccessTokenPlain(form.new_token!);
+ onNewToken(oldToken, newToken);
}
return (
@@ -133,8 +134,7 @@ export function DetailPage({
class="button"
onClick={() => {
if (hasToken) {
- const oldToken = form.old_token as AccessToken;
- onClearToken(oldToken);
+ onClearToken(form.old_token ? createRFC8959AccessTokenPlain(form.old_token) : undefined);
} else {
onClearToken(undefined);
}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx
index cc8f7f9e8..c23e5be17 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx
@@ -13,8 +13,14 @@
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, TalerError, assertUnreachable } from "@gnu-taler/taler-util";
-import { useMerchantApiContext, useTranslationContext } from "@gnu-taler/web-util/browser";
+import {
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js";
@@ -24,43 +30,40 @@ import { useSessionContext } from "../../../context/session.js";
import { useInstanceDetails } from "../../../hooks/instance.js";
import { Notification } from "../../../utils/types.js";
import { LoginPage } from "../../login/index.js";
-import { DetailPage } from "./DetailPage.js";
import { NotFoundPageOrAdminCreate } from "../../notfound/index.js";
+import { DetailPage } from "./DetailPage.js";
interface Props {
onChange: () => void;
onCancel: () => void;
}
-export default function Token({
- onChange,
- onCancel,
-}: Props): VNode {
+export default function Token({ onChange, onCancel }: Props): VNode {
const { i18n } = useTranslationContext();
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { logIn } = useSessionContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
- const result = useInstanceDetails()
+ const result = useInstanceDetails();
- if (!result) return <Loading />
+ if (!result) return <Loading />;
if (result instanceof TalerError) {
- return <ErrorLoadingMerchant error={result} />
+ return <ErrorLoadingMerchant error={result} />;
}
if (result.type === "fail") {
- switch(result.case) {
+ switch (result.case) {
case HttpStatusCode.Unauthorized: {
- return <LoginPage />
+ return <LoginPage />;
}
case HttpStatusCode.NotFound: {
return <NotFoundPageOrAdminCreate />;
}
default: {
- assertUnreachable(result)
+ assertUnreachable(result);
}
}
}
- const hasToken = result.body.auth.method === "token"
+ const hasToken = result.body.auth.method === "token";
return (
<Fragment>
@@ -70,13 +73,24 @@ export default function Token({
hasToken={hasToken}
onClearToken={async (currentToken): Promise<void> => {
try {
- await lib.instance.updateCurrentInstanceAuthentication(currentToken, {
- method: "external",
- })
- onChange();
+ const resp = await lib.instance.updateCurrentInstanceAuthentication(
+ currentToken,
+ {
+ method: "external",
+ },
+ );
+ if (resp.type === "ok") {
+ onChange();
+ } else {
+ return setNotif({
+ message: i18n.str`Failed to clear token`,
+ type: "ERROR",
+ description: resp.detail.hint,
+ });
+ }
} catch (error) {
if (error instanceof Error) {
- setNotif({
+ return setNotif({
message: i18n.str`Failed to clear token`,
type: "ERROR",
description: error.message,
@@ -86,29 +100,45 @@ export default function Token({
}}
onNewToken={async (currentToken, newToken): Promise<void> => {
try {
- await lib.instance.updateCurrentInstanceAuthentication(currentToken, {
- token: newToken,
- method: "token"
- })
- const resp = await lib.authenticate.createAccessTokenBearer(newToken, {
- scope: "write",
- duration: {
- d_us: "forever"
+ {
+ const resp =
+ await lib.instance.updateCurrentInstanceAuthentication(
+ currentToken,
+ {
+ token: newToken,
+ method: "token",
+ },
+ );
+ if (resp.type === "fail") {
+ return setNotif({
+ message: i18n.str`Failed to set new token`,
+ type: "ERROR",
+ description: resp.detail.hint,
+ });
+ }
+ }
+ const resp = await lib.authenticate.createAccessTokenBearer(
+ newToken,
+ {
+ scope: "write",
+ duration: {
+ d_us: "forever",
+ },
+ refreshable: true,
},
- refreshable: true,
- })
+ );
if (resp.type === "ok") {
- logIn({ token: resp.body.token })
- onChange();
+ logIn(resp.body.token);
+ return onChange();
} else {
- setNotif({
+ return setNotif({
message: i18n.str`Failed to set new token`,
type: "ERROR",
});
}
} catch (error) {
if (error instanceof Error) {
- setNotif({
+ return setNotif({
message: i18n.str`Failed to set new token`,
type: "ERROR",
description: error.message,
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx
index 27eab97ed..428476337 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx
@@ -21,16 +21,15 @@
import { TalerError, TalerMerchantApi } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
+import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
import { useInstanceBankAccounts } from "../../../../hooks/bank.js";
import { Notification } from "../../../../utils/types.js";
import { CreatePage } from "./CreatePage.js";
-import { useSessionContext } from "../../../../context/session.js";
export type Entity = TalerMerchantApi.TransferInformation;
interface Props {
@@ -39,7 +38,7 @@ interface Props {
}
export default function CreateTransfer({ onConfirm, onBack }: Props): VNode {
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx
index 4afc400f8..9da7f7efb 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx
@@ -15,7 +15,6 @@
*/
import { HttpStatusCode, TalerError, TalerMerchantApi, TalerMerchantInstanceHttpClient, TalerMerchantManagementResultByMethod, assertUnreachable } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -30,8 +29,8 @@ import {
} from "../../../hooks/instance.js";
import { Notification } from "../../../utils/types.js";
import { LoginPage } from "../../login/index.js";
-import { UpdatePage } from "./UpdatePage.js";
import { NotFoundPageOrAdminCreate } from "../../notfound/index.js";
+import { UpdatePage } from "./UpdatePage.js";
export interface Props {
onBack: () => void;
@@ -44,14 +43,14 @@ export interface Props {
}
export default function Update(props: Props): VNode {
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const updateInstance = lib.instance.updateCurrentInstance.bind(lib.instance)
const result = useInstanceDetails();
return CommonUpdate(props, result, updateInstance,);
}
export function AdminUpdate(props: Props & { instanceId: string }): VNode {
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const t = lib.subInstanceApi(props.instanceId).instance;
const updateInstance = t.updateCurrentInstance.bind(t)
const result = useManagedInstanceDetails(props.instanceId);
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/index.tsx
index e4d260b04..70f246ff1 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/index.tsx
@@ -19,14 +19,14 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { useMerchantApiContext, useTranslationContext } from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
import { Notification } from "../../../../utils/types.js";
import { CreatePage } from "./CreatePage.js";
-import { TalerMerchantApi } from "@gnu-taler/taler-util";
-import { useSessionContext } from "../../../../context/session.js";
export type Entity = TalerMerchantApi.WebhookAddDetails;
interface Props {
@@ -37,7 +37,7 @@ interface Props {
export default function CreateWebhook({ onConfirm, onBack }: Props): VNode {
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
return (
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/index.tsx
index 988a54604..789b8d73b 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/index.tsx
@@ -26,8 +26,7 @@ import {
assertUnreachable,
} from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
@@ -37,9 +36,9 @@ import { NotificationCard } from "../../../../components/menu/index.js";
import { useSessionContext } from "../../../../context/session.js";
import { useInstanceWebhooks } from "../../../../hooks/webhooks.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { ListPage } from "./ListPage.js";
-import { LoginPage } from "../../../login/index.js";
interface Props {
onCreate: () => void;
@@ -49,7 +48,7 @@ interface Props {
export default function ListWebhooks({ onCreate, onSelect }: Props): VNode {
const { i18n } = useTranslationContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
const result = useInstanceWebhooks();
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx
index 1253cd9a2..5b2ba7bb9 100644
--- a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx
@@ -21,7 +21,6 @@
import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -34,9 +33,9 @@ import {
useWebhookDetails,
} from "../../../../hooks/webhooks.js";
import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { UpdatePage } from "./UpdatePage.js";
-import { LoginPage } from "../../../login/index.js";
export type Entity = TalerMerchantApi.WebhookPatchDetails & WithId;
@@ -50,7 +49,7 @@ export default function UpdateWebhook({
onConfirm,
onBack,
}: Props): VNode {
- const { lib } = useMerchantApiContext();
+ const { lib } = useSessionContext();
const { state } = useSessionContext();
const result = useWebhookDetails(tid);
const [notif, setNotif] = useState<Notification | undefined>(undefined);
diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx
index 30b5c37bd..d77bc75fd 100644
--- a/packages/merchant-backoffice-ui/src/paths/login/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/login/index.tsx
@@ -19,10 +19,9 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { HttpStatusCode } from "@gnu-taler/taler-util";
+import { HttpStatusCode, createRFC8959AccessTokenEncoded } from "@gnu-taler/taler-util";
import {
- useMerchantApiContext,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { ComponentChildren, Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
@@ -43,58 +42,19 @@ const tokenRequest = {
export function LoginPage(_p: Props): VNode {
const [token, setToken] = useState("");
const [notif, setNotif] = useState<Notification | undefined>(undefined);
- const { state, logIn, impersonate } = useSessionContext();
- const { lib } = useMerchantApiContext();
+ const { state, logIn } = useSessionContext();
+ const { lib } = useSessionContext();
const { i18n } = useTranslationContext();
- async function doImpersonateImpl(instanceId: string) {
- const newInstanceApi = lib.subInstanceApi(instanceId);
- const cfg = await newInstanceApi.instance.getConfig();
- if (cfg.type !== "ok") {
- setNotif({
- message: "Could not load the configuration of this instance.",
- description: newInstanceApi.instance.baseUrl,
- type: "ERROR",
- });
- return;
- }
- const result = await newInstanceApi.authenticate.createAccessTokenBearer(
- token,
- tokenRequest,
- );
-
- if (result.type === "ok") {
- const { token } = result.body;
- impersonate({ instance: instanceId, baseUrl: new URL(newInstanceApi.instance.baseUrl), token });
- return;
- } else {
- switch (result.case) {
- case HttpStatusCode.Unauthorized: {
- setNotif({
- message: "Your password is incorrect",
- type: "ERROR",
- });
- return;
- }
- case HttpStatusCode.NotFound: {
- setNotif({
- message: "Your instance not found",
- type: "ERROR",
- });
- return;
- }
- }
- }
- }
async function doLoginImpl() {
const result = await lib.authenticate.createAccessTokenBearer(
- token,
+ createRFC8959AccessTokenEncoded(token),
tokenRequest,
);
if (result.type === "ok") {
const { token } = result.body;
- logIn({ token });
+ logIn(token);
return;
} else {
switch (result.case) {
@@ -116,73 +76,6 @@ export function LoginPage(_p: Props): VNode {
}
}
- if (state.status === "loggedIn" && state.impersonate !== undefined) {
- //the user is loggedin but trying to do an impersonation
- return (
- <div class="columns is-centered" style={{ margin: "auto" }}>
- <div class="column is-two-thirds ">
- <div class="modal-card" style={{ width: "100%", margin: 0 }}>
- <header
- class="modal-card-head"
- style={{ border: "1px solid", borderBottom: 0 }}
- >
- <p class="modal-card-title">{i18n.str`Login required`}</p>
- </header>
- <section
- class="modal-card-body"
- style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
- >
- <p>
- <i18n.Translate>
- Need the access token for the instance{" "}
- <b>"{state.instance}"</b>
- </i18n.Translate>
- </p>
- <div class="field is-horizontal">
- <div class="field-label is-normal">
- <label class="label">
- <i18n.Translate>Access Token</i18n.Translate>
- </label>
- </div>
- <div class="field-body">
- <div class="field">
- <p class="control is-expanded">
- <input
- class="input"
- type="password"
- placeholder={"current access token"}
- name="token"
- onKeyPress={(e) =>
- e.keyCode === 13
- ? doImpersonateImpl(state.instance)
- : null
- }
- value={token}
- onInput={(e): void => setToken(e?.currentTarget.value)}
- />
- </p>
- </div>
- </div>
- </div>
- </section>
- <footer
- class="modal-card-foot "
- style={{
- justifyContent: "flex-end",
- border: "1px solid",
- borderTop: 0,
- }}
- >
- <AsyncButton onClick={() => doImpersonateImpl(state.instance)}>
- <i18n.Translate>Confirm</i18n.Translate>
- </AsyncButton>
- </footer>
- </div>
- </div>
- </div>
- );
- }
-
return (
<Fragment>
<NotificationCard notification={notif} />
diff --git a/packages/merchant-backoffice-ui/src/paths/notfound/index.tsx b/packages/merchant-backoffice-ui/src/paths/notfound/index.tsx
index d780b5988..4d348c02b 100644
--- a/packages/merchant-backoffice-ui/src/paths/notfound/index.tsx
+++ b/packages/merchant-backoffice-ui/src/paths/notfound/index.tsx
@@ -24,6 +24,7 @@ import { Fragment, h, VNode } from "preact";
import { Link, route } from "preact-router";
import { NotificationCard } from "../../components/menu/index.js";
import {
+ cleanAllCache,
DEFAULT_ADMIN_USERNAME,
useSessionContext,
} from "../../context/session.js";
@@ -57,6 +58,9 @@ export function NotFoundPageOrAdminCreate(): VNode {
<InstanceCreatePage
forceId={DEFAULT_ADMIN_USERNAME}
onConfirm={() => {
+ // we need to clear everything since we take some
+ // 404 as "default instance don't exist"
+ cleanAllCache()
route(InstancePaths.bank_list);
}}
/>
diff --git a/packages/pogen/package.json b/packages/pogen/package.json
index 5e601c4ca..24edc348b 100644
--- a/packages/pogen/package.json
+++ b/packages/pogen/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/pogen",
- "version": "0.0.5",
+ "version": "0.10.7",
"bin": {
"pogen": "bin/pogen"
},
diff --git a/packages/pogen/src/potextract.ts b/packages/pogen/src/potextract.ts
index 3e9a95ded..243d44c6f 100644
--- a/packages/pogen/src/potextract.ts
+++ b/packages/pogen/src/potextract.ts
@@ -171,12 +171,12 @@ function processFile(
}
function formatMsgLine(head: string, msg: string) {
+ const m = msg.match(/(.*\n|.+$)/g)
+ if (!m) return;
// Do escaping, wrap break at newlines
console.log("head", JSON.stringify(head));
console.log("msg", JSON.stringify(msg));
- let parts = msg
- .match(/(.*\n|.+$)/g)
- .map((x) => x.replace(/\n/g, "\\n").replace(/"/g, '\\"'))
+ let parts = m.map((x) => x.replace(/\n/g, "\\n").replace(/"/g, '\\"'))
.map((p) => wordwrap(p))
.reduce((a, b) => a.concat(b));
if (parts.length == 1) {
diff --git a/packages/taler-harness/Makefile b/packages/taler-harness/Makefile
index e9663aa61..dea37ec7b 100644
--- a/packages/taler-harness/Makefile
+++ b/packages/taler-harness/Makefile
@@ -37,6 +37,7 @@ install-nodeps:
ln -sf ../lib/taler-harness/node_modules/taler-harness/bin/taler-harness.mjs $(DESTDIR)$(BINDIR)/taler-harness
deps:
pnpm install --frozen-lockfile --filter @gnu-taler/taler-harness...
+ pnpm run --filter @gnu-taler/taler-harness... compile
install:
$(MAKE) deps
$(MAKE) install-nodeps
diff --git a/packages/taler-harness/debian/changelog b/packages/taler-harness/debian/changelog
index 0862b1aa3..269c6b99d 100644
--- a/packages/taler-harness/debian/changelog
+++ b/packages/taler-harness/debian/changelog
@@ -1,3 +1,9 @@
+taler-harness (0.10.7) unstable; urgency=low
+
+ * Release 0.10.7
+
+ -- Florian Dold <dold@taler.net> Mon, 22 Apr 2024 20:16:39 +0200
+
taler-harness (0.10.6) unstable; urgency=low
* Release 0.10.6
diff --git a/packages/taler-harness/package.json b/packages/taler-harness/package.json
index 4b08cab89..38d640f51 100644
--- a/packages/taler-harness/package.json
+++ b/packages/taler-harness/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/taler-harness",
- "version": "0.10.6",
+ "version": "0.10.7",
"description": "",
"engines": {
"node": ">=0.12.0"
diff --git a/packages/taler-harness/src/bench1.ts b/packages/taler-harness/src/bench1.ts
index 428114e0e..d260ea731 100644
--- a/packages/taler-harness/src/bench1.ts
+++ b/packages/taler-harness/src/bench1.ts
@@ -29,7 +29,6 @@ import {
} from "@gnu-taler/taler-util";
import {
AccessStats,
- applyRunConfigDefaults,
createNativeWalletHost2,
Wallet,
WalletApiOperation,
@@ -75,7 +74,7 @@ export async function runBench1(configJson: any): Promise<void> {
// my assumption is that the in-memory db file gets too large
if (i % restartWallet == 0) {
if (Object.keys(wallet).length !== 0) {
- wallet.stop();
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
console.log("wallet DB stats", j2s(getDbStats!()));
}
@@ -105,9 +104,7 @@ export async function runBench1(configJson: any): Promise<void> {
exchangeBaseUrl: b1conf.exchange,
});
- await wallet.runTaskLoop({
- stopWhenDone: true,
- });
+ await wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
logger.info(
`Finished withdrawal amount=${withdrawAmount} time=${Date.now() - start}`,
@@ -123,16 +120,14 @@ export async function runBench1(configJson: any): Promise<void> {
depositPaytoUri: b1conf.payto,
});
- await wallet.runTaskLoop({
- stopWhenDone: true,
- });
+ await wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
logger.info(`Finished deposit amount=10 time=${Date.now() - start}`);
}
}
}
- wallet.stop();
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
console.log("wallet DB stats", j2s(getDbStats!()));
}
diff --git a/packages/taler-harness/src/bench3.ts b/packages/taler-harness/src/bench3.ts
index f138dff68..ddf763c5b 100644
--- a/packages/taler-harness/src/bench3.ts
+++ b/packages/taler-harness/src/bench3.ts
@@ -85,7 +85,7 @@ export async function runBench3(configJson: any): Promise<void> {
// my assumption is that the in-memory db file gets too large
if (i % restartWallet == 0) {
if (Object.keys(wallet).length !== 0) {
- wallet.stop();
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
console.log("wallet DB stats", j2s(getDbStats!()));
}
@@ -115,9 +115,7 @@ export async function runBench3(configJson: any): Promise<void> {
exchangeBaseUrl: b3conf.exchange,
});
- await wallet.runTaskLoop({
- stopWhenDone: true,
- });
+ await wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
logger.info(
`Finished withdrawal amount=${withdrawAmount} time=${Date.now() - start}`,
@@ -135,15 +133,13 @@ export async function runBench3(configJson: any): Promise<void> {
depositPaytoUri: payto,
});
- await wallet.runTaskLoop({
- stopWhenDone: true,
- });
+ await wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
logger.info(`Finished deposit amount=10 time=${Date.now() - start}`);
}
}
- wallet.stop();
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
console.log("wallet DB stats", j2s(getDbStats!()));
}
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts
index a8bdb9850..fd34fe241 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 {
@@ -793,7 +793,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);
}
@@ -831,7 +831,7 @@ export class LibeufinBankService
"suggested_withdrawal_exchange",
e.baseUrl,
);
- config.write(this.configFile, { excludeDefaults: true });
+ config.writeTo(this.configFile, { excludeDefaults: true });
}
get baseUrl(): string {
@@ -1061,7 +1061,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) {
@@ -1127,7 +1127,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);
}
@@ -1136,13 +1136,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) {
@@ -1153,7 +1153,7 @@ export class ExchangeService implements ExchangeServiceInterface {
"age_groups",
maskStr,
);
- config.write(this.configFilename, { excludeDefaults: true });
+ config.writeTo(this.configFilename, { excludeDefaults: true });
}
get masterPub() {
@@ -1174,7 +1174,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(
@@ -1215,7 +1215,7 @@ export class ExchangeService implements ExchangeServiceInterface {
"password",
exchangeBankAccount.accountPassword,
);
- config.write(this.configFilename, { excludeDefaults: true });
+ config.writeTo(this.configFilename, { excludeDefaults: true });
}
exchangeHttpProc: ProcessWrapper | undefined;
@@ -1710,7 +1710,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);
}
@@ -1728,7 +1728,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 2dcde39b9..315173b7f 100644
--- a/packages/taler-harness/src/index.ts
+++ b/packages/taler-harness/src/index.ts
@@ -30,10 +30,11 @@ import {
TalerAuthenticationHttpClient,
TalerBankConversionHttpClient,
TalerCoreBankHttpClient,
- TalerErrorCode,
TalerMerchantInstanceHttpClient,
TalerMerchantManagementHttpClient,
TransactionsResponse,
+ createRFC8959AccessTokenEncoded,
+ createRFC8959AccessTokenPlain,
decodeCrock,
encodeCrock,
generateIban,
@@ -41,7 +42,6 @@ import {
randomBytes,
rsaBlind,
setGlobalLogLevelFromString,
- setPrintHttpRequestAsCurl,
stringifyPayTemplateUri,
} from "@gnu-taler/taler-util";
import { clk } from "@gnu-taler/taler-util/clk";
@@ -55,7 +55,8 @@ import {
WalletApiOperation,
} from "@gnu-taler/taler-wallet-core";
import {
- downloadExchangeInfo, topupReserveWithBank,
+ downloadExchangeInfo,
+ topupReserveWithBank,
} from "@gnu-taler/taler-wallet-core/dbless";
import { deepStrictEqual } from "assert";
import fs from "fs";
@@ -78,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");
@@ -354,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);
@@ -387,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.",
});
@@ -614,7 +664,10 @@ deploymentCli
},
)
.maybeOption("bankToken", ["--bank-admin-token"], clk.STRING, {
- help: "libeufin bank admin's password if the account creation is restricted",
+ help: "libeufin bank admin's token if the account creation is restricted",
+ })
+ .maybeOption("bankPassword", ["--bank-admin-password"], clk.STRING, {
+ help: "libeufin bank admin's password if the account creation is restricted, it will override --bank-admin-token",
})
.requiredOption("name", ["--legal-name"], clk.STRING, {
help: "legal name of the merchant",
@@ -638,10 +691,13 @@ deploymentCli
help: "if everything worked ok, change the password of the accounts at the end",
})
.action(async (args) => {
- const managementToken = args.provisionBankMerchant
- .merchantToken as AccessToken;
- const bankAdminPassword = args.provisionBankMerchant
- .bankToken as AccessToken;
+ const managementToken = createRFC8959AccessTokenPlain(
+ args.provisionBankMerchant.merchantToken,
+ );
+ const bankAdminPassword = args.provisionBankMerchant.bankPassword;
+ const bankAdminTokenArg = args.provisionBankMerchant.bankToken
+ ? createRFC8959AccessTokenPlain(args.provisionBankMerchant.bankToken)
+ : undefined;
const id = args.provisionBankMerchant.id;
const name = args.provisionBankMerchant.name;
const email = args.provisionBankMerchant.email;
@@ -694,21 +750,48 @@ deploymentCli
return;
}
+ let bankAdminToken: AccessToken | undefined;
+ if (bankAdminPassword) {
+ const adminAuth = new TalerAuthenticationHttpClient(
+ bank.getAuthenticationAPI("admin").href,
+ httpLib,
+ );
+
+ const resp = await adminAuth.createAccessTokenBasic(
+ "admin",
+ bankAdminPassword,
+ {
+ scope: "write",
+ duration: {
+ d_us: 1000 * 1000 * 10, //10 secs
+ },
+ refreshable: false,
+ },
+ );
+ if (resp.type === "fail") {
+ logger.error(`could not get bank admin token from password.`);
+ return;
+ }
+ bankAdminToken = resp.body.access_token;
+ } else {
+ bankAdminToken = bankAdminTokenArg;
+ }
+
/**
* create bank account
*/
let accountPayto: PaytoString;
{
- const resp = await bank.createAccount(bankAdminPassword, {
+ const resp = await bank.createAccount(bankAdminToken, {
name: name,
password: password,
username: id,
contact_data:
email || phone
? {
- email: email,
- phone: phone,
- }
+ email: email,
+ phone: phone,
+ }
: undefined,
});
@@ -730,7 +813,7 @@ deploymentCli
address: {},
auth: {
method: "token",
- token: `secret-token:${password}`,
+ token: createRFC8959AccessTokenPlain(password),
},
default_pay_delay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ hours: 1 }),
@@ -762,7 +845,7 @@ deploymentCli
*/
{
const resp = await merchantInstance.addBankAccount(
- password as AccessToken,
+ createRFC8959AccessTokenEncoded(password),
{
payto_uri: accountPayto,
credit_facade_url: bank.getRevenueAPI(id).href,
@@ -805,7 +888,7 @@ deploymentCli
{
const resp = await merchantInstance.addTemplate(
- password as AccessToken,
+ createRFC8959AccessTokenEncoded(password),
{
template_id: "default",
template_description: "First template",
@@ -840,7 +923,7 @@ deploymentCli
let finalPassword = password;
if (args.provisionBankMerchant.randomPassword) {
- const prevPassword = password as AccessToken;
+ const prevPassword = password;
const randomPassword = encodeCrock(randomBytes(16));
logger.info("random password: ", randomPassword);
let token: AccessToken;
@@ -885,10 +968,10 @@ deploymentCli
{
const resp = await merchantInstance.updateCurrentInstanceAuthentication(
- prevPassword,
+ createRFC8959AccessTokenEncoded(prevPassword),
{
method: "token",
- token: `secret-token:${randomPassword}` as AccessToken,
+ token: createRFC8959AccessTokenPlain(randomPassword),
},
);
if (resp.type === "fail") {
@@ -902,7 +985,7 @@ deploymentCli
{
const resp = await merchantInstance.updateBankAccount(
- randomPassword as AccessToken,
+ createRFC8959AccessTokenEncoded(randomPassword),
wireAccount,
{
credit_facade_url: bank.getRevenueAPI(id).href,
@@ -960,17 +1043,15 @@ deploymentCli
const httpLib = createPlatformHttpLib({});
const baseUrl = args.provisionMerchantInstance.merchantApiBaseUrl;
const api = new TalerMerchantManagementHttpClient(baseUrl, httpLib);
- const mt = args.provisionMerchantInstance.managementToken;
- const mtWithoutPrefix = mt.startsWith("secret-token:")
- ? mt.substring("secret-token:".length)
- : mt;
- const managementToken = mtWithoutPrefix as AccessToken;
-
- const it = args.provisionMerchantInstance.instanceToken;
- const itWithoutPrefix = it.startsWith("secret-token:")
- ? it.substring("secret-token:".length)
- : it;
- const instanceToken = itWithoutPrefix as AccessToken;
+ const managementToken = createRFC8959AccessTokenEncoded(
+ args.provisionMerchantInstance.managementToken,
+ );
+ const instanceTokenEnc = createRFC8959AccessTokenPlain(
+ args.provisionMerchantInstance.instanceToken,
+ );
+ const instanceTokenPlain = createRFC8959AccessTokenPlain(
+ args.provisionMerchantInstance.instanceToken,
+ );
const instanceId = args.provisionMerchantInstance.id;
const instancceName = args.provisionMerchantInstance.name;
const bankURL = args.provisionMerchantInstance.bankURL;
@@ -982,7 +1063,7 @@ deploymentCli
address: {},
auth: {
method: "token",
- token: `secret-token:${instanceToken}`,
+ token: instanceTokenPlain,
},
default_pay_delay: Duration.toTalerProtocolDuration(
Duration.fromSpec({ hours: 1 }),
@@ -1005,16 +1086,16 @@ 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:
bankUser && bankPassword
? {
- type: "basic",
- username: bankUser,
- password: bankPassword,
- }
+ type: "basic",
+ username: bankUser,
+ password: bankPassword,
+ }
: undefined,
});
if (createAccountResp.type != "ok") {
@@ -1117,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-harness/src/integrationtests/test-currency-scope.ts b/packages/taler-harness/src/integrationtests/test-currency-scope.ts
index 26445247a..69e45f678 100644
--- a/packages/taler-harness/src/integrationtests/test-currency-scope.ts
+++ b/packages/taler-harness/src/integrationtests/test-currency-scope.ts
@@ -18,7 +18,7 @@
* Imports.
*/
import { Duration, j2s } from "@gnu-taler/taler-util";
-import { Wallet, WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { defaultCoinConfig } from "../harness/denomStructures.js";
import {
ExchangeService,
diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull-large.ts b/packages/taler-harness/src/integrationtests/test-peer-pull-large.ts
new file mode 100644
index 000000000..6de3c2e33
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-pull-large.ts
@@ -0,0 +1,194 @@
+/*
+ This file is part of GNU Taler
+ (C) 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/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ j2s,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import {
+ BankServiceHandle,
+ ExchangeService,
+ GlobalTestState,
+ WalletClient,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+];
+
+export async function runPeerPullLargeTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ coinConfigList,
+ );
+
+ let allW1Notifications: WalletNotification[] = [];
+ let allW2Notifications: WalletNotification[] = [];
+
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ persistent: true,
+ handleNotification(wn) {
+ allW2Notifications.push(wn);
+ },
+ });
+
+ // Withdraw digital cash into the wallet.
+ const wallet1 = w1.walletClient;
+ const wallet2 = w2.walletClient;
+
+ await checkNormalPeerPull(t, bank, exchange, wallet1, wallet2);
+}
+
+async function checkNormalPeerPull(
+ t: GlobalTestState,
+ bank: BankServiceHandle,
+ exchange: ExchangeService,
+ wallet1: WalletClient,
+ wallet2: WalletClient,
+): Promise<void> {
+ const withdrawRes = await withdrawViaBankV2(t, {
+ walletClient: wallet2,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:500",
+ });
+
+ await withdrawRes.withdrawalFinishedCond;
+
+ const purseExpiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const resp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:200" as AmountString,
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+
+ const peerPullCreditReadyCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPullCreditReadyCond;
+
+ const creditTx = await wallet1.call(WalletApiOperation.GetTransactionById, {
+ transactionId: resp.transactionId,
+ });
+
+ t.assertDeepEqual(creditTx.type, TransactionType.PeerPullCredit);
+ t.assertTrue(!!creditTx.talerUri);
+
+ const checkResp = await wallet2.client.call(
+ WalletApiOperation.PreparePeerPullDebit,
+ {
+ talerUri: creditTx.talerUri,
+ },
+ );
+
+ console.log(`checkResp: ${j2s(checkResp)}`);
+
+ const peerPullCreditDoneCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ const peerPullDebitDoneCond = wallet2.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === checkResp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ await wallet2.client.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ await peerPullCreditDoneCond;
+ await peerPullDebitDoneCond;
+
+ const txn1 = await wallet1.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ const txn2 = await wallet2.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log(`txn1: ${j2s(txn1)}`);
+ console.log(`txn2: ${j2s(txn2)}`);
+}
+
+runPeerPullLargeTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-peer-push-large.ts b/packages/taler-harness/src/integrationtests/test-peer-push-large.ts
new file mode 100644
index 000000000..b7fbe9f6e
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-push-large.ts
@@ -0,0 +1,177 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 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/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WalletNotification,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+import { CoinConfig } from "../harness/denomStructures.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+];
+
+/**
+ * Run a test for a multi-batch peer push payment.
+ */
+export async function runPeerPushLargeTest(t: GlobalTestState) {
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t, coinConfigList);
+
+ let allW1Notifications: WalletNotification[] = [];
+ let allW2Notifications: WalletNotification[] = [];
+
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ handleNotification(wn) {
+ allW2Notifications.push(wn);
+ },
+ });
+
+ // Withdraw digital cash into the wallet.
+
+ const withdrawRes = await withdrawViaBankV2(t, {
+ walletClient: w1.walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:300",
+ });
+
+ await withdrawRes.withdrawalFinishedCond;
+
+ const purse_expiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const checkResp0 = await w1.walletClient.call(
+ WalletApiOperation.CheckPeerPushDebit,
+ {
+ amount: "TESTKUDOS:200" as AmountString,
+ },
+ );
+
+ t.assertAmountEquals(checkResp0.amountEffective, "TESTKUDOS:200");
+
+ const resp = await w1.walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "Hello World 🥺",
+ amount: "TESTKUDOS:200" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ console.log(resp);
+
+ const peerPushReadyCond = w1.walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready &&
+ x.transactionId === resp.transactionId,
+ );
+
+ await peerPushReadyCond;
+
+ const txDetails = await w1.walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: resp.transactionId,
+ },
+ );
+ t.assertDeepEqual(txDetails.type, TransactionType.PeerPushDebit);
+ t.assertTrue(!!txDetails.talerUri);
+
+ const checkResp = await w2.walletClient.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ {
+ talerUri: txDetails.talerUri,
+ },
+ );
+
+ console.log(checkResp);
+
+ const acceptResp = await w2.walletClient.call(
+ WalletApiOperation.ConfirmPeerPushCredit,
+ {
+ transactionId: checkResp.transactionId,
+ },
+ );
+
+ console.log(acceptResp);
+
+ await w2.walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ const txn1 = await w1.walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ const txn2 = await w2.walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log(`txn1: ${j2s(txn1)}`);
+ console.log(`txn2: ${j2s(txn2)}`);
+}
+
+runPeerPushLargeTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts
index 15167d133..004de87c8 100644
--- a/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts
+++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts
@@ -44,25 +44,25 @@ const coinCommon = {
rsaKeySize: 1024,
};
+const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+];
+
/**
* Run test for paying a merchant with balance locked behind a pending refresh.
*/
export async function runWalletBlockedPayMerchantTest(t: GlobalTestState) {
// Set up test environment
- const coinConfigList: CoinConfig[] = [
- {
- ...coinCommon,
- name: "n1",
- value: "TESTKUDOS:1",
- },
- {
- ...coinCommon,
- name: "n5",
- value: "TESTKUDOS:5",
- },
- ];
-
const { bank, exchange, merchant } = await createSimpleTestkudosEnvironmentV2(
t,
coinConfigList,
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts
index 3251750da..b36e6ef61 100644
--- a/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts
+++ b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts
@@ -28,6 +28,7 @@ import { defaultCoinConfig } from "../harness/denomStructures.js";
import {
BankService,
ExchangeService,
+ FakebankService,
GlobalTestState,
setupDb,
} from "../harness/harness.js";
@@ -50,7 +51,7 @@ export async function runWalletExchangeUpdateTest(
nameSuffix: "two",
});
- const bank = await BankService.create(t, {
+ const bank = await FakebankService.create(t, {
allowRegistrations: true,
currency: "TESTKUDOS",
database: db.connStr,
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-handover.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-handover.ts
new file mode 100644
index 000000000..aed266eb0
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-handover.ts
@@ -0,0 +1,187 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 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/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ TalerCorebankApiClient,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+} from "../harness/helpers.js";
+
+/**
+ * Test handing over a withdrawal to another wallet.
+ */
+export async function runWithdrawalHandoverTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Do one normal withdrawal with the new split API
+ {
+ // Create a withdrawal operation
+
+ const user = await bankClient.createRandomBankUser();
+ const userBankClient = new TalerCorebankApiClient(bankClient.baseUrl);
+ userBankClient.setAuth(user);
+ const wop = await userBankClient.createWithdrawalOperation(
+ user.username,
+ "TESTKUDOS:10",
+ );
+
+ const checkResp = await walletClient.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ t.assertTrue(!!checkResp.defaultExchangeBaseUrl);
+
+ const prepareResp = await walletClient.call(
+ WalletApiOperation.PrepareBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: checkResp.defaultExchangeBaseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ console.log(`prepareResp: ${j2s(prepareResp)}`);
+
+ const txns1 = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ });
+ console.log(j2s(txns1));
+
+ await walletClient.call(WalletApiOperation.ConfirmWithdrawal, {
+ transactionId: prepareResp.transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareResp.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ },
+ });
+
+ await userBankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareResp.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ }
+
+ // Do one another withdrawal with handover.
+ {
+ t.logStep("start-subtest-handover");
+
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ });
+
+ // Create a withdrawal operation
+
+ const user = await bankClient.createRandomBankUser();
+ const userBankClient = new TalerCorebankApiClient(bankClient.baseUrl);
+ userBankClient.setAuth(user);
+ const wop = await userBankClient.createWithdrawalOperation(
+ user.username,
+ "TESTKUDOS:10",
+ );
+
+ const checkResp = await walletClient.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ t.assertTrue(!!checkResp.defaultExchangeBaseUrl);
+
+ const prepareRespW1 = await walletClient.call(
+ WalletApiOperation.PrepareBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: checkResp.defaultExchangeBaseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ const prepareRespW2 = await w2.walletClient.call(
+ WalletApiOperation.PrepareBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: checkResp.defaultExchangeBaseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ await w2.walletClient.call(WalletApiOperation.ConfirmWithdrawal, {
+ transactionId: prepareRespW2.transactionId,
+ });
+
+ await w2.walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareRespW2.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ },
+ });
+
+ await userBankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ console.log(`wopid is ${wop.withdrawal_id}`);
+
+ t.logStep("start-wait-w2-done");
+ await w2.walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareRespW2.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ t.logStep("done-wait-w2-done");
+
+ t.logStep("start-wait-w1-done");
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareRespW1.transactionId as TransactionIdStr,
+ txState: {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.CompletedByOtherWallet,
+ },
+ });
+
+ t.logStep("done-wait-w1-done");
+ }
+}
+
+runWithdrawalHandoverTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
index 54c211c6b..4b23d7762 100644
--- a/packages/taler-harness/src/integrationtests/testrunner.ts
+++ b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -72,6 +72,8 @@ import { runPaymentTransientTest } from "./test-payment-transient.js";
import { runPaymentZeroTest } from "./test-payment-zero.js";
import { runPaymentTest } from "./test-payment.js";
import { runPaywallFlowTest } from "./test-paywall-flow.js";
+import { runPeerPullLargeTest } from "./test-peer-pull-large.js";
+import { runPeerPushLargeTest } from "./test-peer-push-large.js";
import { runPeerRepairTest } from "./test-peer-repair.js";
import { runPeerToPeerPullTest } from "./test-peer-to-peer-pull.js";
import { runPeerToPeerPushTest } from "./test-peer-to-peer-push.js";
@@ -115,6 +117,7 @@ import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrat
import { runWithdrawalConversionTest } from "./test-withdrawal-conversion.js";
import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js";
import { runWithdrawalFeesTest } from "./test-withdrawal-fees.js";
+import { runWithdrawalHandoverTest } from "./test-withdrawal-handover.js";
import { runWithdrawalHugeTest } from "./test-withdrawal-huge.js";
import { runWithdrawalManualTest } from "./test-withdrawal-manual.js";
@@ -224,6 +227,9 @@ const allTests: TestMainFunction[] = [
runWalletBlockedPayPeerPullTest,
runWalletExchangeUpdateTest,
runWalletRefreshErrorsTest,
+ runPeerPullLargeTest,
+ runPeerPushLargeTest,
+ runWithdrawalHandoverTest,
];
export interface TestRunSpec {
diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json
index 5f192762a..74b2d6155 100644
--- a/packages/taler-util/package.json
+++ b/packages/taler-util/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/taler-util",
- "version": "0.10.6",
+ "version": "0.10.7",
"description": "Generic helper functionality for GNU Taler",
"type": "module",
"types": "./lib/index.node.d.ts",
@@ -52,7 +52,11 @@
"default": "./lib/http-impl.missing.js"
},
"#argon2-impl": {
- "node": "./lib/argon2-impl.node.js",
+ "node": "./lib/argon2-impl.wasm.js",
+ "deno": "./lib/argon2-impl.wasm.js",
+ "worker": "./lib/argon2-impl.wasm.js",
+ "browser": "./lib/argon2-impl.wasm.js",
+ "webpack": "./lib/argon2-impl.wasm.js",
"default": "./lib/argon2-impl.missing.js"
}
},
diff --git a/packages/taler-util/src/argon2-impl.node.ts b/packages/taler-util/src/argon2-impl.wasm.ts
index d1a36c4fe..d1a36c4fe 100644
--- a/packages/taler-util/src/argon2-impl.node.ts
+++ b/packages/taler-util/src/argon2-impl.wasm.ts
diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts
index 701fc8835..54d450d82 100644
--- a/packages/taler-util/src/codec.ts
+++ b/packages/taler-util/src/codec.ts
@@ -361,6 +361,40 @@ export function codecForStringURL(shouldEndWithSlash?: boolean): Codec<string> {
}
/**
+ * Return a codec for a value that must be a string.
+ */
+export function codecForURL(shouldEndWithSlash?: boolean): Codec<URL> {
+ return {
+ decode(x: any, c?: Context): URL {
+ if (typeof x !== "string") {
+ throw new DecodingError(
+ `expected string at ${renderContext(c)} but got ${typeof x}`,
+ );
+ }
+ if (shouldEndWithSlash && !x.endsWith("/")) {
+ throw new DecodingError(
+ `expected URL string that ends with slash at ${renderContext(
+ c,
+ )} but got ${x}`,
+ );
+ }
+ try {
+ const url = new URL(x);
+ return url;
+ } catch (e) {
+ if (e instanceof Error) {
+ throw new DecodingError(e.message);
+ } else {
+ throw new DecodingError(
+ `expected an URL string at ${renderContext(c)} but got "${x}"`,
+ );
+ }
+ }
+ },
+ };
+}
+
+/**
* Codec that allows any value.
*/
export function codecForAny(): Codec<any> {
@@ -457,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>>>(
@@ -480,5 +527,3 @@ export function codecForEither<T extends Array<Codec<unknown>>>(
},
};
}
-
-const x = codecForEither(codecForString(), codecForNumber());
diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts
index 4dea7e1b6..9378d25e8 100644
--- a/packages/taler-util/src/errors.ts
+++ b/packages/taler-util/src/errors.ts
@@ -278,6 +278,10 @@ export class TalerError<T = any> extends Error {
}
}
+export function safeStringifyException(e: any): string {
+ return JSON.stringify(getErrorDetailFromException(e), undefined, 2);
+}
+
/**
* Convert an exception (or anything that was thrown) into
* a TalerErrorDetail object.
diff --git a/packages/taler-util/src/http-client/authentication.ts b/packages/taler-util/src/http-client/authentication.ts
index b8affee7b..8897a2fa0 100644
--- a/packages/taler-util/src/http-client/authentication.ts
+++ b/packages/taler-util/src/http-client/authentication.ts
@@ -92,14 +92,14 @@ export class TalerAuthenticationHttpClient {
* @returns
*/
async createAccessTokenBearer(
- token: string,
+ token: AccessToken,
body: TalerAuthentication.TokenRequest,
) {
const url = new URL(`token`, this.baseUrl);
const resp = await this.httpLib.fetch(url.href, {
method: "POST",
headers: {
- Authorization: makeBearerTokenAuthHeader(token as AccessToken),
+ Authorization: makeBearerTokenAuthHeader(token),
},
body,
});
diff --git a/packages/taler-util/src/http-client/bank-core.ts b/packages/taler-util/src/http-client/bank-core.ts
index 59698a68b..6c8051ada 100644
--- a/packages/taler-util/src/http-client/bank-core.ts
+++ b/packages/taler-util/src/http-client/bank-core.ts
@@ -19,9 +19,11 @@ import {
HttpStatusCode,
LibtoolVersion,
LongPollParams,
+ OperationAlternative,
+ OperationFail,
+ OperationOk,
TalerErrorCode,
codecForChallenge,
- codecForTalerErrorDetail,
codecForTanTransmission,
opKnownAlternativeFailure,
opKnownHttpFailure,
@@ -64,6 +66,7 @@ import {
} from "./types.js";
import {
CacheEvictor,
+ IdempotencyRetry,
addLongPollingParam,
addPaginationParams,
makeBearerTokenAuthHeader,
@@ -181,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:
@@ -277,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:
@@ -493,9 +500,25 @@ export class TalerCoreBankHttpClient {
async createTransaction(
auth: UserAndToken,
body: TalerCorebankApi.CreateTransactionRequest,
+ idempotencyCheck: IdempotencyRetry | undefined,
cid?: string,
- ) {
+ ): Promise<
+ //manually definition all return types because of recursion
+ | OperationOk<TalerCorebankApi.CreateTransactionResponse>
+ | OperationAlternative<HttpStatusCode.Accepted, TalerCorebankApi.Challenge>
+ | OperationFail<HttpStatusCode.NotFound>
+ | OperationFail<HttpStatusCode.BadRequest>
+ | OperationFail<HttpStatusCode.Unauthorized>
+ | OperationFail<TalerErrorCode.BANK_UNALLOWED_DEBIT>
+ | OperationFail<TalerErrorCode.BANK_ADMIN_CREDITOR>
+ | OperationFail<TalerErrorCode.BANK_SAME_ACCOUNT>
+ | OperationFail<TalerErrorCode.BANK_UNKNOWN_CREDITOR>
+ | OperationFail<TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED>
+ > {
const url = new URL(`accounts/${auth.username}/transactions`, this.baseUrl);
+ if (idempotencyCheck) {
+ body.request_uid = idempotencyCheck.uid;
+ }
const resp = await this.httpLib.fetch(url.href, {
method: "POST",
headers: {
@@ -530,6 +553,12 @@ export class TalerCoreBankHttpClient {
return opKnownTalerFailure(details.code, details);
case TalerErrorCode.BANK_UNALLOWED_DEBIT:
return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
+ if (!idempotencyCheck) {
+ return opKnownTalerFailure(details.code, details);
+ }
+ const nextRetry = idempotencyCheck.next();
+ return this.createTransaction(auth, body, nextRetry, cid);
default:
return opUnknownFailure(resp, details);
}
@@ -727,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/challenger.ts b/packages/taler-util/src/http-client/challenger.ts
new file mode 100644
index 000000000..aa530570d
--- /dev/null
+++ b/packages/taler-util/src/http-client/challenger.ts
@@ -0,0 +1,291 @@
+import { HttpRequestLibrary, readTalerErrorResponse } from "../http-common.js";
+import { HttpStatusCode } from "../http-status-codes.js";
+import { createPlatformHttpLib } from "../http.js";
+import { TalerCoreBankCacheEviction } from "../index.node.js";
+import { LibtoolVersion } from "../libtool-version.js";
+import {
+ FailCasesByMethod,
+ RedirectResult,
+ ResultByMethod,
+ opFixedSuccess,
+ opKnownAlternativeFailure,
+ opKnownHttpFailure,
+ opSuccessFromHttp,
+ opUnknownFailure,
+} from "../operation.js";
+import {
+ AccessToken,
+ codecForChallengeCreateResponse,
+ codecForChallengeSetupResponse,
+ codecForChallengeStatus,
+ codecForChallengerAuthResponse,
+ codecForChallengerInfoResponse,
+ codecForChallengerTermsOfServiceResponse,
+ codecForInvalidPinResponse,
+} from "./types.js";
+import { CacheEvictor, makeBearerTokenAuthHeader, nullEvictor } from "./utils.js";
+
+export type ChallengerResultByMethod<prop extends keyof ChallengerHttpClient> =
+ ResultByMethod<ChallengerHttpClient, prop>;
+export type ChallengerErrorsByMethod<prop extends keyof ChallengerHttpClient> =
+ FailCasesByMethod<ChallengerHttpClient, prop>;
+
+export enum ChallengerCacheEviction {
+ CREATE_CHALLENGE,
+}
+
+/**
+ */
+export class ChallengerHttpClient {
+ httpLib: HttpRequestLibrary;
+ cacheEvictor: CacheEvictor<ChallengerCacheEviction>;
+ public readonly PROTOCOL_VERSION = "1:0:0";
+
+ constructor(
+ readonly baseUrl: string,
+ httpClient?: HttpRequestLibrary,
+ cacheEvictor?: CacheEvictor<ChallengerCacheEviction>,
+ ) {
+ this.httpLib = httpClient ?? createPlatformHttpLib();
+ this.cacheEvictor = cacheEvictor ?? nullEvictor;
+ }
+
+ isCompatible(version: string): boolean {
+ const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version);
+ return compare?.compatible ?? false;
+ }
+ /**
+ * https://docs.taler.net/core/api-challenger.html#get--config
+ *
+ */
+ async getConfig() {
+ const url = new URL(`config`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(
+ resp,
+ codecForChallengerTermsOfServiceResponse(),
+ );
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+ /**
+ * https://docs.taler.net/core/api-challenger.html#post--setup-$CLIENT_ID
+ *
+ */
+ async setup(clientId: string, token: AccessToken) {
+ const url = new URL(`setup/${clientId}`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(token),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForChallengeSetupResponse());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ // LOGIN
+
+ /**
+ * https://docs.taler.net/core/api-challenger.html#post--authorize-$NONCE
+ *
+ */
+ async login(
+ nonce: string,
+ clientId: string,
+ redirectUri: string,
+ state: string | undefined,
+ ) {
+ const url = new URL(`authorize/${nonce}`, this.baseUrl);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("client_id", clientId);
+ url.searchParams.set("redirect_uri", redirectUri);
+ if (state) {
+ url.searchParams.set("state", state);
+ }
+ // url.searchParams.set("scope", "code");
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForChallengeStatus());
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotAcceptable:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.InternalServerError:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ // CHALLENGE
+
+ /**
+ * https://docs.taler.net/core/api-challenger.html#post--challenge-$NONCE
+ *
+ */
+ async challenge(nonce: string, body: Record<"email", string>) {
+ const url = new URL(`challenge/${nonce}`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body: new URLSearchParams(Object.entries(body)).toString(),
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ redirect: "manual",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ await this.cacheEvictor.notifySuccess(
+ ChallengerCacheEviction.CREATE_CHALLENGE,
+ );
+ return opSuccessFromHttp(resp, codecForChallengeCreateResponse());
+ }
+ case HttpStatusCode.Found:
+ const redirect = resp.headers.get("Location")!;
+ return opFixedSuccess<RedirectResult>({
+ redirectURL: new URL(redirect),
+ });
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotAcceptable:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.TooManyRequests:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.InternalServerError:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ // SOLVE
+
+ /**
+ * https://docs.taler.net/core/api-challenger.html#post--solve-$NONCE
+ *
+ */
+ async solve(nonce: string, body: Record<string, string>) {
+ const url = new URL(`solve/${nonce}`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body: new URLSearchParams(Object.entries(body)).toString(),
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ redirect: "manual",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Found:
+ const redirect = resp.headers.get("Location")!;
+ return opFixedSuccess<RedirectResult>({
+ redirectURL: new URL(redirect),
+ });
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Forbidden:
+ return opKnownAlternativeFailure(
+ resp,
+ resp.status,
+ codecForInvalidPinResponse(),
+ );
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotAcceptable:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.TooManyRequests:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.InternalServerError:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ // AUTH
+
+ /**
+ * https://docs.taler.net/core/api-challenger.html#post--token
+ *
+ */
+ async token(
+ client_id: string,
+ redirect_uri: string,
+ client_secret: AccessToken,
+ code: string,
+ ) {
+ const url = new URL(`token`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams(
+ Object.entries({
+ client_id,
+ redirect_uri,
+ client_secret,
+ code,
+ grant_type: "authorization_code",
+ }),
+ ).toString(),
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForChallengerAuthResponse());
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ // INFO
+
+ /**
+ * https://docs.taler.net/core/api-challenger.html#get--info
+ *
+ */
+ async info(token: AccessToken) {
+ const url = new URL(`info`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(token),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForChallengerInfoResponse());
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+}
diff --git a/packages/taler-util/src/http-client/exchange.ts b/packages/taler-util/src/http-client/exchange.ts
index ea7f44cf9..68d68267f 100644
--- a/packages/taler-util/src/http-client/exchange.ts
+++ b/packages/taler-util/src/http-client/exchange.ts
@@ -33,7 +33,7 @@ import {
codecForExchangeConfig,
codecForExchangeKeys,
} from "./types.js";
-import { addPaginationParams } from "./utils.js";
+import { CacheEvictor, addPaginationParams, nullEvictor } from "./utils.js";
export type TalerExchangeResultByMethod<
prop extends keyof TalerExchangeHttpClient,
@@ -42,17 +42,25 @@ export type TalerExchangeErrorsByMethod<
prop extends keyof TalerExchangeHttpClient,
> = FailCasesByMethod<TalerExchangeHttpClient, prop>;
+export enum TalerExchangeCacheEviction {
+ CREATE_DESCISION,
+}
+
+
/**
*/
export class TalerExchangeHttpClient {
httpLib: HttpRequestLibrary;
public readonly PROTOCOL_VERSION = "18:0:1";
+ cacheEvictor: CacheEvictor<TalerExchangeCacheEviction>;
constructor(
readonly baseUrl: string,
httpClient?: HttpRequestLibrary,
+ cacheEvictor?: CacheEvictor<TalerExchangeCacheEviction>,
) {
this.httpLib = httpClient ?? createPlatformHttpLib();
+ this.cacheEvictor = cacheEvictor ?? nullEvictor;
}
isCompatible(version: string): boolean {
@@ -60,6 +68,27 @@ export class TalerExchangeHttpClient {
return compare?.compatible ?? false;
}
/**
+ * https://docs.taler.net/core/api-exchange.html#get--seed
+ *
+ */
+ async getSeed() {
+ const url = new URL(`seed`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ const buffer = await resp.bytes();
+ const uintar = new Uint8Array(buffer);
+
+ return opFixedSuccess(uintar);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+ /**
* https://docs.taler.net/core/api-exchange.html#get--config
*
*/
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
index d682dcfa0..cad2c839e 100644
--- a/packages/taler-util/src/http-client/merchant.ts
+++ b/packages/taler-util/src/http-client/merchant.ts
@@ -32,6 +32,7 @@ import {
codecForInventorySummaryResponse,
codecForMerchantConfig,
codecForMerchantOrderPrivateStatusResponse,
+ codecForMerchantPosProductDetail,
codecForMerchantRefundResponse,
codecForOrderHistory,
codecForOtpDeviceDetails,
@@ -122,7 +123,7 @@ export enum TalerMerchantManagementCacheEviction {
* Uses libtool's current:revision:age versioning.
*/
export class TalerMerchantInstanceHttpClient {
- public readonly PROTOCOL_VERSION = "10:0:6";
+ public readonly PROTOCOL_VERSION = "15:0:0";
readonly httpLib: HttpRequestLibrary;
readonly cacheEvictor: CacheEvictor<TalerMerchantInstanceCacheEviction>;
@@ -859,6 +860,32 @@ export class TalerMerchantInstanceHttpClient {
}
/**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-pos
+ */
+ async getPointOfSaleInventory(token: AccessToken | undefined) {
+ const url = new URL(`private/pos`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForMerchantPosProductDetail());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+
+ }
+
+ /**
* https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-products-$PRODUCT_ID
*/
async getProductDetails(token: AccessToken | undefined, productId: string) {
diff --git a/packages/taler-util/src/http-client/types.ts b/packages/taler-util/src/http-client/types.ts
index c843e075a..fe8a2ff51 100644
--- a/packages/taler-util/src/http-client/types.ts
+++ b/packages/taler-util/src/http-client/types.ts
@@ -18,6 +18,7 @@ import {
import { PaytoString, codecForPaytoString } from "../payto.js";
import {
AmountString,
+ InternationalizedString,
codecForInternationalizedString,
codecForLocation,
} from "../taler-types.js";
@@ -185,10 +186,54 @@ export interface LoginToken {
}
declare const __ac_token: unique symbol;
+/**
+ * Use `createAccessToken(string)` function to build one.
+ */
export type AccessToken = string & {
[__ac_token]: true;
};
+/**
+ * Create a rfc8959 access token.
+ * Adds secret-token: prefix if there is none.
+ * Encode the token with rfc7230 to send in a http header.
+ *
+ * @param token
+ * @returns
+ */
+export function createRFC8959AccessTokenEncoded(token: string): AccessToken {
+ return (
+ token.startsWith("secret-token:")
+ ? token
+ : `secret-token:${encodeURIComponent(token)}`
+ ) as AccessToken;
+}
+
+/**
+ * Create a rfc8959 access token.
+ * Adds secret-token: prefix if there is none.
+ *
+ * @param token
+ * @returns
+ */
+export function createRFC8959AccessTokenPlain(token: string): AccessToken {
+ return (
+ token.startsWith("secret-token:") ? token : `secret-token:${token}`
+ ) as AccessToken;
+}
+
+/**
+ * Convert string to access token.
+ *
+ * @param clientSecret
+ * @returns
+ */
+export function createClientSecretAccessToken(
+ clientSecret: string,
+): AccessToken {
+ return clientSecret as AccessToken;
+}
+
declare const __officer_signature: unique symbol;
export type OfficerSignature = string & {
[__officer_signature]: true;
@@ -295,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())
@@ -557,6 +603,37 @@ export const codecForInventoryEntry =
.property("product_serial", codecForNumber())
.build("TalerMerchantApi.InventoryEntry");
+export const codecForMerchantPosProductDetail =
+ (): Codec<TalerMerchantApi.MerchantPosProductDetail> =>
+ buildCodecForObject<TalerMerchantApi.MerchantPosProductDetail>()
+ .property("product_serial", codecForNumber())
+ .property("product_id", codecOptional(codecForString()))
+ .property("categories", codecForList(codecForNumber()))
+ .property("description", codecForString())
+ .property("description_i18n", codecForInternationalizedString())
+ .property("unit", codecForString())
+ .property("price", codecForAmountString())
+ .property("image", codecForString())
+ .property("taxes", codecOptional(codecForList(codecForTax())))
+ .property("total_stock", codecForNumber())
+ .property("minimum_age", codecOptional(codecForNumber()))
+ .build("TalerMerchantApi.MerchantPosProductDetail");
+
+export const codecForMerchantCategory =
+ (): Codec<TalerMerchantApi.MerchantCategory> =>
+ buildCodecForObject<TalerMerchantApi.MerchantCategory>()
+ .property("id", codecForNumber())
+ .property("name", codecForString())
+ .property("name_i18n", codecForInternationalizedString())
+ .build("TalerMerchantApi.MerchantCategory");
+
+export const codecForFullInventoryDetailsResponse =
+ (): Codec<TalerMerchantApi.FullInventoryDetailsResponse> =>
+ buildCodecForObject<TalerMerchantApi.FullInventoryDetailsResponse>()
+ .property("categories", codecForList(codecForMerchantCategory()))
+ .property("products", codecForList(codecForMerchantPosProductDetail()))
+ .build("TalerMerchantApi.FullInventoryDetailsResponse");
+
export const codecForProductDetail =
(): Codec<TalerMerchantApi.ProductDetail> =>
buildCodecForObject<TalerMerchantApi.ProductDetail>()
@@ -565,9 +642,9 @@ export const codecForProductDetail =
.property("unit", codecForString())
.property("price", codecForAmountString())
.property("image", codecForString())
- .property("taxes", codecForList(codecForTax()))
- .property("address", codecForLocation())
- .property("next_restock", codecForTimestamp)
+ .property("taxes", codecOptional(codecForList(codecForTax())))
+ .property("address", codecOptional(codecForLocation()))
+ .property("next_restock", codecOptional(codecForTimestamp))
.property("total_stock", codecForNumber())
.property("total_sold", codecForNumber())
.property("total_lost", codecForNumber())
@@ -825,7 +902,10 @@ export const codecForTemplateDetails =
.property("otp_id", codecOptional(codecForString()))
.property("template_contract", codecForTemplateContractDetails())
.property("required_currency", codecOptional(codecForString()))
- .property("editable_defaults", codecOptional(codecForTemplateContractDetailsDefaults()))
+ .property(
+ "editable_defaults",
+ codecOptional(codecForTemplateContractDetailsDefaults()),
+ )
.build("TalerMerchantApi.TemplateDetails");
export const codecForTemplateContractDetails =
@@ -853,7 +933,10 @@ export const codecForWalletTemplateDetails =
buildCodecForObject<TalerMerchantApi.WalletTemplateDetails>()
.property("template_contract", codecForTemplateContractDetails())
.property("required_currency", codecOptional(codecForString()))
- .property("editable_defaults", codecOptional(codecForTemplateContractDetailsDefaults()))
+ .property(
+ "editable_defaults",
+ codecOptional(codecForTemplateContractDetailsDefaults()),
+ )
.build("TalerMerchantApi.WalletTemplateDetails");
export const codecForWebhookSummaryResponse =
@@ -986,10 +1069,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 =
@@ -1004,6 +1097,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())
@@ -1017,6 +1111,15 @@ export const codecForAccountData = (): Codec<TalerCorebankApi.AccountData> =>
),
),
)
+ .property(
+ "status",
+ codecOptional(
+ codecForEither(
+ codecForConstString("active"),
+ codecForConstString("deleted"),
+ ),
+ ),
+ )
.build("TalerCorebankApi.AccountData");
export const codecForChallengeContactData =
@@ -1340,7 +1443,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>()
@@ -1410,51 +1513,6 @@ export const codecForAmlDecision = (): Codec<TalerExchangeApi.AmlDecision> =>
.property("kyc_requirements", codecOptional(codecForList(codecForString())))
.build("TalerExchangeApi.AmlDecision");
-// version: string;
-
-// // Name of the API.
-// name: "taler-conversion-info";
-
-// // Currency used by this bank.
-// regional_currency: string;
-
-// // How the bank SPA should render this currency.
-// regional_currency_specification: CurrencySpecification;
-
-// // External currency used during conversion.
-// fiat_currency: string;
-
-// // How the bank SPA should render this currency.
-// fiat_currency_specification: CurrencySpecification;
-
-// Extra conversion rate information.
-// // Only present if server opts in to report the static conversion rate.
-// conversion_info?: {
-
-// // Fee to subtract after applying the cashin ratio.
-// cashin_fee: AmountString;
-
-// // Fee to subtract after applying the cashout ratio.
-// cashout_fee: AmountString;
-
-// // Minimum amount authorised for cashin, in fiat before conversion
-// cashin_min_amount: AmountString;
-
-// // Minimum amount authorised for cashout, in regional before conversion
-// cashout_min_amount: AmountString;
-
-// // Smallest possible regional amount, converted amount is rounded to this amount
-// cashin_tiny_amount: AmountString;
-
-// // Smallest possible fiat amount, converted amount is rounded to this amount
-// cashout_tiny_amount: AmountString;
-
-// // Rounding mode used during cashin conversion
-// cashin_rounding_mode: "zero" | "up" | "nearest";
-
-// // Rounding mode used during cashout conversion
-// cashout_rounding_mode: "zero" | "up" | "nearest";
-// }
export const codecForConversionInfo =
(): Codec<TalerBankConversionApi.ConversionInfo> =>
buildCodecForObject<TalerBankConversionApi.ConversionInfo>()
@@ -1500,11 +1558,65 @@ export const codecForConversionBankConfig =
.property("conversion_rate", codecForConversionInfo())
.build("ConversionBankConfig.IntegrationConfig");
-// export const codecFor =
-// (): Codec<TalerWireGatewayApi.PublicAccountsResponse> =>
-// buildCodecForObject<TalerWireGatewayApi.PublicAccountsResponse>()
-// .property("", codecForString())
-// .build("TalerWireGatewayApi.PublicAccountsResponse");
+export const codecForChallengerTermsOfServiceResponse =
+ (): Codec<ChallengerApi.ChallengerTermsOfServiceResponse> =>
+ buildCodecForObject<ChallengerApi.ChallengerTermsOfServiceResponse>()
+ .property("name", codecForConstString("challenger"))
+ .property("version", codecForString())
+ .property("implementation", codecOptional(codecForString()))
+ .build("ChallengerApi.ChallengerTermsOfServiceResponse");
+
+export const codecForChallengeSetupResponse =
+ (): Codec<ChallengerApi.ChallengeSetupResponse> =>
+ buildCodecForObject<ChallengerApi.ChallengeSetupResponse>()
+ .property("nonce", codecForString())
+ .build("ChallengerApi.ChallengeSetupResponse");
+
+export const codecForChallengeStatus =
+ (): Codec<ChallengerApi.ChallengeStatus> =>
+ buildCodecForObject<ChallengerApi.ChallengeStatus>()
+ .property("restrictions", codecOptional(codecForMap(codecForAny())))
+ .property("fix_address", codecForBoolean())
+ .property("last_address", codecOptional(codecForMap(codecForAny())))
+ .property("changes_left", codecForNumber())
+ .build("ChallengerApi.ChallengeStatus");
+export const codecForChallengeCreateResponse =
+ (): Codec<ChallengerApi.ChallengeCreateResponse> =>
+ buildCodecForObject<ChallengerApi.ChallengeCreateResponse>()
+ .property("attempts_left", codecForNumber())
+ .property("address", codecForAny())
+ .property("transmitted", codecForBoolean())
+ .property("next_tx_time", codecForString())
+ .build("ChallengerApi.ChallengeCreateResponse");
+
+export const codecForInvalidPinResponse =
+ (): Codec<ChallengerApi.InvalidPinResponse> =>
+ buildCodecForObject<ChallengerApi.InvalidPinResponse>()
+ .property("ec", codecOptional(codecForNumber()))
+ .property("hint", codecForAny())
+ .property("addresses_left", codecForNumber())
+ .property("pin_transmissions_left", codecForNumber())
+ .property("auth_attempts_left", codecForNumber())
+ .property("exhausted", codecForBoolean())
+ .property("no_challenge", codecForBoolean())
+ .build("ChallengerApi.InvalidPinResponse");
+
+export const codecForChallengerAuthResponse =
+ (): Codec<ChallengerApi.ChallengerAuthResponse> =>
+ buildCodecForObject<ChallengerApi.ChallengerAuthResponse>()
+ .property("access_token", codecForString())
+ .property("token_type", codecForAny())
+ .property("expires_in", codecForNumber())
+ .build("ChallengerApi.ChallengerAuthResponse");
+
+export const codecForChallengerInfoResponse =
+ (): Codec<ChallengerApi.ChallengerInfoResponse> =>
+ buildCodecForObject<ChallengerApi.ChallengerInfoResponse>()
+ .property("id", codecForNumber())
+ .property("address", codecForAny())
+ .property("address_type", codecForString())
+ .property("expires", codecForTimestamp)
+ .build("ChallengerApi.ChallengerInfoResponse");
type EmailAddress = string;
type PhoneNumber = string;
@@ -1876,6 +1988,7 @@ export namespace TalerBankConversionApi {
cashout_rounding_mode: RoundingMode;
}
}
+
export namespace TalerBankIntegrationApi {
export interface BankVersion {
// libtool-style representation of the Bank protocol version, see
@@ -1953,6 +2066,7 @@ export namespace TalerBankIntegrationApi {
confirm_transfer_url?: string;
}
}
+
export namespace TalerCorebankApi {
export interface IntegrationConfig {
// libtool-style representation of the Bank protocol version, see
@@ -1981,6 +2095,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;
@@ -2083,6 +2202,12 @@ export namespace TalerCorebankApi {
// query string parameter of the 'payto' field. In case it
// is given in both places, the paytoUri's takes the precedence.
amount?: AmountString;
+
+ // Nonce to make the request idempotent. Requests with the same
+ // request_uid that differ in any of the other fields
+ // are rejected.
+ // @since v4, will become mandatory in the next version.
+ request_uid?: ShortHashCode;
}
export interface CreateTransactionResponse {
@@ -2133,6 +2258,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.
@@ -2174,7 +2304,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;
}
@@ -2231,6 +2365,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;
@@ -2240,6 +2379,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 {
@@ -2255,6 +2402,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
@@ -2273,6 +2425,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 {
@@ -3592,7 +3752,7 @@ export namespace TalerMerchantApi {
// After the auth token has been set (with method "token"),
// the value must be provided in a "Authorization: Bearer $token"
// header.
- token?: string;
+ token?: AccessToken;
}
export interface InstanceReconfigurationMessage {
@@ -3939,6 +4099,68 @@ export namespace TalerMerchantApi {
product_serial: Integer;
}
+ export interface FullInventoryDetailsResponse {
+ // List of products that are present in the inventory.
+ products: MerchantPosProductDetail[];
+
+ // List of categories in the inventory.
+ categories: MerchantCategory[];
+ }
+
+ export interface MerchantPosProductDetail {
+ // A unique numeric ID of the product
+ product_serial: number;
+
+ // A merchant-internal unique identifier for the product
+ product_id?: string;
+
+ // A list of category IDs this product belongs to.
+ // Typically, a product only belongs to one category, but more than one is supported.
+ categories: number[];
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions.
+ description_i18n: { [lang_tag: string]: string };
+
+ // Unit in which the product is measured (liters, kilograms, packages, etc.).
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: AmountString;
+
+ // An optional base64-encoded product image.
+ image?: ImageDataUrl;
+
+ // A list of taxes paid by the merchant for one unit of this product.
+ taxes?: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // Optional, if missing treat as "infinite".
+ total_stock?: Integer;
+
+ // Minimum age buyer must have (in years).
+ minimum_age?: Integer;
+ }
+
+ export interface MerchantCategory {
+ // A unique numeric ID of the category
+ id: number;
+
+ // The name of the category. This will be shown to users and used in the order summary.
+ name: string;
+
+ // Map from IETF BCP 47 language tags to localized names
+ name_i18n?: { [lang_tag: string]: string };
+ }
+
export interface ProductDetail {
// Human-readable product description.
description: string;
@@ -3960,7 +4182,7 @@ export namespace TalerMerchantApi {
image: ImageDataUrl;
// A list of taxes paid by the merchant for one unit of this product.
- taxes: Tax[];
+ taxes?: Tax[];
// Number of units of the product in stock in sum in total,
// including all existing sales ever. Given in product-specific
@@ -3975,7 +4197,7 @@ export namespace TalerMerchantApi {
total_lost: Integer;
// Identifies where the product is in stock.
- address: Location;
+ address?: Location;
// Identifies when we expect the next restocking to happen.
next_restock?: Timestamp;
@@ -4335,174 +4557,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.
otp_device_id: string;
@@ -4636,7 +4690,6 @@ export namespace TalerMerchantApi {
// This parameter is optional.
// Since protocol **v13**.
required_currency?: string;
-
}
export interface TemplateContractDetails {
// Human-readable summary for the template.
@@ -4699,7 +4752,6 @@ export namespace TalerMerchantApi {
// This parameter is optional.
// Since protocol **v13**.
required_currency?: string;
-
}
export interface TemplateSummaryResponse {
@@ -5199,3 +5251,132 @@ export namespace TalerMerchantApi {
master_pub: EddsaPublicKey;
}
}
+
+export namespace ChallengerApi {
+ export interface ChallengerTermsOfServiceResponse {
+ // Name of the service
+ name: "challenger";
+
+ // libtool-style representation of the Challenger protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // URN of the implementation (needed to interpret 'revision' in version).
+ // @since v0, may become mandatory in the future.
+ implementation?: string;
+ }
+
+ export interface ChallengeSetupResponse {
+ // Nonce to use when constructing /authorize endpoint.
+ nonce: string;
+ }
+
+ export interface Restriction {
+ regex?: string;
+ hint?: string;
+ hint_i18n?: InternationalizedString;
+ }
+
+ export interface ChallengeStatus {
+ // Object; map of keys (names of the fields of the address
+ // to be entered by the user) to objects with a "regex" (string)
+ // containing an extended Posix regular expression for allowed
+ // address field values, and a "hint"/"hint_i18n" giving a
+ // human-readable explanation to display if the value entered
+ // by the user does not match the regex. Keys that are not mapped
+ // to such an object have no restriction on the value provided by
+ // the user. See "ADDRESS_RESTRICTIONS" in the challenger configuration.
+ restrictions: Record<string, Restriction> | undefined;
+
+ // indicates if the given address cannot be changed anymore, the
+ // form should be read-only if set to true.
+ fix_address: boolean;
+
+ // form values from the previous submission if available, details depend
+ // on the ADDRESS_TYPE, should be used to pre-populate the form
+ last_address: Record<string, string> | undefined;
+
+ // number of times the address can still be changed, may or may not be
+ // shown to the user
+ changes_left: Integer;
+ }
+
+ export interface ChallengeCreateResponse {
+ // how many more attempts are allowed, might be shown to the user,
+ // highlighting might be appropriate for low values such as 1 or 2 (the
+ // form will never be used if the value is zero)
+ attempts_left: Integer;
+
+ // the address that is being validated, might be shown or not
+ address: Object;
+
+ // true if we just retransmitted the challenge, false if we sent a
+ // challenge recently and thus refused to transmit it again this time;
+ // might make a useful hint to the user
+ transmitted: boolean;
+
+ // timestamp explaining when we would re-transmit the challenge the next
+ // time (at the earliest) if requested by the user
+ next_tx_time: string;
+ }
+
+ export interface InvalidPinResponse {
+ // numeric Taler error code, should be shown to indicate the error
+ // compactly for reporting to developers
+ ec?: number;
+
+ // human-readable Taler error code, should be shown for the user to
+ // understand the error
+ hint: string;
+
+ // how many times is the user still allowed to change the address;
+ // if 0, the user should not be shown a link to jump to the
+ // address entry form
+ addresses_left: Integer;
+
+ // how many times might the PIN still be retransmitted
+ pin_transmissions_left: Integer;
+
+ // how many times might the user still try entering the PIN code
+ auth_attempts_left: Integer;
+
+ // if true, the PIN was not even evaluated as the user previously
+ // exhausted the number of attempts
+ exhausted: boolean;
+
+ // if true, the PIN was not even evaluated as no challenge was ever
+ // issued (the user must have skipped the step of providing their
+ // address first!)
+ no_challenge: boolean;
+ }
+
+ export interface ChallengerAuthResponse {
+ // Token used to authenticate access in /info.
+ access_token: string;
+
+ // Type of the access token.
+ token_type: "Bearer";
+
+ // Amount of time that an access token is valid (in seconds).
+ expires_in: Integer;
+ }
+
+ export interface ChallengerInfoResponse {
+ // Unique ID of the record within Challenger
+ // (identifies the rowid of the token).
+ id: Integer;
+
+ // Address that was validated.
+ // Key-value pairs, details depend on the
+ // address_type.
+ address: Object;
+
+ // Type of the address.
+ address_type: string;
+
+ // How long do we consider the address to be
+ // valid for this user.
+ expires: Timestamp;
+ }
+}
diff --git a/packages/taler-util/src/http-client/utils.ts b/packages/taler-util/src/http-client/utils.ts
index d6623cf00..bf186ce46 100644
--- a/packages/taler-util/src/http-client/utils.ts
+++ b/packages/taler-util/src/http-client/utils.ts
@@ -18,7 +18,7 @@
* Imports.
*/
import { base64FromArrayBuffer } from "../base64.js";
-import { stringToBytes } from "../taler-crypto.js";
+import { encodeCrock, getRandomBytes, stringToBytes } from "../taler-crypto.js";
import { AccessToken, LongPollParams, PaginationParams } from "./types.js";
/**
@@ -39,7 +39,7 @@ export function makeBasicAuthHeader(
* @returns
*/
export function makeBearerTokenAuthHeader(token: AccessToken): string {
- return `Bearer secret-token:${token}`;
+ return `Bearer ${token}`;
}
/**
@@ -90,3 +90,27 @@ export interface CacheEvictor<T> {
export const nullEvictor: CacheEvictor<unknown> = {
notifySuccess: () => Promise.resolve(),
};
+
+export class IdempotencyRetry {
+ public readonly uid: string;
+ public readonly timesLeft: number;
+ public readonly maxTries: number;
+
+ private constructor(timesLeft: number, maxTimesLeft: number) {
+ this.timesLeft = timesLeft;
+ this.maxTries = maxTimesLeft;
+ this.uid = encodeCrock(getRandomBytes(32))
+ }
+
+ static tryFiveTimes() {
+ return new IdempotencyRetry(5, 5)
+ }
+
+ next(): IdempotencyRetry | undefined {
+ const left = this.timesLeft -1
+ if (left <= 0) {
+ return undefined
+ }
+ return new IdempotencyRetry(left, this.maxTries);
+ }
+}
diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts
index cc75debd5..d8cd36287 100644
--- a/packages/taler-util/src/http-common.ts
+++ b/packages/taler-util/src/http-common.ts
@@ -268,6 +268,47 @@ export async function readSuccessResponseJsonOrErrorCode<T>(
};
}
+export async function readResponseJsonOrErrorCode<T>(
+ httpResponse: HttpResponse,
+ codec: Codec<T>,
+): Promise<{ isError: boolean; response: T }> {
+ let respJson;
+ try {
+ respJson = await httpResponse.json();
+ } catch (e: any) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ validationError: e.toString(),
+ },
+ "Couldn't parse JSON format from response",
+ );
+ }
+ let parsedResponse: T;
+ try {
+ parsedResponse = codec.decode(respJson);
+ } catch (e: any) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ validationError: e.toString(),
+ },
+ "Response invalid",
+ );
+ }
+ return {
+ isError: !(httpResponse.status >= 200 && httpResponse.status < 300),
+ response: parsedResponse,
+ };
+}
+
+
type HttpErrorDetails = {
requestUrl: string;
requestMethod: string;
diff --git a/packages/taler-util/src/http-impl.node.ts b/packages/taler-util/src/http-impl.node.ts
index 8606bc451..45a12c258 100644
--- a/packages/taler-util/src/http-impl.node.ts
+++ b/packages/taler-util/src/http-impl.node.ts
@@ -123,8 +123,8 @@ export class HttpLibImpl implements HttpRequestLibrary {
if (opt?.headers) {
Object.entries(opt?.headers).forEach(([key, value]) => {
if (value === undefined) return;
- requestHeadersMap[key] = value
- })
+ requestHeadersMap[key] = value;
+ });
}
logger.trace(`request timeout ${timeoutMs} ms`);
@@ -181,10 +181,10 @@ export class HttpLibImpl implements HttpRequestLibrary {
return arg + " '" + String(v) + "'";
}
console.log(
- `curl -X ${options.method} ${parsedUrl.href} ${ifUndefined(
+ `curl -X ${options.method} "${parsedUrl.href}" ${headers} ${ifUndefined(
"-d",
payload,
- )} ${headers}`,
+ )}`,
);
}
diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts
index 9bd4834d2..24d6e9950 100644
--- a/packages/taler-util/src/index.ts
+++ b/packages/taler-util/src/index.ts
@@ -22,6 +22,7 @@ export * from "./http-client/bank-conversion.js";
export * from "./http-client/authentication.js";
export * from "./http-client/bank-core.js";
export * from "./http-client/merchant.js";
+export * from "./http-client/challenger.js";
export * from "./http-client/bank-integration.js";
export * from "./http-client/bank-revenue.js";
export * from "./http-client/bank-wire.js";
diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts
index 1c6ca4b85..d4dfe7589 100644
--- a/packages/taler-util/src/notifications.ts
+++ b/packages/taler-util/src/notifications.ts
@@ -30,8 +30,12 @@ export enum NotificationType {
BalanceChange = "balance-change",
BackupOperationError = "backup-error",
TransactionStateTransition = "transaction-state-transition",
+ /**
+ * @deprecated
+ */
WithdrawalOperationTransition = "withdrawal-operation-transition",
ExchangeStateTransition = "exchange-state-transition",
+ Idle = "idle",
TaskObservabilityEvent = "task-observability-event",
RequestObservabilityEvent = "request-observability-event",
}
@@ -230,6 +234,10 @@ export interface WithdrawalOperationTransitionNotification {
uri: string;
}
+export interface IdleNotification {
+ type: NotificationType.Idle;
+}
+
export type WalletNotification =
| BalanceChangeNotification
| WithdrawalOperationTransitionNotification
@@ -237,4 +245,5 @@ export type WalletNotification =
| ExchangeStateTransitionNotification
| TransactionStateTransitionNotification
| TaskProgressNotification
- | RequestProgressNotification;
+ | RequestProgressNotification
+ | IdleNotification;
diff --git a/packages/taler-util/src/operation.ts b/packages/taler-util/src/operation.ts
index 771f5860b..e2ab9d4e4 100644
--- a/packages/taler-util/src/operation.ts
+++ b/packages/taler-util/src/operation.ts
@@ -19,6 +19,7 @@
*/
import {
HttpResponse,
+ readResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
readTalerErrorResponse,
} from "./http-common.js";
@@ -126,7 +127,7 @@ export async function opKnownAlternativeFailure<T extends HttpStatusCode, B>(
s: T,
codec: Codec<B>,
): Promise<OperationAlternative<T, B>> {
- const body = await readSuccessResponseJsonOrThrow(resp, codec);
+ const body = (await readResponseJsonOrErrorCode(resp, codec)).response;
return { type: "fail", case: s, body };
}
@@ -193,3 +194,5 @@ export type FailCasesByMethod<TT extends object, p extends keyof TT> = Exclude<
ResultByMethod<TT, p>,
OperationOk<any>
>;
+
+export type RedirectResult = { redirectURL: URL }
diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts
index e587773e2..950161b10 100644
--- a/packages/taler-util/src/taler-crypto.ts
+++ b/packages/taler-util/src/taler-crypto.ts
@@ -21,23 +21,23 @@
/**
* Imports.
*/
-import * as nacl from "./nacl-fast.js";
-import { hmacSha256, hmacSha512 } from "./kdf.js";
import bigint from "big-integer";
+import * as fflate from "fflate";
+import { AmountLike, Amounts } from "./amounts.js";
import * as argon2 from "./argon2.js";
+import { canonicalJson } from "./helpers.js";
+import { hmacSha256, hmacSha512 } from "./kdf.js";
+import { Logger } from "./logging.js";
+import * as nacl from "./nacl-fast.js";
+import { secretbox } from "./nacl-fast.js";
import {
CoinEnvelope,
CoinPublicKeyString,
- DenominationPubKey,
DenomKeyType,
+ DenominationPubKey,
HashCodeString,
} from "./taler-types.js";
-import { Logger } from "./logging.js";
-import { secretbox } from "./nacl-fast.js";
-import * as fflate from "fflate";
-import { canonicalJson } from "./helpers.js";
import { TalerProtocolDuration, TalerProtocolTimestamp } from "./time.js";
-import { AmountLike, Amounts } from "./amounts.js";
export type Flavor<T, FlavorT extends string> = T & {
_flavor?: `taler.${FlavorT}`;
@@ -974,6 +974,7 @@ export function hashWire(paytoUri: string, salt: string): string {
export enum TalerSignaturePurpose {
MERCHANT_TRACK_TRANSACTION = 1103,
WALLET_RESERVE_WITHDRAW = 1200,
+ WALLET_RESERVE_HISTORY = 1208,
WALLET_COIN_DEPOSIT = 1201,
GLOBAL_FEES = 1022,
MASTER_DENOMINATION_KEY_VALIDITY = 1025,
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/taler-types.ts b/packages/taler-util/src/taler-types.ts
index 2b8e55e38..392e7149c 100644
--- a/packages/taler-util/src/taler-types.ts
+++ b/packages/taler-util/src/taler-types.ts
@@ -1335,6 +1335,7 @@ export type AmountString = string & { [__amount_str]: true };
export type Base32String = string;
export type EddsaSignatureString = string;
export type EddsaPublicKeyString = string;
+export type EddsaPrivateKeyString = string;
export type CoinPublicKeyString = string;
export const codecForDenomination = (): Codec<ExchangeDenomination> =>
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/transactions-types.ts b/packages/taler-util/src/transactions-types.ts
index bddc03c25..cee3de9fa 100644
--- a/packages/taler-util/src/transactions-types.ts
+++ b/packages/taler-util/src/transactions-types.ts
@@ -151,6 +151,7 @@ export enum TransactionMinorState {
RefundAvailable = "refund-available",
AcceptRefund = "accept-refund",
PaidByOther = "paid-by-other",
+ CompletedByOtherWallet = "completed-by-other-wallet",
}
export enum TransactionAction {
@@ -214,7 +215,6 @@ export type Transaction =
| TransactionWithdrawal
| TransactionPayment
| TransactionRefund
- | TransactionReward
| TransactionRefresh
| TransactionDeposit
| TransactionPeerPullCredit
@@ -231,7 +231,6 @@ export enum TransactionType {
Payment = "payment",
Refund = "refund",
Refresh = "refresh",
- Reward = "reward",
Deposit = "deposit",
PeerPushDebit = "peer-push-debit",
PeerPushCredit = "peer-push-credit",
@@ -641,23 +640,6 @@ export interface TransactionRefund extends TransactionCommon {
paymentInfo: RefundPaymentInfo | undefined;
}
-export interface TransactionReward extends TransactionCommon {
- type: TransactionType.Reward;
-
- // Raw amount of the tip, without extra fees that apply
- amountRaw: AmountString;
-
- /**
- * More information about the merchant
- */
- // merchant: MerchantInfo;
-
- // Amount will be (or was) added to the wallet's balance after fees and refreshing
- amountEffective: AmountString;
-
- merchantBaseUrl: string;
-}
-
/**
* A transaction shown for refreshes.
* Only shown for (1) refreshes not associated with other transactions
diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts
index 0653bc473..b9fd24754 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";
@@ -62,6 +63,7 @@ import {
CoinEnvelope,
DenomKeyType,
DenominationPubKey,
+ EddsaPrivateKeyString,
ExchangeAuditor,
ExchangeWireAccount,
InternationalizedString,
@@ -148,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.
*/
@@ -293,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;
}
@@ -744,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;
@@ -1062,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;
@@ -1469,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())
@@ -1484,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())
@@ -1708,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())
@@ -1726,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 {
@@ -1743,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 {
@@ -1756,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;
@@ -1774,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");
@@ -1787,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");
@@ -1798,7 +1751,7 @@ export interface GetExchangeResourcesRequest {
export const codecForGetExchangeResourcesRequest =
(): Codec<GetExchangeResourcesRequest> =>
buildCodecForObject<GetExchangeResourcesRequest>()
- .property("exchangeBaseUrl", codecForString())
+ .property("exchangeBaseUrl", codecForCanonBaseUrl())
.build("GetExchangeResourcesRequest");
export interface GetExchangeResourcesResponse {
@@ -1812,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");
@@ -1823,7 +1776,7 @@ export interface ForceExchangeUpdateRequest {
export const codecForForceExchangeUpdateRequest =
(): Codec<AddExchangeRequest> =>
buildCodecForObject<AddExchangeRequest>()
- .property("exchangeBaseUrl", codecForString())
+ .property("exchangeBaseUrl", codecForCanonBaseUrl())
.build("AddExchangeRequest");
export interface GetExchangeTosRequest {
@@ -1834,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");
@@ -1843,14 +1796,24 @@ export interface AcceptManualWithdrawalRequest {
exchangeBaseUrl: string;
amount: AmountString;
restrictAge?: number;
+
+ /**
+ * Instead of generating a fresh, random reserve key pair,
+ * use the provided reserve private key.
+ *
+ * Use with caution. Usage of this field may be restricted
+ * to developer mode.
+ */
+ forceReservePriv?: EddsaPrivateKeyString;
}
export const codecForAcceptManualWithdrawalRequest =
(): Codec<AcceptManualWithdrawalRequest> =>
buildCodecForObject<AcceptManualWithdrawalRequest>()
- .property("exchangeBaseUrl", codecForString())
+ .property("exchangeBaseUrl", codecForCanonBaseUrl())
.property("amount", codecForAmountString())
.property("restrictAge", codecOptional(codecForNumber()))
+ .property("forceReservePriv", codecOptional(codecForString()))
.build("AcceptManualWithdrawalRequest");
export interface GetWithdrawalDetailsForAmountRequest {
@@ -1872,6 +1835,36 @@ export interface GetWithdrawalDetailsForAmountRequest {
clientCancellationId?: string;
}
+export interface PrepareBankIntegratedWithdrawalRequest {
+ talerWithdrawUri: string;
+ exchangeBaseUrl: string;
+ forcedDenomSel?: ForcedDenomSel;
+ restrictAge?: number;
+}
+
+export const codecForPrepareBankIntegratedWithdrawalRequest =
+ (): Codec<PrepareBankIntegratedWithdrawalRequest> =>
+ buildCodecForObject<PrepareBankIntegratedWithdrawalRequest>()
+ .property("exchangeBaseUrl", codecForCanonBaseUrl())
+ .property("talerWithdrawUri", codecForString())
+ .property("forcedDenomSel", codecForAny())
+ .property("restrictAge", codecOptional(codecForNumber()))
+ .build("PrepareBankIntegratedWithdrawalRequest");
+
+export interface PrepareBankIntegratedWithdrawalResponse {
+ transactionId: string;
+}
+
+export interface ConfirmWithdrawalRequest {
+ transactionId: string;
+}
+
+export const codecForConfirmWithdrawalRequestRequest =
+ (): Codec<ConfirmWithdrawalRequest> =>
+ buildCodecForObject<ConfirmWithdrawalRequest>()
+ .property("transactionId", codecForString())
+ .build("ConfirmWithdrawalRequest");
+
export interface AcceptBankIntegratedWithdrawalRequest {
talerWithdrawUri: string;
exchangeBaseUrl: string;
@@ -1882,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()))
@@ -1891,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()))
@@ -1904,7 +1897,7 @@ export interface AcceptExchangeTosRequest {
export const codecForAcceptExchangeTosRequest =
(): Codec<AcceptExchangeTosRequest> =>
buildCodecForObject<AcceptExchangeTosRequest>()
- .property("exchangeBaseUrl", codecForString())
+ .property("exchangeBaseUrl", codecForCanonBaseUrl())
.build("AcceptExchangeTosRequest");
export interface ForgetExchangeTosRequest {
@@ -1914,7 +1907,7 @@ export interface ForgetExchangeTosRequest {
export const codecForForgetExchangeTosRequest =
(): Codec<ForgetExchangeTosRequest> =>
buildCodecForObject<ForgetExchangeTosRequest>()
- .property("exchangeBaseUrl", codecForString())
+ .property("exchangeBaseUrl", codecForCanonBaseUrl())
.build("ForgetExchangeTosRequest");
export interface AcceptRefundRequest {
@@ -1939,7 +1932,6 @@ export const codecForApplyRefundFromPurchaseIdRequest =
export interface GetWithdrawalDetailsForUriRequest {
talerWithdrawUri: string;
restrictAge?: number;
- notifyChangeFromPendingTimeoutMs?: number;
}
export const codecForGetWithdrawalDetailsForUri =
@@ -1947,10 +1939,6 @@ export const codecForGetWithdrawalDetailsForUri =
buildCodecForObject<GetWithdrawalDetailsForUriRequest>()
.property("talerWithdrawUri", codecForString())
.property("restrictAge", codecOptional(codecForNumber()))
- .property(
- "notifyChangeFromPendingTimeoutMs",
- codecOptional(codecForNumber()),
- )
.build("GetWithdrawalDetailsForUriRequest");
export interface ListKnownBankAccountsRequest {
@@ -2023,7 +2011,7 @@ export interface SharePaymentRequest {
}
export const codecForSharePaymentRequest = (): Codec<SharePaymentRequest> =>
buildCodecForObject<SharePaymentRequest>()
- .property("merchantBaseUrl", codecForString())
+ .property("merchantBaseUrl", codecForCanonBaseUrl())
.property("orderId", codecForString())
.build("SharePaymentRequest");
@@ -2172,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 {
@@ -2235,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;
}
@@ -2378,7 +2340,7 @@ export const codecForWithdrawUriInfoResponse =
),
)
.property("amount", codecForAmountString())
- .property("defaultExchangeBaseUrl", codecOptional(codecForString()))
+ .property("defaultExchangeBaseUrl", codecOptional(codecForCanonBaseUrl()))
.property("possibleExchanges", codecForList(codecForExchangeListItem()))
.build("WithdrawUriInfoResponse");
@@ -2621,30 +2583,30 @@ export const codecForWithdrawFakebankRequest =
.build("WithdrawFakebankRequest");
export interface ActiveTask {
- id: string;
+ taskId: string;
transaction: TransactionIdStr | undefined;
firstTry: AbsoluteTime | undefined;
nextTry: AbsoluteTime | undefined;
- counter: number | undefined;
+ retryCounter: number | undefined;
lastError: TalerErrorDetail | undefined;
}
-export interface GetActiveTasks {
+export interface GetActiveTasksResponse {
tasks: ActiveTask[];
}
export const codecForActiveTask = (): Codec<ActiveTask> =>
buildCodecForObject<ActiveTask>()
- .property("id", codecForString())
+ .property("taskId", codecForString())
.property("transaction", codecOptional(codecForTransactionIdStr()))
- .property("counter", codecForNumber())
- .property("firstTry", codecForAbsoluteTime)
- .property("nextTry", codecForAbsoluteTime)
- .property("lastError", codecForTalerErrorDetail())
+ .property("retryCounter", codecOptional(codecForNumber()))
+ .property("firstTry", codecOptional(codecForAbsoluteTime))
+ .property("nextTry", codecOptional(codecForAbsoluteTime))
+ .property("lastError", codecOptional(codecForTalerErrorDetail()))
.build("ActiveTask");
-export const codecForGetActiveTasks = (): Codec<GetActiveTasks> =>
- buildCodecForObject<GetActiveTasks>()
+export const codecForGetActiveTasks = (): Codec<GetActiveTasksResponse> =>
+ buildCodecForObject<GetActiveTasksResponse>()
.property("tasks", codecForList(codecForActiveTask()))
.build("GetActiveTasks");
@@ -2743,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");
@@ -2882,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 {
@@ -2905,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 {
@@ -2920,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;
}
@@ -3034,6 +3010,18 @@ export interface TestingWaitTransactionRequest {
txState: TransactionState;
}
+export interface TestingGetReserveHistoryRequest {
+ reservePub: string;
+ exchangeBaseUrl: string;
+}
+
+export const codecForTestingGetReserveHistoryRequest =
+ (): Codec<TestingGetReserveHistoryRequest> =>
+ buildCodecForObject<TestingGetReserveHistoryRequest>()
+ .property("reservePub", codecForString())
+ .property("exchangeBaseUrl", codecForString())
+ .build("TestingGetReserveHistoryRequest");
+
export interface TestingGetDenomStatsRequest {
exchangeBaseUrl: string;
}
@@ -3047,7 +3035,7 @@ export interface TestingGetDenomStatsResponse {
export const codecForTestingGetDenomStatsRequest =
(): Codec<TestingGetDenomStatsRequest> =>
buildCodecForObject<TestingGetDenomStatsRequest>()
- .property("exchangeBaseUrl", codecForString())
+ .property("exchangeBaseUrl", codecForCanonBaseUrl())
.build("TestingGetDenomStatsRequest");
export interface WithdrawalExchangeAccountDetails {
@@ -3167,7 +3155,7 @@ export const codecForAddGlobalCurrencyExchangeRequest =
(): Codec<AddGlobalCurrencyExchangeRequest> =>
buildCodecForObject<AddGlobalCurrencyExchangeRequest>()
.property("currency", codecForString())
- .property("exchangeBaseUrl", codecForString())
+ .property("exchangeBaseUrl", codecForCanonBaseUrl())
.property("exchangeMasterPub", codecForString())
.build("AddGlobalCurrencyExchangeRequest");
@@ -3181,7 +3169,7 @@ export const codecForRemoveGlobalCurrencyExchangeRequest =
(): Codec<RemoveGlobalCurrencyExchangeRequest> =>
buildCodecForObject<RemoveGlobalCurrencyExchangeRequest>()
.property("currency", codecForString())
- .property("exchangeBaseUrl", codecForString())
+ .property("exchangeBaseUrl", codecForCanonBaseUrl())
.property("exchangeMasterPub", codecForString())
.build("RemoveGlobalCurrencyExchangeRequest");
@@ -3195,7 +3183,7 @@ export const codecForAddGlobalCurrencyAuditorRequest =
(): Codec<AddGlobalCurrencyAuditorRequest> =>
buildCodecForObject<AddGlobalCurrencyAuditorRequest>()
.property("currency", codecForString())
- .property("auditorBaseUrl", codecForString())
+ .property("auditorBaseUrl", codecForCanonBaseUrl())
.property("auditorPub", codecForString())
.build("AddGlobalCurrencyAuditorRequest");
@@ -3209,20 +3197,10 @@ export const codecForRemoveGlobalCurrencyAuditorRequest =
(): Codec<RemoveGlobalCurrencyAuditorRequest> =>
buildCodecForObject<RemoveGlobalCurrencyAuditorRequest>()
.property("currency", codecForString())
- .property("auditorBaseUrl", codecForString())
+ .property("auditorBaseUrl", codecForCanonBaseUrl())
.property("auditorPub", codecForString())
.build("RemoveGlobalCurrencyAuditorRequest");
-export interface RetryLoopOpts {
- /**
- * Stop the retry loop when all lifeness-giving pending operations
- * are done.
- *
- * Defaults to false.
- */
- stopWhenDone?: boolean;
-}
-
/**
* Information about one provider.
*
diff --git a/packages/taler-wallet-cli/debian/changelog b/packages/taler-wallet-cli/debian/changelog
index 4030caec7..e136caa61 100644
--- a/packages/taler-wallet-cli/debian/changelog
+++ b/packages/taler-wallet-cli/debian/changelog
@@ -1,3 +1,9 @@
+taler-wallet-cli (0.10.7) unstable; urgency=low
+
+ * Release 0.10.7
+
+ -- Florian Dold <dold@taler.net> Mon, 22 Apr 2024 20:16:39 +0200
+
taler-wallet-cli (0.10.6) unstable; urgency=low
* Release 0.10.6
diff --git a/packages/taler-wallet-cli/package.json b/packages/taler-wallet-cli/package.json
index 054931b25..922556749 100644
--- a/packages/taler-wallet-cli/package.json
+++ b/packages/taler-wallet-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/taler-wallet-cli",
- "version": "0.10.6",
+ "version": "0.10.7",
"description": "",
"engines": {
"node": ">=0.18.0"
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
index 7bb74b1c6..b915de538 100644
--- a/packages/taler-wallet-cli/src/index.ts
+++ b/packages/taler-wallet-cli/src/index.ts
@@ -57,6 +57,7 @@ import { JsonMessage, runRpcServer } from "@gnu-taler/taler-util/twrpc";
import {
AccessStats,
createNativeWalletHost2,
+ nativeCrypto,
Wallet,
WalletApiOperation,
WalletCoreApiClient,
@@ -89,7 +90,8 @@ setUnhandledRejectionHandler((error: any) => {
processExit(1);
});
-const defaultWalletDbPath = pathHomedir() + "/" + ".talerwalletdb.json";
+const defaultWalletDbPath = pathHomedir() + "/" + ".talerwalletdb.sqlite3";
+const defaultWalletCoreSocket = pathHomedir() + "/" + ".wallet-core.sock";
function assertUnreachable(x: never): never {
throw new Error("Didn't expect to get here");
@@ -299,6 +301,17 @@ async function createLocalWallet(
}
}
+function writeObservabilityLog(notif: WalletNotification): void {
+ if (observabilityEventFile) {
+ switch (notif.type) {
+ case NotificationType.RequestObservabilityEvent:
+ case NotificationType.TaskObservabilityEvent:
+ fs.appendFileSync(observabilityEventFile, JSON.stringify(notif) + "\n");
+ break;
+ }
+ }
+}
+
async function withWallet<T>(
walletCliArgs: WalletCliArgsType,
f: (ctx: WalletContext) => Promise<T>,
@@ -307,17 +320,7 @@ async function withWallet<T>(
const onNotif = (notif: WalletNotification) => {
waiter.notify(notif);
- if (observabilityEventFile) {
- switch (notif.type) {
- case NotificationType.RequestObservabilityEvent:
- case NotificationType.TaskObservabilityEvent:
- fs.appendFileSync(
- observabilityEventFile,
- JSON.stringify(notif) + "\n",
- );
- break;
- }
- }
+ writeObservabilityLog(notif);
};
if (walletCliArgs.wallet.walletConnection) {
@@ -347,7 +350,7 @@ async function withWallet<T>(
},
};
const result = await f(ctx);
- wh.wallet.stop();
+ await wh.wallet.client.call(WalletApiOperation.Shutdown, {});
if (process.env.TALER_WALLET_DBSTATS) {
console.log("database stats:");
console.log(j2s(wh.getStats()));
@@ -356,23 +359,6 @@ async function withWallet<T>(
}
}
-/**
- * Run a function with a local wallet.
- *
- * Stops the wallet after the function is done.
- */
-async function withLocalWallet<T>(
- walletCliArgs: WalletCliArgsType,
- f: (w: { client: WalletCoreApiClient; ws: Wallet }) => Promise<T>,
-): Promise<T> {
- const wh = await createLocalWallet(walletCliArgs);
- const w = wh.wallet;
- const res = await f({ client: w.client, ws: w });
- logger.info("Work done, stopping wallet.");
- w.stop();
- return res;
-}
-
walletCli
.subcommand("balance", "balance", { help: "Show wallet balance." })
.flag("json", ["--json"], {
@@ -446,6 +432,7 @@ transactionsCli.action(async (args) => {
currency: args.transactions.currency,
search: args.transactions.search,
includeRefreshes: args.transactions.includeRefreshes,
+ sort: "stable-ascending",
},
);
console.log(JSON.stringify(pending, undefined, 2));
@@ -579,12 +566,8 @@ walletCli
help: "Run until no more work is left.",
})
.action(async (args) => {
- await withLocalWallet(args, async (wallet) => {
- logger.info("running until pending operations are finished");
- await wallet.ws.runTaskLoop({
- stopWhenDone: true,
- });
- wallet.ws.stop();
+ await withWallet(args, async (ctx) => {
+ await ctx.client.call(WalletApiOperation.TestingWaitTasksDone, {});
});
});
@@ -724,7 +707,7 @@ walletCli
break;
}
default:
- console.log(`URI type (${parsedTalerUri}) not handled`);
+ console.log(`URI type (${parsedTalerUri.type}) not handled`);
break;
}
return;
@@ -741,6 +724,7 @@ withdrawCli
.requiredOption("amount", ["--amount"], clk.AMOUNT, {
help: "Amount to withdraw",
})
+ .maybeOption("forcedReservePriv", ["--forced-reserve-priv"], clk.STRING, {})
.maybeOption("restrictAge", ["--restrict-age"], clk.INT)
.action(async (args) => {
await withWallet(args, async (wallet) => {
@@ -753,7 +737,7 @@ withdrawCli
exchangeBaseUrl: exchangeBaseUrl,
},
);
- const acct = d.paytoUris[0];
+ const acct = d.withdrawalAccountsList[0];
if (!acct) {
console.log("exchange has no accounts");
return;
@@ -764,10 +748,11 @@ withdrawCli
amount,
exchangeBaseUrl,
restrictAge: parseInt(String(args.withdrawManually.restrictAge), 10),
+ forceReservePriv: args.withdrawManually.forcedReservePriv,
},
);
const reservePub = resp.reservePub;
- const completePaytoUri = addPaytoQueryParams(acct, {
+ const completePaytoUri = addPaytoQueryParams(acct.paytoUri, {
amount: args.withdrawManually.amount,
message: `Taler top-up ${reservePub}`,
});
@@ -986,7 +971,7 @@ depositCli
.requiredArgument("amount", clk.AMOUNT)
.requiredArgument("targetPayto", clk.STRING)
.action(async (args) => {
- await withLocalWallet(args, async (wallet) => {
+ await withWallet(args, async (wallet) => {
const resp = await wallet.client.call(
WalletApiOperation.CreateDepositGroup,
{
@@ -1190,6 +1175,34 @@ const advancedCli = walletCli.subcommand("advancedArgs", "advanced", {
});
advancedCli
+ .subcommand("genReserve", "gen-reserve", {
+ help: "Generate a reserve key pair (not stored in the DB).",
+ })
+ .action(async (args) => {
+ const pair = await nativeCrypto.createEddsaKeypair({});
+ console.log(
+ j2s({
+ reservePub: pair.pub,
+ reservePriv: pair.priv,
+ }),
+ );
+ });
+
+advancedCli
+ .subcommand("tasks", "tasks", {
+ help: "Show active wallet-core tasks.",
+ })
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const tasks = await wallet.client.call(
+ WalletApiOperation.GetActiveTasks,
+ {},
+ );
+ console.log(j2s(tasks));
+ });
+ });
+
+advancedCli
.subcommand("sampleTransactions", "sample-transactions", {
help: "Print sample wallet-core transactions",
})
@@ -1202,25 +1215,34 @@ advancedCli
help: "Serve the wallet API via a unix domain socket.",
})
.requiredOption("unixPath", ["--unix-path"], clk.STRING, {
- default: "wallet-core.sock",
+ default: defaultWalletCoreSocket,
})
.flag("noInit", ["--no-init"], {
help: "Do not initialize the wallet. The client must send the initWallet message.",
})
.action(async (args) => {
- logger.info(`serving at ${args.serve.unixPath}`);
- const onNotif = (notif: WalletNotification) => {
- if (observabilityEventFile) {
- switch (notif.type) {
- case NotificationType.RequestObservabilityEvent:
- case NotificationType.TaskObservabilityEvent:
- fs.appendFileSync(
- observabilityEventFile,
- JSON.stringify(notif) + "\n",
- );
- break;
- }
+ const socketPath = args.serve.unixPath;
+ logger.info(`serving at ${socketPath}`);
+ let cleanupCalled = false;
+
+ const cleanupSocket = (signal: string, code: number) => {
+ if (cleanupCalled) {
+ return;
}
+ cleanupCalled = true;
+ try {
+ logger.info("cleaning up socket");
+ fs.unlinkSync(socketPath);
+ } catch (e) {
+ logger.warn(`unable to clean up socket: ${e}`);
+ }
+ process.exit(128 + code);
+ };
+ process.on("SIGTERM", cleanupSocket);
+ process.on("SIGINT", cleanupSocket);
+
+ const onNotif = (notif: WalletNotification) => {
+ writeObservabilityLog(notif);
};
const wh = await createLocalWallet(args, onNotif, args.serve.noInit);
const w = wh.wallet;
@@ -1316,10 +1338,8 @@ advancedCli
exchangeBaseUrl: "http://localhost:8081/",
merchantBaseUrl: "http://localhost:8083/",
});
- await wallet.runTaskLoop({
- stopWhenDone: true,
- });
- wallet.stop();
+ await wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
});
advancedCli
diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json
index 3b5cb6c91..46b3cef4e 100644
--- a/packages/taler-wallet-core/package.json
+++ b/packages/taler-wallet-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/taler-wallet-core",
- "version": "0.10.6",
+ "version": "0.10.7",
"description": "",
"engines": {
"node": ">=0.18.0"
diff --git a/packages/taler-wallet-core/src/attention.ts b/packages/taler-wallet-core/src/attention.ts
index 60d2117f1..7a52ceaa3 100644
--- a/packages/taler-wallet-core/src/attention.ts
+++ b/packages/taler-wallet-core/src/attention.ts
@@ -29,7 +29,7 @@ import {
UserAttentionsResponse,
} from "@gnu-taler/taler-util";
import { timestampPreciseFromDb, timestampPreciseToDb } from "./db.js";
-import { InternalWalletState, WalletExecutionContext } from "./wallet.js";
+import { WalletExecutionContext } from "./wallet.js";
const logger = new Logger("operations/attention.ts");
@@ -37,20 +37,23 @@ export async function getUserAttentionsUnreadCount(
wex: WalletExecutionContext,
req: UserAttentionsRequest,
): Promise<UserAttentionsCountResponse> {
- const total = await wex.db.runReadOnlyTx(["userAttention"], async (tx) => {
- let count = 0;
- await tx.userAttention.iter().forEach((x) => {
- if (
- req.priority !== undefined &&
- UserAttentionPriority[x.info.type] !== req.priority
- )
- return;
- if (x.read !== undefined) return;
- count++;
- });
+ const total = await wex.db.runReadOnlyTx(
+ { storeNames: ["userAttention"] },
+ async (tx) => {
+ let count = 0;
+ await tx.userAttention.iter().forEach((x) => {
+ if (
+ req.priority !== undefined &&
+ UserAttentionPriority[x.info.type] !== req.priority
+ )
+ return;
+ if (x.read !== undefined) return;
+ count++;
+ });
- return count;
- });
+ return count;
+ },
+ );
return { total };
}
@@ -59,30 +62,33 @@ export async function getUserAttentions(
wex: WalletExecutionContext,
req: UserAttentionsRequest,
): Promise<UserAttentionsResponse> {
- return await wex.db.runReadOnlyTx(["userAttention"], async (tx) => {
- const pending: UserAttentionUnreadList = [];
- await tx.userAttention.iter().forEach((x) => {
- if (
- req.priority !== undefined &&
- UserAttentionPriority[x.info.type] !== req.priority
- )
- return;
- pending.push({
- info: x.info,
- when: timestampPreciseFromDb(x.created),
- read: x.read !== undefined,
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["userAttention"] },
+ async (tx) => {
+ const pending: UserAttentionUnreadList = [];
+ await tx.userAttention.iter().forEach((x) => {
+ if (
+ req.priority !== undefined &&
+ UserAttentionPriority[x.info.type] !== req.priority
+ )
+ return;
+ pending.push({
+ info: x.info,
+ when: timestampPreciseFromDb(x.created),
+ read: x.read !== undefined,
+ });
});
- });
- return { pending };
- });
+ return { pending };
+ },
+ );
}
export async function markAttentionRequestAsRead(
wex: WalletExecutionContext,
req: UserAttentionByIdRequest,
): Promise<void> {
- await wex.db.runReadWriteTx(["userAttention"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => {
const ua = await tx.userAttention.get([req.entityId, req.type]);
if (!ua) throw Error("attention request not found");
tx.userAttention.put({
@@ -104,7 +110,7 @@ export async function addAttentionRequest(
info: AttentionInfo,
entityId: string,
): Promise<void> {
- await wex.db.runReadWriteTx(["userAttention"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => {
await tx.userAttention.put({
info,
entityId,
@@ -125,7 +131,7 @@ export async function removeAttentionRequest(
wex: WalletExecutionContext,
req: UserAttentionByIdRequest,
): Promise<void> {
- await wex.db.runReadWriteTx(["userAttention"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["userAttention"] }, async (tx) => {
const ua = await tx.userAttention.get([req.entityId, req.type]);
if (!ua) throw Error("attention request not found");
await tx.userAttention.delete([req.entityId, req.type]);
diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts
index c32ed8b8c..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,
@@ -183,7 +182,7 @@ async function runBackupCycleForProvider(
args: BackupForProviderArgs,
): Promise<TaskRunResult> {
const provider = await wex.db.runReadOnlyTx(
- ["backupProviders"],
+ { storeNames: ["backupProviders"] },
async (tx) => {
return tx.backupProviders.get(args.backupProviderBaseUrl);
},
@@ -232,10 +231,10 @@ async function runBackupCycleForProvider(
headers: {
"content-type": "application/octet-stream",
"sync-signature": syncSigResp.sig,
- "if-none-match": newHash,
+ "if-none-match": JSON.stringify(newHash),
...(provider.lastBackupHash
? {
- "if-match": provider.lastBackupHash,
+ "if-match": JSON.stringify(provider.lastBackupHash),
}
: {}),
},
@@ -244,20 +243,23 @@ async function runBackupCycleForProvider(
logger.trace(`sync response status: ${resp.status}`);
if (resp.status === HttpStatusCode.NotModified) {
- await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- return;
- }
- prov.lastBackupCycleTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- prov.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
- };
- await tx.backupProviders.put(prov);
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ return;
+ }
+ prov.lastBackupCycleTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ prov.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
removeAttentionRequest(wex, {
entityId: provider.baseUrl,
@@ -290,41 +292,47 @@ async function runBackupCycleForProvider(
if (res === undefined) {
//claimed
- await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ prov.shouldRetryFreshProposal = true;
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
+
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
+ }
+ const result = res;
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
const prov = await tx.backupProviders.get(provider.baseUrl);
if (!prov) {
logger.warn("backup provider not found anymore");
return;
}
- prov.shouldRetryFreshProposal = true;
+ // const opId = TaskIdentifiers.forBackup(prov);
+ // await scheduleRetryInTx(ws, tx, opId);
+ prov.currentPaymentProposalId = result.proposalId;
+ prov.shouldRetryFreshProposal = false;
prov.state = {
tag: BackupProviderStateTag.Retrying,
};
await tx.backupProviders.put(prov);
- });
-
- throw Error("not implemented");
- // return {
- // type: TaskRunResultType.Pending,
- // };
- }
- const result = res;
-
- await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- logger.warn("backup provider not found anymore");
- return;
- }
- // const opId = TaskIdentifiers.forBackup(prov);
- // await scheduleRetryInTx(ws, tx, opId);
- prov.currentPaymentProposalId = result.proposalId;
- prov.shouldRetryFreshProposal = false;
- prov.state = {
- tag: BackupProviderStateTag.Retrying,
- };
- await tx.backupProviders.put(prov);
- });
+ },
+ );
addAttentionRequest(
wex,
@@ -343,21 +351,24 @@ async function runBackupCycleForProvider(
}
if (resp.status === HttpStatusCode.NoContent) {
- await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- return;
- }
- prov.lastBackupHash = encodeCrock(currentBackupHash);
- prov.lastBackupCycleTimestamp = timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- );
- prov.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
- };
- await tx.backupProviders.put(prov);
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ return;
+ }
+ prov.lastBackupHash = encodeCrock(currentBackupHash);
+ prov.lastBackupCycleTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ prov.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
removeAttentionRequest(wex, {
entityId: provider.baseUrl,
@@ -376,22 +387,25 @@ async function runBackupCycleForProvider(
// const blob = await decryptBackup(backupConfig, backupEnc);
// FIXME: Re-implement backup import with merging
// await importBackup(ws, blob, cryptoData);
- await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- logger.warn("backup provider not found anymore");
- return;
- }
- prov.lastBackupHash = encodeCrock(hash(backupEnc));
- // FIXME: Allocate error code for this situation?
- // FIXME: Add operation retry record!
- const opId = TaskIdentifiers.forBackup(prov);
- //await scheduleRetryInTx(ws, tx, opId);
- prov.state = {
- tag: BackupProviderStateTag.Retrying,
- };
- await tx.backupProviders.put(prov);
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ prov.lastBackupHash = encodeCrock(hash(backupEnc));
+ // FIXME: Allocate error code for this situation?
+ // FIXME: Add operation retry record!
+ const opId = TaskIdentifiers.forBackup(prov);
+ //await scheduleRetryInTx(ws, tx, opId);
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ },
+ );
logger.info("processed existing backup");
// Now upload our own, merged backup.
return await runBackupCycleForProvider(wex, args);
@@ -414,7 +428,7 @@ export async function processBackupForProvider(
backupProviderBaseUrl: string,
): Promise<TaskRunResult> {
const provider = await wex.db.runReadOnlyTx(
- ["backupProviders"],
+ { storeNames: ["backupProviders"] },
async (tx) => {
return await tx.backupProviders.get(backupProviderBaseUrl);
},
@@ -444,9 +458,12 @@ export async function removeBackupProvider(
wex: WalletExecutionContext,
req: RemoveBackupProviderRequest,
): Promise<void> {
- await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
- await tx.backupProviders.delete(req.provider);
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ await tx.backupProviders.delete(req.provider);
+ },
+ );
}
export interface RunBackupCycleRequest {
@@ -473,7 +490,7 @@ export async function runBackupCycle(
req: RunBackupCycleRequest,
): Promise<void> {
const providers = await wex.db.runReadOnlyTx(
- ["backupProviders"],
+ { storeNames: ["backupProviders"] },
async (tx) => {
if (req.providers) {
const rs = await Promise.all(
@@ -552,57 +569,65 @@ export async function addBackupProvider(
): Promise<AddBackupProviderResponse> {
logger.info(`adding backup provider ${j2s(req)}`);
await provideBackupState(wex);
- const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
- await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
- const oldProv = await tx.backupProviders.get(canonUrl);
- if (oldProv) {
- logger.info("old backup provider found");
+ const canonUrl = req.backupProviderBaseUrl;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ const oldProv = await tx.backupProviders.get(canonUrl);
+ if (oldProv) {
+ logger.info("old backup provider found");
+ if (req.activate) {
+ oldProv.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ };
+ logger.info("setting existing backup provider to active");
+ await tx.backupProviders.put(oldProv);
+ }
+ return;
+ }
+ },
+ );
+ const termsUrl = new URL("config", canonUrl);
+ const resp = await wex.http.fetch(termsUrl.href);
+ const terms = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForSyncTermsOfServiceResponse(),
+ );
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders"] },
+ async (tx) => {
+ let state: BackupProviderState;
+ //FIXME: what is the difference provisional and ready?
if (req.activate) {
- oldProv.state = {
+ state = {
tag: BackupProviderStateTag.Ready,
nextBackupTimestamp: timestampPreciseToDb(
TalerPreciseTimestamp.now(),
),
};
- logger.info("setting existing backup provider to active");
- await tx.backupProviders.put(oldProv);
+ } else {
+ state = {
+ tag: BackupProviderStateTag.Provisional,
+ };
}
- return;
- }
- });
- const termsUrl = new URL("config", canonUrl);
- const resp = await wex.http.fetch(termsUrl.href);
- const terms = await readSuccessResponseJsonOrThrow(
- resp,
- codecForSyncTermsOfServiceResponse(),
+ await tx.backupProviders.put({
+ state,
+ name: req.name,
+ terms: {
+ annualFee: terms.annual_fee,
+ storageLimitInMegabytes: terms.storage_limit_in_megabytes,
+ supportedProtocolVersion: terms.version,
+ },
+ shouldRetryFreshProposal: false,
+ paymentProposalIds: [],
+ baseUrl: canonUrl,
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ },
);
- await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
- let state: BackupProviderState;
- //FIXME: what is the difference provisional and ready?
- if (req.activate) {
- state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
- } else {
- state = {
- tag: BackupProviderStateTag.Provisional,
- };
- }
- await tx.backupProviders.put({
- state,
- name: req.name,
- terms: {
- annualFee: terms.annual_fee,
- storageLimitInMegabytes: terms.storage_limit_in_megabytes,
- supportedProtocolVersion: terms.version,
- },
- shouldRetryFreshProposal: false,
- paymentProposalIds: [],
- baseUrl: canonUrl,
- uids: [encodeCrock(getRandomBytes(32))],
- });
- });
return await runFirstBackupCycleForProvider(wex, {
backupProviderBaseUrl: canonUrl,
@@ -706,7 +731,7 @@ export async function getBackupInfo(
): Promise<BackupInfo> {
const backupConfig = await provideBackupState(wex);
const providerRecords = await wex.db.runReadOnlyTx(
- ["backupProviders", "operationRetries"],
+ { storeNames: ["backupProviders", "operationRetries"] },
async (tx) => {
return await tx.backupProviders.iter().mapAsync(async (bp) => {
const opId = TaskIdentifiers.forBackup(bp);
@@ -752,7 +777,7 @@ export async function getBackupRecovery(
): Promise<BackupRecovery> {
const bs = await provideBackupState(wex);
const providers = await wex.db.runReadOnlyTx(
- ["backupProviders"],
+ { storeNames: ["backupProviders"] },
async (tx) => {
return await tx.backupProviders.iter().toArray();
},
@@ -774,48 +799,51 @@ async function backupRecoveryTheirs(
wex: WalletExecutionContext,
br: BackupRecovery,
) {
- await wex.db.runReadWriteTx(["backupProviders", "config"], async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- ConfigRecordKey.WalletBackupState,
- );
- checkDbInvariant(!!backupStateEntry);
- checkDbInvariant(
- backupStateEntry.key === ConfigRecordKey.WalletBackupState,
- );
- backupStateEntry.value.lastBackupNonce = undefined;
- backupStateEntry.value.lastBackupTimestamp = undefined;
- backupStateEntry.value.lastBackupCheckTimestamp = undefined;
- backupStateEntry.value.lastBackupPlainHash = undefined;
- backupStateEntry.value.walletRootPriv = br.walletRootPriv;
- backupStateEntry.value.walletRootPub = encodeCrock(
- eddsaGetPublic(decodeCrock(br.walletRootPriv)),
- );
- await tx.config.put(backupStateEntry);
- for (const prov of br.providers) {
- const existingProv = await tx.backupProviders.get(prov.url);
- if (!existingProv) {
- await tx.backupProviders.put({
- baseUrl: prov.url,
- name: prov.name,
- paymentProposalIds: [],
- shouldRetryFreshProposal: false,
- state: {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: timestampPreciseToDb(
- TalerPreciseTimestamp.now(),
- ),
- },
- uids: [encodeCrock(getRandomBytes(32))],
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["backupProviders", "config"] },
+ async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ checkDbInvariant(!!backupStateEntry);
+ checkDbInvariant(
+ backupStateEntry.key === ConfigRecordKey.WalletBackupState,
+ );
+ backupStateEntry.value.lastBackupNonce = undefined;
+ backupStateEntry.value.lastBackupTimestamp = undefined;
+ backupStateEntry.value.lastBackupCheckTimestamp = undefined;
+ backupStateEntry.value.lastBackupPlainHash = undefined;
+ backupStateEntry.value.walletRootPriv = br.walletRootPriv;
+ backupStateEntry.value.walletRootPub = encodeCrock(
+ eddsaGetPublic(decodeCrock(br.walletRootPriv)),
+ );
+ await tx.config.put(backupStateEntry);
+ for (const prov of br.providers) {
+ const existingProv = await tx.backupProviders.get(prov.url);
+ if (!existingProv) {
+ await tx.backupProviders.put({
+ baseUrl: prov.url,
+ name: prov.name,
+ paymentProposalIds: [],
+ shouldRetryFreshProposal: false,
+ state: {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ },
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ }
}
- }
- const providers = await tx.backupProviders.iter().toArray();
- for (const prov of providers) {
- prov.lastBackupCycleTimestamp = undefined;
- prov.lastBackupHash = undefined;
- await tx.backupProviders.put(prov);
- }
- });
+ const providers = await tx.backupProviders.iter().toArray();
+ for (const prov of providers) {
+ prov.lastBackupCycleTimestamp = undefined;
+ prov.lastBackupHash = undefined;
+ await tx.backupProviders.put(prov);
+ }
+ },
+ );
}
async function backupRecoveryOurs(
@@ -831,7 +859,7 @@ export async function loadBackupRecovery(
): Promise<void> {
const bs = await provideBackupState(wex);
const providers = await wex.db.runReadOnlyTx(
- ["backupProviders"],
+ { storeNames: ["backupProviders"] },
async (tx) => {
return await tx.backupProviders.iter().toArray();
},
@@ -879,7 +907,7 @@ export async function provideBackupState(
wex: WalletExecutionContext,
): Promise<WalletBackupConfState> {
const bs: ConfigRecord | undefined = await wex.db.runReadOnlyTx(
- ["config"],
+ { storeNames: ["config"] },
async (tx) => {
return await tx.config.get(ConfigRecordKey.WalletBackupState);
},
@@ -895,7 +923,7 @@ export async function provideBackupState(
// FIXME: device ID should be configured when wallet is initialized
// and be based on hostname
const deviceId = `wallet-core-${encodeCrock(d)}`;
- return await wex.db.runReadWriteTx(["config"], async (tx) => {
+ return await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
ConfigRecordKey.WalletBackupState,
);
@@ -933,7 +961,7 @@ export async function setWalletDeviceId(
deviceId: string,
): Promise<void> {
await provideBackupState(wex);
- await wex.db.runReadWriteTx(["config"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
ConfigRecordKey.WalletBackupState,
);
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
index ca7642163..5a805b477 100644
--- a/packages/taler-wallet-core/src/balance.ts
+++ b/packages/taler-wallet-core/src/balance.ts
@@ -357,6 +357,9 @@ export async function getBalancesInsideTransaction(
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.FailedAbortingBank:
case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ case WithdrawalGroupStatus.DialogProposed:
case WithdrawalGroupStatus.Done:
// Does not count as pendingIncoming
return;
@@ -472,19 +475,21 @@ export async function getBalances(
logger.trace("starting to compute balance");
const wbal = await wex.db.runReadWriteTx(
- [
- "coinAvailability",
- "coins",
- "depositGroups",
- "exchangeDetails",
- "exchanges",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- "purchases",
- "refreshGroups",
- "withdrawalGroups",
- "peerPushDebit",
- ],
+ {
+ storeNames: [
+ "coinAvailability",
+ "coins",
+ "depositGroups",
+ "exchangeDetails",
+ "exchanges",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "purchases",
+ "refreshGroups",
+ "withdrawalGroups",
+ "peerPushDebit",
+ ],
+ },
async (tx) => {
return getBalancesInsideTransaction(wex, tx);
},
@@ -557,13 +562,15 @@ export async function getPaymentBalanceDetails(
req: PaymentRestrictionsForBalance,
): Promise<PaymentBalanceDetails> {
return await wex.db.runReadOnlyTx(
- [
- "coinAvailability",
- "refreshGroups",
- "exchanges",
- "exchangeDetails",
- "denominations",
- ],
+ {
+ storeNames: [
+ "coinAvailability",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ ],
+ },
async (tx) => {
return getPaymentBalanceDetailsInTx(wex, tx, req);
},
@@ -729,25 +736,28 @@ export async function getBalanceDetail(
): Promise<PaymentBalanceDetails> {
const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = [];
const wires = new Array<string>();
- await wex.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
- if (!details || req.currency !== details.currency) {
- continue;
- }
- details.wireInfo.accounts.forEach((a) => {
- const payto = parsePaytoUri(a.payto_uri);
- if (payto && !wires.includes(payto.targetType)) {
- wires.push(payto.targetType);
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || req.currency !== details.currency) {
+ continue;
}
- });
- exchanges.push({
- exchangePub: details.masterPublicKey,
- exchangeBaseUrl: e.baseUrl,
- });
- }
- });
+ details.wireInfo.accounts.forEach((a) => {
+ const payto = parsePaytoUri(a.payto_uri);
+ if (payto && !wires.includes(payto.targetType)) {
+ wires.push(payto.targetType);
+ }
+ });
+ exchanges.push({
+ exchangePub: details.masterPublicKey,
+ exchangeBaseUrl: e.baseUrl,
+ });
+ }
+ },
+ );
return await getPaymentBalanceDetails(wex, {
currency: req.currency,
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
index 6a7d79d83..a60e41ecd 100644
--- a/packages/taler-wallet-core/src/coinSelection.ts
+++ b/packages/taler-wallet-core/src/coinSelection.ts
@@ -268,14 +268,16 @@ export async function selectPayCoins(
}
return await wex.db.runReadOnlyTx(
- [
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "exchanges",
- "exchangeDetails",
- "coins",
- ],
+ {
+ storeNames: [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ],
+ },
async (tx) => {
const materialAvSel = await internalSelectPayCoins(wex, tx, req, false);
@@ -1140,15 +1142,17 @@ export async function selectPeerCoins(
}
return await wex.db.runReadWriteTx(
- [
- "exchanges",
- "contractTerms",
- "coins",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "exchangeDetails",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ],
+ },
async (tx): Promise<SelectPeerCoinsResult> => {
const exchanges = await tx.exchanges.iter().toArray();
const currency = Amounts.currencyOf(instructedAmount);
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
index 6d116c47e..edaba5ba4 100644
--- a/packages/taler-wallet-core/src/common.ts
+++ b/packages/taler-wallet-core/src/common.ts
@@ -21,6 +21,7 @@ import {
AbsoluteTime,
AmountJson,
Amounts,
+ AsyncFlag,
CoinRefreshRequest,
CoinStatus,
Duration,
@@ -35,6 +36,7 @@ import {
TalerProtocolTimestamp,
TombstoneIdStr,
TransactionIdStr,
+ WalletNotification,
assertUnreachable,
checkDbInvariant,
checkLogicInvariant,
@@ -769,3 +771,53 @@ export enum PendingTaskType {
declare const __taskIdStr: unique symbol;
export type TaskIdStr = string & { [__taskIdStr]: true };
+
+/**
+ * Wait until the wallet is in a particular state.
+ *
+ * Two functions must be provided:
+ * 1. checkState, which checks if the wallet is in the
+ * desired state.
+ * 2. filterNotification, which checks whether a notification
+ * might have lead to a state change.
+ */
+export async function genericWaitForState(
+ wex: WalletExecutionContext,
+ args: {
+ checkState: () => Promise<boolean>;
+ filterNotification: (notif: WalletNotification) => boolean;
+ },
+): Promise<void> {
+ await wex.taskScheduler.ensureRunning();
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const flag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our refresh.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (args.filterNotification(notif)) {
+ flag.raise();
+ }
+ });
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ flag.raise();
+ });
+
+ try {
+ while (true) {
+ if (wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+ if (await args.checkState()) {
+ return;
+ }
+ // Wait for the next transition
+ await flag.wait();
+ flag.reset();
+ }
+ } catch (e) {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
index 77ee65e52..2a2958a71 100644
--- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
+++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -214,6 +214,10 @@ export interface TalerCryptoInterface {
signPurseCreation(req: SignPurseCreationRequest): Promise<EddsaSigningResult>;
+ signReserveHistoryReq(
+ req: SignReserveHistoryReqRequest,
+ ): Promise<SignReserveHistoryReqResponse>;
+
signPurseDeposits(
req: SignPurseDepositsRequest,
): Promise<SignPurseDepositsResponse>;
@@ -438,6 +442,11 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<SignCoinHistoryResponse> {
throw new Error("Function not implemented.");
},
+ signReserveHistoryReq: function (
+ req: SignReserveHistoryReqRequest,
+ ): Promise<SignReserveHistoryReqResponse> {
+ throw new Error("Function not implemented.");
+ },
};
export type WithArg<X> = X extends (req: infer T) => infer R
@@ -475,6 +484,15 @@ export interface SignPurseCreationRequest {
minAge: number;
}
+export interface SignReserveHistoryReqRequest {
+ reservePriv: string;
+ startOffset: number;
+}
+
+export interface SignReserveHistoryReqResponse {
+ sig: string;
+}
+
export interface SpendCoinDetails {
coinPub: string;
coinPriv: string;
@@ -1468,15 +1486,12 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
const hExchangeBaseUrl = hash(stringToBytes(req.exchangeBaseUrl + "\0"));
const deposits: PurseDeposit[] = [];
for (const c of req.coins) {
- let haveAch: boolean;
let maybeAch: Uint8Array;
if (c.ageCommitmentProof) {
- haveAch = true;
maybeAch = decodeCrock(
AgeRestriction.hashCommitment(c.ageCommitmentProof.commitment),
);
} else {
- haveAch = false;
maybeAch = new Uint8Array(32);
}
const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_DEPOSIT)
@@ -1733,6 +1748,23 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
sig: sigResp.sig,
};
},
+ async signReserveHistoryReq(
+ tci: TalerCryptoInterfaceR,
+ req: SignReserveHistoryReqRequest,
+ ): Promise<SignReserveHistoryReqResponse> {
+ const reserveHistoryBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_RESERVE_HISTORY,
+ )
+ .put(bufferForUint64(req.startOffset))
+ .build();
+ const sigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(reserveHistoryBlob),
+ priv: req.reservePriv,
+ });
+ return {
+ sig: sigResp.sig,
+ };
+ },
};
export interface EddsaSignRequest {
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
index 5bab70968..b75e48c39 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -34,6 +34,7 @@ import {
Amounts,
AttentionInfo,
BackupProviderTerms,
+ CancellationToken,
Codec,
CoinEnvelope,
CoinPublicKeyString,
@@ -297,6 +298,11 @@ export enum WithdrawalGroupStatus {
SuspendedReady = 0x0110_0004,
/**
+ * Proposed to the user, has can choose to accept/refuse.
+ */
+ DialogProposed = 0x0101_0000,
+
+ /**
* We are telling the bank that we don't want to complete
* the withdrawal!
*/
@@ -337,6 +343,21 @@ export enum WithdrawalGroupStatus {
AbortedExchange = 0x0503_0001,
AbortedBank = 0x0503_0002,
+
+ /**
+ * User didn't refused the withdrawal.
+ */
+ AbortedUserRefused = 0x0503_0003,
+
+ /**
+ * Another wallet confirmed the withdrawal
+ * (by POSTing the reserve pub to the bank)
+ * before we had the chance.
+ *
+ * In this situation, we'll let the other wallet continue
+ * and give up ourselves.
+ */
+ AbortedOtherWallet = 0x0503_0004,
}
/**
@@ -3269,7 +3290,12 @@ export async function openStoredBackupsDatabase(
onStoredBackupsDbUpgradeNeeded,
);
- const handle = new DbAccessImpl(backupsDbHandle, StoredBackupStores);
+ const handle = new DbAccessImpl(
+ backupsDbHandle,
+ StoredBackupStores,
+ {},
+ CancellationToken.CONTINUE,
+ );
return handle;
}
@@ -3283,7 +3309,7 @@ export async function openStoredBackupsDatabase(
export async function openTalerDatabase(
idbFactory: IDBFactory,
onVersionChange: () => void,
-): Promise<DbAccess<typeof WalletStoresV1>> {
+): Promise<IDBDatabase> {
const metaDbHandle = await openDatabase(
idbFactory,
TALER_WALLET_META_DB_NAME,
@@ -3292,9 +3318,14 @@ export async function openTalerDatabase(
onMetaDbUpgradeNeeded,
);
- const metaDb = new DbAccessImpl(metaDbHandle, walletMetadataStore);
+ const metaDb = new DbAccessImpl(
+ metaDbHandle,
+ walletMetadataStore,
+ {},
+ CancellationToken.CONTINUE,
+ );
let currentMainVersion: string | undefined;
- await metaDb.runReadWriteTx(["metaConfig"], async (tx) => {
+ await metaDb.runReadWriteTx({ storeNames: ["metaConfig"] }, async (tx) => {
const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY);
if (!dbVersionRecord) {
currentMainVersion = TALER_WALLET_MAIN_DB_NAME;
@@ -3319,12 +3350,15 @@ export async function openTalerDatabase(
case "taler-wallet-main-v9":
// We consider this a pre-release
// development version, no migration is done.
- await metaDb.runReadWriteTx(["metaConfig"], async (tx) => {
- await tx.metaConfig.put({
- key: CURRENT_DB_CONFIG_KEY,
- value: TALER_WALLET_MAIN_DB_NAME,
- });
- });
+ await metaDb.runReadWriteTx(
+ { storeNames: ["metaConfig"] },
+ async (tx) => {
+ await tx.metaConfig.put({
+ key: CURRENT_DB_CONFIG_KEY,
+ value: TALER_WALLET_MAIN_DB_NAME,
+ });
+ },
+ );
break;
default:
throw Error(
@@ -3341,11 +3375,15 @@ export async function openTalerDatabase(
onTalerDbUpgradeNeeded,
);
- const handle = new DbAccessImpl(mainDbHandle, WalletStoresV1);
-
- await applyFixups(handle);
+ const mainDbAccess = new DbAccessImpl(
+ mainDbHandle,
+ WalletStoresV1,
+ {},
+ CancellationToken.CONTINUE,
+ );
+ await applyFixups(mainDbAccess);
- return handle;
+ return mainDbHandle;
}
export async function deleteTalerDatabase(
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
index 50f26ea9c..c4cd98d73 100644
--- a/packages/taler-wallet-core/src/deposits.ts
+++ b/packages/taler-wallet-core/src/deposits.ts
@@ -139,22 +139,25 @@ export class DepositTransactionContext implements TransactionContext {
const ws = this.wex;
// FIXME: We should check first if we are in a final state
// where deletion is allowed.
- await ws.db.runReadWriteTx(["depositGroups", "tombstones"], async (tx) => {
- const tipRecord = await tx.depositGroups.get(depositGroupId);
- if (tipRecord) {
- await tx.depositGroups.delete(depositGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
- });
- }
- });
+ await ws.db.runReadWriteTx(
+ { storeNames: ["depositGroups", "tombstones"] },
+ async (tx) => {
+ const tipRecord = await tx.depositGroups.get(depositGroupId);
+ if (tipRecord) {
+ await tx.depositGroups.delete(depositGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
+ });
+ }
+ },
+ );
return;
}
async suspendTransaction(): Promise<void> {
const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["depositGroups"],
+ { storeNames: ["depositGroups"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
@@ -197,7 +200,7 @@ export class DepositTransactionContext implements TransactionContext {
async abortTransaction(): Promise<void> {
const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["depositGroups"],
+ { storeNames: ["depositGroups"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
@@ -235,7 +238,7 @@ export class DepositTransactionContext implements TransactionContext {
async resumeTransaction(): Promise<void> {
const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["depositGroups"],
+ { storeNames: ["depositGroups"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
@@ -278,7 +281,7 @@ export class DepositTransactionContext implements TransactionContext {
async failTransaction(): Promise<void> {
const { wex, depositGroupId, transactionId, taskId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["depositGroups"],
+ { storeNames: ["depositGroups"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
@@ -435,7 +438,7 @@ async function refundDepositGroup(
default: {
const coinPub = payCoinSelection.coinPubs[i];
const coinExchange = await wex.db.runReadOnlyTx(
- ["coins"],
+ { storeNames: ["coins"] },
async (tx) => {
const coinRecord = await tx.coins.get(coinPub);
checkDbInvariant(!!coinRecord);
@@ -497,14 +500,16 @@ async function refundDepositGroup(
const currency = Amounts.currencyOf(depositGroup.totalPayCost);
const res = await wex.db.runReadWriteTx(
- [
- "depositGroups",
- "refreshGroups",
- "refreshSessions",
- "coins",
- "denominations",
- "coinAvailability",
- ],
+ {
+ storeNames: [
+ "depositGroups",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
if (!newDg) {
@@ -571,7 +576,7 @@ async function waitForRefreshOnDepositGroup(
depositGroupId: depositGroup.depositGroupId,
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["depositGroups", "refreshGroups"],
+ { storeNames: ["depositGroups", "refreshGroups"] },
async (tx) => {
const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
let newOpState: DepositOperationStatus | undefined;
@@ -660,7 +665,7 @@ async function processDepositGroupPendingKyc(
kycStatusRes.status === HttpStatusCode.NoContent
) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["depositGroups"],
+ { storeNames: ["depositGroups"] },
async (tx) => {
const newDg = await tx.depositGroups.get(depositGroupId);
if (!newDg) {
@@ -719,7 +724,7 @@ async function transitionToKycRequired(
const kycStatus = await kycStatusReq.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
const transitionInfo = await wex.db.runReadWriteTx(
- ["depositGroups"],
+ { storeNames: ["depositGroups"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
@@ -768,10 +773,10 @@ async function processDepositGroupPendingTrack(
const coinPub = payCoinSelection.coinPubs[i];
// FIXME: Make the URL part of the coin selection?
const exchangeBaseUrl = await wex.db.runReadWriteTx(
- ["coins"],
+ { storeNames: ["coins"] },
async (tx) => {
const coinRecord = await tx.coins.get(coinPub);
- checkDbInvariant(!!coinRecord);
+ checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`);
return coinRecord.exchangeBaseUrl;
},
);
@@ -844,41 +849,44 @@ async function processDepositGroupPendingTrack(
}
if (updatedTxStatus !== undefined) {
- await wex.db.runReadWriteTx(["depositGroups"], async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return;
- }
- if (!dg.statusPerCoin) {
- return;
- }
- if (updatedTxStatus !== undefined) {
- dg.statusPerCoin[i] = updatedTxStatus;
- }
- if (newWiredCoin) {
- /**
- * FIXME: if there is a new wire information from the exchange
- * it should add up to the previous tracking states.
- *
- * This may loose information by overriding prev state.
- *
- * And: add checks to integration tests
- */
- if (!dg.trackingState) {
- dg.trackingState = {};
+ await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return;
}
-
- dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
- }
- await tx.depositGroups.put(dg);
- });
+ if (!dg.statusPerCoin) {
+ return;
+ }
+ if (updatedTxStatus !== undefined) {
+ dg.statusPerCoin[i] = updatedTxStatus;
+ }
+ if (newWiredCoin) {
+ /**
+ * FIXME: if there is a new wire information from the exchange
+ * it should add up to the previous tracking states.
+ *
+ * This may loose information by overriding prev state.
+ *
+ * And: add checks to integration tests
+ */
+ if (!dg.trackingState) {
+ dg.trackingState = {};
+ }
+
+ dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
+ }
+ await tx.depositGroups.put(dg);
+ },
+ );
}
}
let allWired = true;
const transitionInfo = await wex.db.runReadWriteTx(
- ["depositGroups"],
+ { storeNames: ["depositGroups"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
@@ -929,7 +937,7 @@ async function processDepositGroupPendingDeposit(
logger.info("processing deposit group in pending(deposit)");
const depositGroupId = depositGroup.depositGroupId;
const contractTermsRec = await wex.db.runReadOnlyTx(
- ["contractTerms"],
+ { storeNames: ["contractTerms"] },
async (tx) => {
return tx.contractTerms.get(depositGroup.contractTermsHash);
},
@@ -987,14 +995,16 @@ async function processDepositGroupPendingDeposit(
}
const transitionDone = await wex.db.runReadWriteTx(
- [
- "depositGroups",
- "coins",
- "coinAvailability",
- "refreshGroups",
- "refreshSessions",
- "denominations",
- ],
+ {
+ storeNames: [
+ "depositGroups",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ ],
+ },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
@@ -1094,27 +1104,30 @@ async function processDepositGroupPendingDeposit(
codecForBatchDepositSuccess(),
);
- await wex.db.runReadWriteTx(["depositGroups"], async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return;
- }
- if (!dg.statusPerCoin) {
- return;
- }
- for (const batchIndex of batchIndexes) {
- const coinStatus = dg.statusPerCoin[batchIndex];
- switch (coinStatus) {
- case DepositElementStatus.DepositPending:
- dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking;
- await tx.depositGroups.put(dg);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["depositGroups"] },
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return;
}
- }
- });
+ if (!dg.statusPerCoin) {
+ return;
+ }
+ for (const batchIndex of batchIndexes) {
+ const coinStatus = dg.statusPerCoin[batchIndex];
+ switch (coinStatus) {
+ case DepositElementStatus.DepositPending:
+ dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking;
+ await tx.depositGroups.put(dg);
+ }
+ }
+ },
+ );
}
const transitionInfo = await wex.db.runReadWriteTx(
- ["depositGroups"],
+ { storeNames: ["depositGroups"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
@@ -1140,7 +1153,7 @@ export async function processDepositGroup(
depositGroupId: string,
): Promise<TaskRunResult> {
const depositGroup = await wex.db.runReadOnlyTx(
- ["depositGroups"],
+ { storeNames: ["depositGroups"] },
async (tx) => {
return tx.depositGroups.get(depositGroupId);
},
@@ -1174,7 +1187,7 @@ async function getExchangeWireFee(
time: TalerProtocolTimestamp,
): Promise<WireFee> {
const exchangeDetails = await wex.db.runReadOnlyTx(
- ["exchangeDetails", "exchanges"],
+ { storeNames: ["exchangeDetails", "exchanges"] },
async (tx) => {
const ex = await tx.exchanges.get(baseUrl);
if (!ex || !ex.detailsPointer) return undefined;
@@ -1281,19 +1294,22 @@ export async function checkDepositGroup(
const exchangeInfos: ExchangeHandle[] = [];
- await wex.db.runReadOnlyTx(["exchangeDetails", "exchanges"], async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
- if (!details || amount.currency !== details.currency) {
- continue;
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["exchangeDetails", "exchanges"] },
+ async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || amount.currency !== details.currency) {
+ continue;
+ }
+ exchangeInfos.push({
+ master_pub: details.masterPublicKey,
+ url: e.baseUrl,
+ });
}
- exchangeInfos.push({
- master_pub: details.masterPublicKey,
- url: e.baseUrl,
- });
- }
- });
+ },
+ );
const now = AbsoluteTime.now();
const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
@@ -1404,19 +1420,22 @@ export async function createDepositGroup(
const exchangeInfos: { url: string; master_pub: string }[] = [];
- await wex.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
- if (!details || amount.currency !== details.currency) {
- continue;
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges", "exchangeDetails"] },
+ async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || amount.currency !== details.currency) {
+ continue;
+ }
+ exchangeInfos.push({
+ master_pub: details.masterPublicKey,
+ url: e.baseUrl,
+ });
}
- exchangeInfos.push({
- master_pub: details.masterPublicKey,
- url: e.baseUrl,
- });
- }
- });
+ },
+ );
const now = AbsoluteTime.now();
const wireDeadline = AbsoluteTime.toProtocolTimestamp(
@@ -1569,16 +1588,18 @@ export async function createDepositGroup(
const transactionId = ctx.transactionId;
const newTxState = await wex.db.runReadWriteTx(
- [
- "depositGroups",
- "coins",
- "recoupGroups",
- "denominations",
- "refreshGroups",
- "refreshSessions",
- "coinAvailability",
- "contractTerms",
- ],
+ {
+ storeNames: [
+ "depositGroups",
+ "coins",
+ "recoupGroups",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coinAvailability",
+ "contractTerms",
+ ],
+ },
async (tx) => {
if (depositGroup.payCoinSelection) {
await spendCoins(wex, tx, {
@@ -1635,7 +1656,7 @@ export async function getCounterpartyEffectiveDepositAmount(
const exchangeSet: Set<string> = new Set();
await wex.db.runReadOnlyTx(
- ["coins", "denominations", "exchangeDetails", "exchanges"],
+ { storeNames: ["coins", "denominations", "exchangeDetails", "exchanges"] },
async (tx) => {
for (let i = 0; i < pcs.length; i++) {
const denom = await getDenomInfo(
@@ -1694,10 +1715,9 @@ async function getTotalFeesForDepositAmount(
const coinFee: AmountJson[] = [];
const refreshFee: AmountJson[] = [];
const exchangeSet: Set<string> = new Set();
- const currency = Amounts.currencyOf(total);
await wex.db.runReadOnlyTx(
- ["coins", "denominations", "exchanges", "exchangeDetails"],
+ { storeNames: ["coins", "denominations", "exchanges", "exchangeDetails"] },
async (tx) => {
for (let i = 0; i < pcs.length; i++) {
const denom = await getDenomInfo(
diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts
index db2ff5d06..5cb9400be 100644
--- a/packages/taler-wallet-core/src/dev-experiments.ts
+++ b/packages/taler-wallet-core/src/dev-experiments.ts
@@ -79,23 +79,26 @@ export async function applyDevExperiment(
}
case "insert-pending-refresh": {
const refreshGroupId = encodeCrock(getRandomBytes(32));
- await wex.db.runReadWriteTx(["refreshGroups"], async (tx) => {
- const newRg: RefreshGroupRecord = {
- currency: "TESTKUDOS",
- expectedOutputPerCoin: [],
- inputPerCoin: [],
- oldCoinPubs: [],
- operationStatus: RefreshOperationStatus.Pending,
- reason: RefreshReason.Manual,
- refreshGroupId,
- statusPerCoin: [],
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- timestampFinished: undefined,
- originatingTransactionId: undefined,
- infoPerExchange: {},
- };
- await tx.refreshGroups.put(newRg);
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["refreshGroups"] },
+ async (tx) => {
+ const newRg: RefreshGroupRecord = {
+ currency: "TESTKUDOS",
+ expectedOutputPerCoin: [],
+ inputPerCoin: [],
+ oldCoinPubs: [],
+ operationStatus: RefreshOperationStatus.Pending,
+ reason: RefreshReason.Manual,
+ refreshGroupId,
+ statusPerCoin: [],
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ timestampFinished: undefined,
+ originatingTransactionId: undefined,
+ infoPerExchange: {},
+ };
+ await tx.refreshGroups.put(newRg);
+ },
+ );
wex.taskScheduler.startShepherdTask(
constructTaskIdentifier({
tag: PendingTaskType.Refresh,
@@ -105,23 +108,26 @@ export async function applyDevExperiment(
return;
}
case "insert-denom-loss": {
- await wex.db.runReadWriteTx(["denomLossEvents"], async (tx) => {
- const eventId = encodeCrock(getRandomBytes(32));
- const newRg: DenomLossEventRecord = {
- amount: "TESTKUDOS:42",
- currency: "TESTKUDOS",
- exchangeBaseUrl: "https://exchange.test.taler.net/",
- denomLossEventId: eventId,
- denomPubHashes: [
- encodeCrock(getRandomBytes(64)),
- encodeCrock(getRandomBytes(64)),
- ],
- eventType: DenomLossEventType.DenomExpired,
- status: DenomLossStatus.Done,
- timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
- };
- await tx.denomLossEvents.put(newRg);
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["denomLossEvents"] },
+ async (tx) => {
+ const eventId = encodeCrock(getRandomBytes(32));
+ const newRg: DenomLossEventRecord = {
+ amount: "TESTKUDOS:42",
+ currency: "TESTKUDOS",
+ exchangeBaseUrl: "https://exchange.test.taler.net/",
+ denomLossEventId: eventId,
+ denomPubHashes: [
+ encodeCrock(getRandomBytes(64)),
+ encodeCrock(getRandomBytes(64)),
+ ],
+ eventType: DenomLossEventType.DenomExpired,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ await tx.denomLossEvents.put(newRg);
+ },
+ );
return;
}
}
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
index 4a784cebb..6262ae4d3 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,
@@ -312,8 +311,8 @@ async function makeExchangeListItem(
): Promise<ExchangeListItem> {
const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
? {
- error: lastError,
- }
+ error: lastError,
+ }
: undefined;
let scopeInfo: ScopeInfo | undefined = undefined;
@@ -377,13 +376,15 @@ export async function lookupExchangeByUri(
req: GetExchangeEntryByUrlRequest,
): Promise<ExchangeListItem> {
return await wex.db.runReadOnlyTx(
- [
- "exchanges",
- "exchangeDetails",
- "operationRetries",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
async (tx) => {
const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
if (!exchangeRec) {
@@ -414,7 +415,7 @@ export async function acceptExchangeTermsOfService(
exchangeBaseUrl: string,
): Promise<void> {
const notif = await wex.db.runReadWriteTx(
- ["exchangeDetails", "exchanges"],
+ { storeNames: ["exchangeDetails", "exchanges"] },
async (tx) => {
const exch = await tx.exchanges.get(exchangeBaseUrl);
if (exch && exch.tosCurrentEtag) {
@@ -449,7 +450,7 @@ export async function forgetExchangeTermsOfService(
exchangeBaseUrl: string,
): Promise<void> {
const notif = await wex.db.runReadWriteTx(
- ["exchangeDetails", "exchanges"],
+ { storeNames: ["exchangeDetails", "exchanges"] },
async (tx) => {
const exch = await tx.exchanges.get(exchangeBaseUrl);
if (exch) {
@@ -912,15 +913,14 @@ async function startUpdateExchangeEntry(
exchangeBaseUrl: string,
options: { forceUpdate?: boolean } = {},
): Promise<void> {
- const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
-
logger.info(
- `starting update of exchange entry ${canonBaseUrl}, forced=${options.forceUpdate ?? false
+ `starting update of exchange entry ${exchangeBaseUrl}, forced=${
+ options.forceUpdate ?? false
}`,
);
const { notification } = await wex.db.runReadWriteTx(
- ["exchanges", "exchangeDetails"],
+ { storeNames: ["exchanges", "exchangeDetails"] },
async (tx) => {
wex.ws.exchangeCache.clear();
return provideExchangeRecordInTx(wex.ws, tx, exchangeBaseUrl);
@@ -935,9 +935,9 @@ async function startUpdateExchangeEntry(
const { oldExchangeState, newExchangeState, taskId } =
await wex.db.runReadWriteTx(
- ["exchanges", "operationRetries"],
+ { 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");
}
@@ -985,7 +985,7 @@ async function startUpdateExchangeEntry(
);
wex.ws.notify({
type: NotificationType.ExchangeStateTransition,
- exchangeBaseUrl: canonBaseUrl,
+ exchangeBaseUrl,
newExchangeState: newExchangeState,
oldExchangeState: oldExchangeState,
});
@@ -1029,13 +1029,15 @@ async function internalWaitReadyExchange(
logger.info(`waiting for ready exchange ${canonUrl}`);
const { exchange, exchangeDetails, retryInfo, scopeInfo } =
await wex.db.runReadOnlyTx(
- [
- "exchanges",
- "exchangeDetails",
- "operationRetries",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
async (tx) => {
const exchange = await tx.exchanges.get(canonUrl);
const exchangeDetails = await getExchangeRecordsInternal(
@@ -1155,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;
}
@@ -1166,14 +1166,14 @@ export async function fetchFreshExchange(
wex.ws.exchangeCache.clear();
}
- wex.taskScheduler.ensureRunning();
+ 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;
}
@@ -1287,10 +1287,9 @@ export async function updateExchangeFromUrlHandler(
exchangeBaseUrl: string,
): Promise<TaskRunResult> {
logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
- exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
const oldExchangeRec = await wex.db.runReadOnlyTx(
- ["exchanges"],
+ { storeNames: ["exchanges"] },
async (tx) => {
return tx.exchanges.get(exchangeBaseUrl);
},
@@ -1450,17 +1449,19 @@ export async function updateExchangeFromUrlHandler(
let peerPaymentsDisabled = checkPeerPaymentsDisabled(keysInfo);
const updated = await wex.db.runReadWriteTx(
- [
- "exchanges",
- "exchangeDetails",
- "exchangeSignKeys",
- "denominations",
- "coins",
- "refreshGroups",
- "recoupGroups",
- "coinAvailability",
- "denomLossEvents",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "exchangeSignKeys",
+ "denominations",
+ "coins",
+ "refreshGroups",
+ "recoupGroups",
+ "coinAvailability",
+ "denomLossEvents",
+ ],
+ },
async (tx) => {
const r = await tx.exchanges.get(exchangeBaseUrl);
if (!r) {
@@ -1548,7 +1549,6 @@ export async function updateExchangeFromUrlHandler(
r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
r.cachebreakNextUpdate = false;
await tx.exchanges.put(r);
- logger.info(`putting new exchange details in DB: ${j2s(newDetails)}`);
const drRowId = await tx.exchangeDetails.put(newDetails);
checkDbInvariant(typeof drRowId.key === "number");
@@ -1666,14 +1666,16 @@ export async function updateExchangeFromUrlHandler(
if (refreshCheckNecessary) {
// Do auto-refresh.
await wex.db.runReadWriteTx(
- [
- "coins",
- "denominations",
- "coinAvailability",
- "refreshGroups",
- "refreshSessions",
- "exchanges",
- ],
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "exchanges",
+ ],
+ },
async (tx) => {
const exchange = await tx.exchanges.get(exchangeBaseUrl);
if (!exchange || !exchange.detailsPointer) {
@@ -1969,7 +1971,7 @@ export class DenomLossTransactionContext implements TransactionContext {
}
async deleteTransaction(): Promise<void> {
const transitionInfo = await this.wex.db.runReadWriteTx(
- ["denomLossEvents"],
+ { storeNames: ["denomLossEvents"] },
async (tx) => {
const rec = await tx.denomLossEvents.get(this.denomLossEventId);
if (rec) {
@@ -2080,7 +2082,7 @@ export async function getExchangePaytoUri(
// We do the update here, since the exchange might not even exist
// yet in our database.
const details = await wex.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails"],
+ { storeNames: ["exchanges", "exchangeDetails"] },
async (tx) => {
return getExchangeRecordsInternal(tx, exchangeBaseUrl);
},
@@ -2122,7 +2124,7 @@ export async function getExchangeTos(
acceptLanguage,
);
- await wex.db.runReadWriteTx(["exchanges"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["exchanges"] }, async (tx) => {
const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
if (updateExchangeEntry) {
updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
@@ -2179,13 +2181,15 @@ export async function listExchanges(
): Promise<ExchangesListResponse> {
const exchanges: ExchangeListItem[] = [];
await wex.db.runReadOnlyTx(
- [
- "exchanges",
- "operationRetries",
- "exchangeDetails",
- "globalCurrencyAuditors",
- "globalCurrencyExchanges",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "operationRetries",
+ "exchangeDetails",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ },
async (tx) => {
const exchangeRecords = await tx.exchanges.iter().toArray();
for (const r of exchangeRecords) {
@@ -2222,7 +2226,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) {
@@ -2262,7 +2265,7 @@ export async function getExchangeDetailedInfo(
exchangeBaseurl: string,
): Promise<ExchangeDetailedResponse> {
const exchange = await wex.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails", "denominations"],
+ { storeNames: ["exchanges", "exchangeDetails", "denominations"] },
async (tx) => {
const ex = await tx.exchanges.get(exchangeBaseurl);
const dp = ex?.detailsPointer;
@@ -2518,19 +2521,21 @@ 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(
- [
- "exchanges",
- "exchangeDetails",
- "transactions",
- "coinAvailability",
- "coins",
- "denominations",
- "exchangeSignKeys",
- "withdrawalGroups",
- "planchets",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "coinAvailability",
+ "coins",
+ "denominations",
+ "exchangeSignKeys",
+ "withdrawalGroups",
+ "planchets",
+ ],
+ },
async (tx) => {
const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
if (!exchangeRec) {
@@ -2562,7 +2567,7 @@ export async function getExchangeResources(
): Promise<GetExchangeResourcesResponse> {
// Withdrawals include internal withdrawals from peer transactions
const res = await wex.db.runReadOnlyTx(
- ["exchanges", "withdrawalGroups", "coins"],
+ { storeNames: ["exchanges", "withdrawalGroups", "coins"] },
async (tx) => {
const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl);
if (!exchangeRecord) {
diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts
index 63ccb8b56..1f7d95959 100644
--- a/packages/taler-wallet-core/src/instructedAmountConversion.ts
+++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts
@@ -150,7 +150,14 @@ async function getAvailableDenoms(
const operationType = getOperationType(TransactionType.Deposit);
return await wex.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails", "denominations", "coinAvailability"],
+ {
+ storeNames: [
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const list: CoinInfo[] = [];
const exchanges: Record<string, ExchangeInfo> = {};
diff --git a/packages/taler-wallet-core/src/observable-wrappers.ts b/packages/taler-wallet-core/src/observable-wrappers.ts
index b36f41611..717de41ca 100644
--- a/packages/taler-wallet-core/src/observable-wrappers.ts
+++ b/packages/taler-wallet-core/src/observable-wrappers.ts
@@ -25,7 +25,6 @@ import { IDBDatabase } from "@gnu-taler/idb-bridge";
import {
ObservabilityContext,
ObservabilityEventType,
- RetryLoopOpts,
} from "@gnu-taler/taler-util";
import { TaskIdStr } from "./common.js";
import { TalerCryptoInterface } from "./index.js";
@@ -61,17 +60,22 @@ export class ObservableTaskScheduler implements TaskScheduler {
}
}
+ shutdown(): Promise<void> {
+ return this.impl.shutdown();
+ }
+
getActiveTasks(): TaskIdStr[] {
return this.impl.getActiveTasks();
}
- ensureRunning(): void {
- return this.impl.ensureRunning();
+ isIdle(): boolean {
+ return this.impl.isIdle();
}
- run(opts?: RetryLoopOpts | undefined): Promise<void> {
- return this.impl.run(opts);
+ ensureRunning(): Promise<void> {
+ return this.impl.ensureRunning();
}
+
startShepherdTask(taskId: TaskIdStr): void {
this.declareDep(taskId);
this.oc.observe({
@@ -80,6 +84,7 @@ export class ObservableTaskScheduler implements TaskScheduler {
});
return this.impl.startShepherdTask(taskId);
}
+
stopShepherdTask(taskId: TaskIdStr): void {
this.declareDep(taskId);
this.oc.observe({
@@ -88,6 +93,7 @@ export class ObservableTaskScheduler implements TaskScheduler {
});
return this.impl.stopShepherdTask(taskId);
}
+
resetTaskRetries(taskId: TaskIdStr): Promise<void> {
this.declareDep(taskId);
if (this.taskDepCache.size > 500) {
@@ -99,7 +105,8 @@ export class ObservableTaskScheduler implements TaskScheduler {
});
return this.impl.resetTaskRetries(taskId);
}
- reload(): void {
+
+ async reload(): Promise<void> {
return this.impl.reload();
}
}
@@ -170,21 +177,21 @@ export class ObservableDbAccess<StoreMap> implements DbAccess<StoreMap> {
const location = getCallerInfo();
this.oc.observe({
type: ObservabilityEventType.DbQueryStart,
- name: "<unknown>",
+ name: options.label ?? "<unknown>",
location,
});
try {
const ret = await this.impl.runAllStoresReadOnlyTx(options, txf);
this.oc.observe({
type: ObservabilityEventType.DbQueryFinishSuccess,
- name: "<unknown>",
+ name: options.label ?? "<unknown>",
location,
});
return ret;
} catch (e) {
this.oc.observe({
type: ObservabilityEventType.DbQueryFinishError,
- name: "<unknown>",
+ name: options.label ?? "<unknown>",
location,
});
throw e;
@@ -192,27 +199,30 @@ export class ObservableDbAccess<StoreMap> implements DbAccess<StoreMap> {
}
async runReadWriteTx<T, StoreNameArray extends StoreNames<StoreMap>[]>(
- storeNames: StoreNameArray,
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
): Promise<T> {
const location = getCallerInfo();
this.oc.observe({
type: ObservabilityEventType.DbQueryStart,
- name: "<unknown>",
+ name: opts.label ?? "<unknown>",
location,
});
try {
- const ret = await this.impl.runReadWriteTx(storeNames, txf);
+ const ret = await this.impl.runReadWriteTx(opts, txf);
this.oc.observe({
type: ObservabilityEventType.DbQueryFinishSuccess,
- name: "<unknown>",
+ name: opts.label ?? "<unknown>",
location,
});
return ret;
} catch (e) {
this.oc.observe({
type: ObservabilityEventType.DbQueryFinishError,
- name: "<unknown>",
+ name: opts.label ?? "<unknown>",
location,
});
throw e;
@@ -220,27 +230,30 @@ export class ObservableDbAccess<StoreMap> implements DbAccess<StoreMap> {
}
async runReadOnlyTx<T, StoreNameArray extends StoreNames<StoreMap>[]>(
- storeNames: StoreNameArray,
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
): Promise<T> {
const location = getCallerInfo();
try {
this.oc.observe({
type: ObservabilityEventType.DbQueryStart,
- name: "<unknown>",
+ name: opts.label ?? "<unknown>",
location,
});
- const ret = await this.impl.runReadOnlyTx(storeNames, txf);
+ const ret = await this.impl.runReadOnlyTx(opts, txf);
this.oc.observe({
type: ObservabilityEventType.DbQueryFinishSuccess,
- name: "<unknown>",
+ name: opts.label ?? "<unknown>",
location,
});
return ret;
} catch (e) {
this.oc.observe({
type: ObservabilityEventType.DbQueryFinishError,
- name: "<unknown>",
+ name: opts.label ?? "<unknown>",
location,
});
throw e;
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
index 80e88337e..4a2ef009a 100644
--- a/packages/taler-wallet-core/src/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -200,7 +200,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
const ws = this.wex;
const extraStores = opts.extraStores ?? [];
const transitionInfo = await ws.db.runReadWriteTx(
- ["purchases", ...extraStores],
+ { storeNames: ["purchases", ...extraStores] },
async (tx) => {
const purchaseRec = await tx.purchases.get(this.proposalId);
if (!purchaseRec) {
@@ -227,26 +227,29 @@ export class PayMerchantTransactionContext implements TransactionContext {
async deleteTransaction(): Promise<void> {
const { wex: ws, proposalId } = this;
- await ws.db.runReadWriteTx(["purchases", "tombstones"], async (tx) => {
- let found = false;
- const purchase = await tx.purchases.get(proposalId);
- if (purchase) {
- found = true;
- await tx.purchases.delete(proposalId);
- }
- if (found) {
- await tx.tombstones.put({
- id: TombstoneTag.DeletePayment + ":" + proposalId,
- });
- }
- });
+ await ws.db.runReadWriteTx(
+ { storeNames: ["purchases", "tombstones"] },
+ async (tx) => {
+ let found = false;
+ const purchase = await tx.purchases.get(proposalId);
+ if (purchase) {
+ found = true;
+ await tx.purchases.delete(proposalId);
+ }
+ if (found) {
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePayment + ":" + proposalId,
+ });
+ }
+ },
+ );
}
async suspendTransaction(): Promise<void> {
const { wex, proposalId, transactionId } = this;
wex.taskScheduler.stopShepherdTask(this.taskId);
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@@ -268,15 +271,17 @@ export class PayMerchantTransactionContext implements TransactionContext {
async abortTransaction(): Promise<void> {
const { wex, proposalId, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- [
- "purchases",
- "refreshGroups",
- "refreshSessions",
- "denominations",
- "coinAvailability",
- "coins",
- "operationRetries",
- ],
+ {
+ storeNames: [
+ "purchases",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@@ -344,7 +349,7 @@ export class PayMerchantTransactionContext implements TransactionContext {
async resumeTransaction(): Promise<void> {
const { wex, proposalId, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@@ -367,14 +372,16 @@ export class PayMerchantTransactionContext implements TransactionContext {
async failTransaction(): Promise<void> {
const { wex, proposalId, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- [
- "purchases",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- "operationRetries",
- ],
+ {
+ storeNames: [
+ "purchases",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
@@ -415,15 +422,18 @@ export class RefundTransactionContext implements TransactionContext {
async deleteTransaction(): Promise<void> {
const { wex, refundGroupId, transactionId } = this;
- await wex.db.runReadWriteTx(["refundGroups", "tombstones"], async (tx) => {
- const refundRecord = await tx.refundGroups.get(refundGroupId);
- if (!refundRecord) {
- return;
- }
- await tx.refundGroups.delete(refundGroupId);
- await tx.tombstones.put({ id: transactionId });
- // FIXME: Also tombstone the refund items, so that they won't reappear.
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["refundGroups", "tombstones"] },
+ async (tx) => {
+ const refundRecord = await tx.refundGroups.get(refundGroupId);
+ if (!refundRecord) {
+ return;
+ }
+ await tx.refundGroups.delete(refundGroupId);
+ await tx.tombstones.put({ id: transactionId });
+ // FIXME: Also tombstone the refund items, so that they won't reappear.
+ },
+ );
}
suspendTransaction(): Promise<void> {
@@ -455,31 +465,34 @@ export async function getTotalPaymentCost(
currency: string,
pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
- return wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- const costs: AmountJson[] = [];
- for (let i = 0; i < pcs.length; i++) {
- const denom = await tx.denominations.get([
- pcs[i].exchangeBaseUrl,
- pcs[i].denomPubHash,
- ]);
- if (!denom) {
- throw Error(
- "can't calculate payment cost, denomination for coin not found",
+ return wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ const costs: AmountJson[] = [];
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await tx.denominations.get([
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
);
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
+ costs.push(refreshCost);
}
- const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
- const refreshCost = await getTotalRefreshCost(
- wex,
- tx,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- );
- costs.push(Amounts.parseOrThrow(pcs[i].contribution));
- costs.push(refreshCost);
- }
- const zero = Amounts.zeroOfCurrency(currency);
- return Amounts.sum([zero, ...costs]).amount;
- });
+ const zero = Amounts.zeroOfCurrency(currency);
+ return Amounts.sum([zero, ...costs]).amount;
+ },
+ );
}
async function failProposalPermanently(
@@ -492,7 +505,7 @@ async function failProposalPermanently(
proposalId,
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -554,7 +567,10 @@ export async function expectProposalDownload(
if (parentTx) {
return getFromTransaction(parentTx);
}
- return await wex.db.runReadOnlyTx(["contractTerms"], getFromTransaction);
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms"] },
+ getFromTransaction,
+ );
}
export function extractContractData(
@@ -593,9 +609,12 @@ async function processDownloadProposal(
wex: WalletExecutionContext,
proposalId: string,
): Promise<TaskRunResult> {
- const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return await tx.purchases.get(proposalId);
- });
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return await tx.purchases.get(proposalId);
+ },
+ );
if (!proposal) {
return TaskRunResult.finished();
@@ -766,7 +785,7 @@ async function processDownloadProposal(
logger.trace(`extracted contract data: ${j2s(contractData)}`);
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases", "contractTerms"],
+ { storeNames: ["purchases", "contractTerms"] },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -839,12 +858,15 @@ async function createOrReusePurchase(
claimToken: string | undefined,
noncePriv: string | undefined,
): Promise<string> {
- const oldProposals = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.indexes.byUrlAndOrderId.getAll([
- merchantBaseUrl,
- orderId,
- ]);
- });
+ const oldProposals = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.getAll([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ },
+ );
const oldProposal = oldProposals.find((p) => {
return (
@@ -878,7 +900,7 @@ async function createOrReusePurchase(
// if this transaction was shared and the order is paid then it
// means that another wallet already paid the proposal
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(oldProposal.proposalId);
if (!p) {
@@ -944,7 +966,7 @@ async function createOrReusePurchase(
};
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
await tx.purchases.put(proposalRecord);
const oldTxState: TransactionState = {
@@ -978,7 +1000,7 @@ async function storeFirstPaySuccess(
});
const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
const transitionInfo = await wex.db.runReadWriteTx(
- ["contractTerms", "purchases"],
+ { storeNames: ["contractTerms", "purchases"] },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
@@ -1042,7 +1064,7 @@ async function storePayReplaySuccess(
proposalId,
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
@@ -1085,9 +1107,12 @@ async function handleInsufficientFunds(
): Promise<void> {
logger.trace("handling insufficient funds, trying to re-select coins");
- const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (!proposal) {
return;
}
@@ -1127,16 +1152,19 @@ async function handleInsufficientFunds(
return;
}
- await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- const coinPub = payCoinSelection.coinPubs[i];
- const contrib = payCoinSelection.coinContributions[i];
- prevPayCoins.push({
- coinPub,
- contribution: Amounts.parseOrThrow(contrib),
- });
- }
- });
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const contrib = payCoinSelection.coinContributions[i];
+ prevPayCoins.push({
+ coinPub,
+ contribution: Amounts.parseOrThrow(contrib),
+ });
+ }
+ },
+ );
const res = await selectPayCoins(wex, {
restrictExchanges: {
@@ -1165,14 +1193,16 @@ async function handleInsufficientFunds(
logger.trace("re-selected coins");
await wex.db.runReadWriteTx(
- [
- "purchases",
- "coins",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "refreshSessions",
- ],
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -1221,9 +1251,12 @@ async function checkPaymentByProposalId(
proposalId: string,
sessionId?: string,
): Promise<PreparePayResult> {
- let proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ let proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (!proposal) {
throw Error(`could not get proposal ${proposalId}`);
}
@@ -1232,7 +1265,7 @@ async function checkPaymentByProposalId(
if (existingProposalId) {
logger.trace("using existing purchase for same product");
const oldProposal = await wex.db.runReadOnlyTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
return tx.purchases.get(existingProposalId);
},
@@ -1266,9 +1299,12 @@ async function checkPaymentByProposalId(
});
// First check if we already paid for it.
- const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (
!purchase ||
@@ -1344,7 +1380,7 @@ async function checkPaymentByProposalId(
);
logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -1421,9 +1457,12 @@ export async function getContractTermsDetails(
wex: WalletExecutionContext,
proposalId: string,
): Promise<WalletContractData> {
- const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (!proposal) {
throw Error(`proposal with id ${proposalId} not found`);
@@ -1513,7 +1552,7 @@ async function internalWaitProposalDownloaded(
): Promise<void> {
while (true) {
const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx(
- ["purchases", "operationRetries"],
+ { storeNames: ["purchases", "operationRetries"] },
async (tx) => {
return {
purchase: await tx.purchases.get(ctx.proposalId),
@@ -1610,24 +1649,27 @@ export async function generateDepositPermissions(
coin: CoinRecord;
denom: DenominationRecord;
}> = [];
- await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
- const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
- if (!coin) {
- throw Error("can't pay, allocated coin not found anymore");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error(
- "can't pay, denomination of allocated coin not found anymore",
- );
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
+ const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't pay, allocated coin not found anymore");
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't pay, denomination of allocated coin not found anymore",
+ );
+ }
+ coinWithDenom.push({ coin, denom });
}
- coinWithDenom.push({ coin, denom });
- }
- });
+ },
+ );
for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
const { coin, denom } = coinWithDenom[i];
@@ -1662,7 +1704,7 @@ async function internalWaitPaymentResult(
): Promise<ConfirmPayResult> {
while (true) {
const txRes = await ctx.wex.db.runReadOnlyTx(
- ["purchases", "operationRetries"],
+ { storeNames: ["purchases", "operationRetries"] },
async (tx) => {
const purchase = await tx.purchases.get(ctx.proposalId);
const retryRecord = await tx.operationRetries.get(ctx.taskId);
@@ -1776,9 +1818,12 @@ export async function confirmPay(
logger.trace(
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
);
- const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (!proposal) {
throw Error(`proposal with id ${proposalId} not found`);
@@ -1790,7 +1835,7 @@ export async function confirmPay(
}
const existingPurchase = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (
@@ -1875,14 +1920,16 @@ export async function confirmPay(
);
const transitionInfo = await wex.db.runReadWriteTx(
- [
- "purchases",
- "coins",
- "refreshGroups",
- "refreshSessions",
- "denominations",
- "coinAvailability",
- ],
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const p = await tx.purchases.get(proposal.proposalId);
if (!p) {
@@ -1954,9 +2001,12 @@ export async function processPurchase(
wex: WalletExecutionContext,
proposalId: string,
): Promise<TaskRunResult> {
- const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (!purchase) {
return {
type: TaskRunResultType.Error,
@@ -2013,9 +2063,12 @@ async function processPurchasePay(
wex: WalletExecutionContext,
proposalId: string,
): Promise<TaskRunResult> {
- const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(proposalId);
- });
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
if (!purchase) {
return {
type: TaskRunResultType.Error,
@@ -2056,7 +2109,7 @@ async function processPurchasePay(
if (paid) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
@@ -2128,14 +2181,16 @@ async function processPurchasePay(
);
const transitionDone = await wex.db.runReadWriteTx(
- [
- "purchases",
- "coins",
- "refreshGroups",
- "refreshSessions",
- "denominations",
- "coinAvailability",
- ],
+ {
+ storeNames: [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -2329,7 +2384,7 @@ export async function refuseProposal(
proposalId,
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const proposal = await tx.purchases.get(proposalId);
if (!proposal) {
@@ -2603,42 +2658,45 @@ export async function sharePayment(
merchantBaseUrl: string,
orderId: string,
): Promise<SharePaymentResult> {
- const result = await wex.db.runReadWriteTx(["purchases"], async (tx) => {
- const p = await tx.purchases.indexes.byUrlAndOrderId.get([
- merchantBaseUrl,
- orderId,
- ]);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return undefined;
- }
- if (
- p.purchaseStatus !== PurchaseStatus.DialogProposed &&
- p.purchaseStatus !== PurchaseStatus.DialogShared
- ) {
- // FIXME: purchase can be shared before being paid
- return undefined;
- }
- const oldTxState = computePayMerchantTransactionState(p);
- if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
- p.purchaseStatus = PurchaseStatus.DialogShared;
- p.shared = true;
- await tx.purchases.put(p);
- }
+ const result = await wex.db.runReadWriteTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ const p = await tx.purchases.indexes.byUrlAndOrderId.get([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (
+ p.purchaseStatus !== PurchaseStatus.DialogProposed &&
+ p.purchaseStatus !== PurchaseStatus.DialogShared
+ ) {
+ // FIXME: purchase can be shared before being paid
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
+ p.purchaseStatus = PurchaseStatus.DialogShared;
+ p.shared = true;
+ await tx.purchases.put(p);
+ }
- const newTxState = computePayMerchantTransactionState(p);
+ const newTxState = computePayMerchantTransactionState(p);
- return {
- proposalId: p.proposalId,
- nonce: p.noncePriv,
- session: p.lastSessionId ?? p.downloadSessionId,
- token: p.claimToken,
- transitionInfo: {
- oldTxState,
- newTxState,
- },
- };
- });
+ return {
+ proposalId: p.proposalId,
+ nonce: p.noncePriv,
+ session: p.lastSessionId ?? p.downloadSessionId,
+ token: p.claimToken,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
+ },
+ );
if (result === undefined) {
throw Error("This purchase can't be shared");
@@ -2713,7 +2771,7 @@ async function processPurchaseDialogShared(
);
if (paid) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
@@ -2752,23 +2810,47 @@ async function processPurchaseAutoRefund(
const download = await expectProposalDownload(wex, purchase);
- if (
+ const noAutoRefundOrExpired =
!purchase.autoRefundDeadline ||
AbsoluteTime.isExpired(
AbsoluteTime.fromProtocolTimestamp(
timestampProtocolFromDb(purchase.autoRefundDeadline),
),
- )
- ) {
+ );
+
+ const totalKnownRefund = await wex.db.runReadOnlyTx(
+ { storeNames: ["refundGroups"] },
+ async (tx) => {
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+ const am = Amounts.parseOrThrow(download.contractData.amount);
+ return refunds.reduce((prev, cur) => {
+ if (
+ cur.status === RefundGroupStatus.Done ||
+ cur.status === RefundGroupStatus.Pending
+ ) {
+ return Amounts.add(prev, cur.amountEffective).amount;
+ }
+ return prev;
+ }, Amounts.zeroOfAmount(am));
+ },
+ );
+
+ const refundedIsLessThanPrice =
+ Amounts.cmp(download.contractData.amount, totalKnownRefund) === +1;
+ const nothingMoreToRefund = !refundedIsLessThanPrice;
+
+ if (noAutoRefundOrExpired || nothingMoreToRefund) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
logger.warn("purchase does not exist anymore");
return;
}
- if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
return;
}
const oldTxState = computePayMerchantTransactionState(p);
@@ -2792,8 +2874,8 @@ async function processPurchaseAutoRefund(
download.contractData.contractTermsHash,
);
- requestUrl.searchParams.set("timeout_ms", "1000");
- requestUrl.searchParams.set("await_refund_obtained", "yes");
+ requestUrl.searchParams.set("timeout_ms", "10000");
+ requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund));
const resp = await wex.http.fetch(requestUrl.href, {
cancellationToken: wex.cancellationToken,
@@ -2808,7 +2890,7 @@ async function processPurchaseAutoRefund(
if (orderStatus.refund_pending) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
@@ -2826,9 +2908,10 @@ async function processPurchaseAutoRefund(
},
);
notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
}
- return TaskRunResult.backoff();
+ return TaskRunResult.longpollReturnedPending();
}
async function processPurchaseAbortingRefund(
@@ -2851,7 +2934,7 @@ async function processPurchaseAbortingRefund(
throw Error("can't abort, no coins selected");
}
- await wex.db.runReadOnlyTx(["coins"], async (tx) => {
+ await wex.db.runReadOnlyTx({ storeNames: ["coins"] }, async (tx) => {
for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
const coinPub = payCoinSelection.coinPubs[i];
const coin = await tx.coins.get(coinPub);
@@ -2957,7 +3040,7 @@ async function processPurchaseQueryRefund(
if (!orderStatus.refund_pending) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
@@ -2984,7 +3067,7 @@ async function processPurchaseQueryRefund(
).amount;
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
if (!p) {
@@ -3052,7 +3135,7 @@ export async function startRefundQueryForUri(
throw Error("expected taler://refund URI");
}
const purchaseRecord = await wex.db.runReadOnlyTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
return tx.purchases.indexes.byUrlAndOrderId.get([
parsedUri.merchantBaseUrl,
@@ -3083,7 +3166,7 @@ export async function startQueryRefund(
): Promise<void> {
const ctx = new PayMerchantTransactionContext(wex, proposalId);
const transitionInfo = await wex.db.runReadWriteTx(
- ["purchases"],
+ { storeNames: ["purchases"] },
async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@@ -3176,18 +3259,20 @@ async function storeRefunds(
const currency = Amounts.currencyOf(download.contractData.amount);
const result = await wex.db.runReadWriteTx(
- [
- "coins",
- "denominations",
- "purchases",
- "refundItems",
- "refundGroups",
- "denominations",
- "coins",
- "coinAvailability",
- "refreshGroups",
- "refreshSessions",
- ],
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "purchases",
+ "refundItems",
+ "refundGroups",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
async (tx) => {
const myPurchase = await tx.purchases.get(purchase.proposalId);
if (!myPurchase) {
@@ -3344,9 +3429,20 @@ async function storeRefunds(
}
const oldTxState = computePayMerchantTransactionState(myPurchase);
+
+ const shouldCheckAutoRefund =
+ myPurchase.autoRefundDeadline &&
+ !AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(myPurchase.autoRefundDeadline),
+ ),
+ );
+
if (numPendingItemsTotal === 0) {
if (isAborting) {
myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
+ } else if (shouldCheckAutoRefund) {
+ myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
} else {
myPurchase.purchaseStatus = PurchaseStatus.Done;
}
diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts
index 0bb290440..bfd39b657 100644
--- a/packages/taler-wallet-core/src/pay-peer-common.ts
+++ b/packages/taler-wallet-core/src/pay-peer-common.ts
@@ -43,31 +43,34 @@ export async function queryCoinInfosForSelection(
csel: DbPeerPushPaymentCoinSelection,
): Promise<SpendCoinDetails[]> {
let infos: SpendCoinDetails[] = [];
- await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- for (let i = 0; i < csel.coinPubs.length; i++) {
- const coin = await tx.coins.get(csel.coinPubs[i]);
- if (!coin) {
- throw Error("coin not found anymore");
- }
- const denom = await getDenomInfo(
- wex,
- tx,
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- );
- if (!denom) {
- throw Error("denom for coin not found anymore");
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ for (let i = 0; i < csel.coinPubs.length; i++) {
+ const coin = await tx.coins.get(csel.coinPubs[i]);
+ if (!coin) {
+ throw Error("coin not found anymore");
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ throw Error("denom for coin not found anymore");
+ }
+ infos.push({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ contribution: csel.contributions[i],
+ });
}
- infos.push({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- ageCommitmentProof: coin.ageCommitmentProof,
- contribution: csel.contributions[i],
- });
- }
- });
+ },
+ );
return infos;
}
@@ -75,36 +78,39 @@ export async function getTotalPeerPaymentCost(
wex: WalletExecutionContext,
pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
- return wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- const costs: AmountJson[] = [];
- for (let i = 0; i < pcs.length; i++) {
- const denomInfo = await getDenomInfo(
- wex,
- tx,
- pcs[i].exchangeBaseUrl,
- pcs[i].denomPubHash,
- );
- if (!denomInfo) {
- throw Error(
- "can't calculate payment cost, denomination for coin not found",
+ return wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ const costs: AmountJson[] = [];
+ for (let i = 0; i < pcs.length; i++) {
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const amountLeft = Amounts.sub(
+ denomInfo.value,
+ pcs[i].contribution,
+ ).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
+ denomInfo,
+ amountLeft,
);
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
+ costs.push(refreshCost);
}
- const amountLeft = Amounts.sub(
- denomInfo.value,
- pcs[i].contribution,
- ).amount;
- const refreshCost = await getTotalRefreshCost(
- wex,
- tx,
- denomInfo,
- amountLeft,
- );
- costs.push(Amounts.parseOrThrow(pcs[i].contribution));
- costs.push(refreshCost);
- }
- const zero = Amounts.zeroOfAmount(pcs[0].contribution);
- return Amounts.sum([zero, ...costs]).amount;
- });
+ const zero = Amounts.zeroOfAmount(pcs[0].contribution);
+ return Amounts.sum([zero, ...costs]).amount;
+ },
+ );
}
interface ExchangePurseStatus {
@@ -131,7 +137,7 @@ export async function getMergeReserveInfo(
const newReservePair = await wex.cryptoApi.createEddsaKeypair({});
const mergeReserveRecord: ReserveRecord = await wex.db.runReadWriteTx(
- ["exchanges", "reserves"],
+ { storeNames: ["exchanges", "reserves"] },
async (tx) => {
const ex = await tx.exchanges.get(req.exchangeBaseUrl);
checkDbInvariant(!!ex);
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
index 4155f83e6..840c244d0 100644
--- a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
@@ -110,7 +110,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
async deleteTransaction(): Promise<void> {
const { wex: ws, pursePub } = this;
await ws.db.runReadWriteTx(
- ["withdrawalGroups", "peerPullCredit", "tombstones"],
+ { storeNames: ["withdrawalGroups", "peerPullCredit", "tombstones"] },
async (tx) => {
const pullIni = await tx.peerPullCredit.get(pursePub);
if (!pullIni) {
@@ -140,7 +140,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
async suspendTransaction(): Promise<void> {
const { wex, pursePub, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullCredit"],
+ { storeNames: ["peerPullCredit"] },
async (tx) => {
const pullCreditRec = await tx.peerPullCredit.get(pursePub);
if (!pullCreditRec) {
@@ -200,7 +200,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
async failTransaction(): Promise<void> {
const { wex, pursePub, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullCredit"],
+ { storeNames: ["peerPullCredit"] },
async (tx) => {
const pullCreditRec = await tx.peerPullCredit.get(pursePub);
if (!pullCreditRec) {
@@ -251,7 +251,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
async resumeTransaction(): Promise<void> {
const { wex, pursePub, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullCredit"],
+ { storeNames: ["peerPullCredit"] },
async (tx) => {
const pullCreditRec = await tx.peerPullCredit.get(pursePub);
if (!pullCreditRec) {
@@ -310,7 +310,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
async abortTransaction(): Promise<void> {
const { wex, pursePub, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullCredit"],
+ { storeNames: ["peerPullCredit"] },
async (tx) => {
const pullCreditRec = await tx.peerPullCredit.get(pursePub);
if (!pullCreditRec) {
@@ -388,7 +388,7 @@ async function queryPurseForPeerPullCredit(
case HttpStatusCode.Gone: {
// Exchange says that purse doesn't exist anymore => expired!
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullCredit"],
+ { storeNames: ["peerPullCredit"] },
async (tx) => {
const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
if (!finPi) {
@@ -426,9 +426,12 @@ async function queryPurseForPeerPullCredit(
return TaskRunResult.backoff();
}
- const reserve = await wex.db.runReadOnlyTx(["reserves"], async (tx) => {
- return await tx.reserves.get(pullIni.mergeReserveRowId);
- });
+ const reserve = await wex.db.runReadOnlyTx(
+ { storeNames: ["reserves"] },
+ async (tx) => {
+ return await tx.reserves.get(pullIni.mergeReserveRowId);
+ },
+ );
if (!reserve) {
throw Error("reserve for peer pull credit not found in wallet DB");
@@ -449,7 +452,7 @@ async function queryPurseForPeerPullCredit(
},
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullCredit"],
+ { storeNames: ["peerPullCredit"] },
async (tx) => {
const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
if (!finPi) {
@@ -497,7 +500,7 @@ async function longpollKycStatus(
kycStatusRes.status === HttpStatusCode.NoContent
) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullCredit"],
+ { storeNames: ["peerPullCredit"] },
async (tx) => {
const peerIni = await tx.peerPullCredit.get(pursePub);
if (!peerIni) {
@@ -548,13 +551,15 @@ async function processPeerPullCreditAbortingDeletePurse(
logger.info(`deleted purse with response status ${resp.status}`);
const transitionInfo = await wex.db.runReadWriteTx(
- [
- "peerPullCredit",
- "refreshGroups",
- "denominations",
- "coinAvailability",
- "coins",
- ],
+ {
+ storeNames: [
+ "peerPullCredit",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ },
async (tx) => {
const ppiRec = await tx.peerPullCredit.get(pursePub);
if (!ppiRec) {
@@ -593,7 +598,7 @@ async function handlePeerPullCreditWithdrawing(
const wgId = pullIni.withdrawalGroupId;
let finished: boolean = false;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullCredit", "withdrawalGroups"],
+ { storeNames: ["peerPullCredit", "withdrawalGroups"] },
async (tx) => {
const ppi = await tx.peerPullCredit.get(pullIni.pursePub);
if (!ppi) {
@@ -640,16 +645,19 @@ async function handlePeerPullCreditCreatePurse(
): Promise<TaskRunResult> {
const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
const pursePub = pullIni.pursePub;
- const mergeReserve = await wex.db.runReadOnlyTx(["reserves"], async (tx) => {
- return tx.reserves.get(pullIni.mergeReserveRowId);
- });
+ const mergeReserve = await wex.db.runReadOnlyTx(
+ { storeNames: ["reserves"] },
+ async (tx) => {
+ return tx.reserves.get(pullIni.mergeReserveRowId);
+ },
+ );
if (!mergeReserve) {
throw Error("merge reserve for peer pull payment not found in database");
}
const contractTermsRecord = await wex.db.runReadOnlyTx(
- ["contractTerms"],
+ { storeNames: ["contractTerms"] },
async (tx) => {
return tx.contractTerms.get(pullIni.contractTermsHash);
},
@@ -737,7 +745,7 @@ async function handlePeerPullCreditCreatePurse(
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullCredit"],
+ { storeNames: ["peerPullCredit"] },
async (tx) => {
const pi2 = await tx.peerPullCredit.get(pursePub);
if (!pi2) {
@@ -758,9 +766,12 @@ export async function processPeerPullCredit(
wex: WalletExecutionContext,
pursePub: string,
): Promise<TaskRunResult> {
- const pullIni = await wex.db.runReadOnlyTx(["peerPullCredit"], async (tx) => {
- return tx.peerPullCredit.get(pursePub);
- });
+ const pullIni = await wex.db.runReadOnlyTx(
+ { storeNames: ["peerPullCredit"] },
+ async (tx) => {
+ return tx.peerPullCredit.get(pursePub);
+ },
+ );
if (!pullIni) {
throw Error("peer pull payment initiation not found in database");
}
@@ -847,7 +858,7 @@ async function processPeerPullCreditKycRequired(
const kycStatus = await kycStatusRes.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
const { transitionInfo, result } = await wex.db.runReadWriteTx(
- ["peerPullCredit"],
+ { storeNames: ["peerPullCredit"] },
async (tx) => {
const peerInc = await tx.peerPullCredit.get(pursePub);
if (!peerInc) {
@@ -947,42 +958,45 @@ async function getPreferredExchangeForCurrency(
): Promise<string | undefined> {
// Find an exchange with the matching currency.
// Prefer exchanges with the most recent withdrawal.
- const url = await wex.db.runReadOnlyTx(["exchanges"], async (tx) => {
- const exchanges = await tx.exchanges.iter().toArray();
- let candidate = undefined;
- for (const e of exchanges) {
- if (e.detailsPointer?.currency !== currency) {
- continue;
- }
- if (!candidate) {
- candidate = e;
- continue;
- }
- if (candidate.lastWithdrawal && !e.lastWithdrawal) {
- continue;
- }
- const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
- e.lastWithdrawal,
- );
- const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
- candidate.lastWithdrawal,
- );
- if (exchangeLastWithdrawal && candidateLastWithdrawal) {
- if (
- AbsoluteTime.cmp(
- AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
- AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
- ) > 0
- ) {
+ const url = await wex.db.runReadOnlyTx(
+ { storeNames: ["exchanges"] },
+ async (tx) => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ let candidate = undefined;
+ for (const e of exchanges) {
+ if (e.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ if (!candidate) {
candidate = e;
+ continue;
+ }
+ if (candidate.lastWithdrawal && !e.lastWithdrawal) {
+ continue;
+ }
+ const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
+ e.lastWithdrawal,
+ );
+ const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
+ candidate.lastWithdrawal,
+ );
+ if (exchangeLastWithdrawal && candidateLastWithdrawal) {
+ if (
+ AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
+ AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
+ ) > 0
+ ) {
+ candidate = e;
+ }
}
}
- }
- if (candidate) {
- return candidate.baseUrl;
- }
- return undefined;
- });
+ if (candidate) {
+ return candidate.baseUrl;
+ }
+ return undefined;
+ },
+ );
return url;
}
@@ -1039,7 +1053,7 @@ export async function initiatePeerPullPayment(
const mergeTimestamp = TalerPreciseTimestamp.now();
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullCredit", "contractTerms"],
+ { storeNames: ["peerPullCredit", "contractTerms"] },
async (tx) => {
const ppi: PeerPullCreditRecord = {
amount: req.partialContractTerms.amount,
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
index 705317eb6..0355b58ad 100644
--- a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -33,6 +33,7 @@ import {
HttpStatusCode,
Logger,
NotificationType,
+ ObservabilityEventType,
PeerContractTerms,
PreparePeerPullDebitRequest,
PreparePeerPullDebitResponse,
@@ -125,13 +126,16 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
const transactionId = this.transactionId;
const ws = this.wex;
const peerPullDebitId = this.peerPullDebitId;
- await ws.db.runReadWriteTx(["peerPullDebit", "tombstones"], async (tx) => {
- const debit = await tx.peerPullDebit.get(peerPullDebitId);
- if (debit) {
- await tx.peerPullDebit.delete(peerPullDebitId);
- await tx.tombstones.put({ id: transactionId });
- }
- });
+ await ws.db.runReadWriteTx(
+ { storeNames: ["peerPullDebit", "tombstones"] },
+ async (tx) => {
+ const debit = await tx.peerPullDebit.get(peerPullDebitId);
+ if (debit) {
+ await tx.peerPullDebit.delete(peerPullDebitId);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ },
+ );
}
async suspendTransaction(): Promise<void> {
@@ -140,7 +144,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
const wex = this.wex;
const peerPullDebitId = this.peerPullDebitId;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullDebit"],
+ { storeNames: ["peerPullDebit"] },
async (tx) => {
const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
if (!pullDebitRec) {
@@ -304,7 +308,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
const wex = this.wex;
const extraStores = opts.extraStores ?? [];
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullDebit", ...extraStores],
+ { storeNames: ["peerPullDebit", ...extraStores] },
async (tx) => {
const pi = await tx.peerPullDebit.get(this.peerPullDebitId);
if (!pi) {
@@ -397,7 +401,7 @@ async function handlePurseCreationConflict(
coinSelRes.result.coins,
);
- await ws.db.runReadWriteTx(["peerPullDebit"], async (tx) => {
+ await ws.db.runReadWriteTx({ storeNames: ["peerPullDebit"] }, async (tx) => {
const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
if (!myPpi) {
return;
@@ -425,6 +429,11 @@ async function processPeerPullDebitPendingDeposit(
wex: WalletExecutionContext,
peerPullInc: PeerPullPaymentIncomingRecord,
): Promise<TaskRunResult> {
+ const ctx = new PeerPullDebitTransactionContext(
+ wex,
+ peerPullInc.peerPullDebitId,
+ );
+
const pursePub = peerPullInc.pursePub;
const coinSel = peerPullInc.coinSel;
@@ -464,15 +473,17 @@ async function processPeerPullDebitPendingDeposit(
// FIXME: Missing notification here!
const transitionDone = await wex.db.runReadWriteTx(
- [
- "exchanges",
- "coins",
- "denominations",
- "refreshGroups",
- "refreshSessions",
- "peerPullDebit",
- "coinAvailability",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const pi = await tx.peerPullDebit.get(peerPullDebitId);
if (!pi) {
@@ -512,70 +523,82 @@ async function processPeerPullDebitPendingDeposit(
}
}
- const coins = await queryCoinInfosForSelection(wex, coinSel);
-
- const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
- exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
- pursePub: peerPullInc.pursePub,
- coins,
- });
-
const purseDepositUrl = new URL(
`purses/${pursePub}/deposit`,
peerPullInc.exchangeBaseUrl,
);
- const depositPayload: ExchangePurseDeposits = {
- deposits: depositSigsResp.deposits,
- };
+ // FIXME: We could skip batches that we've already submitted.
- if (logger.shouldLogTrace()) {
- logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
- }
+ const coins = await queryCoinInfosForSelection(wex, coinSel);
- const httpResp = await wex.http.fetch(purseDepositUrl.href, {
- method: "POST",
- body: depositPayload,
- cancellationToken: wex.cancellationToken,
- });
+ const maxBatchSize = 100;
- const ctx = new PeerPullDebitTransactionContext(
- wex,
- peerPullInc.peerPullDebitId,
- );
+ for (let i = 0; i < coins.length; i += maxBatchSize) {
+ const batchSize = Math.min(maxBatchSize, coins.length - i);
- switch (httpResp.status) {
- case HttpStatusCode.Ok: {
- const resp = await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForAny(),
- );
- logger.trace(`purse deposit response: ${j2s(resp)}`);
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Depositing batch at ${i}/${coins.length} of size ${batchSize}`,
+ });
- await ctx.transition(async (r) => {
- if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
- return TransitionResultType.Stay;
- }
- r.status = PeerPullDebitRecordStatus.Done;
- return TransitionResultType.Transition;
- });
- return TaskRunResult.finished();
- }
- case HttpStatusCode.Gone: {
- await ctx.abortTransaction();
- return TaskRunResult.backoff();
- }
- case HttpStatusCode.Conflict: {
- return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
+ const batchCoins = coins.slice(i, i + batchSize);
+ const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
+ pursePub: peerPullInc.pursePub,
+ coins: batchCoins,
+ });
+
+ const depositPayload: ExchangePurseDeposits = {
+ deposits: depositSigsResp.deposits,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
}
- default: {
- const errResp = await readTalerErrorResponse(httpResp);
- return {
- type: TaskRunResultType.Error,
- errorDetail: errResp,
- };
+
+ const httpResp = await wex.http.fetch(purseDepositUrl.href, {
+ method: "POST",
+ body: depositPayload,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok: {
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForAny(),
+ );
+ logger.trace(`purse deposit response: ${j2s(resp)}`);
+ continue;
+ }
+ case HttpStatusCode.Gone: {
+ await ctx.abortTransaction();
+ return TaskRunResult.backoff();
+ }
+ case HttpStatusCode.Conflict: {
+ return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
}
}
+
+ // All batches succeeded, we can transition!
+
+ await ctx.transition(async (r) => {
+ if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
+ return TransitionResultType.Stay;
+ }
+ r.status = PeerPullDebitRecordStatus.Done;
+ return TransitionResultType.Transition;
+ });
+ return TaskRunResult.finished();
}
async function processPeerPullDebitAbortingRefresh(
@@ -590,7 +613,7 @@ async function processPeerPullDebitAbortingRefresh(
peerPullDebitId,
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPullDebit", "refreshGroups"],
+ { storeNames: ["peerPullDebit", "refreshGroups"] },
async (tx) => {
const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
let newOpState: PeerPullDebitRecordStatus | undefined;
@@ -632,7 +655,7 @@ export async function processPeerPullDebit(
peerPullDebitId: string,
): Promise<TaskRunResult> {
const peerPullInc = await wex.db.runReadOnlyTx(
- ["peerPullDebit"],
+ { storeNames: ["peerPullDebit"] },
async (tx) => {
return tx.peerPullDebit.get(peerPullDebitId);
},
@@ -662,7 +685,7 @@ export async function confirmPeerPullDebit(
peerPullDebitId = parsedTx.peerPullDebitId;
const peerPullInc = await wex.db.runReadOnlyTx(
- ["peerPullDebit"],
+ { storeNames: ["peerPullDebit"] },
async (tx) => {
return tx.peerPullDebit.get(peerPullDebitId);
},
@@ -708,15 +731,17 @@ export async function confirmPeerPullDebit(
// FIXME: Missing notification here!
await wex.db.runReadWriteTx(
- [
- "exchanges",
- "coins",
- "denominations",
- "refreshGroups",
- "refreshSessions",
- "peerPullDebit",
- "coinAvailability",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const pi = await tx.peerPullDebit.get(peerPullDebitId);
if (!pi) {
@@ -780,7 +805,7 @@ export async function preparePeerPullDebit(
}
const existing = await wex.db.runReadOnlyTx(
- ["peerPullDebit", "contractTerms"],
+ { storeNames: ["peerPullDebit", "contractTerms"] },
async (tx) => {
const peerPullDebitRecord =
await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
@@ -893,7 +918,7 @@ export async function preparePeerPullDebit(
const totalAmount = await getTotalPeerPaymentCost(wex, coins);
await wex.db.runReadWriteTx(
- ["peerPullDebit", "contractTerms"],
+ { storeNames: ["peerPullDebit", "contractTerms"] },
async (tx) => {
await tx.contractTerms.put({
h: contractTermsHash,
diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
index 281b3ff61..93f1a63a7 100644
--- a/packages/taler-wallet-core/src/pay-peer-push-credit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
@@ -114,7 +114,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
async deleteTransaction(): Promise<void> {
const { wex, peerPushCreditId } = this;
await wex.db.runReadWriteTx(
- ["withdrawalGroups", "peerPushCredit", "tombstones"],
+ { storeNames: ["withdrawalGroups", "peerPushCredit", "tombstones"] },
async (tx) => {
const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!pushInc) {
@@ -143,7 +143,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
async suspendTransaction(): Promise<void> {
const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushCredit"],
+ { storeNames: ["peerPushCredit"] },
async (tx) => {
const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
if (!pushCreditRec) {
@@ -197,7 +197,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
async abortTransaction(): Promise<void> {
const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushCredit"],
+ { storeNames: ["peerPushCredit"] },
async (tx) => {
const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
if (!pushCreditRec) {
@@ -254,7 +254,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
async resumeTransaction(): Promise<void> {
const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushCredit"],
+ { storeNames: ["peerPushCredit"] },
async (tx) => {
const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
if (!pushCreditRec) {
@@ -307,7 +307,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
async failTransaction(): Promise<void> {
const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushCredit"],
+ { storeNames: ["peerPushCredit"] },
async (tx) => {
const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
if (!pushCreditRec) {
@@ -365,7 +365,7 @@ export async function preparePeerPushCredit(
}
const existing = await wex.db.runReadOnlyTx(
- ["contractTerms", "peerPushCredit"],
+ { storeNames: ["contractTerms", "peerPushCredit"] },
async (tx) => {
const existingPushInc =
await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([
@@ -460,7 +460,7 @@ export async function preparePeerPushCredit(
);
const transitionInfo = await wex.db.runReadWriteTx(
- ["contractTerms", "peerPushCredit"],
+ { storeNames: ["contractTerms", "peerPushCredit"] },
async (tx) => {
const rec: PeerPushPaymentIncomingRecord = {
peerPushCreditId,
@@ -545,7 +545,7 @@ async function longpollKycStatus(
kycStatusRes.status === HttpStatusCode.NoContent
) {
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushCredit"],
+ { storeNames: ["peerPushCredit"] },
async (tx) => {
const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!peerInc) {
@@ -606,7 +606,7 @@ async function processPeerPushCreditKycRequired(
const kycStatus = await kycStatusRes.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
const { transitionInfo, result } = await wex.db.runReadWriteTx(
- ["peerPushCredit"],
+ { storeNames: ["peerPushCredit"] },
async (tx) => {
const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!peerInc) {
@@ -731,14 +731,16 @@ async function handlePendingMerge(
});
const txRes = await wex.db.runReadWriteTx(
- [
- "contractTerms",
- "peerPushCredit",
- "withdrawalGroups",
- "reserves",
- "exchanges",
- "exchangeDetails",
- ],
+ {
+ storeNames: [
+ "contractTerms",
+ "peerPushCredit",
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ ],
+ },
async (tx) => {
const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!peerInc) {
@@ -798,7 +800,7 @@ async function handlePendingWithdrawing(
const wgId = peerInc.withdrawalGroupId;
let finished: boolean = false;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushCredit", "withdrawalGroups"],
+ { storeNames: ["peerPushCredit", "withdrawalGroups"] },
async (tx) => {
const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId);
if (!ppi) {
@@ -846,7 +848,7 @@ export async function processPeerPushCredit(
let peerInc: PeerPushPaymentIncomingRecord | undefined;
let contractTerms: PeerContractTerms | undefined;
await wex.db.runReadWriteTx(
- ["contractTerms", "peerPushCredit"],
+ { storeNames: ["contractTerms", "peerPushCredit"] },
async (tx) => {
peerInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!peerInc) {
@@ -915,7 +917,7 @@ export async function confirmPeerPushCredit(
logger.trace(`confirming peer-push-credit ${peerPushCreditId}`);
await wex.db.runReadWriteTx(
- ["contractTerms", "peerPushCredit"],
+ { storeNames: ["contractTerms", "peerPushCredit"] },
async (tx) => {
peerInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!peerInc) {
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
index b6771be89..6452407ff 100644
--- a/packages/taler-wallet-core/src/pay-peer-push-debit.ts
+++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -20,6 +20,7 @@ import {
CheckPeerPushDebitResponse,
CoinRefreshRequest,
ContractTermsUtil,
+ ExchangePurseDeposits,
HttpStatusCode,
InitiatePeerPushDebitRequest,
InitiatePeerPushDebitResponse,
@@ -103,19 +104,22 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
async deleteTransaction(): Promise<void> {
const { wex, pursePub, transactionId } = this;
- await wex.db.runReadWriteTx(["peerPushDebit", "tombstones"], async (tx) => {
- const debit = await tx.peerPushDebit.get(pursePub);
- if (debit) {
- await tx.peerPushDebit.delete(pursePub);
- await tx.tombstones.put({ id: transactionId });
- }
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushDebit", "tombstones"] },
+ async (tx) => {
+ const debit = await tx.peerPushDebit.get(pursePub);
+ if (debit) {
+ await tx.peerPushDebit.delete(pursePub);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ },
+ );
}
async suspendTransaction(): Promise<void> {
const { wex, pursePub, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushDebit"],
+ { storeNames: ["peerPushDebit"] },
async (tx) => {
const pushDebitRec = await tx.peerPushDebit.get(pursePub);
if (!pushDebitRec) {
@@ -173,7 +177,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
async abortTransaction(): Promise<void> {
const { wex, pursePub, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushDebit"],
+ { storeNames: ["peerPushDebit"] },
async (tx) => {
const pushDebitRec = await tx.peerPushDebit.get(pursePub);
if (!pushDebitRec) {
@@ -227,7 +231,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
async resumeTransaction(): Promise<void> {
const { wex, pursePub, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushDebit"],
+ { storeNames: ["peerPushDebit"] },
async (tx) => {
const pushDebitRec = await tx.peerPushDebit.get(pursePub);
if (!pushDebitRec) {
@@ -285,7 +289,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
async failTransaction(): Promise<void> {
const { wex, pursePub, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushDebit"],
+ { storeNames: ["peerPushDebit"] },
async (tx) => {
const pushDebitRec = await tx.peerPushDebit.get(pursePub);
if (!pushDebitRec) {
@@ -433,7 +437,7 @@ async function handlePurseCreationConflict(
assertUnreachable(coinSelRes);
}
- await wex.db.runReadWriteTx(["peerPushDebit"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["peerPushDebit"] }, async (tx) => {
const myPpi = await tx.peerPushDebit.get(peerPushInitiation.pursePub);
if (!myPpi) {
return;
@@ -469,7 +473,7 @@ async function processPeerPushDebitCreateReserve(
logger.trace(`processing ${transactionId} pending(create-reserve)`);
const contractTermsRecord = await wex.db.runReadOnlyTx(
- ["contractTerms"],
+ { storeNames: ["contractTerms"] },
async (tx) => {
return tx.contractTerms.get(hContractTerms);
},
@@ -502,16 +506,18 @@ async function processPeerPushDebitCreateReserve(
assertUnreachable(coinSelRes);
}
const transitionDone = await wex.db.runReadWriteTx(
- [
- "exchanges",
- "contractTerms",
- "coins",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "refreshSessions",
- "peerPushDebit",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPushDebit",
+ ],
+ },
async (tx) => {
const ppi = await tx.peerPushDebit.get(pursePub);
if (!ppi) {
@@ -564,12 +570,6 @@ async function processPeerPushDebitCreateReserve(
peerPushInitiation.coinSel,
);
- const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
- exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
- pursePub: peerPushInitiation.pursePub,
- coins,
- });
-
const encryptContractRequest: EncryptContractRequest = {
contractTerms: contractTermsRecord.contractTermsRaw,
mergePriv: peerPushInitiation.mergePriv,
@@ -580,66 +580,115 @@ async function processPeerPushDebitCreateReserve(
nonce: peerPushInitiation.contractEncNonce,
};
- logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`);
-
const econtractResp = await wex.cryptoApi.encryptContractForMerge(
encryptContractRequest,
);
- const createPurseUrl = new URL(
- `purses/${peerPushInitiation.pursePub}/create`,
- peerPushInitiation.exchangeBaseUrl,
- );
+ const maxBatchSize = 100;
- const reqBody = {
- amount: peerPushInitiation.amount,
- merge_pub: peerPushInitiation.mergePub,
- purse_sig: purseSigResp.sig,
- h_contract_terms: hContractTerms,
- purse_expiration: timestampProtocolFromDb(purseExpiration),
- deposits: depositSigsResp.deposits,
- min_age: 0,
- econtract: econtractResp.econtract,
- };
+ for (let i = 0; i < coins.length; i += maxBatchSize) {
+ const batchSize = Math.min(maxBatchSize, coins.length - i);
+ const batchCoins = coins.slice(i, i + batchSize);
- logger.trace(`request body: ${j2s(reqBody)}`);
+ const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
+ pursePub: peerPushInitiation.pursePub,
+ coins: batchCoins,
+ });
- const httpResp = await wex.http.fetch(createPurseUrl.href, {
- method: "POST",
- body: reqBody,
- cancellationToken: wex.cancellationToken,
- });
+ if (i == 0) {
+ // First batch creates the purse!
- {
- const resp = await httpResp.json();
- logger.info(`resp: ${j2s(resp)}`);
- }
+ logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`);
- switch (httpResp.status) {
- case HttpStatusCode.Ok:
- break;
- case HttpStatusCode.Forbidden: {
- // FIXME: Store this error!
- await ctx.failTransaction();
- return TaskRunResult.finished();
- }
- case HttpStatusCode.Conflict: {
- // Handle double-spending
- return handlePurseCreationConflict(wex, peerPushInitiation, httpResp);
- }
- default: {
- const errResp = await readTalerErrorResponse(httpResp);
- return {
- type: TaskRunResultType.Error,
- errorDetail: errResp,
+ const createPurseUrl = new URL(
+ `purses/${peerPushInitiation.pursePub}/create`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+
+ const reqBody = {
+ amount: peerPushInitiation.amount,
+ merge_pub: peerPushInitiation.mergePub,
+ purse_sig: purseSigResp.sig,
+ h_contract_terms: hContractTerms,
+ purse_expiration: timestampProtocolFromDb(purseExpiration),
+ deposits: depositSigsResp.deposits,
+ min_age: 0,
+ econtract: econtractResp.econtract,
};
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`request body: ${j2s(reqBody)}`);
+ }
+
+ const httpResp = await wex.http.fetch(createPurseUrl.href, {
+ method: "POST",
+ body: reqBody,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok:
+ // Possibly on to the next batch.
+ continue;
+ case HttpStatusCode.Forbidden: {
+ // FIXME: Store this error!
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.Conflict: {
+ // Handle double-spending
+ return handlePurseCreationConflict(wex, peerPushInitiation, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+ } else {
+ const purseDepositUrl = new URL(
+ `purses/${pursePub}/deposit`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+
+ const depositPayload: ExchangePurseDeposits = {
+ deposits: depositSigsResp.deposits,
+ };
+
+ const httpResp = await wex.http.fetch(purseDepositUrl.href, {
+ method: "POST",
+ body: depositPayload,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok:
+ // Possibly on to the next batch.
+ continue;
+ case HttpStatusCode.Forbidden: {
+ // FIXME: Store this error!
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.Conflict: {
+ // Handle double-spending
+ return handlePurseCreationConflict(wex, peerPushInitiation, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
}
}
- if (httpResp.status !== HttpStatusCode.Ok) {
- // FIXME: do proper error reporting
- throw Error("got error response from exchange");
- }
+ // All batches done!
await transitionPeerPushDebitTransaction(wex, pursePub, {
stFrom: PeerPushDebitStatus.PendingCreatePurse,
@@ -676,14 +725,16 @@ async function processPeerPushDebitAbortingDeletePurse(
logger.info(`deleted purse with response status ${resp.status}`);
const transitionInfo = await wex.db.runReadWriteTx(
- [
- "peerPushDebit",
- "refreshGroups",
- "refreshSessions",
- "denominations",
- "coinAvailability",
- "coins",
- ],
+ {
+ storeNames: [
+ "peerPushDebit",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ },
async (tx) => {
const ppiRec = await tx.peerPushDebit.get(pursePub);
if (!ppiRec) {
@@ -735,6 +786,7 @@ interface SimpleTransition {
stTo: PeerPushDebitStatus;
}
+// FIXME: This should be a transition on the peer push debit transaction context!
async function transitionPeerPushDebitTransaction(
wex: WalletExecutionContext,
pursePub: string,
@@ -745,7 +797,7 @@ async function transitionPeerPushDebitTransaction(
pursePub,
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushDebit"],
+ { storeNames: ["peerPushDebit"] },
async (tx) => {
const ppiRec = await tx.peerPushDebit.get(pursePub);
if (!ppiRec) {
@@ -782,7 +834,7 @@ async function processPeerPushDebitAbortingRefreshDeleted(
await waitRefreshFinal(wex, peerPushInitiation.abortRefreshGroupId);
}
const transitionInfo = await wex.db.runReadWriteTx(
- ["refreshGroups", "peerPushDebit"],
+ { storeNames: ["refreshGroups", "peerPushDebit"] },
async (tx) => {
const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
let newOpState: PeerPushDebitStatus | undefined;
@@ -831,7 +883,7 @@ async function processPeerPushDebitAbortingRefreshExpired(
pursePub: peerPushInitiation.pursePub,
});
const transitionInfo = await wex.db.runReadWriteTx(
- ["peerPushDebit", "refreshGroups"],
+ { storeNames: ["peerPushDebit", "refreshGroups"] },
async (tx) => {
const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
let newOpState: PeerPushDebitStatus | undefined;
@@ -914,14 +966,16 @@ async function processPeerPushDebitReady(
} else if (resp.status === HttpStatusCode.Gone) {
logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`);
const transitionInfo = await wex.db.runReadWriteTx(
- [
- "peerPushDebit",
- "refreshGroups",
- "refreshSessions",
- "denominations",
- "coinAvailability",
- "coins",
- ],
+ {
+ storeNames: [
+ "peerPushDebit",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ },
async (tx) => {
const ppiRec = await tx.peerPushDebit.get(pursePub);
if (!ppiRec) {
@@ -975,7 +1029,7 @@ export async function processPeerPushDebit(
pursePub: string,
): Promise<TaskRunResult> {
const peerPushInitiation = await wex.db.runReadOnlyTx(
- ["peerPushDebit"],
+ { storeNames: ["peerPushDebit"] },
async (tx) => {
return tx.peerPushDebit.get(pursePub);
},
@@ -1074,16 +1128,18 @@ export async function initiatePeerPushDebit(
const contractEncNonce = encodeCrock(getRandomBytes(24));
const transitionInfo = await wex.db.runReadWriteTx(
- [
- "exchanges",
- "contractTerms",
- "coins",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "refreshSessions",
- "peerPushDebit",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPushDebit",
+ ],
+ },
async (tx) => {
const ppi: PeerPushDebitRecord = {
amount: Amounts.stringify(instructedAmount),
diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts
index d78e9bc6e..dc15bbdd1 100644
--- a/packages/taler-wallet-core/src/query.ts
+++ b/packages/taler-wallet-core/src/query.ts
@@ -15,8 +15,9 @@
*/
/**
- * Database query abstractions.
- * @module Query
+ * @fileoverview
+ * Query helpers for IndexedDB databases.
+ *
* @author Florian Dold
*/
@@ -35,7 +36,12 @@ import {
IDBValidKey,
IDBVersionChangeEvent,
} from "@gnu-taler/idb-bridge";
-import { Codec, Logger, openPromise } from "@gnu-taler/taler-util";
+import {
+ CancellationToken,
+ Codec,
+ Logger,
+ openPromise,
+} from "@gnu-taler/taler-util";
const logger = new Logger("query.ts");
@@ -558,6 +564,7 @@ function runTx<Arg, Res>(
tx: IDBTransaction,
arg: Arg,
f: (t: Arg, t2: IDBTransaction) => Promise<Res>,
+ triggerContext: InternalTriggerContext,
): Promise<Res> {
const stack = Error("Failed transaction was started here.");
return new Promise((resolve, reject) => {
@@ -578,6 +585,7 @@ function runTx<Arg, Res>(
logger.error(`${stack.stack}`);
reject(Error(msg));
}
+ triggerContext.handleAfterCommit();
resolve(funResult);
};
tx.onerror = () => {
@@ -622,6 +630,7 @@ function runTx<Arg, Res>(
function makeReadContext(
tx: IDBTransaction,
storePick: { [n: string]: StoreWithIndexes<any, any, any> },
+ triggerContext: InternalTriggerContext,
): any {
const ctx: { [s: string]: StoreReadOnlyAccessor<any, any> } = {};
for (const storeAlias in storePick) {
@@ -634,10 +643,12 @@ function makeReadContext(
const indexName = indexDescriptor.name;
indexes[indexAlias] = {
get(key) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).index(indexName).get(key);
return requestToPromise(req);
},
iter(query) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx
.objectStore(storeName)
.index(indexName)
@@ -645,6 +656,7 @@ function makeReadContext(
return new ResultStream<any>(req);
},
getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx
.objectStore(storeName)
.index(indexName)
@@ -652,6 +664,7 @@ function makeReadContext(
return requestToPromise(req);
},
getAllKeys(query, count) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx
.objectStore(storeName)
.index(indexName)
@@ -659,6 +672,7 @@ function makeReadContext(
return requestToPromise(req);
},
count(query) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).index(indexName).count(query);
return requestToPromise(req);
},
@@ -667,14 +681,17 @@ function makeReadContext(
ctx[storeAlias] = {
indexes,
get(key) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).get(key);
return requestToPromise(req);
},
getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).getAll(query, count);
return requestToPromise(req);
},
iter(query) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).openCursor(query);
return new ResultStream<any>(req);
},
@@ -686,6 +703,7 @@ function makeReadContext(
function makeWriteContext(
tx: IDBTransaction,
storePick: { [n: string]: StoreWithIndexes<any, any, any> },
+ triggerContext: InternalTriggerContext,
): any {
const ctx: { [s: string]: StoreReadWriteAccessor<any, any> } = {};
for (const storeAlias in storePick) {
@@ -698,10 +716,12 @@ function makeWriteContext(
const indexName = indexDescriptor.name;
indexes[indexAlias] = {
get(key) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).index(indexName).get(key);
return requestToPromise(req);
},
iter(query) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx
.objectStore(storeName)
.index(indexName)
@@ -709,6 +729,7 @@ function makeWriteContext(
return new ResultStream<any>(req);
},
getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx
.objectStore(storeName)
.index(indexName)
@@ -716,6 +737,7 @@ function makeWriteContext(
return requestToPromise(req);
},
getAllKeys(query, count) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx
.objectStore(storeName)
.index(indexName)
@@ -723,6 +745,7 @@ function makeWriteContext(
return requestToPromise(req);
},
count(query) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).index(indexName).count(query);
return requestToPromise(req);
},
@@ -731,18 +754,23 @@ function makeWriteContext(
ctx[storeAlias] = {
indexes,
get(key) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).get(key);
return requestToPromise(req);
},
getAll(query, count) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).getAll(query, count);
return requestToPromise(req);
},
iter(query) {
+ triggerContext.storesAccessed.add(storeName);
const req = tx.objectStore(storeName).openCursor(query);
return new ResultStream<any>(req);
},
async add(r, k) {
+ triggerContext.storesAccessed.add(storeName);
+ triggerContext.storesModified.add(storeName);
const req = tx.objectStore(storeName).add(r, k);
const key = await requestToPromise(req);
return {
@@ -750,6 +778,8 @@ function makeWriteContext(
};
},
async put(r, k) {
+ triggerContext.storesAccessed.add(storeName);
+ triggerContext.storesModified.add(storeName);
const req = tx.objectStore(storeName).put(r, k);
const key = await requestToPromise(req);
return {
@@ -757,6 +787,8 @@ function makeWriteContext(
};
},
delete(k) {
+ triggerContext.storesAccessed.add(storeName);
+ triggerContext.storesModified.add(storeName);
const req = tx.objectStore(storeName).delete(k);
return requestToPromise(req);
},
@@ -787,21 +819,63 @@ export interface DbAccess<StoreMap> {
): Promise<T>;
runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
- storeNames: StoreNameArray,
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
): Promise<T>;
runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
- storeNames: StoreNameArray,
+ opts: {
+ storeNames: StoreNameArray;
+ label?: string;
+ },
txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
): Promise<T>;
}
+export interface AfterCommitInfo {
+ mode: IDBTransactionMode;
+ scope: Set<string>;
+ accessedStores: Set<string>;
+ modifiedStores: Set<string>;
+}
+
export interface TriggerSpec {
/**
* Trigger run after every successful commit, run outside of the transaction.
*/
- afterCommit?: (mode: IDBTransactionMode, stores: string[]) => void;
+ afterCommit?: (info: AfterCommitInfo) => void;
+
+ // onRead(store, value)
+ // initState<State> () => State
+ // beforeCommit<State>? (tx: Transaction, s: State | undefined) => Promise<void>;
+}
+
+class InternalTriggerContext {
+ storesScope: Set<string>;
+ storesAccessed: Set<string> = new Set();
+ storesModified: Set<string> = new Set();
+
+ constructor(
+ private triggerSpec: TriggerSpec,
+ private mode: IDBTransactionMode,
+ scope: string[],
+ ) {
+ this.storesScope = new Set(scope);
+ }
+
+ handleAfterCommit() {
+ if (this.triggerSpec.afterCommit) {
+ this.triggerSpec.afterCommit({
+ mode: this.mode,
+ accessedStores: this.storesAccessed,
+ modifiedStores: this.storesModified,
+ scope: this.storesScope,
+ });
+ }
+ }
}
/**
@@ -814,6 +888,7 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> {
private db: IDBDatabase,
private stores: StoreMap,
private triggers: TriggerSpec = {},
+ private cancellationToken: CancellationToken,
) {}
idbHandle(): IDBDatabase {
@@ -836,12 +911,18 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> {
strStoreNames.push(swi.storeName);
accessibleStores[swi.storeName] = swi;
}
- const tx = this.db.transaction(strStoreNames, "readwrite");
- const writeContext = makeWriteContext(tx, accessibleStores);
- return runTx(tx, writeContext, txf);
+ const mode = "readwrite";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
+ const tx = this.db.transaction(strStoreNames, mode);
+ const writeContext = makeWriteContext(tx, accessibleStores, triggerContext);
+ return runTx(tx, writeContext, txf, triggerContext);
}
- runAllStoresReadOnlyTx<T>(
+ async runAllStoresReadOnlyTx<T>(
options: {
label?: string;
},
@@ -857,56 +938,67 @@ export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> {
strStoreNames.push(swi.storeName);
accessibleStores[swi.storeName] = swi;
}
- const tx = this.db.transaction(strStoreNames, "readonly");
- const writeContext = makeReadContext(tx, accessibleStores);
- return runTx(tx, writeContext, txf);
+ const mode = "readonly";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
+ const tx = this.db.transaction(strStoreNames, mode);
+ const writeContext = makeReadContext(tx, accessibleStores, triggerContext);
+ const res = await runTx(tx, writeContext, txf, triggerContext);
+ return res;
}
- runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
- storeNames: StoreNameArray,
+ async runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ opts: {
+ storeNames: StoreNameArray;
+ },
txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
): Promise<T> {
const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
{};
const strStoreNames: string[] = [];
- for (const sn of storeNames) {
+ for (const sn of opts.storeNames) {
const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
strStoreNames.push(swi.storeName);
accessibleStores[swi.storeName] = swi;
}
const mode = "readwrite";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
const tx = this.db.transaction(strStoreNames, mode);
- const writeContext = makeWriteContext(tx, accessibleStores);
- const res = runTx(tx, writeContext, txf);
- if (this.triggers.afterCommit) {
- this.triggers.afterCommit(mode, strStoreNames);
- }
+ const writeContext = makeWriteContext(tx, accessibleStores, triggerContext);
+ const res = await runTx(tx, writeContext, txf, triggerContext);
return res;
}
runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
- storeNames: StoreNameArray,
+ opts: {
+ storeNames: StoreNameArray;
+ },
txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
): Promise<T> {
const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
{};
const strStoreNames: string[] = [];
- for (const sn of storeNames) {
+ for (const sn of opts.storeNames) {
const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
strStoreNames.push(swi.storeName);
accessibleStores[swi.storeName] = swi;
}
const mode = "readonly";
+ const triggerContext = new InternalTriggerContext(
+ this.triggers,
+ mode,
+ strStoreNames,
+ );
const tx = this.db.transaction(strStoreNames, mode);
- const readContext = makeReadContext(tx, accessibleStores);
- const res = runTx(tx, readContext, txf);
- if (this.triggers.afterCommit) {
- this.triggers.afterCommit(mode, strStoreNames);
- }
+ const readContext = makeReadContext(tx, accessibleStores, triggerContext);
+ const res = runTx(tx, readContext, txf, triggerContext);
return res;
}
-
- registerPostCommitTrigger(args: {
- handler: (storeNames: string[]) => void;
- }): void {}
}
diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts
index 758ba106d..6a09f9a0e 100644
--- a/packages/taler-wallet-core/src/recoup.ts
+++ b/packages/taler-wallet-core/src/recoup.ts
@@ -99,7 +99,7 @@ async function recoupRewardCoin(
// Thus we just put the coin to sleep.
// FIXME: somehow report this to the user
await wex.db.runReadWriteTx(
- ["recoupGroups", "denominations", "refreshGroups", "coins"],
+ { storeNames: ["recoupGroups", "denominations", "refreshGroups", "coins"] },
async (tx) => {
const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
if (!recoupGroup) {
@@ -121,7 +121,7 @@ async function recoupRefreshCoin(
cs: RefreshCoinSource,
): Promise<void> {
const d = await wex.db.runReadOnlyTx(
- ["coins", "denominations"],
+ { storeNames: ["coins", "denominations"] },
async (tx) => {
const denomInfo = await getDenomInfo(
wex,
@@ -168,7 +168,7 @@ async function recoupRefreshCoin(
}
await wex.db.runReadWriteTx(
- ["coins", "denominations", "recoupGroups", "refreshGroups"],
+ { storeNames: ["coins", "denominations", "recoupGroups", "refreshGroups"] },
async (tx) => {
const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
if (!recoupGroup) {
@@ -233,7 +233,7 @@ export async function recoupWithdrawCoin(
): Promise<void> {
const reservePub = cs.reservePub;
const denomInfo = await wex.db.runReadOnlyTx(
- ["denominations"],
+ { storeNames: ["denominations"] },
async (tx) => {
const denomInfo = await getDenomInfo(
wex,
@@ -276,7 +276,7 @@ export async function recoupWithdrawCoin(
// FIXME: verify that our expectations about the amount match
await wex.db.runReadWriteTx(
- ["coins", "denominations", "recoupGroups", "refreshGroups"],
+ { storeNames: ["coins", "denominations", "recoupGroups", "refreshGroups"] },
async (tx) => {
const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
if (!recoupGroup) {
@@ -300,9 +300,12 @@ export async function processRecoupGroup(
wex: WalletExecutionContext,
recoupGroupId: string,
): Promise<TaskRunResult> {
- let recoupGroup = await wex.db.runReadOnlyTx(["recoupGroups"], async (tx) => {
- return tx.recoupGroups.get(recoupGroupId);
- });
+ let recoupGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["recoupGroups"] },
+ async (tx) => {
+ return tx.recoupGroups.get(recoupGroupId);
+ },
+ );
if (!recoupGroup) {
return TaskRunResult.finished();
}
@@ -320,9 +323,12 @@ export async function processRecoupGroup(
});
await Promise.all(ps);
- recoupGroup = await wex.db.runReadOnlyTx(["recoupGroups"], async (tx) => {
- return tx.recoupGroups.get(recoupGroupId);
- });
+ recoupGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["recoupGroups"] },
+ async (tx) => {
+ return tx.recoupGroups.get(recoupGroupId);
+ },
+ );
if (!recoupGroup) {
return TaskRunResult.finished();
}
@@ -339,22 +345,25 @@ export async function processRecoupGroup(
const reservePrivMap: Record<string, string> = {};
for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
const coinPub = recoupGroup.coinPubs[i];
- await wex.db.runReadOnlyTx(["coins", "reserves"], async (tx) => {
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- throw Error(`Coin ${coinPub} not found, can't request recoup`);
- }
- if (coin.coinSource.type === CoinSourceType.Withdraw) {
- const reserve = await tx.reserves.indexes.byReservePub.get(
- coin.coinSource.reservePub,
- );
- if (!reserve) {
- return;
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "reserves"] },
+ async (tx) => {
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ throw Error(`Coin ${coinPub} not found, can't request recoup`);
}
- reserveSet.add(coin.coinSource.reservePub);
- reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv;
- }
- });
+ if (coin.coinSource.type === CoinSourceType.Withdraw) {
+ const reserve = await tx.reserves.indexes.byReservePub.get(
+ coin.coinSource.reservePub,
+ );
+ if (!reserve) {
+ return;
+ }
+ reserveSet.add(coin.coinSource.reservePub);
+ reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv;
+ }
+ },
+ );
}
for (const reservePub of reserveSet) {
@@ -385,14 +394,16 @@ export async function processRecoupGroup(
}
await wex.db.runReadWriteTx(
- [
- "recoupGroups",
- "coinAvailability",
- "denominations",
- "refreshGroups",
- "refreshSessions",
- "coins",
- ],
+ {
+ storeNames: [
+ "recoupGroups",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ ],
+ },
async (tx) => {
const rg2 = await tx.recoupGroups.get(recoupGroupId);
if (!rg2) {
@@ -502,7 +513,7 @@ async function processRecoupForCoin(
coinIdx: number,
): Promise<void> {
const coin = await wex.db.runReadOnlyTx(
- ["coins", "recoupGroups"],
+ { storeNames: ["coins", "recoupGroups"] },
async (tx) => {
const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
if (!recoupGroup) {
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
index 99ac5737b..7800967e6 100644
--- a/packages/taler-wallet-core/src/refresh.ts
+++ b/packages/taler-wallet-core/src/refresh.ts
@@ -186,7 +186,7 @@ export class RefreshTransactionContext implements TransactionContext {
? [...baseStores, ...opts.extraStores]
: baseStores;
const transitionInfo = await this.wex.db.runReadWriteTx(
- stores,
+ { storeNames: stores },
async (tx) => {
const wgRec = await tx.refreshGroups.get(this.refreshGroupId);
let oldTxState: TransactionState;
@@ -565,7 +565,14 @@ async function refreshMelt(
): Promise<void> {
const ctx = new RefreshTransactionContext(wex, refreshGroupId);
const d = await wex.db.runReadWriteTx(
- ["refreshGroups", "refreshSessions", "coins", "denominations"],
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ ],
+ },
async (tx) => {
const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
if (!refreshGroup) {
@@ -723,7 +730,7 @@ async function refreshMelt(
refreshSession.norevealIndex = norevealIndex;
await wex.db.runReadWriteTx(
- ["refreshGroups", "refreshSessions"],
+ { storeNames: ["refreshGroups", "refreshSessions"] },
async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) {
@@ -755,13 +762,15 @@ async function handleRefreshMeltGone(
// FIXME: Validate signature.
await ctx.wex.db.runReadWriteTx(
- [
- "refreshGroups",
- "refreshSessions",
- "coins",
- "denominations",
- "coinAvailability",
- ],
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
if (!rg) {
@@ -832,13 +841,15 @@ async function handleRefreshMeltConflict(
// FIXME: If response seems wrong, report to auditor (in the future!);
await ctx.wex.db.runReadWriteTx(
- [
- "refreshGroups",
- "refreshSessions",
- "denominations",
- "coins",
- "coinAvailability",
- ],
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
if (!rg) {
@@ -891,13 +902,15 @@ async function handleRefreshMeltNotFound(
): Promise<void> {
// FIXME: Validate the exchange's error response
await ctx.wex.db.runReadWriteTx(
- [
- "refreshGroups",
- "refreshSessions",
- "coins",
- "denominations",
- "coinAvailability",
- ],
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
if (!rg) {
@@ -993,7 +1006,14 @@ async function refreshReveal(
);
const ctx = new RefreshTransactionContext(wex, refreshGroupId);
const d = await wex.db.runReadOnlyTx(
- ["refreshGroups", "refreshSessions", "coins", "denominations"],
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ ],
+ },
async (tx) => {
const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
if (!refreshGroup) {
@@ -1187,13 +1207,15 @@ async function refreshReveal(
}
await wex.db.runReadWriteTx(
- [
- "coins",
- "denominations",
- "coinAvailability",
- "refreshGroups",
- "refreshSessions",
- ],
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ },
async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) {
@@ -1247,13 +1269,15 @@ async function handleRefreshRevealError(
errDetails: TalerErrorDetail,
): Promise<void> {
await ctx.wex.db.runReadWriteTx(
- [
- "refreshGroups",
- "refreshSessions",
- "coins",
- "denominations",
- "coinAvailability",
- ],
+ {
+ storeNames: [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ },
async (tx) => {
const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
if (!rg) {
@@ -1288,7 +1312,7 @@ export async function processRefreshGroup(
logger.trace(`processing refresh group ${refreshGroupId}`);
const refreshGroup = await wex.db.runReadOnlyTx(
- ["refreshGroups"],
+ { storeNames: ["refreshGroups"] },
async (tx) => tx.refreshGroups.get(refreshGroupId),
);
if (!refreshGroup) {
@@ -1344,7 +1368,7 @@ export async function processRefreshGroup(
// status of the whole refresh group.
const transitionInfo = await wex.db.runReadWriteTx(
- ["coins", "coinAvailability", "refreshGroups"],
+ { storeNames: ["coins", "coinAvailability", "refreshGroups"] },
async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (!rg) {
@@ -1420,7 +1444,7 @@ async function processRefreshSession(
`processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
);
let { refreshGroup, refreshSession } = await wex.db.runReadOnlyTx(
- ["refreshGroups", "refreshSessions"],
+ { storeNames: ["refreshGroups", "refreshSessions"] },
async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
@@ -1710,7 +1734,7 @@ export function getRefreshesForTransaction(
wex: WalletExecutionContext,
transactionId: string,
): Promise<string[]> {
- return wex.db.runReadOnlyTx(["refreshGroups"], async (tx) => {
+ return wex.db.runReadOnlyTx({ storeNames: ["refreshGroups"] }, async (tx) => {
const groups =
await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll(
transactionId,
@@ -1736,13 +1760,15 @@ export async function forceRefresh(
throw Error("refusing to create empty refresh group");
}
const res = await wex.db.runReadWriteTx(
- [
- "refreshGroups",
- "coinAvailability",
- "refreshSessions",
- "denominations",
- "coins",
- ],
+ {
+ storeNames: [
+ "refreshGroups",
+ "coinAvailability",
+ "refreshSessions",
+ "denominations",
+ "coins",
+ ],
+ },
async (tx) => {
let coinPubs: CoinRefreshRequest[] = [];
for (const c of req.refreshCoinSpecs) {
@@ -1828,7 +1854,7 @@ async function internalWaitRefreshFinal(
// Check if refresh is final
const res = await ctx.wex.db.runReadOnlyTx(
- ["refreshGroups", "operationRetries"],
+ { storeNames: ["refreshGroups", "operationRetries"] },
async (tx) => {
return {
rg: await tx.refreshGroups.get(ctx.refreshGroupId),
diff --git a/packages/taler-wallet-core/src/reward.ts b/packages/taler-wallet-core/src/reward.ts
deleted file mode 100644
index 85e8c6606..000000000
--- a/packages/taler-wallet-core/src/reward.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 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/>
- */
-
-/**
- * Imports.
- */
-import {
- AcceptTipResponse,
- Logger,
- PrepareTipResult,
- TransactionAction,
- TransactionIdStr,
- TransactionMajorState,
- TransactionMinorState,
- TransactionState,
- TransactionType,
- assertUnreachable,
-} from "@gnu-taler/taler-util";
-import {
- PendingTaskType,
- TaskIdStr,
- TaskRunResult,
- TombstoneTag,
- TransactionContext,
- constructTaskIdentifier,
-} from "./common.js";
-import { RewardRecord, RewardRecordStatus } from "./db.js";
-import {
- constructTransactionIdentifier,
-} from "./transactions.js";
-import { InternalWalletState, WalletExecutionContext } from "./wallet.js";
-
-export class RewardTransactionContext implements TransactionContext {
- public transactionId: TransactionIdStr;
- public taskId: TaskIdStr;
-
- constructor(
- public wex: WalletExecutionContext,
- public walletRewardId: string,
- ) {
- this.transactionId = constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId,
- });
- this.taskId = constructTaskIdentifier({
- tag: PendingTaskType.RewardPickup,
- walletRewardId,
- });
- }
-
- async deleteTransaction(): Promise<void> {
- throw Error("unsupported operation");
- }
-
- async suspendTransaction(): Promise<void> {
- throw Error("unsupported operation");
- }
-
- async abortTransaction(): Promise<void> {
- throw Error("unsupported operation");
- }
-
- async resumeTransaction(): Promise<void> {
- throw Error("unsupported operation");
- }
-
- async failTransaction(): Promise<void> {
- throw Error("unsupported operation");
- }
-}
-
-/**
- * Get the (DD37-style) transaction status based on the
- * database record of a reward.
- */
-export function computeRewardTransactionStatus(
- tipRecord: RewardRecord,
-): TransactionState {
- switch (tipRecord.status) {
- case RewardRecordStatus.Done:
- return {
- major: TransactionMajorState.Done,
- };
- case RewardRecordStatus.Aborted:
- return {
- major: TransactionMajorState.Aborted,
- };
- case RewardRecordStatus.PendingPickup:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Pickup,
- };
- case RewardRecordStatus.DialogAccept:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.Proposed,
- };
- case RewardRecordStatus.SuspendedPickup:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Pickup,
- };
- case RewardRecordStatus.Failed:
- return {
- major: TransactionMajorState.Failed,
- };
- default:
- assertUnreachable(tipRecord.status);
- }
-}
-
-export function computeTipTransactionActions(
- tipRecord: RewardRecord,
-): TransactionAction[] {
- switch (tipRecord.status) {
- case RewardRecordStatus.Done:
- return [TransactionAction.Delete];
- case RewardRecordStatus.Failed:
- return [TransactionAction.Delete];
- case RewardRecordStatus.Aborted:
- return [TransactionAction.Delete];
- case RewardRecordStatus.PendingPickup:
- return [TransactionAction.Suspend, TransactionAction.Fail];
- case RewardRecordStatus.SuspendedPickup:
- return [TransactionAction.Resume, TransactionAction.Fail];
- case RewardRecordStatus.DialogAccept:
- return [TransactionAction.Abort];
- default:
- assertUnreachable(tipRecord.status);
- }
-}
-
-export async function prepareReward(
- ws: InternalWalletState,
- talerTipUri: string,
-): Promise<PrepareTipResult> {
- throw Error("the rewards feature is not supported anymore");
-}
-
-export async function processTip(
- ws: InternalWalletState,
- walletTipId: string,
-): Promise<TaskRunResult> {
- return TaskRunResult.finished();
-}
-
-export async function acceptTip(
- ws: InternalWalletState,
- transactionId: TransactionIdStr,
-): Promise<AcceptTipResponse> {
- throw Error("the rewards feature is not supported anymore");
-}
diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts
index 4ca472e7b..3b160d97f 100644
--- a/packages/taler-wallet-core/src/shepherd.ts
+++ b/packages/taler-wallet-core/src/shepherd.ts
@@ -27,7 +27,6 @@ import {
NotificationType,
ObservabilityContext,
ObservabilityEventType,
- RetryLoopOpts,
TalerErrorDetail,
TaskThrottler,
TransactionIdStr,
@@ -37,6 +36,7 @@ import {
assertUnreachable,
getErrorDetailFromException,
j2s,
+ safeStringifyException,
} from "@gnu-taler/taler-util";
import { processBackupForProvider } from "./backup/index.js";
import {
@@ -91,7 +91,6 @@ import {
computeRefreshTransactionState,
processRefreshGroup,
} from "./refresh.js";
-import { computeRewardTransactionStatus } from "./reward.js";
import {
constructTransactionIdentifier,
parseTransactionIdentifier,
@@ -143,13 +142,14 @@ function taskGivesLiveness(taskId: string): boolean {
}
export interface TaskScheduler {
- ensureRunning(): void;
- run(opts?: RetryLoopOpts): Promise<void>;
+ ensureRunning(): Promise<void>;
startShepherdTask(taskId: TaskIdStr): void;
stopShepherdTask(taskId: TaskIdStr): void;
resetTaskRetries(taskId: TaskIdStr): Promise<void>;
- reload(): void;
+ reload(): Promise<void>;
getActiveTasks(): TaskIdStr[];
+ isIdle(): boolean;
+ shutdown(): Promise<void>;
}
export class TaskSchedulerImpl implements TaskScheduler {
@@ -177,58 +177,73 @@ export class TaskSchedulerImpl implements TaskScheduler {
return [...this.sheps.keys()];
}
- ensureRunning(): void {
+ async shutdown(): Promise<void> {
+ const tasksIds = [...this.sheps.keys()];
+ logger.info(`Stopping task shepherd.`);
+ for (const taskId of tasksIds) {
+ this.stopShepherdTask(taskId);
+ }
+ }
+
+ async ensureRunning(): Promise<void> {
if (this.isRunning) {
return;
}
+ this.isRunning = true;
+ try {
+ await this.loadTasksFromDb();
+ } catch (e) {
+ this.isRunning = false;
+ throw e;
+ }
this.run()
.catch((e) => {
logger.error("error running task loop");
logger.error(`err: ${e}`);
})
.then(() => {
- logger.info("done running task loop");
+ logger.trace("done running task loop");
+ this.isRunning = false;
});
}
- async run(opts: RetryLoopOpts = {}): Promise<void> {
- if (this.isRunning) {
- throw Error("task loop already running");
+ isIdle(): boolean {
+ let alive = false;
+ const taskIds = [...this.sheps.keys()];
+ for (const taskId of taskIds) {
+ if (taskGivesLiveness(taskId)) {
+ alive = true;
+ break;
+ }
}
- logger.info("Running task loop.");
- this.isRunning = true;
- await this.loadTasksFromDb();
- logger.info("loaded!");
- logger.info(`sheps: ${this.sheps.size}`);
+ // We're idle if no task is alive anymore.
+ return !alive;
+ }
+
+ private async run(): Promise<void> {
+ logger.trace("Running task loop.");
+ logger.trace(`sheps: ${this.sheps.size}`);
while (true) {
- if (opts.stopWhenDone) {
- let alive = false;
- const taskIds = [...this.sheps.keys()];
- logger.info(`current task IDs: ${j2s(taskIds)}`);
- logger.info(`sheps: ${this.sheps.size}`);
- for (const taskId of taskIds) {
- if (taskGivesLiveness(taskId)) {
- alive = true;
- break;
- }
- }
- if (!alive) {
- logger.info("Breaking out of task loop (no more work).");
- break;
- }
- }
if (this.ws.stopped) {
- logger.info("Breaking out of task loop (wallet stopped).");
+ logger.trace("Breaking out of task loop (wallet stopped).");
break;
}
+
+ if (this.isIdle()) {
+ this.ws.notify({
+ type: NotificationType.Idle,
+ });
+ }
+
await this.iterCond.wait();
}
- this.isRunning = false;
- logger.info("Done with task loop.");
+ logger.trace("Done with task loop.");
}
startShepherdTask(taskId: TaskIdStr): void {
- this.ensureRunning();
+ this.ensureRunning().catch((e) => {
+ logger.error(`error running scheduler: ${safeStringifyException(e)}`);
+ });
// Run in the background, no await!
this.internalStartShepherdTask(taskId);
}
@@ -238,8 +253,8 @@ export class TaskSchedulerImpl implements TaskScheduler {
*
* Mostly useful to interrupt all waits when time-travelling.
*/
- reload() {
- this.ensureRunning();
+ async reload(): Promise<void> {
+ await this.ensureRunning();
const tasksIds = [...this.sheps.keys()];
logger.info(`reloading sheperd with ${tasksIds.length} tasks`);
for (const taskId of tasksIds) {
@@ -354,11 +369,11 @@ export class TaskSchedulerImpl implements TaskScheduler {
};
}
if (info.cts.token.isCancelled) {
- logger.info("task cancelled, not processing result");
+ logger.trace("task cancelled, not processing result");
return;
}
if (this.ws.stopped) {
- logger.info("wallet stopped, not processing result");
+ logger.trace("wallet stopped, not processing result");
return;
}
wex.oc.observe({
@@ -373,9 +388,8 @@ export class TaskSchedulerImpl implements TaskScheduler {
taskId,
res.errorDetail,
);
- let delay: Duration;
const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry);
- delay = AbsoluteTime.remaining(t);
+ const delay = AbsoluteTime.remaining(t);
logger.trace(`Waiting for ${delay.d_ms} ms`);
await this.wait(taskId, info, delay);
break;
@@ -383,9 +397,8 @@ export class TaskSchedulerImpl implements TaskScheduler {
case TaskRunResultType.Backoff: {
logger.trace(`Shepherd for ${taskId} got backoff result.`);
const retryRecord = await storePendingTaskPending(this.ws, taskId);
- let delay: Duration;
const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry);
- delay = AbsoluteTime.remaining(t);
+ const delay = AbsoluteTime.remaining(t);
logger.trace(`Waiting for ${delay.d_ms} ms`);
await this.wait(taskId, info, delay);
break;
@@ -397,13 +410,14 @@ export class TaskSchedulerImpl implements TaskScheduler {
await storeTaskProgress(this.ws, taskId);
break;
}
- case TaskRunResultType.ScheduleLater:
+ case TaskRunResultType.ScheduleLater: {
logger.trace(`Shepherd for ${taskId} got schedule-later result.`);
await storeTaskProgress(this.ws, taskId);
const delay = AbsoluteTime.remaining(res.runAt);
logger.trace(`Waiting for ${delay.d_ms} ms`);
await this.wait(taskId, info, delay);
break;
+ }
case TaskRunResultType.Finished:
logger.trace(`Shepherd for ${taskId} got finished result.`);
await storePendingTaskFinished(this.ws, taskId);
@@ -469,9 +483,12 @@ async function storeTaskProgress(
ws: InternalWalletState,
pendingTaskId: string,
): Promise<void> {
- await ws.db.runReadWriteTx(["operationRetries"], async (tx) => {
- await tx.operationRetries.delete(pendingTaskId);
- });
+ await ws.db.runReadWriteTx(
+ { storeNames: ["operationRetries"] },
+ async (tx) => {
+ await tx.operationRetries.delete(pendingTaskId);
+ },
+ );
}
async function storePendingTaskPending(
@@ -518,9 +535,12 @@ async function storePendingTaskFinished(
ws: InternalWalletState,
pendingTaskId: string,
): Promise<void> {
- await ws.db.runReadWriteTx(["operationRetries"], async (tx) => {
- await tx.operationRetries.delete(pendingTaskId);
- });
+ await ws.db.runReadWriteTx(
+ { storeNames: ["operationRetries"] },
+ async (tx) => {
+ await tx.operationRetries.delete(pendingTaskId);
+ },
+ );
}
function getWalletExecutionContextForTask(
@@ -713,13 +733,6 @@ async function getTransactionState(
}
return computeRefreshTransactionState(rec);
}
- case TransactionType.Reward: {
- const rec = await tx.rewards.get(parsedTxId.walletRewardId);
- if (!rec) {
- return undefined;
- }
- return computeRewardTransactionStatus(rec);
- }
case TransactionType.Recoup:
throw Error("not yet supported");
case TransactionType.DenomLoss: {
@@ -867,8 +880,6 @@ export function listTaskForTransactionId(transactionId: string): TaskIdStr[] {
];
case TransactionType.Refund:
return [];
- case TransactionType.Reward:
- return [];
case TransactionType.Withdrawal:
return [
constructTaskIdentifier({
@@ -925,11 +936,6 @@ export function convertTaskToTransactionId(
tag: TransactionType.Refresh,
refreshGroupId: parsedTaskId.refreshGroupId,
});
- case PendingTaskType.RewardPickup:
- return constructTransactionIdentifier({
- tag: TransactionType.Reward,
- walletRewardId: parsedTaskId.walletRewardId,
- });
case PendingTaskType.PeerPushDebit:
return constructTransactionIdentifier({
tag: TransactionType.PeerPushDebit,
@@ -956,18 +962,20 @@ export async function getActiveTaskIds(
taskIds: [],
};
await ws.db.runReadWriteTx(
- [
- "exchanges",
- "refreshGroups",
- "withdrawalGroups",
- "purchases",
- "depositGroups",
- "recoupGroups",
- "peerPullCredit",
- "peerPushDebit",
- "peerPullDebit",
- "peerPushCredit",
- ],
+ {
+ storeNames: [
+ "exchanges",
+ "refreshGroups",
+ "withdrawalGroups",
+ "purchases",
+ "depositGroups",
+ "recoupGroups",
+ "peerPullCredit",
+ "peerPushDebit",
+ "peerPullDebit",
+ "peerPushCredit",
+ ],
+ },
async (tx) => {
const active = GlobalIDB.KeyRange.bound(
OPERATION_STATUS_ACTIVE_FIRST,
diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts
index 32c0765b4..899c4a8b2 100644
--- a/packages/taler-wallet-core/src/testing.ts
+++ b/packages/taler-wallet-core/src/testing.ts
@@ -39,8 +39,6 @@ import {
j2s,
Logger,
NotificationType,
- OpenedPromise,
- openPromise,
parsePaytoUri,
PreparePayResultType,
TalerCorebankApiClient,
@@ -58,6 +56,7 @@ import {
readSuccessResponseJsonOrThrow,
} from "@gnu-taler/taler-util/http";
import { getBalances } from "./balance.js";
+import { genericWaitForState } from "./common.js";
import { createDepositGroup } from "./deposits.js";
import { fetchFreshExchange } from "./exchanges.js";
import {
@@ -402,52 +401,56 @@ export async function waitUntilAllTransactionsFinal(
wex: WalletExecutionContext,
): Promise<void> {
logger.info("waiting until all transactions are in a final state");
- wex.taskScheduler.ensureRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = wex.ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
+ await wex.taskScheduler.ensureRunning();
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ if (notif.type !== NotificationType.TransactionStateTransition) {
+ return false;
+ }
switch (notif.newTxState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
- break;
+ return false;
default:
- p.resolve();
+ return true;
}
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(wex, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
+ },
+ async checkState() {
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ for (const tx of txs.transactions) {
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ return false;
+ }
}
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
+ return true;
+ },
+ });
logger.info("done waiting until all transactions are in a final state");
}
+export async function waitTasksDone(
+ wex: WalletExecutionContext,
+): Promise<void> {
+ await genericWaitForState(wex, {
+ async checkState() {
+ return wex.taskScheduler.isIdle();
+ },
+ filterNotification(notif) {
+ return notif.type === NotificationType.Idle;
+ },
+ });
+}
+
/**
* Wait until all chosen transactions are in a final state.
*/
@@ -462,59 +465,51 @@ export async function waitUntilGivenTransactionsFinal(
if (transactionIds.length === 0) {
return;
}
- wex.taskScheduler.ensureRunning();
+
const txIdSet = new Set(transactionIds);
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = wex.ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
+
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ if (notif.type !== NotificationType.TransactionStateTransition) {
+ return false;
+ }
if (!txIdSet.has(notif.transactionId)) {
- return;
+ return false;
}
switch (notif.newTxState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
case TransactionMajorState.Suspended:
case TransactionMajorState.SuspendedAborting:
- break;
- default:
- p.resolve();
- }
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(wex, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- if (!txIdSet.has(tx.transactionId)) {
- // Don't look at this transaction, we're not interested in it.
- continue;
+ return false;
}
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
+ return true;
+ },
+ async checkState() {
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ for (const tx of txs.transactions) {
+ if (!txIdSet.has(tx.transactionId)) {
+ // Don't look at this transaction, we're not interested in it.
+ continue;
+ }
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ return false;
+ }
}
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
+ // No transaction is pending, we're done waiting!
+ return true;
+ },
+ });
logger.info("done waiting until given transactions are in a final state");
}
@@ -522,52 +517,43 @@ export async function waitUntilRefreshesDone(
wex: WalletExecutionContext,
): Promise<void> {
logger.info("waiting until all refresh transactions are in a final state");
- wex.taskScheduler.ensureRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = wex.ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
+
+ await genericWaitForState(wex, {
+ filterNotification(notif) {
+ if (notif.type !== NotificationType.TransactionStateTransition) {
+ return false;
+ }
switch (notif.newTxState.major) {
case TransactionMajorState.Pending:
case TransactionMajorState.Aborting:
- break;
+ return false;
default:
- p.resolve();
+ return true;
}
- }
- });
- while (1) {
- p = openPromise();
- const txs = await getTransactions(wex, {
- includeRefreshes: true,
- filterByState: "nonfinal",
- });
- let finished = true;
- for (const tx of txs.transactions) {
- if (tx.type !== TransactionType.Refresh) {
- continue;
- }
- switch (tx.txState.major) {
- case TransactionMajorState.Pending:
- case TransactionMajorState.Aborting:
- case TransactionMajorState.Suspended:
- case TransactionMajorState.SuspendedAborting:
- finished = false;
- logger.info(
- `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
- );
- break;
+ },
+ async checkState() {
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ for (const tx of txs.transactions) {
+ if (tx.type !== TransactionType.Refresh) {
+ continue;
+ }
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ return false;
+ }
}
- }
- if (finished) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- cancelNotifs();
+ return true;
+ },
+ });
logger.info("done waiting until all refreshes are in a final state");
}
@@ -575,33 +561,10 @@ async function waitUntilTransactionPendingReady(
wex: WalletExecutionContext,
transactionId: string,
): Promise<void> {
- logger.info(`starting waiting for ${transactionId} to be in pending(ready)`);
- wex.taskScheduler.ensureRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = wex.ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- p.resolve();
- }
+ return await waitTransactionState(wex, transactionId, {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
});
- while (1) {
- p = openPromise();
- const tx = await getTransactionById(wex, {
- transactionId,
- });
- if (
- tx.txState.major == TransactionMajorState.Pending &&
- tx.txState.minor === TransactionMinorState.Ready
- ) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
- logger.info(`done waiting for ${transactionId} to be in pending(ready)`);
- cancelNotifs();
}
/**
@@ -617,34 +580,22 @@ export async function waitTransactionState(
txState,
)})`,
);
- wex.taskScheduler.ensureRunning();
- let p: OpenedPromise<void> | undefined = undefined;
- const cancelNotifs = wex.ws.addNotificationListener((notif) => {
- if (!p) {
- return;
- }
- if (notif.type === NotificationType.TransactionStateTransition) {
- p.resolve();
- }
+ await genericWaitForState(wex, {
+ async checkState() {
+ const tx = await getTransactionById(wex, {
+ transactionId,
+ });
+ return (
+ tx.txState.major === txState.major && tx.txState.minor === txState.minor
+ );
+ },
+ filterNotification(notif) {
+ return notif.type === NotificationType.TransactionStateTransition;
+ },
});
- while (1) {
- p = openPromise();
- const tx = await getTransactionById(wex, {
- transactionId,
- });
- if (
- tx.txState.major === txState.major &&
- tx.txState.minor === txState.minor
- ) {
- break;
- }
- // Wait until transaction state changed
- await p.promise;
- }
logger.info(
`done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`,
);
- cancelNotifs();
}
export async function waitUntilTransactionWithAssociatedRefreshesFinal(
@@ -669,7 +620,7 @@ export async function runIntegrationTest2(
wex: WalletExecutionContext,
args: IntegrationTestV2Args,
): Promise<void> {
- wex.taskScheduler.ensureRunning();
+ await wex.taskScheduler.ensureRunning();
logger.info("running test with arguments", args);
const exchangeInfo = await fetchFreshExchange(wex, args.exchangeBaseUrl);
@@ -907,9 +858,12 @@ export async function testPay(
if (r.type != ConfirmPayResultType.Done) {
throw Error("payment not done");
}
- const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
- return tx.purchases.get(result.proposalId);
- });
+ const purchase = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(result.proposalId);
+ },
+ );
checkLogicInvariant(!!purchase);
return {
numCoins: purchase.payInfo?.payCoinSelection?.coinContributions.length ?? 0,
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
index 536f0de4b..1a5ac8f6a 100644
--- a/packages/taler-wallet-core/src/transactions.ts
+++ b/packages/taler-wallet-core/src/transactions.ts
@@ -129,7 +129,6 @@ import {
computeRefreshTransactionState,
RefreshTransactionContext,
} from "./refresh.js";
-import { RewardTransactionContext } from "./reward.js";
import type { WalletExecutionContext } from "./wallet.js";
import {
augmentPaytoUrisForWithdrawal,
@@ -199,7 +198,6 @@ function shouldSkipSearch(
*/
const txOrder: { [t in TransactionType]: number } = {
[TransactionType.Withdrawal]: 1,
- [TransactionType.Reward]: 2,
[TransactionType.Payment]: 3,
[TransactionType.PeerPullCredit]: 4,
[TransactionType.PeerPullDebit]: 5,
@@ -228,12 +226,14 @@ export async function getTransactionById(
case TransactionType.Withdrawal: {
const withdrawalGroupId = parsedTx.withdrawalGroupId;
return await wex.db.runReadWriteTx(
- [
- "withdrawalGroups",
- "exchangeDetails",
- "exchanges",
- "operationRetries",
- ],
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ ],
+ },
async (tx) => {
const withdrawalGroupRecord =
await tx.withdrawalGroups.get(withdrawalGroupId);
@@ -269,7 +269,7 @@ export async function getTransactionById(
case TransactionType.DenomLoss: {
const rec = await wex.db.runReadOnlyTx(
- ["denomLossEvents"],
+ { storeNames: ["denomLossEvents"] },
async (tx) => {
return tx.denomLossEvents.get(parsedTx.denomLossEventId);
},
@@ -286,13 +286,15 @@ export async function getTransactionById(
case TransactionType.Payment: {
const proposalId = parsedTx.proposalId;
return await wex.db.runReadWriteTx(
- [
- "purchases",
- "tombstones",
- "operationRetries",
- "contractTerms",
- "refundGroups",
- ],
+ {
+ storeNames: [
+ "purchases",
+ "tombstones",
+ "operationRetries",
+ "contractTerms",
+ "refundGroups",
+ ],
+ },
async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) throw Error("not found");
@@ -319,7 +321,7 @@ export async function getTransactionById(
// FIXME: We should return info about the refresh here!;
const refreshGroupId = parsedTx.refreshGroupId;
return await wex.db.runReadOnlyTx(
- ["refreshGroups", "operationRetries"],
+ { storeNames: ["refreshGroups", "operationRetries"] },
async (tx) => {
const refreshGroupRec = await tx.refreshGroups.get(refreshGroupId);
if (!refreshGroupRec) {
@@ -333,14 +335,10 @@ export async function getTransactionById(
);
}
- case TransactionType.Reward: {
- throw Error("unsupported operation");
- }
-
case TransactionType.Deposit: {
const depositGroupId = parsedTx.depositGroupId;
return await wex.db.runReadWriteTx(
- ["depositGroups", "operationRetries"],
+ { storeNames: ["depositGroups", "operationRetries"] },
async (tx) => {
const depositRecord = await tx.depositGroups.get(depositGroupId);
if (!depositRecord) throw Error("not found");
@@ -355,7 +353,14 @@ export async function getTransactionById(
case TransactionType.Refund: {
return await wex.db.runReadOnlyTx(
- ["refundGroups", "purchases", "operationRetries", "contractTerms"],
+ {
+ storeNames: [
+ "refundGroups",
+ "purchases",
+ "operationRetries",
+ "contractTerms",
+ ],
+ },
async (tx) => {
const refundRecord = await tx.refundGroups.get(
parsedTx.refundGroupId,
@@ -373,7 +378,7 @@ export async function getTransactionById(
}
case TransactionType.PeerPullDebit: {
return await wex.db.runReadWriteTx(
- ["peerPullDebit", "contractTerms"],
+ { storeNames: ["peerPullDebit", "contractTerms"] },
async (tx) => {
const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId);
if (!debit) throw Error("not found");
@@ -392,7 +397,7 @@ export async function getTransactionById(
case TransactionType.PeerPushDebit: {
return await wex.db.runReadWriteTx(
- ["peerPushDebit", "contractTerms"],
+ { storeNames: ["peerPushDebit", "contractTerms"] },
async (tx) => {
const debit = await tx.peerPushDebit.get(parsedTx.pursePub);
if (!debit) throw Error("not found");
@@ -409,12 +414,14 @@ export async function getTransactionById(
case TransactionType.PeerPushCredit: {
const peerPushCreditId = parsedTx.peerPushCreditId;
return await wex.db.runReadWriteTx(
- [
- "peerPushCredit",
- "contractTerms",
- "withdrawalGroups",
- "operationRetries",
- ],
+ {
+ storeNames: [
+ "peerPushCredit",
+ "contractTerms",
+ "withdrawalGroups",
+ "operationRetries",
+ ],
+ },
async (tx) => {
const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
if (!pushInc) throw Error("not found");
@@ -447,12 +454,14 @@ export async function getTransactionById(
case TransactionType.PeerPullCredit: {
const pursePub = parsedTx.pursePub;
return await wex.db.runReadWriteTx(
- [
- "peerPullCredit",
- "contractTerms",
- "withdrawalGroups",
- "operationRetries",
- ],
+ {
+ storeNames: [
+ "peerPullCredit",
+ "contractTerms",
+ "withdrawalGroups",
+ "operationRetries",
+ ],
+ },
async (tx) => {
const pushInc = await tx.peerPullCredit.get(pursePub);
if (!pushInc) throw Error("not found");
@@ -1045,7 +1054,14 @@ export async function getWithdrawalTransactionByUri(
request: WithdrawalTransactionByURIRequest,
): Promise<TransactionWithdrawal | undefined> {
return await wex.db.runReadWriteTx(
- ["withdrawalGroups", "exchangeDetails", "exchanges", "operationRetries"],
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ ],
+ },
async (tx) => {
const withdrawalGroupRecord =
await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
@@ -1098,28 +1114,30 @@ export async function getTransactions(
}
await wex.db.runReadOnlyTx(
- [
- "coins",
- "denominations",
- "depositGroups",
- "exchangeDetails",
- "exchanges",
- "operationRetries",
- "peerPullDebit",
- "peerPushDebit",
- "peerPushCredit",
- "peerPullCredit",
- "planchets",
- "purchases",
- "contractTerms",
- "recoupGroups",
- "rewards",
- "tombstones",
- "withdrawalGroups",
- "refreshGroups",
- "refundGroups",
- "denomLossEvents",
- ],
+ {
+ storeNames: [
+ "coins",
+ "denominations",
+ "depositGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ "peerPullDebit",
+ "peerPushDebit",
+ "peerPushCredit",
+ "peerPullCredit",
+ "planchets",
+ "purchases",
+ "contractTerms",
+ "recoupGroups",
+ "rewards",
+ "tombstones",
+ "withdrawalGroups",
+ "refreshGroups",
+ "refundGroups",
+ "denomLossEvents",
+ ],
+ },
async (tx) => {
await iterRecordsForPeerPushDebit(tx, filter, async (pi) => {
const amount = Amounts.parseOrThrow(pi.amount);
@@ -1531,7 +1549,6 @@ export type ParsedTransactionIdentifier =
| { tag: TransactionType.PeerPushDebit; pursePub: string }
| { tag: TransactionType.Refresh; refreshGroupId: string }
| { tag: TransactionType.Refund; refundGroupId: string }
- | { tag: TransactionType.Reward; walletRewardId: string }
| { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
| { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string }
| { tag: TransactionType.Recoup; recoupGroupId: string }
@@ -1557,8 +1574,6 @@ export function constructTransactionIdentifier(
return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr;
case TransactionType.Refund:
return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr;
- case TransactionType.Reward:
- return `txn:${pTxId.tag}:${pTxId.walletRewardId}` as TransactionIdStr;
case TransactionType.Withdrawal:
return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
case TransactionType.InternalWithdrawal:
@@ -1616,11 +1631,6 @@ export function parseTransactionIdentifier(
tag: TransactionType.Refund,
refundGroupId: rest[0],
};
- case TransactionType.Reward:
- return {
- tag: TransactionType.Reward,
- walletRewardId: rest[0],
- };
case TransactionType.Withdrawal:
return {
tag: TransactionType.Withdrawal,
@@ -1669,11 +1679,6 @@ function maybeTaskFromTransaction(
tag: PendingTaskType.Purchase,
proposalId: parsedTx.proposalId,
});
- case TransactionType.Reward:
- return constructTaskIdentifier({
- tag: PendingTaskType.RewardPickup,
- walletRewardId: parsedTx.walletRewardId,
- });
case TransactionType.Refresh:
return constructTaskIdentifier({
tag: PendingTaskType.Refresh,
@@ -1721,7 +1726,7 @@ export async function retryTransaction(
logger.info(`resetting retry timeout for ${transactionId}`);
const taskId = maybeTaskFromTransaction(transactionId);
if (taskId) {
- wex.taskScheduler.resetTaskRetries(taskId);
+ await wex.taskScheduler.resetTaskRetries(taskId);
}
}
@@ -1753,8 +1758,6 @@ async function getContextForTransaction(
return new PeerPushCreditTransactionContext(wex, tx.peerPushCreditId);
case TransactionType.Refund:
return new RefundTransactionContext(wex, tx.refundGroupId);
- case TransactionType.Reward:
- return new RewardTransactionContext(wex, tx.walletRewardId);
case TransactionType.Recoup:
//return new RecoupTransactionContext(ws, tx.recoupGroupId);
throw new Error("not yet supported");
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 15803ce8d..9a8ea8470 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,
@@ -47,6 +49,7 @@ import {
ConfirmPayResult,
ConfirmPeerPullDebitRequest,
ConfirmPeerPushCreditRequest,
+ ConfirmWithdrawalRequest,
ConvertAmountRequest,
CreateDepositGroupRequest,
CreateDepositGroupResponse,
@@ -60,7 +63,7 @@ import {
FailTransactionRequest,
ForceRefreshRequest,
ForgetKnownBankAccountsRequest,
- GetActiveTasks,
+ GetActiveTasksResponse,
GetAmountRequest,
GetBalanceDetailRequest,
GetContractTermsDetailsRequest,
@@ -91,6 +94,8 @@ import {
ListGlobalCurrencyAuditorsResponse,
ListGlobalCurrencyExchangesResponse,
ListKnownBankAccountsRequest,
+ PrepareBankIntegratedWithdrawalRequest,
+ PrepareBankIntegratedWithdrawalResponse,
PrepareDepositRequest,
PrepareDepositResponse,
PreparePayRequest,
@@ -195,6 +200,8 @@ export enum WalletApiOperation {
SetExchangeTosForgotten = "SetExchangeTosForgotten",
StartRefundQueryForUri = "startRefundQueryForUri",
StartRefundQuery = "startRefundQuery",
+ PrepareBankIntegratedWithdrawal = "prepareBankIntegratedWithdrawal",
+ ConfirmWithdrawal = "confirmWithdrawal",
AcceptBankIntegratedWithdrawal = "acceptBankIntegratedWithdrawal",
GetExchangeTos = "getExchangeTos",
GetExchangeDetailedInfo = "getExchangeDetailedInfo",
@@ -235,10 +242,6 @@ export enum WalletApiOperation {
Recycle = "recycle",
ApplyDevExperiment = "applyDevExperiment",
ValidateIban = "validateIban",
- TestingWaitTransactionsFinal = "testingWaitTransactionsFinal",
- TestingWaitRefreshesFinal = "testingWaitRefreshesFinal",
- TestingWaitTransactionState = "testingWaitTransactionState",
- TestingSetTimetravel = "testingSetTimetravel",
GetCurrencySpecification = "getCurrencySpecification",
ListStoredBackups = "listStoredBackups",
CreateStoredBackup = "createStoredBackup",
@@ -247,7 +250,6 @@ export enum WalletApiOperation {
UpdateExchangeEntry = "updateExchangeEntry",
ListExchangesForScopedCurrency = "listExchangesForScopedCurrency",
PrepareWithdrawExchange = "prepareWithdrawExchange",
- TestingInfiniteTransactionLoop = "testingInfiniteTransactionLoop",
GetExchangeResources = "getExchangeResources",
DeleteExchange = "deleteExchange",
ListGlobalCurrencyExchanges = "listGlobalCurrencyExchanges",
@@ -257,9 +259,18 @@ export enum WalletApiOperation {
AddGlobalCurrencyAuditor = "addGlobalCurrencyAuditor",
RemoveGlobalCurrencyAuditor = "removeGlobalCurrencyAuditor",
ListAssociatedRefreshes = "listAssociatedRefreshes",
+ Shutdown = "shutdown",
+ CanonicalizeBaseUrl = "canonicalizeBaseUrl",
+ TestingWaitTransactionsFinal = "testingWaitTransactionsFinal",
+ TestingWaitRefreshesFinal = "testingWaitRefreshesFinal",
+ TestingWaitTransactionState = "testingWaitTransactionState",
+ TestingWaitTasksDone = "testingWaitTasksDone",
+ TestingSetTimetravel = "testingSetTimetravel",
+ TestingInfiniteTransactionLoop = "testingInfiniteTransactionLoop",
TestingListTaskForTransaction = "testingListTasksForTransaction",
TestingGetDenomStats = "testingGetDenomStats",
TestingPing = "testingPing",
+ TestingGetReserveHistory = "testingGetReserveHistory",
}
// group: Initialization
@@ -277,6 +288,12 @@ export type InitWalletOp = {
response: InitResponse;
};
+export type ShutdownOp = {
+ op: WalletApiOperation.Shutdown;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
/**
* Change the configuration of wallet-core.
*
@@ -468,7 +485,27 @@ export type GetWithdrawalDetailsForUriOp = {
};
/**
+ * Prepare a bank-integrated withdrawal operation.
+ */
+export type PrepareBankIntegratedWithdrawalOp = {
+ op: WalletApiOperation.PrepareBankIntegratedWithdrawal;
+ request: PrepareBankIntegratedWithdrawalRequest;
+ response: PrepareBankIntegratedWithdrawalResponse;
+};
+
+/**
+ * Confirm a withdrawal transaction.
+ */
+export type ConfirmWithdrawalOp = {
+ op: WalletApiOperation.ConfirmWithdrawal;
+ request: ConfirmWithdrawalRequest;
+ response: EmptyObject;
+};
+
+/**
* Accept a bank-integrated withdrawal.
+ *
+ * @deprecated in favor of prepare/confirm withdrawal.
*/
export type AcceptBankIntegratedWithdrawalOp = {
op: WalletApiOperation.AcceptBankIntegratedWithdrawal;
@@ -925,6 +962,12 @@ export type ValidateIbanOp = {
response: ValidateIbanResponse;
};
+export type CanonicalizeBaseUrlOp = {
+ op: WalletApiOperation.CanonicalizeBaseUrl;
+ request: CanonicalizeBaseUrlRequest;
+ response: CanonicalizeBaseUrlResponse;
+};
+
// group: Database Management
/**
@@ -1073,7 +1116,7 @@ export type GetPendingTasksOp = {
export type GetActiveTasksOp = {
op: WalletApiOperation.GetActiveTasks;
request: EmptyObject;
- response: GetActiveTasks;
+ response: GetActiveTasksResponse;
};
/**
@@ -1113,6 +1156,15 @@ export type TestingWaitTransactionsFinalOp = {
};
/**
+ * Wait until all transactions are in a final state.
+ */
+export type TestingWaitTasksDoneOp = {
+ op: WalletApiOperation.TestingWaitTasksDone;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
* Wait until all refresh transactions are in a final state.
*/
export type TestingWaitRefreshesFinalOp = {
@@ -1136,6 +1188,12 @@ export type TestingPingOp = {
response: EmptyObject;
};
+export type TestingGetReserveHistoryOp = {
+ op: WalletApiOperation.TestingGetReserveHistory;
+ request: EmptyObject;
+ response: any;
+};
+
/**
* Get stats about an exchange denomination.
*/
@@ -1253,6 +1311,7 @@ export type WalletOperations = {
[WalletApiOperation.TestingWaitRefreshesFinal]: TestingWaitRefreshesFinalOp;
[WalletApiOperation.TestingSetTimetravel]: TestingSetTimetravelOp;
[WalletApiOperation.TestingWaitTransactionState]: TestingWaitTransactionStateOp;
+ [WalletApiOperation.TestingWaitTasksDone]: TestingWaitTasksDoneOp;
[WalletApiOperation.GetCurrencySpecification]: GetCurrencySpecificationOp;
[WalletApiOperation.CreateStoredBackup]: CreateStoredBackupsOp;
[WalletApiOperation.ListStoredBackups]: ListStoredBackupsOp;
@@ -1273,6 +1332,11 @@ export type WalletOperations = {
[WalletApiOperation.TestingListTaskForTransaction]: TestingListTasksForTransactionOp;
[WalletApiOperation.TestingGetDenomStats]: TestingGetDenomStatsOp;
[WalletApiOperation.TestingPing]: TestingPingOp;
+ [WalletApiOperation.Shutdown]: ShutdownOp;
+ [WalletApiOperation.PrepareBankIntegratedWithdrawal]: PrepareBankIntegratedWithdrawalOp;
+ [WalletApiOperation.ConfirmWithdrawal]: ConfirmWithdrawalOp;
+ [WalletApiOperation.CanonicalizeBaseUrl]: CanonicalizeBaseUrlOp;
+ [WalletApiOperation.TestingGetReserveHistory]: TestingGetReserveHistoryOp;
};
export type WalletCoreRequestType<
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index fb5a93693..fe03dbf62 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -22,7 +22,7 @@
/**
* Imports.
*/
-import { IDBFactory } from "@gnu-taler/idb-bridge";
+import { IDBDatabase, IDBFactory } from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
ActiveTask,
@@ -55,7 +55,6 @@ import {
PrepareWithdrawExchangeRequest,
PrepareWithdrawExchangeResponse,
RecoverStoredBackupRequest,
- RetryLoopOpts,
StoredBackupList,
TalerError,
TalerErrorCode,
@@ -71,6 +70,7 @@ import {
WalletCoreVersion,
WalletNotification,
WalletRunConfig,
+ canonicalizeBaseUrl,
checkDbInvariant,
codecForAbortTransaction,
codecForAcceptBankIntegratedWithdrawalRequest,
@@ -83,10 +83,12 @@ import {
codecForAddKnownBankAccounts,
codecForAny,
codecForApplyDevExperiment,
+ codecForCanonicalizeBaseUrlRequest,
codecForCheckPeerPullPaymentRequest,
codecForCheckPeerPushDebitRequest,
codecForConfirmPayRequest,
codecForConfirmPeerPushPaymentRequest,
+ codecForConfirmWithdrawalRequestRequest,
codecForConvertAmountRequest,
codecForCreateDepositGroupRequest,
codecForDeleteExchangeRequest,
@@ -112,6 +114,7 @@ import {
codecForIntegrationTestV2Args,
codecForListExchangesForScopedCurrencyRequest,
codecForListKnownBankAccounts,
+ codecForPrepareBankIntegratedWithdrawalRequest,
codecForPrepareDepositRequest,
codecForPreparePayRequest,
codecForPreparePayTemplateRequest,
@@ -131,6 +134,7 @@ import {
codecForSuspendTransaction,
codecForTestPayArgs,
codecForTestingGetDenomStatsRequest,
+ codecForTestingGetReserveHistoryRequest,
codecForTestingListTasksForTransactionRequest,
codecForTestingSetTimetravelRequest,
codecForTransactionByIdRequest,
@@ -146,11 +150,15 @@ import {
parsePaytoUri,
parseTalerUri,
performanceNow,
+ safeStringifyException,
sampleWalletCoreTransactions,
setDangerousTimetravel,
validateIban,
} from "@gnu-taler/taler-util";
-import type { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
+import {
+ readSuccessResponseJsonOrThrow,
+ type HttpRequestLibrary,
+} from "@gnu-taler/taler-util/http";
import {
getUserAttentions,
getUserAttentionsUnreadCount,
@@ -244,7 +252,12 @@ import {
checkPeerPushDebit,
initiatePeerPushDebit,
} from "./pay-peer-push-debit.js";
-import { DbAccess } from "./query.js";
+import {
+ AfterCommitInfo,
+ DbAccess,
+ DbAccessImpl,
+ TriggerSpec,
+} from "./query.js";
import { forceRefresh } from "./refresh.js";
import {
TaskScheduler,
@@ -256,6 +269,7 @@ import {
runIntegrationTest,
runIntegrationTest2,
testPay,
+ waitTasksDone,
waitTransactionState,
waitUntilAllTransactionsFinal,
waitUntilRefreshesDone,
@@ -289,9 +303,11 @@ import {
} from "./wallet-api-types.js";
import {
acceptWithdrawalFromUri,
+ confirmWithdrawal,
createManualWithdrawal,
getWithdrawalDetailsForAmount,
getWithdrawalDetailsForUri,
+ prepareBankIntegratedWithdrawal,
} from "./withdraw.js";
const logger = new Logger("wallet.ts");
@@ -326,28 +342,31 @@ type CancelFn = () => void;
*/
async function fillDefaults(wex: WalletExecutionContext): Promise<void> {
const notifications: WalletNotification[] = [];
- await wex.db.runReadWriteTx(["config", "exchanges"], async (tx) => {
- const appliedRec = await tx.config.get("currencyDefaultsApplied");
- let alreadyApplied = appliedRec ? !!appliedRec.value : false;
- if (alreadyApplied) {
- logger.trace("defaults already applied");
- return;
- }
- for (const exch of wex.ws.config.builtin.exchanges) {
- const resp = await addPresetExchangeEntry(
- tx,
- exch.exchangeBaseUrl,
- exch.currencyHint,
- );
- if (resp.notification) {
- notifications.push(resp.notification);
+ await wex.db.runReadWriteTx(
+ { storeNames: ["config", "exchanges"] },
+ async (tx) => {
+ const appliedRec = await tx.config.get("currencyDefaultsApplied");
+ let alreadyApplied = appliedRec ? !!appliedRec.value : false;
+ if (alreadyApplied) {
+ logger.trace("defaults already applied");
+ return;
}
- }
- await tx.config.put({
- key: ConfigRecordKey.CurrencyDefaultsApplied,
- value: true,
- });
- });
+ for (const exch of wex.ws.config.builtin.exchanges) {
+ const resp = await addPresetExchangeEntry(
+ tx,
+ exch.exchangeBaseUrl,
+ exch.currencyHint,
+ );
+ if (resp.notification) {
+ notifications.push(resp.notification);
+ }
+ }
+ await tx.config.put({
+ key: ConfigRecordKey.CurrencyDefaultsApplied,
+ value: true,
+ });
+ },
+ );
for (const notif of notifications) {
wex.ws.notify(notif);
}
@@ -382,7 +401,7 @@ async function listKnownBankAccounts(
currency?: string,
): Promise<KnownBankAccounts> {
const accounts: KnownBankAccountsInfo[] = [];
- await wex.db.runReadOnlyTx(["bankAccounts"], async (tx) => {
+ await wex.db.runReadOnlyTx({ storeNames: ["bankAccounts"] }, async (tx) => {
const knownAccounts = await tx.bankAccounts.iter().toArray();
for (const r of knownAccounts) {
if (currency && currency !== r.currency) {
@@ -410,7 +429,7 @@ async function addKnownBankAccounts(
alias: string,
currency: string,
): Promise<void> {
- await wex.db.runReadWriteTx(["bankAccounts"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => {
tx.bankAccounts.put({
uri: payto,
alias: alias,
@@ -427,7 +446,7 @@ async function forgetKnownBankAccounts(
wex: WalletExecutionContext,
payto: string,
): Promise<void> {
- await wex.db.runReadWriteTx(["bankAccounts"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["bankAccounts"] }, async (tx) => {
const account = await tx.bankAccounts.get(payto);
if (!account) {
throw Error(`account not found: ${payto}`);
@@ -442,39 +461,42 @@ async function setCoinSuspended(
coinPub: string,
suspended: boolean,
): Promise<void> {
- await wex.db.runReadWriteTx(["coins", "coinAvailability"], async (tx) => {
- const c = await tx.coins.get(coinPub);
- if (!c) {
- logger.warn(`coin ${coinPub} not found, won't suspend`);
- return;
- }
- const coinAvailability = await tx.coinAvailability.get([
- c.exchangeBaseUrl,
- c.denomPubHash,
- c.maxAge,
- ]);
- checkDbInvariant(!!coinAvailability);
- if (suspended) {
- if (c.status !== CoinStatus.Fresh) {
+ await wex.db.runReadWriteTx(
+ { storeNames: ["coins", "coinAvailability"] },
+ async (tx) => {
+ const c = await tx.coins.get(coinPub);
+ if (!c) {
+ logger.warn(`coin ${coinPub} not found, won't suspend`);
return;
}
- if (coinAvailability.freshCoinCount === 0) {
- throw Error(
- `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
- );
- }
- coinAvailability.freshCoinCount--;
- c.status = CoinStatus.FreshSuspended;
- } else {
- if (c.status == CoinStatus.Dormant) {
- return;
+ const coinAvailability = await tx.coinAvailability.get([
+ c.exchangeBaseUrl,
+ c.denomPubHash,
+ c.maxAge,
+ ]);
+ checkDbInvariant(!!coinAvailability);
+ if (suspended) {
+ if (c.status !== CoinStatus.Fresh) {
+ return;
+ }
+ if (coinAvailability.freshCoinCount === 0) {
+ throw Error(
+ `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
+ );
+ }
+ coinAvailability.freshCoinCount--;
+ c.status = CoinStatus.FreshSuspended;
+ } else {
+ if (c.status == CoinStatus.Dormant) {
+ return;
+ }
+ coinAvailability.freshCoinCount++;
+ c.status = CoinStatus.Fresh;
}
- coinAvailability.freshCoinCount++;
- c.status = CoinStatus.Fresh;
- }
- await tx.coins.put(c);
- await tx.coinAvailability.put(coinAvailability);
- });
+ await tx.coins.put(c);
+ await tx.coinAvailability.put(coinAvailability);
+ },
+ );
}
/**
@@ -483,55 +505,58 @@ async function setCoinSuspended(
async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> {
const coinsJson: CoinDumpJson = { coins: [] };
logger.info("dumping coins");
- await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
- const coins = await tx.coins.iter().toArray();
- for (const c of coins) {
- const denom = await tx.denominations.get([
- c.exchangeBaseUrl,
- c.denomPubHash,
- ]);
- if (!denom) {
- logger.warn("no denom found for coin");
- continue;
- }
- const cs = c.coinSource;
- let refreshParentCoinPub: string | undefined;
- if (cs.type == CoinSourceType.Refresh) {
- refreshParentCoinPub = cs.oldCoinPub;
- }
- let withdrawalReservePub: string | undefined;
- if (cs.type == CoinSourceType.Withdraw) {
- withdrawalReservePub = cs.reservePub;
- }
- const denomInfo = await getDenomInfo(
- wex,
- tx,
- c.exchangeBaseUrl,
- c.denomPubHash,
- );
- if (!denomInfo) {
- logger.warn("no denomination found for coin");
- continue;
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["coins", "denominations"] },
+ async (tx) => {
+ const coins = await tx.coins.iter().toArray();
+ for (const c of coins) {
+ const denom = await tx.denominations.get([
+ c.exchangeBaseUrl,
+ c.denomPubHash,
+ ]);
+ if (!denom) {
+ logger.warn("no denom found for coin");
+ continue;
+ }
+ const cs = c.coinSource;
+ let refreshParentCoinPub: string | undefined;
+ if (cs.type == CoinSourceType.Refresh) {
+ refreshParentCoinPub = cs.oldCoinPub;
+ }
+ let withdrawalReservePub: string | undefined;
+ if (cs.type == CoinSourceType.Withdraw) {
+ withdrawalReservePub = cs.reservePub;
+ }
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ c.exchangeBaseUrl,
+ c.denomPubHash,
+ );
+ if (!denomInfo) {
+ logger.warn("no denomination found for coin");
+ continue;
+ }
+ coinsJson.coins.push({
+ coin_pub: c.coinPub,
+ denom_pub: denomInfo.denomPub,
+ denom_pub_hash: c.denomPubHash,
+ denom_value: denom.value,
+ exchange_base_url: c.exchangeBaseUrl,
+ refresh_parent_coin_pub: refreshParentCoinPub,
+ withdrawal_reserve_pub: withdrawalReservePub,
+ coin_status: c.status,
+ ageCommitmentProof: c.ageCommitmentProof,
+ spend_allocation: c.spendAllocation
+ ? {
+ amount: c.spendAllocation.amount,
+ id: c.spendAllocation.id,
+ }
+ : undefined,
+ });
}
- coinsJson.coins.push({
- coin_pub: c.coinPub,
- denom_pub: denomInfo.denomPub,
- denom_pub_hash: c.denomPubHash,
- denom_value: denom.value,
- exchange_base_url: c.exchangeBaseUrl,
- refresh_parent_coin_pub: refreshParentCoinPub,
- withdrawal_reserve_pub: withdrawalReservePub,
- coin_status: c.status,
- ageCommitmentProof: c.ageCommitmentProof,
- spend_allocation: c.spendAllocation
- ? {
- amount: c.spendAllocation.amount,
- id: c.spendAllocation.id,
- }
- : undefined,
- });
- }
- });
+ },
+ );
return coinsJson;
}
@@ -665,7 +690,7 @@ export interface PendingOperationsResponse {
/**
* Implementation of the "wallet-core" API.
*/
-async function dispatchRequestInternal<Op extends WalletApiOperation>(
+async function dispatchRequestInternal(
wex: WalletExecutionContext,
cts: CancellationToken.Source,
operation: WalletApiOperation,
@@ -708,7 +733,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
// Write to the DB to make sure that we're failing early in
// case the DB is not writeable.
try {
- await wex.db.runReadWriteTx(["config"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
tx.config.put({
key: ConfigRecordKey.LastInitInfo,
value: timestampProtocolToDb(TalerProtocolTimestamp.now()),
@@ -723,7 +748,6 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
wex.ws.initWithConfig(applyRunConfigDefaults(req.config));
- wex.ws.initCalled = true;
if (wex.ws.config.testing.skipDefaults) {
logger.trace("skipping defaults");
} else {
@@ -733,6 +757,11 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
const resp: InitResponse = {
versionInfo: getVersion(wex),
};
+
+ // After initialization, task loop should run.
+ await wex.taskScheduler.ensureRunning();
+
+ wex.ws.initCalled = true;
return resp;
}
case WalletApiOperation.WithdrawTestkudos: {
@@ -815,20 +844,24 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
numLost: 0,
numOffered: 0,
};
- await wex.db.runReadOnlyTx(["denominations"], async (tx) => {
- const denoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
- req.exchangeBaseUrl,
- );
- for (const d of denoms) {
- denomStats.numKnown++;
- if (d.isOffered) {
- denomStats.numOffered++;
- }
- if (d.isLost) {
- denomStats.numLost++;
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ const denoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ req.exchangeBaseUrl,
+ );
+ for (const d of denoms) {
+ denomStats.numKnown++;
+ if (d.isOffered) {
+ denomStats.numOffered++;
+ }
+ if (d.isLost) {
+ denomStats.numLost++;
+ }
}
- }
- });
+ },
+ );
return denomStats;
}
case WalletApiOperation.ListExchanges: {
@@ -877,16 +910,45 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
case WalletApiOperation.GetWithdrawalDetailsForUri: {
const req = codecForGetWithdrawalDetailsForUri().decode(payload);
return await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri, {
- notifyChangeFromPendingTimeoutMs: req.notifyChangeFromPendingTimeoutMs,
restrictAge: req.restrictAge,
});
}
+ case WalletApiOperation.TestingGetReserveHistory: {
+ const req = codecForTestingGetReserveHistoryRequest().decode(payload);
+ const reserve = await wex.db.runReadOnlyTx(
+ { storeNames: ["reserves"] },
+ async (tx) => {
+ return tx.reserves.indexes.byReservePub.get(req.reservePub);
+ },
+ );
+ if (!reserve) {
+ throw Error("no reserve pub found");
+ }
+ const sigResp = await wex.cryptoApi.signReserveHistoryReq({
+ reservePriv: reserve.reservePriv,
+ startOffset: 0,
+ });
+ const exchangeBaseUrl = req.exchangeBaseUrl;
+ const url = new URL(
+ `reserves/${req.reservePub}/history`,
+ exchangeBaseUrl,
+ );
+ const resp = await wex.http.fetch(url.href, {
+ headers: { ["Taler-Reserve-History-Signature"]: sigResp.sig },
+ });
+ const historyJson = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForAny(),
+ );
+ return historyJson;
+ }
case WalletApiOperation.AcceptManualWithdrawal: {
const req = codecForAcceptManualWithdrawalRequest().decode(payload);
const res = await createManualWithdrawal(wex, {
amount: Amounts.parseOrThrow(req.amount),
exchangeBaseUrl: req.exchangeBaseUrl,
restrictAge: req.restrictAge,
+ forceReservePriv: req.forceReservePriv,
});
return res;
}
@@ -941,6 +1003,20 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
restrictAge: req.restrictAge,
});
}
+ case WalletApiOperation.ConfirmWithdrawal: {
+ const req = codecForConfirmWithdrawalRequestRequest().decode(payload);
+ return confirmWithdrawal(wex, req.transactionId);
+ }
+ case WalletApiOperation.PrepareBankIntegratedWithdrawal: {
+ const req =
+ codecForPrepareBankIntegratedWithdrawalRequest().decode(payload);
+ return prepareBankIntegratedWithdrawal(wex, {
+ selectedExchange: req.exchangeBaseUrl,
+ talerWithdrawUri: req.talerWithdrawUri,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ });
+ }
case WalletApiOperation.GetExchangeTos: {
const req = codecForGetExchangeTosRequest().decode(payload);
return getExchangeTos(
@@ -1016,8 +1092,8 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
const tasksInfo = await Promise.all(
allTasksId.map(async (id) => {
- return await wex.ws.db.runReadOnlyTx(
- ["operationRetries"],
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["operationRetries"] },
async (tx) => {
return tx.operationRetries.get(id);
},
@@ -1039,8 +1115,8 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
const lastError = d?.lastError;
return {
- id: taskId,
- counter,
+ taskId: taskId,
+ retryCounter: counter,
firstTry,
nextTry,
lastError,
@@ -1236,106 +1312,136 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
const resp: ListGlobalCurrencyExchangesResponse = {
exchanges: [],
};
- await wex.db.runReadOnlyTx(["globalCurrencyExchanges"], async (tx) => {
- const gceList = await tx.globalCurrencyExchanges.iter().toArray();
- for (const gce of gceList) {
- resp.exchanges.push({
- currency: gce.currency,
- exchangeBaseUrl: gce.exchangeBaseUrl,
- exchangeMasterPub: gce.exchangeMasterPub,
- });
- }
- });
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const gceList = await tx.globalCurrencyExchanges.iter().toArray();
+ for (const gce of gceList) {
+ resp.exchanges.push({
+ currency: gce.currency,
+ exchangeBaseUrl: gce.exchangeBaseUrl,
+ exchangeMasterPub: gce.exchangeMasterPub,
+ });
+ }
+ },
+ );
return resp;
}
case WalletApiOperation.ListGlobalCurrencyAuditors: {
const resp: ListGlobalCurrencyAuditorsResponse = {
auditors: [],
};
- await wex.db.runReadOnlyTx(["globalCurrencyAuditors"], async (tx) => {
- const gcaList = await tx.globalCurrencyAuditors.iter().toArray();
- for (const gca of gcaList) {
- resp.auditors.push({
- currency: gca.currency,
- auditorBaseUrl: gca.auditorBaseUrl,
- auditorPub: gca.auditorPub,
- });
- }
- });
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const gcaList = await tx.globalCurrencyAuditors.iter().toArray();
+ for (const gca of gcaList) {
+ resp.auditors.push({
+ currency: gca.currency,
+ auditorBaseUrl: gca.auditorBaseUrl,
+ auditorPub: gca.auditorPub,
+ });
+ }
+ },
+ );
return resp;
}
case WalletApiOperation.AddGlobalCurrencyExchange: {
const req = codecForAddGlobalCurrencyExchangeRequest().decode(payload);
- await wex.db.runReadWriteTx(["globalCurrencyExchanges"], async (tx) => {
- const key = [req.currency, req.exchangeBaseUrl, req.exchangeMasterPub];
- const existingRec =
- await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
- key,
- );
- if (existingRec) {
- return;
- }
- wex.ws.exchangeCache.clear();
- await tx.globalCurrencyExchanges.add({
- currency: req.currency,
- exchangeBaseUrl: req.exchangeBaseUrl,
- exchangeMasterPub: req.exchangeMasterPub,
- });
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const key = [
+ req.currency,
+ req.exchangeBaseUrl,
+ req.exchangeMasterPub,
+ ];
+ const existingRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (existingRec) {
+ return;
+ }
+ wex.ws.exchangeCache.clear();
+ await tx.globalCurrencyExchanges.add({
+ currency: req.currency,
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ exchangeMasterPub: req.exchangeMasterPub,
+ });
+ },
+ );
return {};
}
case WalletApiOperation.RemoveGlobalCurrencyExchange: {
const req = codecForRemoveGlobalCurrencyExchangeRequest().decode(payload);
- await wex.db.runReadWriteTx(["globalCurrencyExchanges"], async (tx) => {
- const key = [req.currency, req.exchangeBaseUrl, req.exchangeMasterPub];
- const existingRec =
- await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
- key,
- );
- if (!existingRec) {
- return;
- }
- wex.ws.exchangeCache.clear();
- checkDbInvariant(!!existingRec.id);
- await tx.globalCurrencyExchanges.delete(existingRec.id);
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyExchanges"] },
+ async (tx) => {
+ const key = [
+ req.currency,
+ req.exchangeBaseUrl,
+ req.exchangeMasterPub,
+ ];
+ const existingRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (!existingRec) {
+ return;
+ }
+ wex.ws.exchangeCache.clear();
+ checkDbInvariant(!!existingRec.id);
+ await tx.globalCurrencyExchanges.delete(existingRec.id);
+ },
+ );
return {};
}
case WalletApiOperation.AddGlobalCurrencyAuditor: {
const req = codecForAddGlobalCurrencyAuditorRequest().decode(payload);
- await wex.db.runReadWriteTx(["globalCurrencyAuditors"], async (tx) => {
- const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
- const existingRec =
- await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
- key,
- );
- if (existingRec) {
- return;
- }
- await tx.globalCurrencyAuditors.add({
- currency: req.currency,
- auditorBaseUrl: req.auditorBaseUrl,
- auditorPub: req.auditorPub,
- });
- wex.ws.exchangeCache.clear();
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
+ const existingRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (existingRec) {
+ return;
+ }
+ await tx.globalCurrencyAuditors.add({
+ currency: req.currency,
+ auditorBaseUrl: req.auditorBaseUrl,
+ auditorPub: req.auditorPub,
+ });
+ wex.ws.exchangeCache.clear();
+ },
+ );
+ return {};
+ }
+ case WalletApiOperation.TestingWaitTasksDone: {
+ await waitTasksDone(wex);
return {};
}
case WalletApiOperation.RemoveGlobalCurrencyAuditor: {
const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload);
- await wex.db.runReadWriteTx(["globalCurrencyAuditors"], async (tx) => {
- const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
- const existingRec =
- await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
- key,
- );
- if (!existingRec) {
- return;
- }
- checkDbInvariant(!!existingRec.id);
- await tx.globalCurrencyAuditors.delete(existingRec.id);
- wex.ws.exchangeCache.clear();
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["globalCurrencyAuditors"] },
+ async (tx) => {
+ const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
+ const existingRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (!existingRec) {
+ return;
+ }
+ checkDbInvariant(!!existingRec.id);
+ await tx.globalCurrencyAuditors.delete(existingRec.id);
+ wex.ws.exchangeCache.clear();
+ },
+ );
return {};
}
case WalletApiOperation.ImportDb: {
@@ -1380,6 +1486,10 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
await applyDevExperiment(wex, req.devExperimentUri);
return {};
}
+ case WalletApiOperation.Shutdown: {
+ wex.ws.stop();
+ return {};
+ }
case WalletApiOperation.GetVersion: {
return getVersion(wex);
}
@@ -1390,7 +1500,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
case WalletApiOperation.TestingSetTimetravel: {
const req = codecForTestingSetTimetravelRequest().decode(payload);
setDangerousTimetravel(req.offsetMs);
- wex.taskScheduler.reload();
+ await wex.taskScheduler.reload();
return {};
}
case WalletApiOperation.DeleteExchange: {
@@ -1402,6 +1512,12 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
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;
@@ -1421,7 +1537,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
let loopCount = 0;
while (true) {
logger.info(`looping test write tx, iteration ${loopCount}`);
- await wex.db.runReadWriteTx(["config"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => {
await tx.config.put({
key: ConfigRecordKey.TestLoopTx,
value: loopCount,
@@ -1468,7 +1584,7 @@ export function getObservedWalletExecutionContext(
ws: InternalWalletState,
cancellationToken: CancellationToken,
oc: ObservabilityContext,
-) {
+): WalletExecutionContext {
const wex: WalletExecutionContext = {
ws,
cancellationToken,
@@ -1485,7 +1601,7 @@ export function getNormalWalletExecutionContext(
ws: InternalWalletState,
cancellationToken: CancellationToken,
oc: ObservabilityContext,
-) {
+): WalletExecutionContext {
const wex: WalletExecutionContext = {
ws,
cancellationToken,
@@ -1648,15 +1764,6 @@ export class Wallet {
return this.ws.addNotificationListener(f);
}
- stop(): void {
- this.ws.stop();
- }
-
- async runTaskLoop(opts?: RetryLoopOpts): Promise<void> {
- await this.ws.ensureWalletDbOpen();
- return this.ws.taskScheduler.run(opts);
- }
-
async handleCoreApiRequest(
operation: string,
id: string,
@@ -1710,6 +1817,34 @@ export class Cache<T> {
}
/**
+ * Implementation of triggers for the wallet DB.
+ */
+class WalletDbTriggerSpec implements TriggerSpec {
+ constructor(public ws: InternalWalletState) {}
+
+ afterCommit(info: AfterCommitInfo): void {
+ if (info.mode !== "readwrite") {
+ return;
+ }
+ logger.info(
+ `in after commit callback for readwrite, modified ${j2s([
+ ...info.modifiedStores,
+ ])}`,
+ );
+ const modified = info.accessedStores;
+ if (
+ modified.has(WalletStoresV1.exchanges.storeName) ||
+ modified.has(WalletStoresV1.exchangeDetails.storeName) ||
+ modified.has(WalletStoresV1.denominations.storeName) ||
+ modified.has(WalletStoresV1.globalCurrencyAuditors.storeName) ||
+ modified.has(WalletStoresV1.globalCurrencyExchanges.storeName)
+ ) {
+ this.ws.clearAllCaches();
+ }
+ }
+}
+
+/**
* Internal state of the wallet.
*
* This ties together all the operation implementations.
@@ -1755,15 +1890,19 @@ export class InternalWalletState {
private _config: Readonly<WalletRunConfig> | undefined;
- private _db: DbAccess<typeof WalletStoresV1> | undefined = undefined;
+ private _indexedDbHandle: IDBDatabase | undefined = undefined;
+
+ private _dbAccessHandle: DbAccess<typeof WalletStoresV1> | undefined;
private _http: HttpRequestLibrary | undefined = undefined;
get db(): DbAccess<typeof WalletStoresV1> {
- if (!this._db) {
- throw Error("db not initialized");
+ if (!this._dbAccessHandle) {
+ this._dbAccessHandle = this.createDbAccessHandle(
+ CancellationToken.CONTINUE,
+ );
}
- return this._db;
+ return this._dbAccessHandle;
}
devExperimentState: DevExperimentState = {};
@@ -1788,6 +1927,20 @@ export class InternalWalletState {
}
}
+ createDbAccessHandle(
+ cancellationToken: CancellationToken,
+ ): DbAccess<typeof WalletStoresV1> {
+ if (!this._indexedDbHandle) {
+ throw Error("db not initialized");
+ }
+ return new DbAccessImpl(
+ this._indexedDbHandle,
+ WalletStoresV1,
+ new WalletDbTriggerSpec(this),
+ cancellationToken,
+ );
+ }
+
get config(): WalletRunConfig {
if (!this._config) {
throw Error("config not initialized");
@@ -1814,7 +1967,7 @@ export class InternalWalletState {
}
async ensureWalletDbOpen(): Promise<void> {
- if (this._db) {
+ if (this._indexedDbHandle) {
return;
}
const myVersionChange = async (): Promise<void> => {
@@ -1822,7 +1975,7 @@ export class InternalWalletState {
};
try {
const myDb = await openTalerDatabase(this.idb, myVersionChange);
- this._db = myDb;
+ this._indexedDbHandle = myDb;
} catch (e) {
logger.error("error writing to database during initialization");
throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
@@ -1859,6 +2012,9 @@ export class InternalWalletState {
this.stopped = true;
this.timerGroup.stopCurrentAndFutureTimers();
this.cryptoDispatcher.stop();
+ this.taskScheduler.shutdown().catch((e) => {
+ logger.warn(`shutdown failed: ${safeStringifyException(e)}`);
+ });
}
/**
@@ -1893,7 +2049,7 @@ export class InternalWalletState {
} finally {
for (const token of tokens) {
this.resourceLocks.delete(token);
- let waiter = (this.resourceWaiters[token] ?? []).shift();
+ const waiter = (this.resourceWaiters[token] ?? []).shift();
if (waiter) {
waiter.resolve();
}
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
index ecd654edf..814201809 100644
--- a/packages/taler-wallet-core/src/withdraw.ts
+++ b/packages/taler-wallet-core/src/withdraw.ts
@@ -41,6 +41,7 @@ import {
DenomSelItem,
DenomSelectionState,
Duration,
+ EddsaPrivateKeyString,
ExchangeBatchWithdrawRequest,
ExchangeUpdateStatus,
ExchangeWireAccount,
@@ -55,6 +56,7 @@ import {
Logger,
NotificationType,
ObservabilityEventType,
+ PrepareBankIntegratedWithdrawalResponse,
TalerBankIntegrationHttpClient,
TalerError,
TalerErrorCode,
@@ -76,9 +78,10 @@ import {
WithdrawalType,
addPaytoQueryParams,
assertUnreachable,
- canonicalizeBaseUrl,
checkDbInvariant,
+ checkLogicInvariant,
codeForBankWithdrawalOperationPostResponse,
+ codecForBankWithdrawalOperationStatus,
codecForCashinConversionResponse,
codecForConversionBankConfig,
codecForExchangeWithdrawBatchResponse,
@@ -153,6 +156,7 @@ import {
constructTransactionIdentifier,
isUnsuccessfulTransaction,
notifyTransition,
+ parseTransactionIdentifier,
} from "./transactions.js";
import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
@@ -163,7 +167,7 @@ import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
/**
* Logger for this file.
*/
-const logger = new Logger("operations/withdraw.ts");
+const logger = new Logger("withdraw.ts");
/**
* Update the materialized withdrawal transaction based
@@ -325,7 +329,7 @@ export class WithdrawTransactionContext implements TransactionContext {
? [...baseStores, ...opts.extraStores]
: baseStores;
const transitionInfo = await this.wex.db.runReadWriteTx(
- stores,
+ { storeNames: stores },
async (tx) => {
const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId);
let oldTxState: TransactionState;
@@ -465,13 +469,18 @@ export class WithdrawTransactionContext implements TransactionContext {
break;
case WithdrawalGroupStatus.SuspendedAbortingBank:
case WithdrawalGroupStatus.AbortingBank:
+ case WithdrawalGroupStatus.AbortedUserRefused:
// No transition needed, but not an error
return TransitionResult.stay();
+ case WithdrawalGroupStatus.DialogProposed:
+ newStatus = WithdrawalGroupStatus.AbortedUserRefused;
+ break;
case WithdrawalGroupStatus.Done:
case WithdrawalGroupStatus.FailedBankAborted:
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.AbortedBank:
case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
// Not allowed
throw Error("abort not allowed in current state");
default:
@@ -657,6 +666,21 @@ export function computeWithdrawalTransactionStatus(
major: TransactionMajorState.Aborted,
minor: TransactionMinorState.Bank,
};
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Refused,
+ };
+ case WithdrawalGroupStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.CompletedByOtherWallet,
+ };
}
}
@@ -701,12 +725,76 @@ export function computeWithdrawalTransactionActions(
case WithdrawalGroupStatus.SuspendedKyc:
return [TransactionAction.Resume, TransactionAction.Abort];
case WithdrawalGroupStatus.FailedAbortingBank:
- return [TransactionAction.Delete];
case WithdrawalGroupStatus.AbortedExchange:
- return [TransactionAction.Delete];
case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
+ case WithdrawalGroupStatus.AbortedUserRefused:
return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.DialogProposed:
+ return [TransactionAction.Abort];
+ }
+}
+
+async function processWithdrawalGroupDialogProposed(
+ ctx: WithdrawTransactionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ if (
+ withdrawalGroup.wgInfo.withdrawalType !==
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ throw new Error(
+ "processWithdrawalGroupDialogProposed called in unexpected state",
+ );
+ }
+
+ const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri;
+
+ const parsedUri = parseWithdrawUri(talerWithdrawUri);
+
+ checkLogicInvariant(!!parsedUri);
+
+ const wopid = parsedUri.withdrawalOperationId;
+
+ const url = new URL(
+ `withdrawal-operation/${wopid}`,
+ parsedUri.bankIntegrationApiBaseUrl,
+ );
+
+ url.searchParams.set("old_state", "pending");
+ url.searchParams.set("long_poll_ms", "30000");
+
+ const resp = await ctx.wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: ctx.wex.cancellationToken,
+ });
+
+ // If the bank claims that the withdrawal operation is already
+ // pending, but we're still in DialogProposed, some other wallet
+ // must've completed the withdrawal, we're giving up.
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ const body = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForBankWithdrawalOperationStatus(),
+ );
+ if (body.status !== "pending") {
+ await ctx.transition({}, async (rec) => {
+ switch (rec?.status) {
+ case WithdrawalGroupStatus.DialogProposed: {
+ rec.status = WithdrawalGroupStatus.AbortedOtherWallet;
+ return TransitionResult.transition(rec);
+ }
+ }
+ return TransitionResult.stay();
+ });
+ }
+ break;
+ }
}
+
+ return TaskRunResult.longpollReturnedPending();
}
/**
@@ -751,8 +839,6 @@ export async function getBankWithdrawalInfo(
}
const { body: status } = resp;
- logger.info(`bank withdrawal operation status: ${j2s(status)}`);
-
return {
operationId: uriResult.withdrawalOperationId,
apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
@@ -773,9 +859,12 @@ async function getCandidateWithdrawalDenoms(
exchangeBaseUrl: string,
currency: string,
): Promise<DenominationRecord[]> {
- return await wex.db.runReadOnlyTx(["denominations"], async (tx) => {
- return getCandidateWithdrawalDenomsTx(wex, tx, exchangeBaseUrl, currency);
- });
+ return await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ return getCandidateWithdrawalDenomsTx(wex, tx, exchangeBaseUrl, currency);
+ },
+ );
}
export async function getCandidateWithdrawalDenomsTx(
@@ -806,12 +895,15 @@ async function processPlanchetGenerate(
withdrawalGroup: WithdrawalGroupRecord,
coinIdx: number,
): Promise<void> {
- let planchet = await wex.db.runReadOnlyTx(["planchets"], async (tx) => {
- return tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- });
+ let planchet = await wex.db.runReadOnlyTx(
+ { storeNames: ["planchets"] },
+ async (tx) => {
+ return tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ },
+ );
if (planchet) {
return;
}
@@ -837,9 +929,17 @@ async function processPlanchetGenerate(
}
const denomPubHash = maybeDenomPubHash;
- const denom = await wex.db.runReadOnlyTx(["denominations"], async (tx) => {
- return getDenomInfo(wex, tx, withdrawalGroup.exchangeBaseUrl, denomPubHash);
- });
+ const denom = await wex.db.runReadOnlyTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ return getDenomInfo(
+ wex,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ denomPubHash,
+ );
+ },
+ );
checkDbInvariant(!!denom);
const r = await wex.cryptoApi.createPlanchet({
denomPub: denom.denomPub,
@@ -865,7 +965,7 @@ async function processPlanchetGenerate(
ageCommitmentProof: r.ageCommitmentProof,
lastError: undefined,
};
- await wex.db.runReadWriteTx(["planchets"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
const p = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
@@ -1008,48 +1108,51 @@ async function processPlanchetExchangeBatchRequest(
// Indices of coins that are included in the batch request
const requestCoinIdxs: number[] = [];
- await wex.db.runReadOnlyTx(["planchets", "denominations"], async (tx) => {
- for (
- let coinIdx = args.coinStartIndex;
- coinIdx < args.coinStartIndex + args.batchSize &&
- coinIdx < wgContext.numPlanchets;
- coinIdx++
- ) {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroup.withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- continue;
- }
- if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- continue;
- }
- if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) {
- continue;
- }
- const denom = await getDenomInfo(
- wex,
- tx,
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- );
+ await wex.db.runReadOnlyTx(
+ { storeNames: ["planchets", "denominations"] },
+ async (tx) => {
+ for (
+ let coinIdx = args.coinStartIndex;
+ coinIdx < args.coinStartIndex + args.batchSize &&
+ coinIdx < wgContext.numPlanchets;
+ coinIdx++
+ ) {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ continue;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ logger.warn("processPlanchet: planchet already withdrawn");
+ continue;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) {
+ continue;
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ planchet.denomPubHash,
+ );
- if (!denom) {
- logger.error("db inconsistent: denom for planchet not found");
- continue;
- }
+ if (!denom) {
+ logger.error("db inconsistent: denom for planchet not found");
+ continue;
+ }
- const planchetReq: ExchangeWithdrawRequest = {
- denom_pub_hash: planchet.denomPubHash,
- reserve_sig: planchet.withdrawSig,
- coin_ev: planchet.coinEv,
- };
- batchReq.planchets.push(planchetReq);
- requestCoinIdxs.push(coinIdx);
- }
- });
+ const planchetReq: ExchangeWithdrawRequest = {
+ denom_pub_hash: planchet.denomPubHash,
+ reserve_sig: planchet.withdrawSig,
+ coin_ev: planchet.coinEv,
+ };
+ batchReq.planchets.push(planchetReq);
+ requestCoinIdxs.push(coinIdx);
+ }
+ },
+ );
if (batchReq.planchets.length == 0) {
logger.warn("empty withdrawal batch");
@@ -1064,7 +1167,7 @@ async function processPlanchetExchangeBatchRequest(
coinIdx: number,
): Promise<void> {
logger.trace(`withdrawal request failed: ${j2s(errDetail)}`);
- await wex.db.runReadWriteTx(["planchets"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
@@ -1136,7 +1239,7 @@ async function processPlanchetVerifyAndStoreCoin(
const withdrawalGroup = wgContext.wgRecord;
logger.trace(`checking and storing planchet idx=${coinIdx}`);
const d = await wex.db.runReadOnlyTx(
- ["planchets", "denominations"],
+ { storeNames: ["planchets", "denominations"] },
async (tx) => {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
@@ -1200,7 +1303,7 @@ async function processPlanchetVerifyAndStoreCoin(
});
if (!isValid) {
- await wex.db.runReadWriteTx(["planchets"], async (tx) => {
+ await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
@@ -1254,7 +1357,7 @@ async function processPlanchetVerifyAndStoreCoin(
wgContext.planchetsFinished.add(planchet.coinPub);
await wex.db.runReadWriteTx(
- ["planchets", "coins", "coinAvailability", "denominations"],
+ { storeNames: ["planchets", "coins", "coinAvailability", "denominations"] },
async (tx) => {
const p = await tx.planchets.get(planchetCoinPub);
if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
@@ -1280,7 +1383,7 @@ export async function updateWithdrawalDenoms(
`updating denominations used for withdrawal for ${exchangeBaseUrl}`,
);
const exchangeDetails = await wex.db.runReadOnlyTx(
- ["exchanges", "exchangeDetails"],
+ { storeNames: ["exchanges", "exchangeDetails"] },
async (tx) => {
return getExchangeWireDetailsInTx(tx, exchangeBaseUrl);
},
@@ -1343,12 +1446,15 @@ export async function updateWithdrawalDenoms(
}
if (updatedDenominations.length > 0) {
logger.trace("writing denomination batch to db");
- await wex.db.runReadWriteTx(["denominations"], async (tx) => {
- for (let i = 0; i < updatedDenominations.length; i++) {
- const denom = updatedDenominations[i];
- await tx.denominations.put(denom);
- }
- });
+ await wex.db.runReadWriteTx(
+ { storeNames: ["denominations"] },
+ async (tx) => {
+ for (let i = 0; i < updatedDenominations.length; i++) {
+ const denom = updatedDenominations[i];
+ await tx.denominations.put(denom);
+ }
+ },
+ );
wex.ws.denomInfoCache.clear();
logger.trace("done with DB write");
}
@@ -1560,7 +1666,7 @@ async function redenominateWithdrawal(
): Promise<void> {
logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`);
await wex.db.runReadWriteTx(
- ["withdrawalGroups", "planchets", "denominations"],
+ { storeNames: ["withdrawalGroups", "planchets", "denominations"] },
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
@@ -1728,7 +1834,7 @@ async function processWithdrawalGroupPendingReady(
wgRecord: withdrawalGroup,
};
- await wex.db.runReadOnlyTx(["planchets"], async (tx) => {
+ await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
const planchets =
await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
for (const p of planchets) {
@@ -1772,7 +1878,7 @@ async function processWithdrawalGroupPendingReady(
let redenomRequired = false;
- await wex.db.runReadOnlyTx(["planchets"], async (tx) => {
+ await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
const planchets =
await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
for (const p of planchets) {
@@ -1876,7 +1982,7 @@ export async function processWithdrawalGroup(
): Promise<TaskRunResult> {
logger.trace("processing withdrawal group", withdrawalGroupId);
const withdrawalGroup = await wex.db.runReadOnlyTx(
- ["withdrawalGroups"],
+ { storeNames: ["withdrawalGroups"] },
async (tx) => {
return tx.withdrawalGroups.get(withdrawalGroupId);
},
@@ -1886,6 +1992,8 @@ export async function processWithdrawalGroup(
throw Error(`withdrawal group ${withdrawalGroupId} not found`);
}
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
switch (withdrawalGroup.status) {
case WithdrawalGroupStatus.PendingRegisteringBank:
return await processBankRegisterReserve(wex, withdrawalGroupId);
@@ -1903,6 +2011,8 @@ export async function processWithdrawalGroup(
return await processWithdrawalGroupPendingReady(wex, withdrawalGroup);
case WithdrawalGroupStatus.AbortingBank:
return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.DialogProposed:
+ return await processWithdrawalGroupDialogProposed(ctx, withdrawalGroup);
case WithdrawalGroupStatus.AbortedBank:
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.FailedAbortingBank:
@@ -1915,6 +2025,8 @@ export async function processWithdrawalGroup(
case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
case WithdrawalGroupStatus.Done:
case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedUserRefused:
+ case WithdrawalGroupStatus.AbortedOtherWallet:
// Nothing to do.
return TaskRunResult.finished();
default:
@@ -2052,12 +2164,6 @@ export interface GetWithdrawalDetailsForUriOpts {
notifyChangeFromPendingTimeoutMs?: number;
}
-type WithdrawalOperationMemoryMap = {
- [uri: string]: boolean | undefined;
-};
-
-const ongoingChecks: WithdrawalOperationMemoryMap = {};
-
/**
* Get more information about a taler://withdraw URI.
*
@@ -2098,37 +2204,6 @@ export async function getWithdrawalDetailsForUri(
);
});
- // FIXME: this should be removed after the extended version of
- // withdrawal state machine. issue #8099
- if (
- info.status === "pending" &&
- opts.notifyChangeFromPendingTimeoutMs !== undefined &&
- !ongoingChecks[talerWithdrawUri]
- ) {
- ongoingChecks[talerWithdrawUri] = true;
- const bankApi = new TalerBankIntegrationHttpClient(
- info.apiBaseUrl,
- wex.http,
- );
-
- bankApi
- .getWithdrawalOperationById(info.operationId, {
- old_state: "pending",
- timeoutMs: opts.notifyChangeFromPendingTimeoutMs,
- })
- .then((resp) => {
- if (resp.type === "ok" && resp.body.status !== "pending") {
- wex.ws.notify({
- type: NotificationType.WithdrawalOperationTransition,
- uri: talerWithdrawUri,
- });
- }
- })
- .finally(() => {
- ongoingChecks[talerWithdrawUri] = false;
- });
- }
-
return {
operationId: info.operationId,
confirmTransferUrl: info.confirmTransferUrl,
@@ -2147,7 +2222,7 @@ export function augmentPaytoUrisForWithdrawal(
return plainPaytoUris.map((x) =>
addPaytoQueryParams(x, {
amount: Amounts.stringify(instructedAmount),
- message: `Taler Withdrawal ${reservePub}`,
+ message: `Taler ${reservePub}`,
}),
);
}
@@ -2192,9 +2267,12 @@ async function getWithdrawalGroupRecordTx(
withdrawalGroupId: string;
},
): Promise<WithdrawalGroupRecord | undefined> {
- return await db.runReadOnlyTx(["withdrawalGroups"], async (tx) => {
- return tx.withdrawalGroups.get(req.withdrawalGroupId);
- });
+ return await db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return tx.withdrawalGroups.get(req.withdrawalGroupId);
+ },
+ );
}
export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
@@ -2230,7 +2308,7 @@ async function registerReserveWithBank(
withdrawalGroupId: string,
): Promise<void> {
const withdrawalGroup = await wex.db.runReadOnlyTx(
- ["withdrawalGroups"],
+ { storeNames: ["withdrawalGroups"] },
async (tx) => {
return await tx.withdrawalGroups.get(withdrawalGroupId);
},
@@ -2487,7 +2565,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);
@@ -2497,7 +2575,7 @@ export async function internalPrepareCreateWithdrawalGroup(
withdrawalGroupId = args.forcedWithdrawalGroupId;
const wgId = withdrawalGroupId;
const existingWg = await wex.db.runReadOnlyTx(
- ["withdrawalGroups"],
+ { storeNames: ["withdrawalGroups"] },
async (tx) => {
return tx.withdrawalGroups.get(wgId);
},
@@ -2514,10 +2592,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,
);
@@ -2542,7 +2620,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,
@@ -2558,7 +2636,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,
@@ -2568,7 +2646,7 @@ export async function internalPrepareCreateWithdrawalGroup(
withdrawalGroup,
transactionId,
creationInfo: {
- canonExchange,
+ canonExchange: exchangeBaseUrl,
amount,
},
};
@@ -2684,14 +2762,16 @@ export async function internalCreateWithdrawalGroup(
prep.withdrawalGroup.withdrawalGroupId,
);
const res = await wex.db.runReadWriteTx(
- [
- "withdrawalGroups",
- "reserves",
- "exchanges",
- "exchangeDetails",
- "transactions",
- "operationRetries",
- ],
+ {
+ storeNames: [
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "operationRetries",
+ ],
+ },
async (tx) => {
const res = await internalPerformCreateWithdrawalGroup(wex, tx, prep);
await updateWithdrawalTransaction(ctx, tx);
@@ -2705,6 +2785,133 @@ export async function internalCreateWithdrawalGroup(
return res.withdrawalGroup;
}
+export async function prepareBankIntegratedWithdrawal(
+ wex: WalletExecutionContext,
+ req: {
+ talerWithdrawUri: string;
+ selectedExchange: string;
+ forcedDenomSel?: ForcedDenomSel;
+ restrictAge?: number;
+ },
+): Promise<PrepareBankIntegratedWithdrawalResponse> {
+ const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ req.talerWithdrawUri,
+ );
+ },
+ );
+
+ if (existingWithdrawalGroup) {
+ let url: string | undefined;
+ if (
+ existingWithdrawalGroup.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
+ }
+ return {
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
+ }),
+ };
+ }
+
+ const selectedExchange = req.selectedExchange;
+ const exchange = await fetchFreshExchange(wex, selectedExchange);
+
+ const withdrawInfo = await getBankWithdrawalInfo(
+ wex.http,
+ req.talerWithdrawUri,
+ );
+ const exchangePaytoUri = await getExchangePaytoUri(
+ wex,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+
+ const withdrawalAccountList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: withdrawInfo.amount,
+ },
+ wex.cancellationToken,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ amount: withdrawInfo.amount,
+ exchangeBaseUrl: req.selectedExchange,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ exchangeCreditAccounts: withdrawalAccountList,
+ bankInfo: {
+ exchangePaytoUri,
+ talerWithdrawUri: req.talerWithdrawUri,
+ confirmUrl: withdrawInfo.confirmTransferUrl,
+ timestampBankConfirmed: undefined,
+ timestampReserveInfoPosted: undefined,
+ },
+ },
+ restrictAge: req.restrictAge,
+ forcedDenomSel: req.forcedDenomSel,
+ reserveStatus: WithdrawalGroupStatus.DialogProposed,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ transactionId: ctx.transactionId,
+ };
+}
+
+export async function confirmWithdrawal(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+ if (parsedTx?.tag !== TransactionType.Withdrawal) {
+ throw Error("invalid withdrawal transaction ID");
+ }
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ { storeNames: ["withdrawalGroups"] },
+ async (tx) => {
+ return await tx.withdrawalGroups.get(parsedTx.withdrawalGroupId);
+ },
+ );
+
+ if (!withdrawalGroup) {
+ throw Error("withdrawal group not found");
+ }
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+ ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.DialogProposed: {
+ rec.status = WithdrawalGroupStatus.PendingRegisteringBank;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ throw Error("unable to confirm withdrawal in current state");
+ }
+ });
+
+ await wex.taskScheduler.resetTaskRetries(ctx.taskId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
/**
* Accept a bank-integrated withdrawal.
*
@@ -2712,6 +2919,8 @@ export async function internalCreateWithdrawalGroup(
*
* Thus after this call returns, the withdrawal operation can be confirmed
* with the bank.
+ *
+ * @deprecated in favor of prepare/accept
*/
export async function acceptWithdrawalFromUri(
wex: WalletExecutionContext,
@@ -2722,12 +2931,12 @@ 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}`,
);
const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
- ["withdrawalGroups"],
+ { storeNames: ["withdrawalGroups"] },
async (tx) => {
return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
req.talerWithdrawUri,
@@ -2753,7 +2962,7 @@ export async function acceptWithdrawalFromUri(
};
}
- await fetchFreshExchange(wex, selectedExchange);
+ const exchange = await fetchFreshExchange(wex, selectedExchange);
const withdrawInfo = await getBankWithdrawalInfo(
wex.http,
req.talerWithdrawUri,
@@ -2764,8 +2973,6 @@ export async function acceptWithdrawalFromUri(
withdrawInfo.wireTypes,
);
- const exchange = await fetchFreshExchange(wex, selectedExchange);
-
const withdrawalAccountList = await fetchWithdrawalAccountInfo(
wex,
{
@@ -2821,7 +3028,7 @@ async function internalWaitWithdrawalRegistered(
): Promise<void> {
while (true) {
const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx(
- ["withdrawalGroups", "operationRetries"],
+ { storeNames: ["withdrawalGroups", "operationRetries"] },
async (tx) => {
return {
withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
@@ -2957,7 +3164,7 @@ async function fetchAccount(
});
if (reservePub != null) {
paytoUri = addPaytoQueryParams(paytoUri, {
- message: `Taler Withdrawal ${reservePub}`,
+ message: `Taler ${reservePub}`,
});
}
const acctInfo: WithdrawalExchangeAccountDetails = {
@@ -3025,6 +3232,7 @@ export async function createManualWithdrawal(
amount: AmountLike;
restrictAge?: number;
forcedDenomSel?: ForcedDenomSel;
+ forceReservePriv?: EddsaPrivateKeyString;
},
): Promise<AcceptManualWithdrawalResult> {
const { exchangeBaseUrl } = req;
@@ -3036,9 +3244,20 @@ export async function createManualWithdrawal(
"manual withdrawal with conversion from foreign currency is not yet supported",
);
}
- const reserveKeyPair: EddsaKeypair = await wex.cryptoApi.createEddsaKeypair(
- {},
- );
+
+ let reserveKeyPair: EddsaKeypair;
+ if (req.forceReservePriv) {
+ const pubResp = await wex.cryptoApi.eddsaGetPublic({
+ priv: req.forceReservePriv,
+ });
+
+ reserveKeyPair = {
+ priv: req.forceReservePriv,
+ pub: pubResp.pub,
+ };
+ } else {
+ reserveKeyPair = await wex.cryptoApi.createEddsaKeypair({});
+ }
const withdrawalAccountsList = await fetchWithdrawalAccountInfo(
wex,
@@ -3069,7 +3288,7 @@ export async function createManualWithdrawal(
);
const exchangePaytoUris = await wex.db.runReadOnlyTx(
- ["withdrawalGroups", "exchanges", "exchangeDetails"],
+ { storeNames: ["withdrawalGroups", "exchanges", "exchangeDetails"] },
async (tx) => {
return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
},
@@ -3136,7 +3355,7 @@ async function internalWaitWithdrawalFinal(
// Check if refresh is final
const res = await ctx.wex.db.runReadOnlyTx(
- ["withdrawalGroups", "operationRetries"],
+ { storeNames: ["withdrawalGroups", "operationRetries"] },
async (tx) => {
return {
wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
diff --git a/packages/taler-wallet-embedded/package.json b/packages/taler-wallet-embedded/package.json
index cca4e6e2a..ee9efafdd 100644
--- a/packages/taler-wallet-embedded/package.json
+++ b/packages/taler-wallet-embedded/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/taler-wallet-embedded",
- "version": "0.10.6",
+ "version": "0.10.7",
"description": "",
"engines": {
"node": ">=0.18.0"
diff --git a/packages/taler-wallet-embedded/src/wallet-qjs.ts b/packages/taler-wallet-embedded/src/wallet-qjs.ts
index 8502c779a..98b73fc44 100644
--- a/packages/taler-wallet-embedded/src/wallet-qjs.ts
+++ b/packages/taler-wallet-embedded/src/wallet-qjs.ts
@@ -98,19 +98,10 @@ class NativeWalletMessageHandler {
const wR = await createNativeWalletHost2(this.walletArgs);
const w = wR.wallet;
this.maybeWallet = w;
- const resp = await w.handleCoreApiRequest(
- "initWallet",
- "native-init",
- {
- config: this.walletConfig
- },
- );
- initResponse = resp.type == "response" ? resp.result : resp.error;
- w.runTaskLoop().catch((e) => {
- logger.error(
- `Error during wallet retry loop: ${e.stack ?? e.toString()}`,
- );
+ const resp = await w.handleCoreApiRequest("initWallet", "native-init", {
+ config: this.walletConfig,
});
+ initResponse = resp.type == "response" ? resp.result : resp.error;
this.wp.resolve(w);
};
@@ -296,9 +287,8 @@ export async function testWithGv() {
merchantBaseUrl: "https://backend.demo.taler.net/",
merchantAuthToken: "secret-token:sandbox",
});
- await w.wallet.runTaskLoop({
- stopWhenDone: true,
- });
+ await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
+ await w.wallet.client.call(WalletApiOperation.Shutdown, {});
}
export async function testWithFdold() {
@@ -317,9 +307,8 @@ export async function testWithFdold() {
exchangeBaseUrl: "https://exchange.taler.fdold.eu/",
merchantBaseUrl: "https://merchant.taler.fdold.eu/",
});
- await w.wallet.runTaskLoop({
- stopWhenDone: true,
- });
+ await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
+ await w.wallet.client.call(WalletApiOperation.Shutdown, {});
}
export async function testWithLocal(path: string) {
@@ -347,11 +336,9 @@ export async function testWithLocal(path: string) {
merchantBaseUrl: "http://localhost:8083/",
});
console.log("started integration test");
- await w.wallet.runTaskLoop({
- stopWhenDone: true,
- });
+ await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {});
console.log("done with task loop");
- w.wallet.stop();
+ await w.wallet.client.call(WalletApiOperation.Shutdown, {});
console.log("DB stats:", j2s(w.getDbStats()));
}
diff --git a/packages/taler-wallet-webextension/manifest-common.json b/packages/taler-wallet-webextension/manifest-common.json
index 2b06acd6d..32bd5267f 100644
--- a/packages/taler-wallet-webextension/manifest-common.json
+++ b/packages/taler-wallet-webextension/manifest-common.json
@@ -2,7 +2,7 @@
"name": "GNU Taler Wallet (git)",
"description": "Privacy preserving and transparent payments",
"author": "GNU Taler Developers",
- "version": "0.10.6",
+ "version": "0.10.7",
"icons": {
"16": "static/img/taler-logo-16.png",
"19": "static/img/taler-logo-19.png",
@@ -14,5 +14,5 @@
"256": "static/img/taler-logo-256.png",
"512": "static/img/taler-logo-512.png"
},
- "version_name": "0.10.6"
+ "version_name": "0.10.7"
}
diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json
index fe95e93c4..bf063d76e 100644
--- a/packages/taler-wallet-webextension/package.json
+++ b/packages/taler-wallet-webextension/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/taler-wallet-webextension",
- "version": "0.10.6",
+ "version": "0.10.7",
"description": "GNU Taler Wallet browser extension",
"main": "./build/index.js",
"types": "./build/index.d.ts",
diff --git a/packages/taler-wallet-webextension/src/NavigationBar.tsx b/packages/taler-wallet-webextension/src/NavigationBar.tsx
index 527600c96..fe348f7fb 100644
--- a/packages/taler-wallet-webextension/src/NavigationBar.tsx
+++ b/packages/taler-wallet-webextension/src/NavigationBar.tsx
@@ -34,6 +34,7 @@ import {
} from "./components/styled/index.js";
import { useBackendContext } from "./context/backend.js";
import { useAsyncAsHook } from "./hooks/useAsyncAsHook.js";
+import searchIcon from "./svg/search_24px.inline.svg";
import qrIcon from "./svg/qr_code_24px.inline.svg";
import settingsIcon from "./svg/settings_black_24dp.inline.svg";
import warningIcon from "./svg/warning_24px.inline.svg";
@@ -55,7 +56,7 @@ type PageLocation<DynamicPart extends object> = {
function replaceAll(
pattern: string,
vars: Record<string, string>,
- values: Record<string, any>,
+ values: Record<string, string>,
): string {
let result = pattern;
for (const v in vars) {
@@ -75,16 +76,20 @@ function pageDefinition<T extends object>(pattern: string): PageLocation<T> {
`page definition pattern ${pattern} doesn't have any parameter`,
);
- const vars = patternParams.reduce((prev, cur) => {
- const pName = cur.match(/(\w+)/g);
+ const vars = patternParams.reduce(
+ (prev, cur) => {
+ const pName = cur.match(/(\w+)/g);
- //skip things like :? in the path pattern
- if (!pName || !pName[0]) return prev;
- const name = pName[0];
- return { ...prev, [name]: cur };
- }, {} as Record<string, string>);
+ //skip things like :? in the path pattern
+ if (!pName || !pName[0]) return prev;
+ const name = pName[0];
+ return { ...prev, [name]: cur };
+ },
+ {} as Record<string, string>,
+ );
- const f = (values: T): string => replaceAll(pattern, vars, values ?? {});
+ const f = (values: T): string =>
+ replaceAll(pattern, vars, (values ?? {}) as Record<string, string>);
f.pattern = pattern;
return f;
}
@@ -95,6 +100,9 @@ export const Pages = {
balanceHistory: pageDefinition<{ currency?: string }>(
"/balance/history/:currency?",
),
+ searchHistory: pageDefinition<{ currency?: string }>(
+ "/search/history/:currency?",
+ ),
balanceDeposit: pageDefinition<{ amount: string }>(
"/balance/deposit/:amount",
),
@@ -268,6 +276,13 @@ export function WalletNavBar({ path }: { path?: WalletNavBarOptions }): VNode {
<div
style={{ display: "flex", paddingTop: 4, justifyContent: "right" }}
>
+ <a href={Pages.searchHistory({})}>
+ <SvgIcon
+ title={i18n.str`Search transactions`}
+ dangerouslySetInnerHTML={{ __html: searchIcon }}
+ color="white"
+ />
+ </a>
<a href={Pages.qr}>
<SvgIcon
title={i18n.str`QR Reader and Taler URI`}
diff --git a/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx b/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx
index 6666413eb..8b6377fc5 100644
--- a/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx
+++ b/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx
@@ -218,7 +218,7 @@ export function BankDetailsByPaytoType({
&nbsp;
<i18n.Translate>
If you don't already have it in your banking favourites list,
- then copy and past this IBAN and the name into the receiver
+ then copy and paste this IBAN and the name into the receiver
fields in your banking app or website
</i18n.Translate>
</td>
diff --git a/packages/taler-wallet-webextension/src/components/HistoryItem.tsx b/packages/taler-wallet-webextension/src/components/HistoryItem.tsx
index 833448e67..9be9326b2 100644
--- a/packages/taler-wallet-webextension/src/components/HistoryItem.tsx
+++ b/packages/taler-wallet-webextension/src/components/HistoryItem.tsx
@@ -136,8 +136,6 @@ export function HistoryItem(props: { tx: Transaction }): VNode {
}
/>
);
- case TransactionType.Reward:
- return <div>not supported</div>;
case TransactionType.Refresh:
return (
<Layout
diff --git a/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx b/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx
index 372ca7cb7..c94010ede 100644
--- a/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx
+++ b/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx
@@ -42,7 +42,10 @@ interface Props extends JSX.HTMLAttributes {
*/
const cache = { tx: [] as Transaction[] };
-export function PendingTransactions({ goToTransaction, goToURL }: Props): VNode {
+export function PendingTransactions({
+ goToTransaction,
+ goToURL,
+}: Props): VNode {
const api = useBackendContext();
const state = useAsyncAsHook(() =>
api.wallet.call(WalletApiOperation.GetTransactions, {}),
@@ -59,8 +62,8 @@ export function PendingTransactions({ goToTransaction, goToURL }: Props): VNode
!state || state.hasError
? cache.tx
: state.response.transactions.filter(
- (t) => t.txState.major === TransactionMajorState.Pending,
- );
+ (t) => t.txState.major === TransactionMajorState.Pending,
+ );
if (state && !state.hasError) {
cache.tx = transactions;
@@ -87,50 +90,52 @@ export function PendingTransactionsView({
transactions: Transaction[];
}): VNode {
const { i18n } = useTranslationContext();
- const kycTransaction = transactions.find(tx => tx.kycUrl)
+ const kycTransaction = transactions.find((tx) => tx.kycUrl);
if (kycTransaction) {
- return <div
- style={{
- backgroundColor: "lightcyan",
- display: "flex",
- justifyContent: "center",
- }}
- >
- <Banner
- titleHead={i18n.str`KYC requirement`}
+ return (
+ <div
style={{
- backgroundColor: "lightred",
- maxHeight: 150,
- padding: 8,
- flexGrow: 1,
- maxWidth: 500,
- overflowY: transactions.length > 3 ? "scroll" : "hidden",
+ backgroundColor: "#fff3cd",
+ color: "#664d03",
+ display: "flex",
+ justifyContent: "center",
}}
>
- <Grid
- container
- item
- xs={1}
- wrap="nowrap"
- role="button"
- spacing={1}
- alignItems="center"
- onClick={() => {
- goToURL(kycTransaction.kycUrl ?? "#")
+ <Banner
+ titleHead={i18n.str`KYC requirement`}
+ style={{
+ backgroundColor: "lightred",
+ maxHeight: 150,
+ padding: 8,
+ flexGrow: 1, //#fff3cd //#ffecb5
+ maxWidth: 500,
+ overflowY: transactions.length > 3 ? "scroll" : "hidden",
}}
>
- <Grid item>
- <Typography inline bold>
- One or more transaction require a KYC step to complete
- </Typography>
+ <Grid
+ container
+ item
+ xs={1}
+ wrap="nowrap"
+ role="button"
+ spacing={1}
+ alignItems="center"
+ onClick={() => {
+ goToURL(kycTransaction.kycUrl ?? "#");
+ }}
+ >
+ <Grid item>
+ <Typography inline bold>
+ One or more transaction require a KYC step to complete
+ </Typography>
+ </Grid>
</Grid>
-
- </Grid>
- </Banner>
- </div>
+ </Banner>
+ </div>
+ );
}
- if (!goToTransaction) return <Fragment />
+ if (!goToTransaction) return <Fragment />;
return (
<div
diff --git a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx
index 1c566c3e4..a77a69fa6 100644
--- a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx
+++ b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx
@@ -26,471 +26,677 @@ import {
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
-import { Fragment, JSX, VNode, h } from "preact";
+import { Fragment, VNode, h } from "preact";
import { useEffect, useState } from "preact/hooks";
import { Pages } from "../NavigationBar.js";
import { useBackendContext } from "../context/backend.js";
import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import { useSettings } from "../hooks/useSettings.js";
import { Button } from "../mui/Button.js";
+import { TextField } from "../mui/TextField.js";
+import { SafeHandler } from "../mui/handlers.js";
import { WxApiType } from "../wxApi.js";
+import { WalletActivityTrack } from "../wxBackend.js";
import { Modal } from "./Modal.js";
import { Time } from "./Time.js";
-interface Props extends JSX.HTMLAttributes {
-}
+const OPEN_ACTIVITY_HEIGHT_PX = 250;
+const CLOSE_ACTIVITY_HEIGHT_PX = 40;
+
+export function WalletActivity(): VNode {
+ const { i18n } = useTranslationContext();
+ const [, updateSettings] = useSettings();
+
+ const [collapsed, setCollcapsed] = useState(true);
-export function WalletActivity({ }: Props): VNode {
- const { i18n } = useTranslationContext()
- const [settings, updateSettings] = useSettings()
- const api = useBackendContext();
useEffect(() => {
- document.body.style.marginBottom = "250px"
+ document.body.style.marginBottom = `${
+ collapsed ? CLOSE_ACTIVITY_HEIGHT_PX : OPEN_ACTIVITY_HEIGHT_PX
+ }px`;
return () => {
- document.body.style.marginBottom = "0px"
- }
- })
- const [table, setTable] = useState<"tasks" | "events">("tasks")
- return (
- <div style={{ position: "fixed", bottom: 0, background: "white", zIndex: 1, height: 250, overflowY: "scroll", width: "100%" }}>
- <div style={{ display: "flex", justifyContent: "space-between", float: "right" }}>
- <div />
- <div>
- <div style={{ padding: 4, margin: 2, border: "solid 1px black" }} onClick={() => {
- updateSettings("showWalletActivity", false)
- }}>
- close
- </div>
+ document.body.style.marginBottom = "0px";
+ };
+ }, [collapsed]);
+
+ const [table, setTable] = useState<"tasks" | "events">("events");
+ if (collapsed) {
+ return (
+ <div
+ style={{
+ position: "fixed",
+ bottom: 0,
+ background: "lightgrey",
+ zIndex: 1,
+ height: CLOSE_ACTIVITY_HEIGHT_PX,
+ overflowY: "scroll",
+ width: "100%",
+ }}
+ onClick={() => {
+ setCollcapsed(!collapsed);
+ }}
+ >
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "space-around",
+ marginTop: 10,
+ cursor: "pointer",
+ }}
+ >
+ click here to open
</div>
</div>
- <div style={{ display: "flex", justifyContent: "space-around" }}>
- <Button variant={table === "tasks" ? "contained" : "outlined"}
+ );
+ }
+ return (
+ <div
+ style={{
+ position: "fixed",
+ bottom: 0,
+ background: "lightgrey",
+ zIndex: 1,
+ height: OPEN_ACTIVITY_HEIGHT_PX,
+ overflowY: "scroll",
+ width: "100%",
+ }}
+ >
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "space-around",
+ cursor: "pointer",
+ }}
+ onClick={() => {
+ setCollcapsed(!collapsed);
+ }}
+ >
+ <Button
+ variant={table === "events" ? "contained" : "outlined"}
style={{ margin: 4 }}
onClick={async () => {
- setTable("tasks")
+ setTable("events");
}}
>
- <i18n.Translate>Tasks</i18n.Translate>
+ <i18n.Translate>Events</i18n.Translate>
</Button>
- <Button variant={table === "events" ? "contained" : "outlined"}
+ <Button
+ variant={table === "tasks" ? "contained" : "outlined"}
style={{ margin: 4 }}
onClick={async () => {
- setTable("events")
+ setTable("tasks");
}}
>
- <i18n.Translate>Events</i18n.Translate>
+ <i18n.Translate>Active tasks</i18n.Translate>
</Button>
+ <Button
+ variant="outlined"
+ style={{ margin: 4 }}
+ onClick={async () => {
+ updateSettings("showWalletActivity", false);
+ }}
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </Button>
</div>
- {(function (): VNode {
- switch (table) {
- case "events": {
- return <ObservabilityEventsTable />
- }
- case "tasks": {
- return <ActiveTasksTable />
+ <div
+ style={{
+ backgroundColor: "white",
+ }}
+ >
+ {(function (): VNode {
+ switch (table) {
+ case "events": {
+ return <ObservabilityEventsTable />;
+ }
+ case "tasks": {
+ return <ActiveTasksTable />;
+ }
+ default: {
+ assertUnreachable(table);
+ }
}
- default: {
- assertUnreachable(table)
- }
- }
- })()}
+ })()}
+ </div>
</div>
);
}
-interface MoreInfoPRops { events: (WalletNotification & { when: AbsoluteTime })[], onClick: (content: VNode) => void }
-type Notif = {
- id: string;
+interface MoreInfoPRops {
events: (WalletNotification & { when: AbsoluteTime })[];
- description: string;
- start: AbsoluteTime;
- end: AbsoluteTime;
- reference: {
- eventType: NotificationType,
- referenceType: "task" | "transaction" | "operation" | "exchange",
- id: string;
- } | undefined,
- MoreInfo: (p: MoreInfoPRops) => VNode;
+ onClick: (content: VNode) => void;
}
function ShowBalanceChange({ events }: MoreInfoPRops): VNode {
if (!events.length) return <Fragment />;
const not = events[0];
if (not.type !== NotificationType.BalanceChange) return <Fragment />;
- return <Fragment>
- <dt>Transaction</dt>
- <dd>
- <a title={not.hintTransactionId} href={Pages.balanceTransaction({ tid: not.hintTransactionId })}>{not.hintTransactionId.substring(0, 10)}</a>
- </dd>
- </Fragment>
+ return (
+ <Fragment>
+ <dt>Transaction</dt>
+ <dd>
+ <a
+ title={not.hintTransactionId}
+ href={Pages.balanceTransaction({ tid: not.hintTransactionId })}
+ >
+ {not.hintTransactionId.substring(0, 10)}
+ </a>
+ </dd>
+ </Fragment>
+ );
}
function ShowBackupOperationError({ events, onClick }: MoreInfoPRops): VNode {
if (!events.length) return <Fragment />;
const not = events[0];
if (not.type !== NotificationType.BackupOperationError) return <Fragment />;
- return <Fragment>
- <dt>Error</dt>
- <dd>
- <a href="#" onClick={(e) => {
- e.preventDefault();
- const error = not.error
- onClick(<Fragment>
- <dl>
- <dt>Code</dt>
- <dd>{TalerErrorCode[error.code]} ({error.code})</dd>
- <dt>Hint</dt>
- <dd>{error.hint ?? "--"}</dd>
- <dt>Time</dt>
- <dd><Time
- timestamp={error.when}
- format="yyyy/MM/dd HH:mm:ss"
- /></dd>
- </dl>
- <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
- {JSON.stringify(error, undefined, 2)}
- </pre>
- </Fragment>)
- }}>{TalerErrorCode[not.error.code]}</a>
- </dd>
- </Fragment>
-}
-
-function ShowTransactionStateTransition({ events, onClick }: MoreInfoPRops): VNode {
- if (!events.length) return <Fragment />;
- const not = events[0];
- if (not.type !== NotificationType.TransactionStateTransition) return <Fragment />;
- return <Fragment>
- <dt>Old state</dt>
- <dd>
- {not.oldTxState.major} - {not.oldTxState.minor ?? ""}
- </dd>
- <dt>New state</dt>
- <dd>
- {not.newTxState.major} - {not.newTxState.minor ?? ""}
- </dd>
- <dt>Transaction</dt>
- <dd>
- <a title={not.transactionId} href={Pages.balanceTransaction({ tid: not.transactionId })}>{not.transactionId.substring(0, 10)}</a>
- </dd>
- {not.errorInfo ? <Fragment>
+ return (
+ <Fragment>
<dt>Error</dt>
<dd>
- <a href="#" onClick={(e) => {
- if (!not.errorInfo) return;
- e.preventDefault();
- const error = not.errorInfo;
- onClick(<Fragment>
- <dl>
- <dt>Code</dt>
- <dd>{TalerErrorCode[error.code]} ({error.code})</dd>
- <dt>Hint</dt>
- <dd>{error.hint ?? "--"}</dd>
- <dt>Message</dt>
- <dd>{error.message ?? "--"}</dd>
- </dl>
- </Fragment>)
-
- }}>{TalerErrorCode[not.errorInfo.code]}</a>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ const error = not.error;
+ onClick(
+ <Fragment>
+ <dl>
+ <dt>Code</dt>
+ <dd>
+ {TalerErrorCode[error.code]} ({error.code})
+ </dd>
+ <dt>Hint</dt>
+ <dd>{error.hint ?? "--"}</dd>
+ <dt>Time</dt>
+ <dd>
+ <Time timestamp={error.when} format="yyyy/MM/dd HH:mm:ss" />
+ </dd>
+ </dl>
+ <pre
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ >
+ {JSON.stringify(error, undefined, 2)}
+ </pre>
+ </Fragment>,
+ );
+ }}
+ >
+ {TalerErrorCode[not.error.code]}
+ </a>
</dd>
- </Fragment> : undefined}
- <dt>Experimental</dt>
- <dd>
- <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
- {JSON.stringify(not.experimentalUserData, undefined, 2)}
- </pre>
- </dd>
-
-
- </Fragment>
+ </Fragment>
+ );
}
-function ShowExchangeStateTransition({ events, onClick }: MoreInfoPRops): VNode {
+
+function ShowTransactionStateTransition({
+ events,
+ onClick,
+}: MoreInfoPRops): VNode {
if (!events.length) return <Fragment />;
const not = events[0];
- if (not.type !== NotificationType.ExchangeStateTransition) return <Fragment />;
- return <Fragment>
- <dt>Exchange</dt>
- <dd>
- {not.exchangeBaseUrl}
- </dd>
- {not.oldExchangeState && not.newExchangeState.exchangeEntryStatus !== not.oldExchangeState?.exchangeEntryStatus && <Fragment>
- <dt>Entry status</dt>
+ if (not.type !== NotificationType.TransactionStateTransition)
+ return <Fragment />;
+ return (
+ <Fragment>
+ <dt>Old state</dt>
<dd>
- from {not.oldExchangeState.exchangeEntryStatus} to {not.newExchangeState.exchangeEntryStatus}
+ {not.oldTxState.major} - {not.oldTxState.minor ?? ""}
</dd>
- </Fragment>}
- {not.oldExchangeState && not.newExchangeState.exchangeUpdateStatus !== not.oldExchangeState?.exchangeUpdateStatus && <Fragment>
- <dt>Update status</dt>
+ <dt>New state</dt>
<dd>
- from {not.oldExchangeState.exchangeUpdateStatus} to {not.newExchangeState.exchangeUpdateStatus}
+ {not.newTxState.major} - {not.newTxState.minor ?? ""}
</dd>
- </Fragment>}
- {not.oldExchangeState && not.newExchangeState.tosStatus !== not.oldExchangeState?.tosStatus && <Fragment>
- <dt>Tos status</dt>
+ <dt>Transaction</dt>
+ <dd>
+ <a
+ title={not.transactionId}
+ href={Pages.balanceTransaction({ tid: not.transactionId })}
+ >
+ {not.transactionId.substring(0, 10)}
+ </a>
+ </dd>
+ {not.errorInfo ? (
+ <Fragment>
+ <dt>Error</dt>
+ <dd>
+ <a
+ href="#"
+ onClick={(e) => {
+ if (!not.errorInfo) return;
+ e.preventDefault();
+ const error = not.errorInfo;
+ onClick(
+ <Fragment>
+ <dl>
+ <dt>Code</dt>
+ <dd>
+ {TalerErrorCode[error.code]} ({error.code})
+ </dd>
+ <dt>Hint</dt>
+ <dd>{error.hint ?? "--"}</dd>
+ <dt>Message</dt>
+ <dd>{error.message ?? "--"}</dd>
+ </dl>
+ </Fragment>,
+ );
+ }}
+ >
+ {TalerErrorCode[not.errorInfo.code]}
+ </a>
+ </dd>
+ </Fragment>
+ ) : undefined}
+ <dt>Experimental</dt>
<dd>
- from {not.oldExchangeState.tosStatus} to {not.newExchangeState.tosStatus}
+ <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
+ {JSON.stringify(not.experimentalUserData, undefined, 2)}
+ </pre>
</dd>
- </Fragment>}
- </Fragment>
+ </Fragment>
+ );
+}
+function ShowExchangeStateTransition({ events }: MoreInfoPRops): VNode {
+ if (!events.length) return <Fragment />;
+ const not = events[0];
+ if (not.type !== NotificationType.ExchangeStateTransition)
+ return <Fragment />;
+ return (
+ <Fragment>
+ <dt>Exchange</dt>
+ <dd>{not.exchangeBaseUrl}</dd>
+ {not.oldExchangeState &&
+ not.newExchangeState.exchangeEntryStatus !==
+ not.oldExchangeState?.exchangeEntryStatus && (
+ <Fragment>
+ <dt>Entry status</dt>
+ <dd>
+ from {not.oldExchangeState.exchangeEntryStatus} to{" "}
+ {not.newExchangeState.exchangeEntryStatus}
+ </dd>
+ </Fragment>
+ )}
+ {not.oldExchangeState &&
+ not.newExchangeState.exchangeUpdateStatus !==
+ not.oldExchangeState?.exchangeUpdateStatus && (
+ <Fragment>
+ <dt>Update status</dt>
+ <dd>
+ from {not.oldExchangeState.exchangeUpdateStatus} to{" "}
+ {not.newExchangeState.exchangeUpdateStatus}
+ </dd>
+ </Fragment>
+ )}
+ {not.oldExchangeState &&
+ not.newExchangeState.tosStatus !== not.oldExchangeState?.tosStatus && (
+ <Fragment>
+ <dt>Tos status</dt>
+ <dd>
+ from {not.oldExchangeState.tosStatus} to{" "}
+ {not.newExchangeState.tosStatus}
+ </dd>
+ </Fragment>
+ )}
+ </Fragment>
+ );
}
-type ObservaNotifWithTime = ((TaskProgressNotification | RequestProgressNotification) & {
+type ObservaNotifWithTime = (
+ | TaskProgressNotification
+ | RequestProgressNotification
+) & {
when: AbsoluteTime;
-})
+};
function ShowObservabilityEvent({ events, onClick }: MoreInfoPRops): VNode {
// let prev: ObservaNotifWithTime;
- const asd = events.map(not => {
- if (not.type !== NotificationType.RequestObservabilityEvent && not.type !== NotificationType.TaskObservabilityEvent) return <Fragment />;
+ const asd = events.map((not, idx) => {
+ if (
+ not.type !== NotificationType.RequestObservabilityEvent &&
+ not.type !== NotificationType.TaskObservabilityEvent
+ )
+ return <Fragment />;
const title = (function () {
switch (not.event.type) {
case ObservabilityEventType.HttpFetchFinishError:
case ObservabilityEventType.HttpFetchFinishSuccess:
- case ObservabilityEventType.HttpFetchStart: return "HTTP Request"
+ case ObservabilityEventType.HttpFetchStart:
+ return "HTTP Request";
case ObservabilityEventType.DbQueryFinishSuccess:
case ObservabilityEventType.DbQueryFinishError:
- case ObservabilityEventType.DbQueryStart: return "Database"
+ case ObservabilityEventType.DbQueryStart:
+ return "Database";
case ObservabilityEventType.RequestFinishSuccess:
case ObservabilityEventType.RequestFinishError:
- case ObservabilityEventType.RequestStart: return "Wallet"
+ case ObservabilityEventType.RequestStart:
+ return "Wallet";
case ObservabilityEventType.CryptoFinishSuccess:
case ObservabilityEventType.CryptoFinishError:
- case ObservabilityEventType.CryptoStart: return "Crypto"
- case ObservabilityEventType.TaskStart: return "Task start"
- case ObservabilityEventType.TaskStop: return "Task stop"
- case ObservabilityEventType.TaskReset: return "Task reset"
- case ObservabilityEventType.ShepherdTaskResult: return "Schedule"
- case ObservabilityEventType.DeclareTaskDependency: return "Task dependency"
- case ObservabilityEventType.Message: return "Message"
+ case ObservabilityEventType.CryptoStart:
+ return "Crypto";
+ case ObservabilityEventType.TaskStart:
+ return "Task start";
+ case ObservabilityEventType.TaskStop:
+ return "Task stop";
+ case ObservabilityEventType.TaskReset:
+ return "Task reset";
+ case ObservabilityEventType.ShepherdTaskResult:
+ return "Schedule";
+ case ObservabilityEventType.DeclareTaskDependency:
+ return "Task dependency";
+ case ObservabilityEventType.Message:
+ return "Message";
}
})();
- return <ShowObervavilityDetails title={title} notif={not} onClick={onClick} />
-
- })
- return <table>
- <thead>
- <td>Event</td>
- <td>Info</td>
- <td>Start</td>
- <td>End</td>
- </thead>
- <tbody>
- {asd}
- </tbody>
- </table>
+ return (
+ <ShowObervavilityDetails
+ key={idx}
+ title={title}
+ notif={not}
+ onClick={onClick}
+ />
+ );
+ });
+ return (
+ <table>
+ <thead>
+ <td>Event</td>
+ <td>Info</td>
+ <td>Start</td>
+ <td>End</td>
+ </thead>
+ <tbody>{asd}</tbody>
+ </table>
+ );
}
-function ShowObervavilityDetails({ title, notif, onClick, prev }: { title: string, notif: ObservaNotifWithTime, prev?: ObservaNotifWithTime, onClick: (content: VNode) => void }): VNode {
+function ShowObervavilityDetails({
+ title,
+ notif,
+ onClick,
+ prev,
+}: {
+ title: string;
+ notif: ObservaNotifWithTime;
+ prev?: ObservaNotifWithTime;
+ onClick: (content: VNode) => void;
+}): VNode {
switch (notif.event.type) {
case ObservabilityEventType.HttpFetchStart:
case ObservabilityEventType.HttpFetchFinishError:
case ObservabilityEventType.HttpFetchFinishSuccess: {
- return <tr>
- <td><a href="#" onClick={(e) => {
- e.preventDefault();
- onClick(<Fragment>
- <pre
- style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
- >
- {JSON.stringify({ event: notif, prev }, undefined, 2)}
- </pre>
- </Fragment>);
- }}>{title}</a></td>
- <td>
- {notif.event.url} {
- prev?.event.type === ObservabilityEventType.HttpFetchFinishSuccess ? `(${prev.event.status})`
- : prev?.event.type === ObservabilityEventType.HttpFetchFinishError ? <a href="#" onClick={(e) => {
+ return (
+ <tr>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
e.preventDefault();
- if (prev.event.type !== ObservabilityEventType.HttpFetchFinishError) return;
- const error = prev.event.error
- onClick(<Fragment>
- <dl>
- <dt>Code</dt>
- <dd>{TalerErrorCode[error.code]} ({error.code})</dd>
- <dt>Hint</dt>
- <dd>{error.hint ?? "--"}</dd>
- <dt>Time</dt>
- <dd><Time
- timestamp={error.when}
- format="yyyy/MM/dd HH:mm:ss"
- /></dd>
- </dl>
- <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
- {JSON.stringify(error, undefined, 2)}
- </pre>
-
- </Fragment>)
- }}>fail</a> : undefined
- }
- </td>
- <td> <Time
- timestamp={notif.when}
- format="yyyy/MM/dd HH:mm:ss"
- /></td>
- <td> <Time
- timestamp={prev?.when}
- format="yyyy/MM/dd HH:mm:ss"
- /></td>
- </tr>
-
+ onClick(
+ <Fragment>
+ <pre
+ style={{
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>,
+ );
+ }}
+ >
+ {title}
+ </a>
+ </td>
+ <td>
+ {notif.event.url}{" "}
+ {prev?.event.type ===
+ ObservabilityEventType.HttpFetchFinishSuccess ? (
+ `(${prev.event.status})`
+ ) : prev?.event.type ===
+ ObservabilityEventType.HttpFetchFinishError ? (
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ if (
+ prev.event.type !==
+ ObservabilityEventType.HttpFetchFinishError
+ )
+ return;
+ const error = prev.event.error;
+ onClick(
+ <Fragment>
+ <dl>
+ <dt>Code</dt>
+ <dd>
+ {TalerErrorCode[error.code]} ({error.code})
+ </dd>
+ <dt>Hint</dt>
+ <dd>{error.hint ?? "--"}</dd>
+ <dt>Time</dt>
+ <dd>
+ <Time
+ timestamp={error.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </dd>
+ </dl>
+ <pre
+ style={{
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ }}
+ >
+ {JSON.stringify(error, undefined, 2)}
+ </pre>
+ </Fragment>,
+ );
+ }}
+ >
+ fail
+ </a>
+ ) : undefined}
+ </td>
+ <td>
+ {" "}
+ <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ <td>
+ {" "}
+ <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ </tr>
+ );
}
case ObservabilityEventType.DbQueryStart:
case ObservabilityEventType.DbQueryFinishSuccess:
case ObservabilityEventType.DbQueryFinishError: {
- return <tr>
- <td><a href="#" onClick={(e) => {
- e.preventDefault();
- onClick(<Fragment>
- <pre
- style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ return (
+ <tr>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ onClick(
+ <Fragment>
+ <pre
+ style={{
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>,
+ );
+ }}
>
- {JSON.stringify({ event: notif, prev }, undefined, 2)}
- </pre>
- </Fragment>);
- }}>{title}</a></td>
- <td>
- {notif.event.location} {notif.event.name}
- </td>
- <td>
- <Time
- timestamp={notif.when}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- <td>
- <Time
- timestamp={prev?.when}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- </tr>
+ {title}
+ </a>
+ </td>
+ <td>
+ {notif.event.location} {notif.event.name}
+ </td>
+ <td>
+ <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ <td>
+ <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ </tr>
+ );
}
case ObservabilityEventType.TaskStart:
case ObservabilityEventType.TaskStop:
case ObservabilityEventType.DeclareTaskDependency:
case ObservabilityEventType.TaskReset: {
- return <tr>
- <td><a href="#" onClick={(e) => {
- e.preventDefault();
- onClick(<Fragment>
- <pre
- style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ return (
+ <tr>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ onClick(
+ <Fragment>
+ <pre
+ style={{
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>,
+ );
+ }}
>
- {JSON.stringify({ event: notif, prev }, undefined, 2)}
- </pre>
- </Fragment>);
- }}>{title}</a></td>
- <td>
- {notif.event.taskId}
- </td>
- <td>
- <Time
- timestamp={notif.when}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- <td>
- <Time
- timestamp={prev?.when}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- </tr>
+ {title}
+ </a>
+ </td>
+ <td>{notif.event.taskId}</td>
+ <td>
+ <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ <td>
+ <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ </tr>
+ );
}
case ObservabilityEventType.ShepherdTaskResult: {
- return <tr>
- <td><a href="#" onClick={(e) => {
- e.preventDefault();
- onClick(<Fragment>
- <pre
- style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ return (
+ <tr>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ onClick(
+ <Fragment>
+ <pre
+ style={{
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>,
+ );
+ }}
>
- {JSON.stringify({ event: notif, prev }, undefined, 2)}
- </pre>
- </Fragment>);
- }}>{title}</a></td>
- <td>
- {notif.event.resultType}
- </td>
- <td>
- <Time
- timestamp={notif.when}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- <td>
- <Time
- timestamp={prev?.when}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- </tr>
-
+ {title}
+ </a>
+ </td>
+ <td>{notif.event.resultType}</td>
+ <td>
+ <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ <td>
+ <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ </tr>
+ );
}
case ObservabilityEventType.CryptoStart:
case ObservabilityEventType.CryptoFinishSuccess:
case ObservabilityEventType.CryptoFinishError: {
- return <tr>
- <td><a href="#" onClick={(e) => {
- e.preventDefault();
- onClick(<Fragment>
- <pre
- style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ return (
+ <tr>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ onClick(
+ <Fragment>
+ <pre
+ style={{
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>,
+ );
+ }}
>
- {JSON.stringify({ event: notif, prev }, undefined, 2)}
- </pre>
- </Fragment>);
- }}>{title}</a></td>
- <td>
- {notif.event.operation}
- </td>
- <td>
- <Time
- timestamp={notif.when}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- <td>
- <Time
- timestamp={prev?.when}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- </tr>
+ {title}
+ </a>
+ </td>
+ <td>{notif.event.operation}</td>
+ <td>
+ <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ <td>
+ <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ </tr>
+ );
}
case ObservabilityEventType.RequestStart:
case ObservabilityEventType.RequestFinishSuccess:
case ObservabilityEventType.RequestFinishError: {
- return <tr >
- <td><a href="#" onClick={(e) => {
- e.preventDefault();
- onClick(<Fragment>
- <pre
- style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ return (
+ <tr>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ onClick(
+ <Fragment>
+ <pre
+ style={{
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>,
+ );
+ }}
>
- {JSON.stringify({ event: notif, prev }, undefined, 2)}
- </pre>
- </Fragment>);
- }}>{title}</a></td>
- <td>
- {notif.event.type}
- </td>
- <td>
- <Time
- timestamp={notif.when}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- <td>
- <Time
- timestamp={prev?.when}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- </tr>
+ {title}
+ </a>
+ </td>
+ <td>{notif.event.type}</td>
+ <td>
+ <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ <td>
+ <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ </tr>
+ );
}
case ObservabilityEventType.Message:
// FIXME
@@ -498,337 +704,347 @@ function ShowObervavilityDetails({ title, notif, onClick, prev }: { title: strin
}
}
-function getNotificationFor(id: string, event: WalletNotification, start: AbsoluteTime, list: Notif[]): Notif | undefined {
- const eventWithTime = { ...event, when: start }
- switch (event.type) {
- case NotificationType.BalanceChange: {
- return ({
- id,
- events: [eventWithTime],
- reference: {
- eventType: event.type,
- referenceType: "transaction",
- id: event.hintTransactionId,
- },
- description: "Balance change",
- start,
- end: AbsoluteTime.never(),
- MoreInfo: ShowBalanceChange
- })
- }
- case NotificationType.BackupOperationError: {
- return ({
- id,
- events: [eventWithTime],
- reference: undefined,
- description: "Backup error",
- start,
- end: AbsoluteTime.never(),
- MoreInfo: ShowBackupOperationError
- })
- }
- case NotificationType.TransactionStateTransition: {
- const found = list.find(a => a.reference?.eventType === event.type && a.reference.id === event.transactionId)
- if (found) {
- found.end = start;
- found.events.unshift(eventWithTime)
- return undefined
- }
- return ({
- id,
- events: [eventWithTime],
- reference: {
- eventType: event.type,
- referenceType: "transaction",
- id: event.transactionId,
- },
- description: event.type,
- start,
- end: AbsoluteTime.never(),
- MoreInfo: ShowTransactionStateTransition
- })
- }
- case NotificationType.ExchangeStateTransition: {
- const found = list.find(a => a.reference?.eventType === event.type && a.reference.id === event.exchangeBaseUrl)
- if (found) {
- found.end = start;
- found.events.unshift(eventWithTime)
- return undefined
- }
- return ({
- id,
- events: [eventWithTime],
- description: "Exchange update",
- reference: {
- eventType: event.type,
- referenceType: "exchange",
- id: event.exchangeBaseUrl,
- },
- start,
- end: AbsoluteTime.never(),
- MoreInfo: ShowExchangeStateTransition
- })
- }
- case NotificationType.TaskObservabilityEvent: {
- const found = list.find(a => a.reference?.eventType === event.type && a.reference.id === event.taskId)
- if (found) {
- found.end = start;
- found.events.unshift(eventWithTime)
- return undefined
- }
- return ({
- id,
- events: [eventWithTime],
- reference: {
- eventType: event.type,
- referenceType: "task",
- id: event.taskId,
- },
- description: `Task update ${event.taskId}`,
- start,
- end: AbsoluteTime.never(),
- MoreInfo: ShowObservabilityEvent
- })
- }
- case NotificationType.WithdrawalOperationTransition: {
- const found = list.find(a => a.reference?.eventType === event.type && a.reference.id === event.uri)
- if (found) {
- found.end = start;
- found.events.unshift(eventWithTime)
- return undefined
- }
- return ({
- id,
- events: [eventWithTime],
- reference: {
- eventType: event.type,
- referenceType: "task",
- id: event.uri,
- },
- description: `Withdrawal operation updated`,
- start,
- end: AbsoluteTime.never(),
- MoreInfo: ShowObservabilityEvent
- })
- }
- case NotificationType.RequestObservabilityEvent: {
- const found = list.find(a => a.reference?.eventType === event.type && a.reference.id === event.requestId)
- if (found) {
- found.end = start;
- found.events.unshift(eventWithTime)
- return undefined
- }
- return ({
- id,
- events: [eventWithTime],
- reference: {
- eventType: event.type,
- referenceType: "operation",
- id: event.requestId,
- },
- description: `wallet.${event.operation}(${event.requestId})`,
- start,
- end: AbsoluteTime.never(),
- MoreInfo: ShowObservabilityEvent
- })
- }
- default: {
- assertUnreachable(event)
- }
- }
+function refresh(
+ api: WxApiType,
+ onUpdate: (list: WalletActivityTrack[]) => void,
+ filter: string,
+) {
+ api.background
+ .call("getNotifications", { filter })
+ .then((notif) => {
+ onUpdate(notif);
+ })
+ .catch((error) => {
+ console.log(error);
+ });
}
-
-function refresh(api: WxApiType, onUpdate: (list: Notif[]) => void) {
- api.background.call("getNotifications", undefined).then(notif => {
-
- const list: Notif[] = []
- for (const n of notif) {
- if (n.notification.type === NotificationType.RequestObservabilityEvent &&
- n.notification.operation === "getActiveTasks") {
- //ignore monitor request
- continue;
- }
- const event = getNotificationFor(String(list.length), n.notification, n.when, list)
- // pepe.
- if (event) {
- list.unshift(event)
- }
- }
- onUpdate(list);
- }).catch(error => {
- console.log(error)
- })
-}
-
-export function ObservabilityEventsTable({ }: {}): VNode {
- const { i18n } = useTranslationContext()
+export function ObservabilityEventsTable(): VNode {
+ const { i18n } = useTranslationContext();
const api = useBackendContext();
- const [notifications, setNotifications] = useState<Notif[]>([])
- const [showDetails, setShowDetails] = useState<VNode>()
+ const [notifications, setNotifications] = useState<WalletActivityTrack[]>([]);
+ const [showDetails, setShowDetails] = useState<VNode>();
+ const [filter, onChangeFilter] = useState("");
useEffect(() => {
let lastTimeout: ReturnType<typeof setTimeout>;
function periodicRefresh() {
-
- refresh(api, setNotifications)
+ refresh(api, setNotifications, filter);
lastTimeout = setTimeout(() => {
periodicRefresh();
- }, 1000)
+ }, 1000);
- //clear on unload
- return () => { clearTimeout(lastTimeout) }
+ return () => {
+ clearTimeout(lastTimeout);
+ };
}
- return periodicRefresh()
- }, [1]);
-
- return <div>
- <div style={{ display: "flex", justifyContent: "space-between" }}>
+ return periodicRefresh();
+ }, [filter]);
- <div style={{ padding: 4, margin: 2, border: "solid 1px black" }} onClick={() => {
- api.background.call("clearNotifications", undefined).then(d => {
- refresh(api, setNotifications)
- })
- }}>
- clear
+ return (
+ <div>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <TextField
+ label="Filter"
+ variant="outlined"
+ value={filter}
+ onChange={onChangeFilter}
+ />
+ <div
+ style={{
+ padding: 4,
+ margin: 2,
+ border: "solid 1px black",
+ alignSelf: "center",
+ }}
+ onClick={() => {
+ api.background.call("clearNotifications", undefined).then(() => {
+ refresh(api, setNotifications, filter);
+ });
+ }}
+ >
+ clear
+ </div>
</div>
-
-
- </div>
- {showDetails && <Modal title="event details" onClose={{ onClick: (async () => { setShowDetails(undefined) }) as any }} >
- {showDetails}
- </Modal>}
- {notifications.map((not) => {
- return (
- <details key={not.id}>
- <summary>
- <div style={{ width: "90%", display: "inline-flex", justifyContent: "space-between", padding: 4 }}>
- <div style={{ padding: 4 }}>
- {not.description}
- </div>
- <div style={{ padding: 4 }}>
- <Time
- timestamp={not.start}
- format="yyyy/MM/dd HH:mm:ss"
- />
+ {showDetails && (
+ <Modal
+ title="event details"
+ onClose={{
+ onClick: (async () => {
+ setShowDetails(undefined);
+ }) as SafeHandler<void>,
+ }}
+ >
+ {showDetails}
+ </Modal>
+ )}
+ {notifications.map((not) => {
+ return (
+ <details key={not.id}>
+ <summary>
+ <div
+ style={{
+ width: "90%",
+ display: "inline-flex",
+ justifyContent: "space-between",
+ padding: 4,
+ }}
+ >
+ <div style={{ padding: 4 }}>
+ {(() => {
+ switch (not.type) {
+ case NotificationType.BalanceChange:
+ return i18n.str`Balance change`;
+ case NotificationType.BackupOperationError:
+ return i18n.str`Backup failed`;
+ case NotificationType.TransactionStateTransition:
+ return i18n.str`Transaction updated`;
+ case NotificationType.ExchangeStateTransition:
+ return i18n.str`Exchange updated`;
+ case NotificationType.Idle:
+ return i18n.str`Idle`;
+ case NotificationType.TaskObservabilityEvent:
+ return i18n.str`task.${
+ (not.events[0] as TaskProgressNotification).taskId
+ }`;
+ case NotificationType.RequestObservabilityEvent:
+ return i18n.str`wallet.${
+ (not.events[0] as RequestProgressNotification)
+ .operation
+ }(${
+ (not.events[0] as RequestProgressNotification)
+ .requestId
+ })`;
+ case NotificationType.WithdrawalOperationTransition: {
+ return `---`;
+ }
+ default: {
+ assertUnreachable(not.type);
+ }
+ }
+ })()}
+ </div>
+ <div style={{ padding: 4 }}>
+ <Time timestamp={not.start} format="yyyy/MM/dd HH:mm:ss" />
+ </div>
+ <div style={{ padding: 4 }}>
+ <Time timestamp={not.end} format="yyyy/MM/dd HH:mm:ss" />
+ </div>
</div>
- <div style={{ padding: 4 }}><Time
- timestamp={not.end}
- format="yyyy/MM/dd HH:mm:ss"
- /></div>
- </div>
- </summary>
- <not.MoreInfo events={not.events} onClick={(details) => {
- setShowDetails(details)
- }} />
- </details>
- );
- })}
- </div >
+ </summary>
+ {(() => {
+ switch (not.type) {
+ case NotificationType.BalanceChange: {
+ return (
+ <ShowBalanceChange
+ events={not.events}
+ onClick={(details) => {
+ setShowDetails(details);
+ }}
+ />
+ );
+ }
+ case NotificationType.BackupOperationError: {
+ return (
+ <ShowBackupOperationError
+ events={not.events}
+ onClick={(details) => {
+ setShowDetails(details);
+ }}
+ />
+ );
+ }
+ case NotificationType.TransactionStateTransition: {
+ return (
+ <ShowTransactionStateTransition
+ events={not.events}
+ onClick={(details) => {
+ setShowDetails(details);
+ }}
+ />
+ );
+ }
+ case NotificationType.ExchangeStateTransition: {
+ return (
+ <ShowExchangeStateTransition
+ events={not.events}
+ onClick={(details) => {
+ setShowDetails(details);
+ }}
+ />
+ );
+ }
+ case NotificationType.Idle: {
+ return <div>not implemented</div>;
+ }
+ case NotificationType.TaskObservabilityEvent: {
+ return (
+ <ShowObservabilityEvent
+ events={not.events}
+ onClick={(details) => {
+ setShowDetails(details);
+ }}
+ />
+ );
+ }
+ case NotificationType.RequestObservabilityEvent: {
+ return (
+ <ShowObservabilityEvent
+ events={not.events}
+ onClick={(details) => {
+ setShowDetails(details);
+ }}
+ />
+ );
+ }
+ case NotificationType.WithdrawalOperationTransition: {
+ return <div>not implemented</div>;
+ }
+ }
+ })()}
+ </details>
+ );
+ })}
+ </div>
+ );
}
-function ErroDetailModal({ error, onClose }: { error: TalerErrorDetail, onClose: () => void }): VNode {
- return <Modal title="Full detail" onClose={{
- onClick: onClose as any
- }}>
- <dl>
- <dt>Code</dt>
- <dd>{TalerErrorCode[error.code]} ({error.code})</dd>
- <dt>Hint</dt>
- <dd>{error.hint ?? "--"}</dd>
- <dt>Time</dt>
- <dd><Time
- timestamp={error.when}
- format="yyyy/MM/dd HH:mm:ss"
- /></dd>
- </dl>
- <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
- {JSON.stringify(error, undefined, 2)}
- </pre>
- </Modal>
+function ErroDetailModal({
+ error,
+ onClose,
+}: {
+ error: TalerErrorDetail;
+ onClose: () => void;
+}): VNode {
+ return (
+ <Modal
+ title="Full detail"
+ onClose={{
+ onClick: onClose as SafeHandler<void>,
+ }}
+ >
+ <dl>
+ <dt>Code</dt>
+ <dd>
+ {TalerErrorCode[error.code]} ({error.code})
+ </dd>
+ <dt>Hint</dt>
+ <dd>{error.hint ?? "--"}</dd>
+ <dt>Time</dt>
+ <dd>
+ <Time timestamp={error.when} format="yyyy/MM/dd HH:mm:ss" />
+ </dd>
+ </dl>
+ <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
+ {JSON.stringify(error, undefined, 2)}
+ </pre>
+ </Modal>
+ );
}
-export function ActiveTasksTable({ }: {}): VNode {
- const { i18n } = useTranslationContext()
+export function ActiveTasksTable(): VNode {
+ const { i18n } = useTranslationContext();
const api = useBackendContext();
const state = useAsyncAsHook(() => {
return api.wallet.call(WalletApiOperation.GetActiveTasks, {});
});
- const [showError, setShowError] = useState<TalerErrorDetail>()
+ const [showError, setShowError] = useState<TalerErrorDetail>();
const tasks = state && !state.hasError ? state.response.tasks : [];
useEffect(() => {
- if (!state || state.hasError) return
+ if (!state || state.hasError) return;
const lastTimeout = setTimeout(() => {
state.retry();
- }, 1000)
+ }, 1000);
return () => {
- clearTimeout(lastTimeout)
- }
- }, [tasks])
+ clearTimeout(lastTimeout);
+ };
+ }, [tasks]);
- // const listenAllEvents = Array.from<NotificationType>({ length: 1 });
- // listenAllEvents.includes = () => true
- // useEffect(() => {
- // return api.listener.onUpdateNotification(listenAllEvents, (notif) => {
- // state?.retry()
- // });
- // });
- return <Fragment>
- {showError && <ErroDetailModal error={showError} onClose={(async () => { setShowError(undefined) })} />}
+ return (
+ <Fragment>
+ {showError && (
+ <ErroDetailModal
+ error={showError}
+ onClose={async () => {
+ setShowError(undefined);
+ }}
+ />
+ )}
- <table style={{ width: "100%" }}>
- <thead>
- <tr>
- <th>
- <i18n.Translate>Type</i18n.Translate>
- </th>
- <th>
- <i18n.Translate>Id</i18n.Translate>
- </th>
- <th>
- <i18n.Translate>Since</i18n.Translate>
- </th>
- <th>
- <i18n.Translate>Next try</i18n.Translate>
- </th>
- <th>
- <i18n.Translate>Error</i18n.Translate>
- </th>
- <th>
- <i18n.Translate>Transaction</i18n.Translate>
- </th>
- </tr>
- </thead>
- <tbody>
- {tasks.map((task) => {
- const [type, id] = task.id.split(":")
- return (
- <tr>
- <td>{type}</td>
- <td title={id}>{id.substring(0, 10)}</td>
- <td>
- <Time
- timestamp={task.firstTry}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- <td>
- <Time
- timestamp={task.nextTry}
- format="yyyy/MM/dd HH:mm:ss"
- />
- </td>
- <td>{!task.lastError?.code ? "" : <a href="#" onClick={(e) => { e.preventDefault(); setShowError(task.lastError) }}>{TalerErrorCode[task.lastError.code]}</a>}</td>
- <td>
- {task.transaction ? <a title={task.transaction} href={Pages.balanceTransaction({ tid: task.transaction })}>{task.transaction.substring(0, 10)}</a> : "--"}
- </td>
- </tr>
- );
- })}
- </tbody>
- </table>
- </Fragment>
-} \ No newline at end of file
+ <table style={{ width: "100%" }}>
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Type</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Id</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Since</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Next try</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Error</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Transaction</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {tasks.map((task) => {
+ const [type, id] = task.taskId.split(":");
+ return (
+ <tr key={id}>
+ <td>{type}</td>
+ <td title={id}>{id.substring(0, 10)}</td>
+ <td>
+ <Time
+ timestamp={task.firstTry}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ <td>
+ <Time timestamp={task.nextTry} format="yyyy/MM/dd HH:mm:ss" />
+ </td>
+ <td>
+ {!task.lastError?.code ? (
+ ""
+ ) : (
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ setShowError(task.lastError);
+ }}
+ >
+ {TalerErrorCode[task.lastError.code]}
+ </a>
+ )}
+ </td>
+ <td>
+ {task.transaction ? (
+ <a
+ title={task.transaction}
+ href={Pages.balanceTransaction({ tid: task.transaction })}
+ >
+ {task.transaction.substring(0, 10)}
+ </a>
+ ) : (
+ "--"
+ )}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/context/alert.ts b/packages/taler-wallet-webextension/src/context/alert.ts
index 36de7c7e4..e30fdd72c 100644
--- a/packages/taler-wallet-webextension/src/context/alert.ts
+++ b/packages/taler-wallet-webextension/src/context/alert.ts
@@ -19,13 +19,21 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { TalerError, TalerErrorCode, TalerErrorDetail, TranslatedString } from "@gnu-taler/taler-util";
+import {
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
import { ComponentChildren, createContext, h, VNode } from "preact";
import { useContext, useState } from "preact/hooks";
import { HookError } from "../hooks/useAsyncAsHook.js";
import { SafeHandler, withSafe } from "../mui/handlers.js";
import { BackgroundError } from "../wxApi.js";
-import { InternationalizationAPI, useTranslationContext } from "@gnu-taler/web-util/browser";
+import {
+ InternationalizationAPI,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
import { platform } from "../platform/foreground.js";
export type AlertType = "info" | "warning" | "error" | "success";
@@ -175,9 +183,14 @@ export function alertFromError(
//HookError
description = error.message as TranslatedString;
if (error.type === "taler") {
- const msg = isWalletNotAvailable(i18n,error.details)
+ const msg = isWalletNotAvailable(i18n, error.details);
if (msg) {
- description = msg
+ description = msg;
+ } else {
+ const msg2 = isHttpError(i18n, error.details);
+ if (msg2) {
+ description = msg2;
+ }
}
cause = {
details: error.details,
@@ -185,12 +198,17 @@ export function alertFromError(
}
} else {
if (error instanceof BackgroundError) {
- const msg = isWalletNotAvailable(i18n,error.errorDetail)
+ const msg = isWalletNotAvailable(i18n, error.errorDetail);
if (msg) {
- description = msg
+ description = msg;
} else {
- description = (error.errorDetail.hint ??
- `Error code: ${error.errorDetail.code}`) as TranslatedString;
+ const msg2 = isHttpError(i18n, error.errorDetail);
+ if (msg2) {
+ description = msg2;
+ } else {
+ description = (error.errorDetail.hint ??
+ `Error code: ${error.errorDetail.code}`) as TranslatedString;
+ }
}
cause = {
details: error.errorDetail,
@@ -217,20 +235,43 @@ export function alertFromError(
};
}
-function isWalletNotAvailable(i18n: InternationalizationAPI, detail: TalerErrorDetail): TranslatedString | undefined {
- if (detail.code === TalerErrorCode.WALLET_CORE_NOT_AVAILABLE
- && detail.lastError) {
- const le = detail.lastError as TalerErrorDetail
+function isWalletNotAvailable(
+ i18n: InternationalizationAPI,
+ detail: TalerErrorDetail,
+): TranslatedString | undefined {
+ if (
+ detail.code === TalerErrorCode.WALLET_CORE_NOT_AVAILABLE &&
+ detail.lastError
+ ) {
+ const le = detail.lastError as TalerErrorDetail;
if (le.code === TalerErrorCode.WALLET_DB_UNAVAILABLE) {
if (platform.isFirefox() && platform.runningOnPrivateMode()) {
- return i18n.str`Could not open the wallet database. Firefox is known to run into this problem under "permanent private mode".`
+ return i18n.str`Could not open the wallet database. Firefox is known to run into this problem under "permanent private mode".`;
} else {
- return i18n.str`Could not open the wallet database.`
+ return i18n.str`Could not open the wallet database.`;
}
} else {
return (detail.hint ?? `Error code: ${detail.code}`) as TranslatedString;
}
+ }
+ return undefined;
+}
+function isHttpError(
+ i18n: InternationalizationAPI,
+ detail: TalerErrorDetail,
+): TranslatedString | undefined {
+ if (
+ detail.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
+ detail.errorResponse
+ ) {
+ const er = detail.errorResponse as TalerErrorDetail;
+ return (
+ (er.hint as TranslatedString) ??
+ detail.hint ??
+ i18n.str`Unexpected request error, code: ${er.code}`
+ );
}
- return undefined
+ return undefined;
}
+//
diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts
index b9257215f..6b4584fea 100644
--- a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts
+++ b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts
@@ -14,7 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { Amounts } from "@gnu-taler/taler-util";
+import { Amounts, PreparePayResult } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { useState } from "preact/hooks";
import { alertFromError, useAlertContext } from "../../context/alert.js";
@@ -54,7 +54,7 @@ export function useComponentState({
const hook = useAsyncAsHook(async () => {
if (!talerTemplateUri) throw Error("ERROR_NO-URI-FOR-PAYMENT-TEMPLATE");
- let payStatus;
+ let payStatus: PreparePayResult | undefined = undefined;
if (!amountParam && !summaryParam) {
payStatus = await api.wallet.call(
WalletApiOperation.PreparePayForTemplate,
@@ -125,7 +125,9 @@ export function useComponentState({
},
);
setNewOrder(payStatus.talerUri!);
- } catch (e) {}
+ } catch (e) {
+ console.error(e);
+ }
}
const errors = undefinedIfEmpty({
amount: amount && Amounts.isZero(amount) ? i18n.str`required` : undefined,
@@ -164,7 +166,9 @@ export function useComponentState({
}
function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
- return Object.keys(obj).some((k) => (obj as any)[k] !== undefined)
+ return Object.keys(obj).some(
+ (k) => (obj as Record<string, unknown>)[k] !== undefined,
+ )
? obj
: undefined;
}
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts
index 7486d5f97..044f2434f 100644
--- a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts
+++ b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts
@@ -14,15 +14,13 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-/* eslint-disable react-hooks/rules-of-hooks */
import {
AmountJson,
Amounts,
ExchangeFullDetails,
ExchangeListItem,
NotificationType,
- TalerError,
- parseWithdrawExchangeUri,
+ parseWithdrawExchangeUri
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
@@ -209,7 +207,7 @@ export function useComponentStateFromURI({
WalletApiOperation.GetWithdrawalDetailsForUri,
{
talerWithdrawUri,
- notifyChangeFromPendingTimeoutMs: 30 * 1000,
+ // notifyChangeFromPendingTimeoutMs: 30 * 1000,
},
);
const {
@@ -244,9 +242,7 @@ export function useComponentStateFromURI({
}
return api.listener.onUpdateNotification(
[NotificationType.WithdrawalOperationTransition],
- (asd) => {
- uriInfoHook.retry();
- },
+ uriInfoHook.retry,
);
}, [readyToListen]);
@@ -377,9 +373,6 @@ function exchangeSelectionState(
};
}, []);
- const [withdrawError, setWithdrawError] = useState<TalerError | undefined>(
- undefined,
- );
const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false);
async function doWithdrawAndCheckError(): Promise<void> {
@@ -395,9 +388,9 @@ function exchangeSelectionState(
onSuccess(res.transactionId);
}
} catch (e) {
- if (e instanceof TalerError) {
- setWithdrawError(e);
- }
+ console.error(e);
+ // if (e instanceof TalerError) {
+ // }
}
setDoingWithdraw(false);
}
diff --git a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts
index a5e357f7d..bd430f2ef 100644
--- a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts
+++ b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts
@@ -48,14 +48,13 @@ export type HookResponseWithRetry<T> =
export function useAsyncAsHook<T>(
fn: () => Promise<T | false>,
- deps?: any[],
+ deps?: unknown[],
): HookResponseWithRetry<T> {
const [result, setHookResponse] = useState<HookResponse<T>>(undefined);
const args = useMemo(
() => ({
fn,
- // eslint-disable-next-line react-hooks/exhaustive-deps
}),
deps || [],
);
diff --git a/packages/taler-wallet-webextension/src/i18n/de.po b/packages/taler-wallet-webextension/src/i18n/de.po
index 1a285499c..bc66f2136 100644
--- a/packages/taler-wallet-webextension/src/i18n/de.po
+++ b/packages/taler-wallet-webextension/src/i18n/de.po
@@ -17,7 +17,7 @@ msgstr ""
"Project-Id-Version: Taler Wallet\n"
"Report-Msgid-Bugs-To: languages@taler.net\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
-"PO-Revision-Date: 2023-11-25 17:24+0000\n"
+"PO-Revision-Date: 2024-05-07 14:32+0000\n"
"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
"Language-Team: German <https://weblate.taler.net/projects/gnu-taler/"
"webextensions/de/>\n"
@@ -26,7 +26,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
-"X-Generator: Weblate 5.2.1\n"
+"X-Generator: Weblate 5.4.3\n"
#: src/NavigationBar.tsx:139
#, c-format
@@ -56,7 +56,7 @@ msgstr "Dev"
#: src/mui/Typography.tsx:122
#, c-format
msgid "%1$s"
-msgstr ""
+msgstr "%1$s"
#: src/components/PendingTransactions.tsx:74
#, c-format
@@ -215,7 +215,7 @@ msgstr ""
#: src/wallet/AddNewActionView.tsx:57
#, c-format
msgid "Cancel"
-msgstr "Abbrechen"
+msgstr "Zurück"
#: src/wallet/AddNewActionView.tsx:68
#, c-format
@@ -325,7 +325,7 @@ msgstr ""
#: src/components/ShowFullContractTermPopup.tsx:189
#, c-format
msgid "Summary"
-msgstr ""
+msgstr "Zusammenfassung"
#: src/components/ShowFullContractTermPopup.tsx:195
#, c-format
@@ -370,7 +370,7 @@ msgstr ""
#: src/components/ShowFullContractTermPopup.tsx:256
#, c-format
msgid "Delivery date"
-msgstr ""
+msgstr "Lieferdatum"
#: src/components/ShowFullContractTermPopup.tsx:271
#, c-format
@@ -405,7 +405,7 @@ msgstr ""
#: src/components/ShowFullContractTermPopup.tsx:354
#, c-format
msgid "Fulfillment URL"
-msgstr ""
+msgstr "Adresse digitaler Dienstleistung (Fulfillment-URL)"
#: src/components/ShowFullContractTermPopup.tsx:360
#, c-format
@@ -1061,7 +1061,7 @@ msgstr "Konnte die Umsatzanzeige nicht laden"
#: src/wallet/ExchangeSelection/views.tsx:131
#, c-format
msgid "Close"
-msgstr ""
+msgstr "Schließen"
#: src/wallet/ExchangeSelection/views.tsx:160
#, fuzzy, c-format
diff --git a/packages/taler-wallet-webextension/src/i18n/ru.po b/packages/taler-wallet-webextension/src/i18n/ru.po
new file mode 100644
index 000000000..aa002c984
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/i18n/ru.po
@@ -0,0 +1,1977 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-05-10 00:13+0000\n"
+"Last-Translator: Lily Ponomareva <lilyponomareva2017@gmail.com>\n"
+"Language-Team: Russian <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/ru/>\n"
+"Language: ru\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
+"X-Generator: Weblate 5.4.3\n"
+
+#: src/NavigationBar.tsx:139
+#, c-format
+msgid "Balance"
+msgstr "Баланс"
+
+#: src/NavigationBar.tsx:142
+#, c-format
+msgid "Backup"
+msgstr "Резервная копия"
+
+#: src/NavigationBar.tsx:147
+#, c-format
+msgid "QR Reader and Taler URI"
+msgstr "Считыватель QR-кодов и URI Taler"
+
+#: src/NavigationBar.tsx:154
+#, c-format
+msgid "Settings"
+msgstr "Настройки"
+
+#: src/NavigationBar.tsx:184
+#, c-format
+msgid "Dev"
+msgstr "Dev"
+
+#: src/mui/Typography.tsx:122
+#, c-format
+msgid "%1$s"
+msgstr "%1$s"
+
+#: src/components/PendingTransactions.tsx:74
+#, c-format
+msgid "PENDING OPERATIONS"
+msgstr "ОЖИДАЮЩИЕ ОПЕРАЦИИ"
+
+#: src/components/Loading.tsx:36
+#, c-format
+msgid "Loading"
+msgstr "Загружаются"
+
+#: src/wallet/BackupPage.tsx:123
+#, c-format
+msgid "Could not load backup providers"
+msgstr "Не удалось загрузить поставщиков резервного копирования"
+
+#: src/wallet/BackupPage.tsx:202
+#, c-format
+msgid "No backup providers configured"
+msgstr "Поставщики резервного копирования не настроены"
+
+#: src/wallet/BackupPage.tsx:205
+#, c-format
+msgid "Add provider"
+msgstr "Добавить сервис"
+
+#: src/wallet/BackupPage.tsx:219
+#, c-format
+msgid "Sync all backups"
+msgstr "Синхронизация всех резервных копий"
+
+#: src/wallet/BackupPage.tsx:221
+#, c-format
+msgid "Sync now"
+msgstr "Синхронизировать сейчас"
+
+#: src/wallet/BackupPage.tsx:264
+#, c-format
+msgid "Last synced"
+msgstr "Последняя синхронизация"
+
+#: src/wallet/BackupPage.tsx:269
+#, c-format
+msgid "Not synced"
+msgstr "Не синхронизировано"
+
+#: src/wallet/BackupPage.tsx:289
+#, c-format
+msgid "Expires in"
+msgstr "Срок действия истекает в"
+
+#: src/wallet/ProviderDetailPage.tsx:60
+#, c-format
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
+msgstr ""
+"Произошла ошибка при загрузке сведений о поставщике для &quot; %1$s&quot;"
+
+#: src/wallet/ProviderDetailPage.tsx:108
+#, c-format
+msgid "There is not known provider with url &quot;%1$s&quot;."
+msgstr "Нет провайдера с url &quot;%1$s&quot;."
+
+#: src/wallet/ProviderDetailPage.tsx:115
+#, c-format
+msgid "See providers"
+msgstr "Посмотреть провайдеров"
+
+#: src/wallet/ProviderDetailPage.tsx:143
+#, c-format
+msgid "Last backup"
+msgstr "Последняя резервная копия"
+
+#: src/wallet/ProviderDetailPage.tsx:148
+#, c-format
+msgid "Back up"
+msgstr "Создать резервную копию"
+
+#: src/wallet/ProviderDetailPage.tsx:154
+#, c-format
+msgid "Provider fee"
+msgstr "Комиссия провайдера"
+
+#: src/wallet/ProviderDetailPage.tsx:157
+#, c-format
+msgid "per year"
+msgstr "в год"
+
+#: src/wallet/ProviderDetailPage.tsx:163
+#, c-format
+msgid "Extend"
+msgstr "Расширить"
+
+#: src/wallet/ProviderDetailPage.tsx:169
+#, c-format
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms of "
+"service"
+msgstr ""
+"изменились условия, продление сервиса будет означать принятие новых условий "
+"предоставления услуг"
+
+#: src/wallet/ProviderDetailPage.tsx:179
+#, c-format
+msgid "old"
+msgstr "старый"
+
+#: src/wallet/ProviderDetailPage.tsx:183
+#, c-format
+msgid "new"
+msgstr "новый"
+
+#: src/wallet/ProviderDetailPage.tsx:190
+#, c-format
+msgid "fee"
+msgstr "комиссия"
+
+#: src/wallet/ProviderDetailPage.tsx:198
+#, c-format
+msgid "storage"
+msgstr "хранение"
+
+#: src/wallet/ProviderDetailPage.tsx:215
+#, c-format
+msgid "Remove provider"
+msgstr "Удалить провадер"
+
+#: src/wallet/ProviderDetailPage.tsx:228
+#, c-format
+msgid "This provider has reported an error"
+msgstr "Этот провайдер сообщил об ошибке"
+
+#: src/wallet/ProviderDetailPage.tsx:242
+#, c-format
+msgid "There is conflict with another backup from %1$s"
+msgstr "Возник конфликт с другой резервной копией из %1$s"
+
+#: src/wallet/ProviderDetailPage.tsx:253
+#, c-format
+msgid "Backup is not readable"
+msgstr "Резервная копия не читается"
+
+#: src/wallet/ProviderDetailPage.tsx:261
+#, c-format
+msgid "Unknown backup problem: %1$s"
+msgstr "Неизвестная проблема резервного копирования: %1$s"
+
+#: src/wallet/ProviderDetailPage.tsx:283
+#, c-format
+msgid "service paid"
+msgstr "Услуга платная"
+
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format
+msgid "Backup valid until"
+msgstr "Резервная копия действительна до"
+
+#: src/wallet/AddNewActionView.tsx:57
+#, c-format
+msgid "Cancel"
+msgstr "Отмена"
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr "Открыть резервную страницу"
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr "Открыть страницу оплаты"
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr "Открыть страницу возврата средств"
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr "Открыть страницу чаевых"
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr "Открыть страницу вывода средств"
+
+#: src/popup/NoBalanceHelp.tsx:43
+#, c-format
+msgid "Get digital cash"
+msgstr "Получите цифровую наличность"
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr "Не удалось загрузить страницу баланса"
+
+#: src/popup/BalancePage.tsx:175
+#, c-format
+msgid "Add"
+msgstr "Добавить"
+
+#: src/popup/BalancePage.tsx:179
+#, c-format
+msgid "Send %1$s"
+msgstr "Отправить %1$s"
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr "Действие Талер"
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr "На этой странице есть платное действие."
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr "На этой странице есть действие по выводу средств."
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr "На этой странице есть действие чаевых."
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr "На этой странице есть действие уведомить о резерве."
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr "Уведомить"
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr "На этой странице есть действие по возврату средств."
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr "На этой странице неправильно сформирован Taler URI."
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr "Закрыть"
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr "Это всплывающее окно закрывается и вы перенаправляетесь на %1$s"
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, c-format
+msgid "Could not load purchase proposal details"
+msgstr "Не удалось загрузить сведения о предложении покупки"
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, c-format
+msgid "Order Id"
+msgstr "Номер заказа"
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr "Вкратце"
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr "Сумма"
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr "Название продавца"
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr "Юрисдикция продавца"
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr "Адрес продавца"
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr "Логотип продавца"
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr "Сайт продавца"
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr "Email продавца"
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr "Публичный ключ продавца"
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr "Дата поставки"
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr "Адрес доставки"
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr "Продукты"
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr "Создано в"
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr "Крайний срок возврата средств"
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr "Автоматический возврат средств"
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr "Крайний срок оплаты"
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr "URL-адрес выполнения"
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr "Сообщение о выполнении"
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr "Максимальная комиссия за депозит"
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr "максимальная комиссия"
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr "Минимальный возраст"
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr "Комиссия за банковский перевод"
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr "Аудиторы"
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, c-format
+msgid "Exchanges"
+msgstr "Обменники"
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
+msgstr "Баковский счёт"
+
+#: src/components/Part.tsx:160
+#, c-format
+msgid "Bitcoin address"
+msgstr "Биткоин адрес"
+
+#: src/components/Part.tsx:163
+#, c-format
+msgid "IBAN"
+msgstr "IBAN"
+
+#: src/cta/Deposit/views.tsx:38
+#, c-format
+msgid "Could not load deposit status"
+msgstr "Не удалось загрузить статус депозита"
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr "Депозит цифровой налички"
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr "Стоимость"
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr "Комиссия"
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr "К получению"
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr "Отправить &nbsp; %1$s"
+
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr "Подробности перевода биткоина"
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an minimum "
+"amount."
+msgstr ""
+"Обменнику нужна транзакция с 3 выходами, один выход - это счёт обменника, а "
+"два других - это сегвит фейк адрес для метаданных с минимальной суммой."
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional "
+"recipient and copy addresses and amounts"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC"
+msgstr ""
+"Убедитесь что сумма показывает %1$s BTC, в противном случае вам придётся "
+"изменить базовую единицу на BTC"
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr "Счёт"
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr "Хост банка"
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr "Подробности банковского перевода"
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr "Причина"
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr "Имя получателя"
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr "Не удалось загрузить информацию о транзакции"
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr "При попытке завершить транзакцию произошла ошибка"
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr "Эта транзакция не завершена"
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr "Отправить"
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr "Повторить попытку"
+
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr "Забыть"
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr "Внимание!"
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to get "
+"the coins form it."
+msgstr ""
+"Если вы уже перевели деньги на обменник вы потеряете шанс получить монеты с "
+"нее."
+
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr "Подтвердить"
+
+#: src/wallet/Transaction.tsx:267
+#, c-format
+msgid "Withdrawal"
+msgstr "Вывод"
+
+#: src/wallet/Transaction.tsx:286
+#, c-format
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+"Убедитесь что вы указали правильное назначение, иначе деньги не поступят на "
+"этот кошелек."
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check "
+"there is no pending step."
+msgstr ""
+"Банк пока не подтвердил перевод. Перейдите к %1$s %2$s и проверьте нет ли "
+"ожидающих шагов."
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins"
+msgstr "Банк подтвердил перевод. Ожидание пока обменик отправит монеты"
+
+#: src/wallet/Transaction.tsx:325
+#, c-format
+msgid "Details"
+msgstr "Подробности"
+
+#: src/wallet/Transaction.tsx:360
+#, c-format
+msgid "Payment"
+msgstr "Платёж"
+
+#: src/wallet/Transaction.tsx:378
+#, c-format
+msgid "Refunds"
+msgstr "Возвраты"
+
+#: src/wallet/Transaction.tsx:385
+#, c-format
+msgid "%1$s %2$s on %3$s"
+msgstr "%1$s %2$s на %3$s"
+
+#: src/wallet/Transaction.tsx:415
+#, c-format
+msgid "Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+"Продавец создал возврат средств за этот заказ, но не был автоматически "
+"забран."
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr "Предложение"
+
+#: src/wallet/Transaction.tsx:431
+#, c-format
+msgid "Accept"
+msgstr "Принять"
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr "Продавец"
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr "№ счёта-фактуры"
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr "Депозит"
+
+#: src/wallet/Transaction.tsx:496
+#, c-format
+msgid "Refresh"
+msgstr "Обновить"
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr "Чаевые"
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr "Возврат"
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr "№ исходного заказа"
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr "Сводка о покупке"
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr "копировать"
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr "спрятать qr"
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr "показать qr"
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr "Кредит"
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr "Счёт-фактура"
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr "Обменник"
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr "URI"
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr "Дебит"
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr "Перевести"
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr "Страна"
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr "Строки адреса"
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr "Номер дома"
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr "Название дома"
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr "Улица"
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr "Почтовый индекс"
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr "Область города"
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr "Город"
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr "Район"
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr "Регион страны"
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr "Дата"
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr "Комиссия транзакции"
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr "Всего"
+
+#: src/wallet/Transaction.tsx:1074
+#, c-format
+msgid "Withdraw"
+msgstr "Снять средства"
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr "Цена"
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr "Возвращено на счёт"
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr "Поставка"
+
+#: src/wallet/Transaction.tsx:1335
+#, c-format
+msgid "Total transfer"
+msgstr "Итого перевод"
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr "Не удалось загрузить статус оплаты"
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr "Оплата цифровой наличкой"
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr "Покупка"
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr "Чек"
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr "Действительно до"
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr "Список продуктов"
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr "комиссия"
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr "Уже оплачено, вы будете перенаправлены на %1$s"
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr "Уже оплачено"
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr "Уже заявлено"
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr "Оплата с помощью мобильного телефона"
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr "Скрыть QR"
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr "Отсканируйте QR код или &nbsp; %1$s"
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr "Заплатить &nbsp; %1$s"
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr "У вас нет баланса в этой валюте. Сначала снимите цифровые деньги."
+
+#: src/cta/Payment/views.tsx:364
+#, c-format
+msgid ""
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+"Не удалось найти достаточно монет для оплаты. Даже если у вас достаточно %1$"
+"s, могут применяться некоторые ограничения."
+
+#: src/cta/Payment/views.tsx:366
+#, c-format
+msgid "Your current balance is not enough."
+msgstr "Недостаточно средств на балансе."
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr "Сообщение продавца"
+
+#: src/cta/Refund/views.tsx:34
+#, c-format
+msgid "Could not load refund status"
+msgstr "Не удалось загрузить статус возврата"
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr "Возврат цифровой налички"
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr "Вы проигнорировали чаевые."
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr "Возврат средств в выполняется."
+
+#: src/cta/Refund/views.tsx:76
+#, c-format
+msgid "Total to refund"
+msgstr "Всего к возврату"
+
+#: src/cta/Refund/views.tsx:106
+#, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr "Продавец «%1$s»‎ предлагает вам возврат средств."
+
+#: src/cta/Refund/views.tsx:115
+#, c-format
+msgid "Order amount"
+msgstr "Сумма заказа"
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr "Уже возвращено"
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr "Предложен возврат средств"
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr "Принять &nbsp; %1$s"
+
+#: src/cta/Tip/views.tsx:32
+#, c-format
+msgid "Could not load tip status"
+msgstr "Не удалось загрузить статус чаевых"
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr "Чаевые цифровой налички"
+
+#: src/cta/Tip/views.tsx:66
+#, c-format
+msgid "The merchant is offering you a tip"
+msgstr "Продавец предлагает вам чаевые"
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
+msgstr "URL-адрес продавца"
+
+#: src/cta/Tip/views.tsx:90
+#, c-format
+msgid "Receive &nbsp; %1$s"
+msgstr "Получить &nbsp; %1$s"
+
+#: src/cta/Tip/views.tsx:114
+#, c-format
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
+msgstr "Чаевые от %1$s приняты. Проверьте список транзакций для подробностей."
+
+#: src/components/SelectList.tsx:66
+#, c-format
+msgid "Select one option"
+msgstr "Выберете одну опцию"
+
+#: src/components/TermsOfService/views.tsx:39
+#, c-format
+msgid "Could not load"
+msgstr "Невозможно загрузить"
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr "Показать Условия использования"
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr "Я принимаю эти Условия использования"
+
+#: src/components/TermsOfService/views.tsx:107
+#, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr "Обменник не имеет условий использования"
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr "Ознакомиться с Условиями использования"
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr "Ознакомиться с новой версией Условий использования"
+
+#: src/components/TermsOfService/views.tsx:170
+#, c-format
+msgid "The exchange reply with a empty terms of service"
+msgstr "Биржа ответитила с пустыми условиями использования"
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr "Скачать Условия использования"
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr "Скрыть Условия использования"
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, c-format
+msgid "Could not load exchange fees"
+msgstr "Не удалось загрузить комиссию за обмен"
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr "Закрыть"
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, c-format
+msgid "could not find any exchange"
+msgstr "Не удалось найти ни одного обменника"
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr "Не удалось найти ни одного обменника для валюты %1$s"
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr "Описание комиссии за услугу"
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, c-format
+msgid "Select %1$s exchange"
+msgstr "Выберите %1$s обменник"
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr "Сбросить"
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, c-format
+msgid "Use this exchange"
+msgstr "Использовать этот обменник"
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr "Не имеет аудиторов"
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr "валюта"
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr "Операции"
+
+#: src/wallet/ExchangeSelection/views.tsx:252
+#, c-format
+msgid "Deposits"
+msgstr "Депозиты"
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, c-format
+msgid "Denomination"
+msgstr "Деноминация"
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr "до"
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, c-format
+msgid "Withdrawals"
+msgstr "Выводы средств"
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr "Валюта"
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, c-format
+msgid "Coin operations"
+msgstr "Операции моент"
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and is "
+"valid for a period of time. The exchange will charge the indicated amount every "
+"time a coin is used in such operation."
+msgstr ""
+"Каждая операция в этом разделе может отличаться номиналом и действительна в "
+"течение определенного периода времени. Биржа будет взимать указанную сумму "
+"каждый раз, когда монета используется в такой операции."
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, c-format
+msgid "Transfer operations"
+msgstr "Операции переводов"
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is valid "
+"for a period of time. The exchange will charge the indicated amount every time a "
+"transfer is made."
+msgstr ""
+"Каждая операция в этом разделе может отличаться в зависимости от типа "
+"перевода и действительна в течение определенного периода времени. Обменник "
+"будет взимать указанную сумму каждый раз при совершении перевода."
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr "Операция"
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, c-format
+msgid "Wallet operations"
+msgstr "Операции кошелька"
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr "Возможность"
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr "Не удалось получить информацию из URI"
+
+#: src/cta/Withdraw/views.tsx:60
+#, c-format
+msgid "Could not get info of withdrawal"
+msgstr "Не удалось получить информацию о выводе средств"
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr "Вывод цифровых наличных"
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr "Ограничения возраста"
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr "Вывести &nbsp; %1$s"
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr "Вывести на мобильный телефон"
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr "Цифровой счёт-фактура"
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr "Создать"
+
+#: src/cta/InvoicePay/views.tsx:63
+#, c-format
+msgid "Could not finish the payment operation"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr ""
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will send "
+"the coins to this wallet after receiving a wire transfer with the correct "
+"subject."
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr "Добавить Обменник"
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr "Начать вывод"
+
+#: src/wallet/DepositPage/views.tsx:38
+#, c-format
+msgid "Could not load deposit balance"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr "Добавить Счёт"
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr "Выберете счёт"
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr "Добавить другой счёт"
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr "Комиссия депозита"
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr "Всего к депозиту"
+
+#: src/wallet/DepositPage/views.tsx:233
+#, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr "Выберете тип счёта"
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr "URL обменника"
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr "Добавить Обменник"
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr "Добавить новый Обменник"
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr "загрузка"
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr "Версия"
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr "Далее"
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr "Ожидание подтверждения"
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr "ОЖИДАЕТ"
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr "Добавить провайдера резервной копии"
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr "URL"
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr "Название"
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr "URL провайдера"
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr "комиссия за пополнение"
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr "Хранилище"
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid "To complete the process you need to wire%1$s %2$s to the exchange bank account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a banking "
+"app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, c-format
+msgid "Could not toggle auto-open"
+msgstr ""
+
+#: src/wallet/Settings.tsx:121
+#, c-format
+msgid "Could not toggle clipboard"
+msgstr ""
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr "Навигатор"
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr ""
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires more "
+"permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr "Доверять"
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr "Условия использования"
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr "ok"
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr "изменено"
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr "не принято"
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr ""
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr "Исправление проблем"
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr "Режим разработчика"
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr ""
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr "Отбражение"
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr "Расширение браузера"
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr "Разрешения"
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires more "
+"permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr "Следующий шаг"
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr "Попробовать демо"
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr "Узнайте как пополнить ваш баланс на кошельке"
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check the "
+"preference name %2$s)."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr "Инструменты отладки"
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL "
+"YOUR COINS?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr "сбросить"
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr "импортировать базу данных"
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr "экспортировать базу данных"
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr "Монеты"
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr "значение"
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr "статус"
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr "кликите чтобы показать"
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr "Открыть"
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, c-format
+msgid "Could not load list of exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
+#, c-format
+msgid "From my bank account"
+msgstr "Из моего банковского счёта"
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:449
+#, c-format
+msgid "currency not provided"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:459
+#, c-format
+msgid "Specify the amount and the destination"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:483
+#, c-format
+msgid "Use previous destinations:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:503
+#, c-format
+msgid "Or specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:511
+#, c-format
+msgid "Specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:521
+#, c-format
+msgid "To my bank account"
+msgstr "На мой банковский счёт"
+
+#: src/wallet/DestinationSelection.tsx:534
+#, c-format
+msgid "To another wallet"
+msgstr "На другой кошелек"
+
+#: src/cta/Recovery/views.tsx:30
+#, c-format
+msgid "Could not load backup recovery information"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:47
+#, c-format
+msgid "Digital wallet recovery"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:52
+#, c-format
+msgid "Import backup, show info"
+msgstr "Импорт резервной копии, отображение информации"
+
+#: src/wallet/Application.tsx:189
+#, c-format
+msgid "All done, your transaction is in progress"
+msgstr "Все готово, ваша транзакция выполняется"
+
+#: src/components/EditableText.tsx:45
+#, c-format
+msgid "Edit"
+msgstr "Изменить"
+
+#: src/wallet/ManualWithdrawPage.tsx:102
+#, c-format
+msgid "Could not load the list of known exchanges"
+msgstr "Не удалось загрузить список известных обменников"
diff --git a/packages/taler-wallet-webextension/src/mui/Button.tsx b/packages/taler-wallet-webextension/src/mui/Button.tsx
index 1af281d42..12a4d91ea 100644
--- a/packages/taler-wallet-webextension/src/mui/Button.tsx
+++ b/packages/taler-wallet-webextension/src/mui/Button.tsx
@@ -371,7 +371,11 @@ function ButtonBase({
);
}
return (
- <button onClick={doClick} class={classNames} {...rest}>
+ <button onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ doClick();
+ }} class={classNames} {...rest}>
{children}
</button>
);
diff --git a/packages/taler-wallet-webextension/src/platform/api.ts b/packages/taler-wallet-webextension/src/platform/api.ts
index e92903981..3c116fab2 100644
--- a/packages/taler-wallet-webextension/src/platform/api.ts
+++ b/packages/taler-wallet-webextension/src/platform/api.ts
@@ -18,11 +18,9 @@ import {
CoreApiResponse,
TalerUri,
WalletNotification,
- WalletRunConfig
+ WalletRunConfig,
} from "@gnu-taler/taler-util";
-import {
- WalletOperations
-} from "@gnu-taler/taler-wallet-core";
+import { WalletOperations } from "@gnu-taler/taler-wallet-core";
import {
ExtensionOperations,
MessageFromExtension,
@@ -46,11 +44,9 @@ export interface Permissions {
* Compatibility API that works on multiple browsers.
*/
export interface CrossBrowserPermissionsApi {
-
containsClipboardPermissions(): Promise<boolean>;
requestClipboardPermissions(): Promise<boolean>;
removeClipboardPermissions(): Promise<boolean>;
-
}
export enum ExtensionNotificationType {
@@ -67,25 +63,29 @@ export interface ClearNotificaitonNotification {
type: ExtensionNotificationType.ClearNotifications;
}
-export type ExtensionNotification = SettingsChangeNotification | ClearNotificaitonNotification
+export type ExtensionNotification =
+ | SettingsChangeNotification
+ | ClearNotificaitonNotification;
-export type MessageFromBackend = {
- type: "wallet",
- notification: WalletNotification
-} | {
- type: "web-extension",
- notification: ExtensionNotification
-};
+export type MessageFromBackend =
+ | {
+ type: "wallet";
+ notification: WalletNotification;
+ }
+ | {
+ type: "web-extension";
+ notification: ExtensionNotification;
+ };
export type MessageFromFrontend<
Op extends BackgroundOperations | WalletOperations | ExtensionOperations,
> = Op extends BackgroundOperations
? MessageFromFrontendBackground<keyof BackgroundOperations>
: Op extends ExtensionOperations
- ? MessageFromExtension<keyof ExtensionOperations>
- : Op extends WalletOperations
- ? MessageFromFrontendWallet<keyof WalletOperations>
- : never;
+ ? MessageFromExtension<keyof ExtensionOperations>
+ : Op extends WalletOperations
+ ? MessageFromFrontendWallet<keyof WalletOperations>
+ : never;
export type MessageFromFrontendBackground<
Op extends keyof BackgroundOperations,
@@ -109,7 +109,6 @@ export interface WalletWebExVersion {
}
type F = WalletRunConfig["features"];
-type kf = keyof F;
type WebexWalletConfig = {
[P in keyof F as `wallet${Capitalize<P>}`]: F[P];
};
@@ -231,7 +230,13 @@ export interface BackgroundPlatformAPI {
) => Promise<MessageResponse>,
): void;
+ /**
+ * Change web extension Icon
+ */
+ setAlertedIcon(): void;
+ setNormalIcon(): void;
}
+
export interface ForegroundPlatformAPI {
/**
* Check if the extension is running under
@@ -270,7 +275,7 @@ export interface ForegroundPlatformAPI {
/**
* Open a page and close the popup
- * @param url
+ * @param url
*/
openNewURLFromPopup(url: URL): void;
/**
@@ -309,9 +314,9 @@ export interface ForegroundPlatformAPI {
): Promise<MessageResponse>;
/**
- * Used by the wallet frontend to send notification about new information
- * @param message
- */
+ * Used by the wallet frontend to send notification about new information
+ * @param message
+ */
triggerWalletEvent(message: MessageFromBackend): void;
/**
diff --git a/packages/taler-wallet-webextension/src/platform/background.ts b/packages/taler-wallet-webextension/src/platform/background.ts
index 9f3764c25..13808af2b 100644
--- a/packages/taler-wallet-webextension/src/platform/background.ts
+++ b/packages/taler-wallet-webextension/src/platform/background.ts
@@ -16,7 +16,8 @@
import { BackgroundPlatformAPI } from "./api.js";
-export let platform: BackgroundPlatformAPI = undefined as any;
+// it should never be undefined :)
+export let platform: BackgroundPlatformAPI = undefined!;
export function setupPlatform(impl: BackgroundPlatformAPI): void {
platform = impl;
}
diff --git a/packages/taler-wallet-webextension/src/platform/chrome.ts b/packages/taler-wallet-webextension/src/platform/chrome.ts
index 6c5510eb6..e63040f5c 100644
--- a/packages/taler-wallet-webextension/src/platform/chrome.ts
+++ b/packages/taler-wallet-webextension/src/platform/chrome.ts
@@ -53,7 +53,7 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = {
redirectTabToWalletPage,
registerAllIncomingConnections,
registerOnInstalled,
- listenToAllChannels: listenToAllChannels as any,
+ listenToAllChannels ,
registerReloadOnNewVersion,
sendMessageToAllChannels,
openNewURLFromPopup,
@@ -61,6 +61,8 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = {
useServiceWorkerAsBackgroundProcess,
keepAlive,
listenNetworkConnectionState,
+ setAlertedIcon,
+ setNormalIcon,
};
export default api;
@@ -69,7 +71,7 @@ const logger = new Logger("chrome.ts");
const WALLET_STORAGE_KEY = "wallet-settings";
-function jsonParseOrDefault(unparsed: any, def: any) {
+function jsonParseOrDefault(unparsed: string, def: unknown) {
if (!unparsed) return def;
try {
return JSON.parse(unparsed);
@@ -85,7 +87,7 @@ async function getSettingsFromStorage(): Promise<Settings> {
return jsonParseOrDefault(settings, defaultSettings);
}
-function keepAlive(callback: any): void {
+function keepAlive(callback: () => void): void {
if (extensionIsManifestV3()) {
chrome.alarms.create("wallet-worker", { periodInMinutes: 1 });
@@ -103,7 +105,7 @@ function isFirefox(): boolean {
}
export function containsClipboardPermissions(): Promise<boolean> {
- return new Promise((res, rej) => {
+ return new Promise((res) => {
res(false);
// chrome.permissions.contains({ permissions: ["clipboardRead"] }, (resp) => {
// const le = chrome.runtime.lastError?.message;
@@ -116,7 +118,7 @@ export function containsClipboardPermissions(): Promise<boolean> {
}
export async function requestClipboardPermissions(): Promise<boolean> {
- return new Promise((res, rej) => {
+ return new Promise((res) => {
res(false);
// chrome.permissions.request({ permissions: ["clipboardRead"] }, (resp) => {
// const le = chrome.runtime.lastError?.message;
@@ -129,7 +131,7 @@ export async function requestClipboardPermissions(): Promise<boolean> {
}
export function removeClipboardPermissions(): Promise<boolean> {
- return new Promise((res, rej) => {
+ return new Promise((res) => {
res(true);
// chrome.permissions.remove({ permissions: ["clipboardRead"] }, (resp) => {
// const le = chrome.runtime.lastError?.message;
@@ -154,7 +156,7 @@ function getPermissionsApi(): CrossBrowserPermissionsApi {
* @param callback function to be called
*/
function notifyWhenAppIsReady(): Promise<void> {
- return new Promise((resolve, reject) => {
+ return new Promise((resolve) => {
if (extensionIsManifestV3()) {
resolve();
} else {
@@ -276,7 +278,7 @@ async function sendMessageToBackground<
nextMessageIndex = (nextMessageIndex + 1) % (Number.MAX_SAFE_INTEGER - 100);
const messageWithId = { ...message, id: `id_${nextMessageIndex}` };
- return new Promise<any>((resolve, reject) => {
+ return new Promise<MessageResponse>((resolve, reject) => {
logger.trace("send operation to the wallet background", message);
let timedout = false;
const timerId = setTimeout(() => {
@@ -307,7 +309,7 @@ async function sendMessageToBackground<
* To be used by the foreground
*/
let notificationPort: chrome.runtime.Port | undefined;
-function listenToWalletBackground(listener: (m: any) => void): () => void {
+function listenToWalletBackground(listener: (message: MessageFromBackend) => void): () => void {
if (notificationPort === undefined) {
notificationPort = chrome.runtime.connect({ name: "notifications" });
}
@@ -369,7 +371,7 @@ function registerAllIncomingConnections(): void {
notification: {
type: ExtensionNotificationType.SettingsChange,
currentValue: jsonParseOrDefault(
- event[WALLET_STORAGE_KEY],
+ event[WALLET_STORAGE_KEY].newValue,
defaultSettings,
),
},
@@ -415,12 +417,12 @@ function registerReloadOnNewVersion(): void {
});
}
-async function redirectCurrentTabToWalletPage(page: string): Promise<void> {
- let queryOptions = { active: true, lastFocusedWindow: true };
- let [tab] = await chrome.tabs.query(queryOptions);
+// async function redirectCurrentTabToWalletPage(page: string): Promise<void> {
+// let queryOptions = { active: true, lastFocusedWindow: true };
+// let [tab] = await chrome.tabs.query(queryOptions);
- return redirectTabToWalletPage(tab.id!, page);
-}
+// return redirectTabToWalletPage(tab.id!, page);
+// }
async function redirectTabToWalletPage(
tabId: number,
@@ -666,7 +668,7 @@ async function findTalerUriInTab(tabId: number): Promise<string | undefined> {
return;
}
} else {
- return new Promise((resolve, reject) => {
+ return new Promise((resolve) => {
//manifest v2
chrome.tabs.executeScript(
tabId,
@@ -692,9 +694,9 @@ async function findTalerUriInTab(tabId: number): Promise<string | undefined> {
}
}
-async function timeout(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
+// async function timeout(ms: number): Promise<void> {
+// return new Promise((resolve) => setTimeout(resolve, ms));
+// }
async function findTalerUriInClipboard(): Promise<string | undefined> {
//FIXME: add clipboard feature
// try {
diff --git a/packages/taler-wallet-webextension/src/platform/dev.ts b/packages/taler-wallet-webextension/src/platform/dev.ts
index 1e43476ea..d6e743147 100644
--- a/packages/taler-wallet-webextension/src/platform/dev.ts
+++ b/packages/taler-wallet-webextension/src/platform/dev.ts
@@ -38,6 +38,8 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = {
listenNetworkConnectionState,
openNewURLFromPopup: () => undefined,
triggerWalletEvent: () => undefined,
+ setAlertedIcon: () => undefined,
+ setNormalIcon : () => undefined,
getPermissionsApi: () => ({
containsClipboardPermissions: async () => true,
removeClipboardPermissions: async () => false,
diff --git a/packages/taler-wallet-webextension/src/svg/search_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/search_24px.inline.svg
new file mode 100644
index 000000000..d880cbf0f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/search_24px.inline.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" height="24" width="24">
+ <path fill-rule="evenodd" d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z" clip-rule="evenodd" />
+</svg>
+
diff --git a/packages/taler-wallet-webextension/src/wallet/Application.tsx b/packages/taler-wallet-webextension/src/wallet/Application.tsx
index 5c31701e2..884c2eab7 100644
--- a/packages/taler-wallet-webextension/src/wallet/Application.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Application.tsx
@@ -157,7 +157,7 @@ export function Application(): VNode {
)}
/>
- <Route
+<Route
path={Pages.balanceHistory.pattern}
component={({ currency }: { currency?: string }) => (
<WalletTemplate path="balance" goToTransaction={redirectToTxInfo} goToURL={redirectToURL}>
@@ -178,6 +178,27 @@ export function Application(): VNode {
)}
/>
<Route
+ path={Pages.searchHistory.pattern}
+ component={({ currency }: { currency?: string }) => (
+ <WalletTemplate path="balance" goToTransaction={redirectToTxInfo} goToURL={redirectToURL}>
+ <HistoryPage
+ currency={currency}
+ search
+ goToWalletDeposit={(currency: string) =>
+ redirectTo(Pages.sendCash({ amount: `${currency}:0` }))
+ }
+ goToWalletManualWithdraw={(currency?: string) =>
+ redirectTo(
+ Pages.receiveCash({
+ amount: !currency ? undefined : `${currency}:0`,
+ }),
+ )
+ }
+ />
+ </WalletTemplate>
+ )}
+ />
+ <Route
path={Pages.sendCash.pattern}
component={({ amount }: { amount?: string }) => (
<WalletTemplate path="balance" goToURL={redirectToURL}>
@@ -568,17 +589,17 @@ function Redirect({ to }: { to: string }): null {
return null;
}
-function matchesRoute(url: string, route: string): boolean {
- type MatcherFunc = (
- url: string,
- route: string,
- opts: any,
- ) => Record<string, string> | false;
+// function matchesRoute(url: string, route: string): boolean {
+// type MatcherFunc = (
+// url: string,
+// route: string,
+// opts: any,
+// ) => Record<string, string> | false;
- const internalPreactMatcher: MatcherFunc = (Router as any).exec;
- const result = internalPreactMatcher(url, route, {});
- return !result ? false : true;
-}
+// const internalPreactMatcher: MatcherFunc = (Router as any).exec;
+// const result = internalPreactMatcher(url, route, {});
+// return !result ? false : true;
+// }
function CallToActionTemplate({
title,
diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx
index c40a3a64c..8a74a20f1 100644
--- a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx
@@ -169,7 +169,9 @@ const CircleDiv = styled.div`
text-align: center;
text-decoration: none;
text-transform: uppercase;
- transition: background-color 0.15s ease, border-color 0.15s ease,
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease,
color 0.15s ease;
font-size: 16px;
background-color: #86a7bd1a;
@@ -275,6 +277,16 @@ export function ReadyGetView({
</Button>
</Paper>
</Grid>
+ <Grid item xs={1}>
+ <Paper style={{ padding: 8 }}>
+ <p>
+ <i18n.Translate>From a <pre style={{display:"inline"}}>taler://peer-push-credit</pre> URI</i18n.Translate>
+ </p>
+ <a href={Pages.qr}>
+ <i18n.Translate>Enter URI here</i18n.Translate>
+ </a>
+ </Paper>
+ </Grid>
</Grid>
</Grid>
</Container>
diff --git a/packages/taler-wallet-webextension/src/wallet/History.stories.tsx b/packages/taler-wallet-webextension/src/wallet/History.stories.tsx
index c28e4188f..482b8d698 100644
--- a/packages/taler-wallet-webextension/src/wallet/History.stories.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/History.stories.tsx
@@ -49,17 +49,17 @@ export default {
let count = 0;
const commonTransaction = (): TransactionCommon =>
-({
- amountRaw: "USD:10",
- amountEffective: "USD:9",
- txState: {
- major: TransactionMajorState.Done,
- },
- timestamp: TalerProtocolTimestamp.fromSeconds(
- new Date().getTime() / 1000 - count++ * 60 * 60 * 7,
- ),
- transactionId: String(count),
-} as TransactionCommon);
+ ({
+ amountRaw: "USD:10",
+ amountEffective: "USD:9",
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ timestamp: TalerProtocolTimestamp.fromSeconds(
+ new Date().getTime() / 1000 - count++ * 60 * 60 * 7,
+ ),
+ transactionId: String(count),
+ }) as TransactionCommon;
const exampleData = {
withdraw: {
@@ -165,7 +165,9 @@ const exampleData = {
export const SomeBalanceWithNoTransactions = tests.createExample(
TestedComponent,
{
- transactions: [],
+ transactionsByDate: {
+ "11/11/11": [],
+ },
balances: [
{
available: "TESTKUDOS:10" as AmountString,
@@ -186,7 +188,9 @@ export const SomeBalanceWithNoTransactions = tests.createExample(
);
export const OneSimpleTransaction = tests.createExample(TestedComponent, {
- transactions: [exampleData.withdraw],
+ transactionsByDate: {
+ "11/11/11": [exampleData.withdraw],
+ },
balances: [
{
flags: [],
@@ -203,13 +207,14 @@ export const OneSimpleTransaction = tests.createExample(TestedComponent, {
},
],
balanceIndex: 0,
-
});
export const TwoTransactionsAndZeroBalance = tests.createExample(
TestedComponent,
{
- transactions: [exampleData.withdraw, exampleData.deposit],
+ transactionsByDate: {
+ "11/11/11": [exampleData.withdraw, exampleData.deposit],
+ },
balances: [
{
flags: [],
@@ -230,14 +235,16 @@ export const TwoTransactionsAndZeroBalance = tests.createExample(
);
export const OneTransactionPending = tests.createExample(TestedComponent, {
- transactions: [
- {
- ...exampleData.withdraw,
- txState: {
- major: TransactionMajorState.Pending,
+ transactionsByDate: {
+ "11/11/11": [
+ {
+ ...exampleData.withdraw,
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
},
- },
- ],
+ ],
+ },
balances: [
{
flags: [],
@@ -257,22 +264,24 @@ export const OneTransactionPending = tests.createExample(TestedComponent, {
});
export const SomeTransactions = tests.createExample(TestedComponent, {
- transactions: [
- exampleData.withdraw,
- exampleData.payment,
- exampleData.withdraw,
- exampleData.payment,
- {
- ...exampleData.payment,
- info: {
- ...exampleData.payment.info,
- summary:
- "this is a long summary that may be cropped because its too long",
+ transactionsByDate: {
+ "11/11/11": [
+ exampleData.withdraw,
+ exampleData.payment,
+ exampleData.withdraw,
+ exampleData.payment,
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary:
+ "this is a long summary that may be cropped because its too long",
+ },
},
- },
- exampleData.refund,
- exampleData.deposit,
- ],
+ exampleData.refund,
+ exampleData.deposit,
+ ],
+ },
balances: [
{
flags: [],
@@ -294,79 +303,81 @@ export const SomeTransactions = tests.createExample(TestedComponent, {
export const SomeTransactionsInDifferentStates = tests.createExample(
TestedComponent,
{
- transactions: [
- exampleData.withdraw,
- {
- ...exampleData.withdraw,
- exchangeBaseUrl: "https://aborted/withdrawal",
- txState: {
- major: TransactionMajorState.Aborted,
+ transactionsByDate: {
+ "11/11/11": [
+ exampleData.withdraw,
+ {
+ ...exampleData.withdraw,
+ exchangeBaseUrl: "https://aborted/withdrawal",
+ txState: {
+ major: TransactionMajorState.Aborted,
+ },
},
- },
- {
- ...exampleData.withdraw,
- exchangeBaseUrl: "https://pending/withdrawal",
- txState: {
- major: TransactionMajorState.Pending,
+ {
+ ...exampleData.withdraw,
+ exchangeBaseUrl: "https://pending/withdrawal",
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
},
- },
- {
- ...exampleData.withdraw,
- exchangeBaseUrl: "https://failed/withdrawal",
- txState: {
- major: TransactionMajorState.Failed,
+ {
+ ...exampleData.withdraw,
+ exchangeBaseUrl: "https://failed/withdrawal",
+ txState: {
+ major: TransactionMajorState.Failed,
+ },
},
- },
- {
- ...exampleData.payment,
- info: {
- ...exampleData.payment.info,
- summary: "normal payment",
- },
- },
- {
- ...exampleData.payment,
- info: {
- ...exampleData.payment.info,
- summary: "aborting in progress",
- },
- txState: {
- major: TransactionMajorState.Aborting,
- },
- },
- {
- ...exampleData.payment,
- info: {
- ...exampleData.payment.info,
- summary: "aborted payment",
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "normal payment",
+ },
},
- txState: {
- major: TransactionMajorState.Aborted,
- },
- },
- {
- ...exampleData.payment,
- info: {
- ...exampleData.payment.info,
- summary: "pending payment",
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "aborting in progress",
+ },
+ txState: {
+ major: TransactionMajorState.Aborting,
+ },
},
- txState: {
- major: TransactionMajorState.Pending,
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "aborted payment",
+ },
+ txState: {
+ major: TransactionMajorState.Aborted,
+ },
},
- },
- {
- ...exampleData.payment,
- info: {
- ...exampleData.payment.info,
- summary: "failed payment",
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "pending payment",
+ },
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
},
- txState: {
- major: TransactionMajorState.Failed,
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "failed payment",
+ },
+ txState: {
+ major: TransactionMajorState.Failed,
+ },
},
- },
- exampleData.refund,
- exampleData.deposit,
- ],
+ exampleData.refund,
+ exampleData.deposit,
+ ],
+ },
balances: [
{
flags: [],
@@ -389,15 +400,17 @@ export const SomeTransactionsInDifferentStates = tests.createExample(
export const SomeTransactionsWithTwoCurrencies = tests.createExample(
TestedComponent,
{
- transactions: [
- exampleData.withdraw,
- exampleData.payment,
- exampleData.withdraw,
- exampleData.payment,
- exampleData.refresh,
- exampleData.refund,
- exampleData.deposit,
- ],
+ transactionsByDate: {
+ "11/11/11": [
+ exampleData.withdraw,
+ exampleData.payment,
+ exampleData.withdraw,
+ exampleData.payment,
+ exampleData.refresh,
+ exampleData.refund,
+ exampleData.deposit,
+ ],
+ },
balances: [
{
flags: [],
@@ -431,7 +444,9 @@ export const SomeTransactionsWithTwoCurrencies = tests.createExample(
);
export const FiveOfficialCurrencies = tests.createExample(TestedComponent, {
- transactions: [exampleData.withdraw],
+ transactionsByDate: {
+ "11/11/11": [exampleData.withdraw],
+ },
balances: [
{
flags: [],
@@ -505,7 +520,9 @@ export const FiveOfficialCurrencies = tests.createExample(TestedComponent, {
export const FiveOfficialCurrenciesWithHighValue = tests.createExample(
TestedComponent,
{
- transactions: [exampleData.withdraw],
+ transactionsByDate: {
+ "11/11/11": [exampleData.withdraw],
+ },
balances: [
{
flags: [],
@@ -578,12 +595,14 @@ export const FiveOfficialCurrenciesWithHighValue = tests.createExample(
);
export const PeerToPeer = tests.createExample(TestedComponent, {
- transactions: [
- exampleData.pull_credit,
- exampleData.pull_debit,
- exampleData.push_credit,
- exampleData.push_debit,
- ],
+ transactionsByDate: {
+ "11/11/11": [
+ exampleData.pull_credit,
+ exampleData.pull_debit,
+ exampleData.push_credit,
+ exampleData.push_debit,
+ ],
+ },
balances: [
{
flags: [],
diff --git a/packages/taler-wallet-webextension/src/wallet/History.tsx b/packages/taler-wallet-webextension/src/wallet/History.tsx
index fcd21a5ee..f81e6db9f 100644
--- a/packages/taler-wallet-webextension/src/wallet/History.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/History.tsx
@@ -35,7 +35,7 @@ import {
CenteredBoldText,
CenteredText,
DateSeparator,
- NiceSelect
+ NiceSelect,
} from "../components/styled/index.js";
import { alertFromError, useAlertContext } from "../context/alert.js";
import { useBackendContext } from "../context/backend.js";
@@ -45,32 +45,39 @@ import { Button } from "../mui/Button.js";
import { NoBalanceHelp } from "../popup/NoBalanceHelp.js";
import DownloadIcon from "../svg/download_24px.inline.svg";
import UploadIcon from "../svg/upload_24px.inline.svg";
+import { TextField } from "../mui/TextField.js";
+import { TextFieldHandler } from "../mui/handlers.js";
interface Props {
currency?: string;
+ search?: boolean;
goToWalletDeposit: (currency: string) => Promise<void>;
goToWalletManualWithdraw: (currency?: string) => Promise<void>;
}
export function HistoryPage({
currency: _c,
+ search: showSearch,
goToWalletManualWithdraw,
goToWalletDeposit,
}: Props): VNode {
const { i18n } = useTranslationContext();
const api = useBackendContext();
const [balanceIndex, setBalanceIndex] = useState<number>(0);
+ const [search, setSearch] = useState<string>();
+
const [settings] = useSettings();
const state = useAsyncAsHook(async () => {
const b = await api.wallet.call(WalletApiOperation.GetBalances, {});
const balance =
b.balances.length > 0 ? b.balances[balanceIndex] : undefined;
const tx = await api.wallet.call(WalletApiOperation.GetTransactions, {
- scopeInfo: balance?.scopeInfo,
+ scopeInfo: showSearch ? undefined : balance?.scopeInfo,
sort: "descending",
includeRefreshes: settings.showRefeshTransactions,
+ search,
});
return { b, tx };
- }, [balanceIndex]);
+ }, [balanceIndex, search]);
useEffect(() => {
return api.listener.onUpdateNotification(
@@ -105,6 +112,40 @@ export function HistoryPage({
/>
);
}
+
+ const byDate = state.response.tx.transactions.reduce(
+ (rv, x) => {
+ const startDay =
+ x.timestamp.t_s === "never"
+ ? 0
+ : startOfDay(x.timestamp.t_s * 1000).getTime();
+ if (startDay) {
+ if (!rv[startDay]) {
+ rv[startDay] = [];
+ // datesWithTransaction.push(String(startDay));
+ }
+ rv[startDay].push(x);
+ }
+
+ return rv;
+ },
+ {} as { [x: string]: Transaction[] },
+ );
+
+ if (showSearch) {
+ return (
+ <FilteredHistoryView
+ search={{
+ value: search ?? "",
+ onInput: pushAlertOnError(async (d: string) => {
+ setSearch(d);
+ }),
+ }}
+ transactionsByDate={byDate}
+ />
+ );
+ }
+
return (
<HistoryView
balanceIndex={balanceIndex}
@@ -112,7 +153,7 @@ export function HistoryPage({
balances={state.response.b.balances}
goToWalletManualWithdraw={goToWalletManualWithdraw}
goToWalletDeposit={goToWalletDeposit}
- transactions={state.response.tx.transactions}
+ transactionsByDate={byDate}
/>
);
}
@@ -121,7 +162,7 @@ export function HistoryView({
balances,
balanceIndex,
changeBalanceIndex,
- transactions,
+ transactionsByDate,
goToWalletManualWithdraw,
goToWalletDeposit,
}: {
@@ -129,7 +170,7 @@ export function HistoryView({
changeBalanceIndex: (s: number) => void;
goToWalletDeposit: (currency: string) => Promise<void>;
goToWalletManualWithdraw: (currency?: string) => Promise<void>;
- transactions: Transaction[];
+ transactionsByDate: Record<string, Transaction[]>;
balances: WalletBalance[];
}): VNode {
const { i18n } = useTranslationContext();
@@ -140,25 +181,7 @@ export function HistoryView({
? Amounts.jsonifyAmount(balance.available)
: undefined;
- const datesWithTransaction: string[] = [];
- const byDate = transactions.reduce(
- (rv, x) => {
- const startDay =
- x.timestamp.t_s === "never"
- ? 0
- : startOfDay(x.timestamp.t_s * 1000).getTime();
- if (startDay) {
- if (!rv[startDay]) {
- rv[startDay] = [];
- datesWithTransaction.push(String(startDay));
- }
- rv[startDay].push(x);
- }
-
- return rv;
- },
- {} as { [x: string]: Transaction[] },
- );
+ const datesWithTransaction: string[] = Object.keys(transactionsByDate);
return (
<Fragment>
@@ -195,8 +218,8 @@ export function HistoryView({
</Button>
)}
</div>
- <div style={{display:"flex", flexDirection:"column"}}>
- <h3 style={{marginBottom: 0}}>Balance</h3>
+ <div style={{ display: "flex", flexDirection: "column" }}>
+ <h3 style={{ marginBottom: 0 }}>Balance</h3>
<div
style={{
width: "fit-content",
@@ -270,7 +293,72 @@ export function HistoryView({
format="dd MMMM yyyy"
/>
</DateSeparator>
- {byDate[d].map((tx, i) => (
+ {transactionsByDate[d].map((tx, i) => (
+ <HistoryItem key={i} tx={tx} />
+ ))}
+ </Fragment>
+ );
+ })}
+ </section>
+ )}
+ </Fragment>
+ );
+}
+
+export function FilteredHistoryView({
+ search,
+ transactionsByDate,
+}: {
+ search: TextFieldHandler;
+ transactionsByDate: Record<string, Transaction[]>;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ const datesWithTransaction: string[] = Object.keys(transactionsByDate);
+
+ return (
+ <Fragment>
+ <section>
+ <div
+ style={{
+ display: "flex",
+ flexWrap: "wrap",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginRight: 20,
+ }}
+ >
+ <TextField
+ label="Search"
+ variant="filled"
+ error={search.error}
+ required
+ fullWidth
+ value={search.value}
+ onChange={search.onInput}
+ />
+ </div>
+ </section>
+ {datesWithTransaction.length === 0 ? (
+ <section>
+ <i18n.Translate>
+ Your transaction history is empty for this currency.
+ </i18n.Translate>
+ </section>
+ ) : (
+ <section>
+ {datesWithTransaction.map((d, i) => {
+ return (
+ <Fragment key={i}>
+ <DateSeparator>
+ <Time
+ timestamp={AbsoluteTime.fromMilliseconds(
+ Number.parseInt(d, 10),
+ )}
+ format="dd MMMM yyyy"
+ />
+ </DateSeparator>
+ {transactionsByDate[d].map((tx, i) => (
<HistoryItem key={i} tx={tx} />
))}
</Fragment>
diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx
index d36524cc4..a01ea6967 100644
--- a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx
@@ -307,7 +307,7 @@ export function QrReaderPage({ onDetected }: Props): VNode {
<div style={{ width: "75%" }}>
<TextField
label="Taler URI"
- variant="standard"
+ variant="filled"
fullWidth
value={value}
onChange={onChange}
diff --git a/packages/taler-wallet-webextension/src/wallet/Settings.tsx b/packages/taler-wallet-webextension/src/wallet/Settings.tsx
index 34dd24cea..0d0a31a2d 100644
--- a/packages/taler-wallet-webextension/src/wallet/Settings.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Settings.tsx
@@ -159,7 +159,7 @@ export function SettingsView({
<WarningBox>
<i18n.Translate>
The version of wallet core is not supported. (supported
- version: {WALLET_CORE_SUPPORTED_VERSION})
+ version: {WALLET_CORE_SUPPORTED_VERSION}, wallet version: {coreVersion.version})
</i18n.Translate>
</WarningBox>
)}
@@ -274,6 +274,7 @@ function AdvanceSettings(): VNode {
<Checkbox
label={label}
name={name}
+ key={name}
description={description}
enabled={settings[settingsName]}
onToggle={async () => {
diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx
index 27c9b5d77..1f0293352 100644
--- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx
@@ -242,53 +242,56 @@ function TransactionTemplate({
)}
/>
) : undefined}
- {transaction.txState.minor === TransactionMinorState.KycRequired && (
- <AlertView
- alert={{
- type: "warning",
- message: i18n.str`KYC check required for the transaction to complete.`,
- description:
- transaction.kycUrl && typeof transaction.kycUrl === "string" ? (
- <div>
- <i18n.Translate>
- Follow this link to the{` `}
- <a
- rel="noreferrer"
- target="_bank"
- href={transaction.kycUrl}
- >
- KYC verifier.
- </a>
- </i18n.Translate>
- </div>
- ) : (
- i18n.str`No additional information has been provided.`
- ),
- }}
- />
- )}
- {transaction.txState.minor === TransactionMinorState.AmlRequired && (
- <WarningBox>
- <i18n.Translate>
- The transaction has been blocked since the account required an AML
- check.
- </i18n.Translate>
- </WarningBox>
- )}
- {transaction.txState.major === TransactionMajorState.Pending && (
- <WarningBox>
- <div style={{ justifyContent: "center", lineHeight: "25px" }}>
- <i18n.Translate>This transaction is not completed</i18n.Translate>
- <Link onClick={onRetry} style={{ padding: 0 }}>
- <SvgIcon
- title={i18n.str`Retry`}
- dangerouslySetInnerHTML={{ __html: refreshIcon }}
- color="black"
- />
- </Link>
- </div>
- </WarningBox>
- )}
+ {transaction.txState.major === TransactionMajorState.Pending &&
+ (transaction.txState.minor === TransactionMinorState.KycRequired ? (
+ <AlertView
+ alert={{
+ type: "warning",
+ message: i18n.str`KYC check required for the transaction to complete.`,
+ description:
+ transaction.kycUrl &&
+ typeof transaction.kycUrl === "string" ? (
+ <div>
+ <i18n.Translate>
+ Follow this link to the{` `}
+ <a
+ rel="noreferrer"
+ target="_bank"
+ href={transaction.kycUrl}
+ >
+ KYC verifier.
+ </a>
+ </i18n.Translate>
+ </div>
+ ) : (
+ i18n.str`No additional information has been provided.`
+ ),
+ }}
+ />
+ ) : transaction.txState.minor ===
+ TransactionMinorState.AmlRequired ? (
+ <WarningBox>
+ <i18n.Translate>
+ The transaction has been blocked since the account required an
+ AML check.
+ </i18n.Translate>
+ </WarningBox>
+ ) : (
+ <WarningBox>
+ <div style={{ justifyContent: "center", lineHeight: "25px" }}>
+ <i18n.Translate>
+ This transaction is not completed
+ </i18n.Translate>
+ <Link onClick={onRetry} style={{ padding: 0 }}>
+ <SvgIcon
+ title={i18n.str`Retry`}
+ dangerouslySetInnerHTML={{ __html: refreshIcon }}
+ color="black"
+ />
+ </Link>
+ </div>
+ </WarningBox>
+ ))}
{transaction.txState.major === TransactionMajorState.Aborted && (
<InfoBox>
<i18n.Translate>This transaction was aborted.</i18n.Translate>
@@ -1139,9 +1142,6 @@ export function TransactionView({
if (transaction.type === TransactionType.Recoup) {
throw Error("recoup transaction not implemented");
}
- if (transaction.type === TransactionType.Reward) {
- throw Error("recoup transaction not implemented");
- }
assertUnreachable(transaction);
}
diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts
index 495f015ff..4394a982f 100644
--- a/packages/taler-wallet-webextension/src/wxApi.ts
+++ b/packages/taler-wallet-webextension/src/wxApi.ts
@@ -31,8 +31,7 @@ import {
TalerError,
TalerErrorCode,
TalerErrorDetail,
- WalletDiagnostics,
- WalletNotification,
+ WalletNotification
} from "@gnu-taler/taler-util";
import {
WalletCoreApiClient,
@@ -47,6 +46,7 @@ import {
MessageFromFrontendWallet,
} from "./platform/api.js";
import { platform } from "./platform/foreground.js";
+import { WalletActivityTrack } from "./wxBackend.js";
/**
*
@@ -55,7 +55,7 @@ import { platform } from "./platform/foreground.js";
const logger = new Logger("wxApi");
-export const WALLET_CORE_SUPPORTED_VERSION = "1:0:0"
+export const WALLET_CORE_SUPPORTED_VERSION = "4:0:0"
export interface ExtendedPermissionsResponse {
newValue: boolean;
@@ -75,8 +75,10 @@ export interface BackgroundOperations {
response: void;
};
getNotifications: {
- request: void;
- response: WalletEvent[];
+ request: {
+ filter: string;
+ };
+ response: WalletActivityTrack[];
};
clearNotifications: {
request: void;
diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts
index a78e85aba..5fa255f5d 100644
--- a/packages/taler-wallet-webextension/src/wxBackend.ts
+++ b/packages/taler-wallet-webextension/src/wxBackend.ts
@@ -27,11 +27,14 @@ import {
AbsoluteTime,
LogLevel,
Logger,
+ NotificationType,
OpenedPromise,
SetTimeoutTimerAPI,
TalerError,
TalerErrorCode,
TalerErrorDetail,
+ TransactionMinorState,
+ WalletNotification,
getErrorDetailFromException,
makeErrorDetail,
openPromise,
@@ -43,17 +46,18 @@ import {
DbAccess,
SynchronousCryptoWorkerFactoryPlain,
Wallet,
+ WalletApiOperation,
WalletOperations,
WalletStoresV1,
deleteTalerDatabase,
exportDb,
importDb,
} from "@gnu-taler/taler-wallet-core";
+import { BrowserFetchHttpLib } from "@gnu-taler/web-util/browser";
import { MessageFromFrontend, MessageResponse } from "./platform/api.js";
import { platform } from "./platform/background.js";
import { ExtensionOperations } from "./taler-wallet-interaction-loader.js";
-import { BackgroundOperations, WalletEvent } from "./wxApi.js";
-import { BrowserFetchHttpLib } from "@gnu-taler/web-util/browser";
+import { BackgroundOperations } from "./wxApi.js";
/**
* Currently active wallet instance. Might be unloaded and
@@ -65,11 +69,6 @@ let currentWallet: Wallet | undefined;
let currentDatabase: DbAccess<typeof WalletStoresV1> | undefined;
-/**
- * Last version of an outdated DB, if applicable.
- */
-let outdatedDbVersion: number | undefined;
-
const walletInit: OpenedPromise<void> = openPromise<void>();
const logger = new Logger("wxBackend.ts");
@@ -91,16 +90,163 @@ async function resetDb(): Promise<void> {
await reinitWallet();
}
+export type WalletActivityTrack = {
+ id: number;
+ events: (WalletNotification & {when: AbsoluteTime})[];
+ start: AbsoluteTime;
+ type: NotificationType;
+ end: AbsoluteTime;
+ groupId: string;
+};
+
+let counter = 0;
+function getUniqueId(): number {
+ return counter++;
+}
+
//FIXME: maybe circular buffer
-const notifications: WalletEvent[] = []
-async function getNotifications(): Promise<WalletEvent[]> {
- return notifications
+const activity: WalletActivityTrack[] = [];
+
+function addNewWalletActivityNotification(list: WalletActivityTrack[], n: WalletNotification) {
+ const start = AbsoluteTime.now();
+ const ev = {...n, when:start};
+ switch (n.type) {
+ case NotificationType.BalanceChange: {
+ const groupId = `${n.type}:${n.hintTransactionId}`;
+ const found = list.find((a)=>a.groupId === groupId)
+ if (found) {
+ found.end = start;
+ found.events.unshift(ev)
+ return;
+ }
+ list.push({
+ id: getUniqueId(),
+ type: n.type,
+ start,
+ end: AbsoluteTime.never(),
+ events: [ev],
+ groupId,
+ });
+ return;
+ }
+ case NotificationType.BackupOperationError: {
+ const groupId = "";
+ list.push({
+ id: getUniqueId(),
+ type: n.type,
+ start,
+ end: AbsoluteTime.never(),
+ events: [ev],
+ groupId,
+ });
+ return;
+ }
+ case NotificationType.TransactionStateTransition: {
+ const groupId = `${n.type}:${n.transactionId}`;
+ const found = list.find((a)=>a.groupId === groupId)
+ if (found) {
+ found.end = start;
+ found.events.unshift(ev)
+ return;
+ }
+ list.push({
+ id: getUniqueId(),
+ type: n.type,
+ start,
+ end: AbsoluteTime.never(),
+ events: [ev],
+ groupId,
+ });
+ return;
+ }
+ case NotificationType.WithdrawalOperationTransition: {
+ return;
+ }
+ case NotificationType.ExchangeStateTransition: {
+ const groupId = `${n.type}:${n.exchangeBaseUrl}`;
+ const found = list.find((a)=>a.groupId === groupId)
+ if (found) {
+ found.end = start;
+ found.events.unshift(ev)
+ return;
+ }
+ list.push({
+ id: getUniqueId(),
+ type: n.type,
+ start,
+ end: AbsoluteTime.never(),
+ events: [ev],
+ groupId,
+ });
+ return;
+ }
+ case NotificationType.Idle: {
+ const groupId = "";
+ list.push({
+ id: getUniqueId(),
+ type: n.type,
+ start,
+ end: AbsoluteTime.never(),
+ events: [ev],
+ groupId,
+ });
+ return;
+ }
+ case NotificationType.TaskObservabilityEvent: {
+ const groupId = `${n.type}:${n.taskId}`;
+ const found = list.find((a)=>a.groupId === groupId)
+ if (found) {
+ found.end = start;
+ found.events.unshift(ev)
+ return;
+ }
+ list.push({
+ id: getUniqueId(),
+ type: n.type,
+ start,
+ end: AbsoluteTime.never(),
+ events: [ev],
+ groupId,
+ });
+ return;
+ }
+ case NotificationType.RequestObservabilityEvent: {
+ const groupId = `${n.type}:${n.operation}:${n.requestId}`;
+ const found = list.find((a)=>a.groupId === groupId)
+ if (found) {
+ found.end = start;
+ found.events.unshift(ev)
+ return;
+ }
+ list.push({
+ id: getUniqueId(),
+ type: n.type,
+ start,
+ end: AbsoluteTime.never(),
+ events: [ev],
+ groupId,
+ });
+ return;
+ }
+ }
}
-async function clearNotifications(): Promise<void> {
- notifications.splice(0, notifications.length)
+async function getNotifications({
+ filter,
+}: {
+ filter: string;
+}): Promise<WalletActivityTrack[]> {
+ if (!filter) return activity;
+
+ const rg = new RegExp(`.*${filter}.*`);
+ return activity.filter((event) => {
+ return rg.test(event.groupId.toLowerCase());
+ });
}
+async function clearNotifications(): Promise<void> {
+ activity.splice(0, activity.length);
+}
async function runGarbageCollector(): Promise<void> {
const dbBeforeGc = currentDatabase;
@@ -229,8 +375,10 @@ async function dispatch<
case "wallet": {
const w = currentWallet;
if (!w) {
- const lastError: TalerErrorDetail = walletInit.lastError instanceof TalerError ?
- walletInit.lastError.errorDetail : undefined
+ const lastError: TalerErrorDetail =
+ walletInit.lastError instanceof TalerError
+ ? walletInit.lastError.errorDetail
+ : undefined;
return {
type: "error",
@@ -239,16 +387,22 @@ async function dispatch<
error: makeErrorDetail(
TalerErrorCode.WALLET_CORE_NOT_AVAILABLE,
{ lastError },
- `wallet core not available${!lastError ? "" : `,last error: ${lastError.hint}`}`,
+ `wallet core not available${
+ !lastError ? "" : `,last error: ${lastError.hint}`
+ }`,
),
};
}
//multiple client can create the same id, send the wallet an unique key
- const newId = `${req.id}_${nextMessageIndex}`
- const resp = await w.handleCoreApiRequest(req.operation, newId, req.payload);
+ const newId = `${req.id}_${nextMessageIndex}`;
+ const resp = await w.handleCoreApiRequest(
+ req.operation,
+ newId,
+ req.payload,
+ );
//return to the client the original id
- resp.id = req.id
- return resp
+ resp.id = req.id;
+ return resp;
}
}
@@ -267,7 +421,7 @@ async function dispatch<
async function reinitWallet(): Promise<void> {
if (currentWallet) {
- currentWallet.stop();
+ await currentWallet.client.call(WalletApiOperation.Shutdown, {});
currentWallet = undefined;
}
currentDatabase = undefined;
@@ -310,7 +464,7 @@ async function reinitWallet(): Promise<void> {
features: {
allowHttp: settings.walletAllowHttp,
},
- }
+ },
});
} catch (e) {
logger.error("could not initialize wallet", e);
@@ -319,28 +473,23 @@ async function reinitWallet(): Promise<void> {
}
wallet.addNotificationListener((message) => {
if (settings.showWalletActivity) {
- notifications.push({
- notification: message,
- when: AbsoluteTime.now()
- })
+ addNewWalletActivityNotification(activity, message);
}
+ processWalletNotification(message);
+
platform.sendMessageToAllChannels({
type: "wallet",
notification: message,
});
});
- platform.keepAlive(() => {
- return wallet.runTaskLoop().catch((e) => {
- logger.error("error during wallet task loop", e);
- });
- });
// Useful for debugging in the background page.
if (typeof window !== "undefined") {
(window as any).talerWallet = wallet;
}
currentWallet = wallet;
+ updateIconBasedOnBalance();
return walletInit.resolve();
}
@@ -378,3 +527,46 @@ export async function wxMain(): Promise<void> {
console.error(e);
}
}
+
+async function updateIconBasedOnBalance() {
+ const balance = await currentWallet?.client.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ if (balance) {
+ let showAlert = false;
+ for (const b of balance.balances) {
+ if (b.flags.length > 0) {
+ console.log("b.flags", JSON.stringify(b.flags));
+ showAlert = true;
+ break;
+ }
+ }
+
+ if (showAlert) {
+ platform.setAlertedIcon();
+ } else {
+ platform.setNormalIcon();
+ }
+ }
+}
+
+/**
+ * All the actions triggered by notification that need to be
+ * run in the background.
+ *
+ * @param message
+ */
+async function processWalletNotification(message: WalletNotification) {
+ if (
+ message.type === NotificationType.TransactionStateTransition &&
+ (message.newTxState.minor === TransactionMinorState.KycRequired ||
+ message.oldTxState.minor === TransactionMinorState.KycRequired ||
+ message.newTxState.minor === TransactionMinorState.AmlRequired ||
+ message.oldTxState.minor === TransactionMinorState.AmlRequired ||
+ message.newTxState.minor === TransactionMinorState.BankConfirmTransfer ||
+ message.oldTxState.minor === TransactionMinorState.BankConfirmTransfer)
+ ) {
+ await updateIconBasedOnBalance();
+ }
+}
diff --git a/packages/web-util/package.json b/packages/web-util/package.json
index e9a8247ea..369b872b6 100644
--- a/packages/web-util/package.json
+++ b/packages/web-util/package.json
@@ -1,6 +1,6 @@
{
"name": "@gnu-taler/web-util",
- "version": "0.10.6",
+ "version": "0.10.7",
"description": "Generic helper functionality for GNU Taler Web Apps",
"type": "module",
"types": "./lib/index.node.d.ts",
diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx
index ea0ea2f38..b142114e7 100644
--- a/packages/web-util/src/components/Button.tsx
+++ b/packages/web-util/src/components/Button.tsx
@@ -14,36 +14,56 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { AbsoluteTime, OperationFail, OperationOk, OperationResult, TalerError, TranslatedString } from "@gnu-taler/taler-util";
+import {
+ AbsoluteTime,
+ OperationAlternative,
+ OperationFail,
+ OperationOk,
+ OperationResult,
+ TalerError,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
// import { NotificationMessage, notifyInfo } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
-import { HTMLAttributes, useEffect, useState, useTransition } from "preact/compat";
-import { NotificationMessage, buildUnifiedRequestErrorMessage, notifyInfo, useTranslationContext } from "../index.browser.js";
+import { HTMLAttributes, useState } from "preact/compat";
+import {
+ NotificationMessage,
+ buildUnifiedRequestErrorMessage,
+ notifyInfo,
+ useTranslationContext,
+} from "../index.browser.js";
// import { useBankCoreApiContext } from "../context/config.js";
// function errorMap<T extends OperationFail<unknown>>(resp: T, map: (d: T["case"]) => TranslatedString): void {
+export type OnOperationSuccesReturnType<T> = (
+ result: T extends OperationOk<any> ? T : never,
+) => TranslatedString | void;
+export type OnOperationFailReturnType<T> = (
+ (d: (T extends OperationFail<any> ? T : never) | (T extends OperationAlternative<any,any> ? T : never)) => TranslatedString)
+
export interface ButtonHandler<T extends OperationResult<A, B>, A, B> {
- onClick: () => Promise<T | undefined>,
+ onClick: () => Promise<T | undefined>;
onNotification: (n: NotificationMessage) => void;
- onOperationSuccess: ((result: T extends OperationOk<any> ? T : never) => void) | ((result: T extends OperationOk<any> ? T : never) => TranslatedString | undefined),
- onOperationFail: (d: T extends OperationFail<any> ? T : never) => TranslatedString;
+ onOperationSuccess: OnOperationSuccesReturnType<T>;
+ onOperationFail?: OnOperationFailReturnType<T>;
onOperationComplete?: () => void;
}
-interface Props<T extends OperationResult<A, B>, A, B> extends HTMLAttributes<HTMLButtonElement> {
- handler: ButtonHandler<T, A, B> | undefined,
+interface Props<T extends OperationResult<A, B>, A, B>
+ extends HTMLAttributes<HTMLButtonElement> {
+ handler: ButtonHandler<T, A, B> | undefined;
}
/**
* This button accept an async function and report a notification
* on error or success.
- *
+ *
* When the async function is running the inner text will change into
* a "loading" animation.
- *
- * @param param0
- * @returns
+ *
+ * @param param0
+ * @returns
*/
export function Button<T extends OperationResult<A, B>, A, B>({
handler,
@@ -53,69 +73,84 @@ export function Button<T extends OperationResult<A, B>, A, B>({
...rest
}: Props<T, A, B>): VNode {
const { i18n } = useTranslationContext();
- const [running, setRunning] = useState(false)
- return <button {...rest} disabled={disabled || running} onClick={(e) => {
- e.preventDefault();
- if (!handler) { return; }
- setRunning(true)
- handler.onClick().then((resp) => {
- if (resp) {
- if (resp.type === "ok") {
- const result: OperationOk<any> = resp
- // @ts-expect-error this is an operationOk
- const msg = handler.onOperationSuccess(result)
- if (msg) {
- notifyInfo(msg)
- }
+ const [running, setRunning] = useState(false);
+ return (
+ <button
+ {...rest}
+ disabled={disabled || running}
+ onClick={(e) => {
+ e.preventDefault();
+ if (!handler) {
+ return;
}
- if (resp.type === "fail") {
- // @ts-expect-error this is an operationFail
- const error: OperationFail<any> = resp;
- // @ts-expect-error this is an operationFail
- const title = handler.onOperationFail(error)
- handler.onNotification({
- title,
- type: "error",
- description: error.detail.hint as TranslatedString,
- debug: error.detail,
- when: AbsoluteTime.now(),
+ setRunning(true);
+ handler
+ .onClick()
+ .then((resp) => {
+ if (resp) {
+ if (resp.type === "ok") {
+ const result: OperationOk<any> = resp;
+ // @ts-expect-error this is an operationOk
+ const msg = handler.onOperationSuccess(result);
+ if (msg) {
+ notifyInfo(msg);
+ }
+ }
+ if (resp.type === "fail") {
+ const d = 'detail' in resp ? resp.detail : undefined
+
+ const title = !handler.onOperationFail ? "Unexpected error." as TranslatedString : handler.onOperationFail(resp as any);
+ handler.onNotification({
+ title,
+ type: "error",
+ description: d && d.hint ? d.hint as TranslatedString : undefined,
+ debug: d,
+ when: AbsoluteTime.now(),
+ });
+ }
+ }
+ if (handler.onOperationComplete) {
+ handler.onOperationComplete();
+ }
+ setRunning(false);
})
- }
- }
- if (handler.onOperationComplete) {
- handler.onOperationComplete()
- }
- setRunning(false)
- }).catch(error => {
- console.error(error)
+ .catch((error) => {
+ console.error(error);
- if (error instanceof TalerError) {
- handler.onNotification(buildUnifiedRequestErrorMessage(i18n, error))
- } else {
- const description = (error instanceof Error ?
- error.message : String(error)) as TranslatedString
+ if (error instanceof TalerError) {
+ handler.onNotification(
+ buildUnifiedRequestErrorMessage(i18n, error),
+ );
+ } else {
+ const description = (
+ error instanceof Error ? error.message : String(error)
+ ) as TranslatedString;
- handler.onNotification({
- title: i18n.str`Operation failed`,
- type: "error",
- description,
- when: AbsoluteTime.now(),
- })
- }
+ handler.onNotification({
+ title: i18n.str`Operation failed`,
+ type: "error",
+ description,
+ when: AbsoluteTime.now(),
+ });
+ }
- if (handler.onOperationComplete) {
- handler.onOperationComplete()
- }
- setRunning(false)
- })
- }} >
- {running ? <Wait /> : children}
- </button>
+ if (handler.onOperationComplete) {
+ handler.onOperationComplete();
+ }
+ setRunning(false);
+ });
+ }}
+ >
+ {running ? <Wait /> : children}
+ </button>
+ );
}
function Wait(): VNode {
- return <Fragment>
- <style>{`
+ return (
+ <Fragment>
+ <style>
+ {`
#l1 { width: 120px;
height: 20px;
-webkit-mask: radial-gradient(circle closest-side, currentColor 90%, #0000) left/20% 100%;
@@ -125,7 +160,8 @@ function Wait(): VNode {
@keyframes l17 {
100% {background-size:120% 100%}
`}
- </style>
- <div id="l1" />
- </Fragment>
+ </style>
+ <div id="l1" />
+ </Fragment>
+ );
}
diff --git a/packages/web-util/src/context/activity.ts b/packages/web-util/src/context/activity.ts
index 9a16f6673..d12d1efb6 100644
--- a/packages/web-util/src/context/activity.ts
+++ b/packages/web-util/src/context/activity.ts
@@ -14,7 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { ObservabilityEvent, TalerAuthenticationHttpClient, TalerBankConversionHttpClient, TalerCoreBankHttpClient, TalerMerchantInstanceHttpClient, TalerMerchantManagementHttpClient } from "@gnu-taler/taler-util";
+import { ChallengerHttpClient, ObservabilityEvent, TalerAuthenticationHttpClient, TalerBankConversionHttpClient, TalerCoreBankHttpClient, TalerExchangeHttpClient, TalerMerchantInstanceHttpClient, TalerMerchantManagementHttpClient } from "@gnu-taler/taler-util";
type Listener<Event> = (e: Event) => void;
type Unsuscriber = () => void;
@@ -26,7 +26,7 @@ export class ActiviyTracker<Event> {
this.notify = this.notify.bind(this)
this.subscribe = this.subscribe.bind(this)
}
- notify(data: Event) {
+ notify(data: Event): void {
this.observers.forEach((observer) => observer(data))
}
subscribe(func: Listener<Event>): Unsuscriber {
@@ -60,9 +60,17 @@ export interface MerchantLib {
subInstanceApi: (instanceId: string) => MerchantLib;
}
+export interface ExchangeLib {
+ exchange: TalerExchangeHttpClient;
+}
+
export interface BankLib {
bank: TalerCoreBankHttpClient;
conversion: TalerBankConversionHttpClient;
auth: (user: string) => TalerAuthenticationHttpClient;
}
+export interface ChallengerLib {
+ challenger: ChallengerHttpClient;
+}
+
diff --git a/packages/web-util/src/context/challenger-api.ts b/packages/web-util/src/context/challenger-api.ts
new file mode 100644
index 000000000..8748f5f69
--- /dev/null
+++ b/packages/web-util/src/context/challenger-api.ts
@@ -0,0 +1,213 @@
+/*
+ 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 {
+ CacheEvictor,
+ ChallengerApi,
+ ChallengerCacheEviction,
+ ChallengerHttpClient,
+ LibtoolVersion,
+ ObservabilityEvent,
+ ObservableHttpClientLibrary,
+ TalerError
+} from "@gnu-taler/taler-util";
+import {
+ ComponentChildren,
+ FunctionComponent,
+ VNode,
+ createContext,
+ h,
+} from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js";
+import {
+ APIClient,
+ ActiviyTracker,
+ ChallengerLib,
+ Subscriber
+} from "./activity.js";
+import { useTranslationContext } from "./translation.js";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type ChallengerContextType = {
+ url: URL;
+ config: ChallengerApi.ChallengerTermsOfServiceResponse;
+ lib: ChallengerLib;
+ hints: VersionHint[];
+ onActivity: Subscriber<ObservabilityEvent>;
+ cancelRequest: (eventId: string) => void;
+};
+
+// @ts-expect-error default value to undefined, should it be another thing?
+const ChallengerContext = createContext<ChallengerContextType>(undefined);
+
+export const useChallengerApiContext = (): ChallengerContextType =>
+ useContext(ChallengerContext);
+
+enum VersionHint {
+ NONE,
+}
+
+type Evictors = {
+ challenger?: CacheEvictor<ChallengerCacheEviction>;
+}
+
+type ConfigResult<T> =
+ | undefined
+ | { type: "ok"; config: T; hints: VersionHint[] }
+ | { type: "incompatible"; result: T; supported: string }
+ | { type: "error"; error: TalerError };
+
+const CONFIG_FAIL_TRY_AGAIN_MS = 5000;
+
+export const ChallengerApiProvider = ({
+ baseUrl,
+ children,
+ frameOnError,
+ evictors = {},
+}: {
+ baseUrl: URL;
+ children: ComponentChildren;
+ evictors?: Evictors;
+ frameOnError: FunctionComponent<{ children: ComponentChildren }>;
+}): VNode => {
+ const [checked, setChecked] =
+ useState<ConfigResult<ChallengerApi.ChallengerTermsOfServiceResponse>>();
+ const { i18n } = useTranslationContext();
+
+ const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } =
+ buildChallengerApiClient(baseUrl, evictors);
+
+ useEffect(() => {
+ let keepRetrying = true;
+ async function testConfig(): Promise<void> {
+ try {
+ const config = await getRemoteConfig();
+ if (LibtoolVersion.compare(VERSION, config.version)) {
+ setChecked({ type: "ok", config, hints: [] });
+ } else {
+ setChecked({
+ type: "incompatible",
+ result: config,
+ supported: VERSION,
+ });
+ }
+ } catch (error) {
+ if (error instanceof TalerError) {
+ if (keepRetrying) {
+ setTimeout(() => {
+ testConfig();
+ }, CONFIG_FAIL_TRY_AGAIN_MS);
+ }
+ setChecked({ type: "error", error });
+ } else {
+ setChecked({ type: "error", error: TalerError.fromException(error) });
+ }
+ }
+ }
+ testConfig();
+ return () => {
+ // on unload, stop retry
+ keepRetrying = false;
+ };
+ }, []);
+
+ if (checked === undefined) {
+ return h(frameOnError, {
+ children: h("div", {}, "checking compatibility with server..."),
+ });
+ }
+ if (checked.type === "error") {
+ return h(frameOnError, {
+ children: h(ErrorLoading, { error: checked.error, showDetail: true }),
+ });
+ }
+ if (checked.type === "incompatible") {
+ return h(frameOnError, {
+ children: h(
+ "div",
+ {},
+ i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`,
+ ),
+ });
+ }
+
+ const value: ChallengerContextType = {
+ url: baseUrl,
+ config: checked.config,
+ onActivity: onActivity,
+ lib,
+ cancelRequest,
+ hints: checked.hints,
+ };
+ return h(ChallengerContext.Provider, {
+ value,
+ children,
+ });
+};
+
+function buildChallengerApiClient(
+ url: URL,
+ evictors: Evictors,
+): APIClient<ChallengerLib, ChallengerApi.ChallengerTermsOfServiceResponse> {
+ const httpFetch = new BrowserFetchHttpLib({
+ enableThrottling: true,
+ requireTls: false,
+ });
+ const tracker = new ActiviyTracker<ObservabilityEvent>();
+ const httpLib = new ObservableHttpClientLibrary(httpFetch, {
+ observe(ev) {
+ tracker.notify(ev);
+ },
+ });
+
+ const challenger = new ChallengerHttpClient(url.href, httpLib, evictors.challenger);
+
+ async function getRemoteConfig(): Promise<ChallengerApi.ChallengerTermsOfServiceResponse> {
+ const resp = await challenger.getConfig();
+ if (resp.type === "fail") {
+ throw TalerError.fromUncheckedDetail(resp.detail);
+ }
+ return resp.body;
+ }
+
+ return {
+ getRemoteConfig,
+ VERSION: challenger.PROTOCOL_VERSION,
+ lib: {
+ challenger,
+ },
+ onActivity: tracker.subscribe,
+ cancelRequest: httpLib.cancelRequest,
+ };
+}
+
+export const ChallengerApiProviderTesting = ({
+ children,
+ value,
+}: {
+ value: ChallengerContextType;
+ children: ComponentChildren;
+}): VNode => {
+ return h(ChallengerContext.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/web-util/src/context/exchange-api.ts b/packages/web-util/src/context/exchange-api.ts
new file mode 100644
index 000000000..39f889ba9
--- /dev/null
+++ b/packages/web-util/src/context/exchange-api.ts
@@ -0,0 +1,217 @@
+/*
+ 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 {
+ CacheEvictor,
+ LibtoolVersion,
+ ObservabilityEvent,
+ ObservableHttpClientLibrary,
+ TalerError,
+ TalerExchangeApi,
+ TalerExchangeCacheEviction,
+ TalerExchangeHttpClient
+} from "@gnu-taler/taler-util";
+import {
+ ComponentChildren,
+ FunctionComponent,
+ VNode,
+ createContext,
+ h,
+} from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { BrowserFetchHttpLib, ErrorLoading, useTranslationContext } from "../index.browser.js";
+import {
+ APIClient,
+ ActiviyTracker,
+ ExchangeLib,
+ Subscriber,
+} from "./activity.js";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type ExchangeContextType = {
+ url: URL;
+ config: TalerExchangeApi.ExchangeVersionResponse;
+ lib: ExchangeLib;
+ hints: VersionHint[];
+ onActivity: Subscriber<ObservabilityEvent>;
+ cancelRequest: (eventId: string) => void;
+};
+
+// FIXME: below
+// @ts-expect-error default value to undefined, should it be another thing?
+const ExchangeContext = createContext<ExchangeContextType>(undefined);
+
+export const useExchangeApiContext = (): ExchangeContextType =>
+ useContext(ExchangeContext);
+
+enum VersionHint {
+ NONE,
+}
+
+type Evictors = {
+ exchange?: CacheEvictor<TalerExchangeCacheEviction>;
+};
+
+type ConfigResult<T> =
+ | undefined
+ | { type: "ok"; config: T; hints: VersionHint[] }
+ | ConfigResultFail<T>;
+
+type ConfigResultFail<T> =
+ | { type: "incompatible"; result: T; supported: string }
+ | { type: "error"; error: TalerError };
+
+const CONFIG_FAIL_TRY_AGAIN_MS = 5000;
+
+export const ExchangeApiProvider = ({
+ baseUrl,
+ children,
+ evictors = {},
+ frameOnError,
+}: {
+ baseUrl: URL;
+ evictors?: Evictors;
+ children: ComponentChildren;
+ frameOnError: FunctionComponent<{ children: ComponentChildren }>;
+}): VNode => {
+ const [checked, setChecked] =
+ useState<ConfigResult<TalerExchangeApi.ExchangeVersionResponse>>();
+ const { i18n } = useTranslationContext();
+
+ const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } =
+ buildExchangeApiClient(baseUrl, evictors);
+
+ useEffect(() => {
+ let keepRetrying = true;
+ async function testConfig(): Promise<void> {
+ try {
+ const config = await getRemoteConfig();
+ if (LibtoolVersion.compare(VERSION, config.version)) {
+ setChecked({ type: "ok", config, hints: [] });
+ } else {
+ setChecked({
+ type: "incompatible",
+ result: config,
+ supported: VERSION,
+ });
+ }
+ } catch (error) {
+ if (error instanceof TalerError) {
+ if (keepRetrying) {
+ setTimeout(() => {
+ testConfig();
+ }, CONFIG_FAIL_TRY_AGAIN_MS);
+ }
+ setChecked({ type: "error", error });
+ } else {
+ setChecked({ type: "error", error: TalerError.fromException(error) });
+ }
+ }
+ }
+ testConfig();
+ return () => {
+ // on unload, stop retry
+ keepRetrying = false;
+ };
+ }, []);
+
+ if (checked === undefined) {
+ return h(frameOnError, {
+ children: h("div", {}, "checking compatibility with server..."),
+ });
+ }
+ if (checked.type === "error") {
+ return h(frameOnError, {
+ children: h(ErrorLoading, { error: checked.error, showDetail: true }),
+ });
+ }
+ if (checked.type === "incompatible") {
+ return h(frameOnError, {
+ children: h(
+ "div",
+ {},
+ i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`,
+ ),
+ });
+ }
+
+ const value: ExchangeContextType = {
+ url: baseUrl,
+ config: checked.config,
+ onActivity: onActivity,
+ lib,
+ cancelRequest,
+ hints: checked.hints,
+ };
+ return h(ExchangeContext.Provider, {
+ value,
+ children,
+ });
+};
+
+function buildExchangeApiClient(
+ url: URL,
+ evictors: Evictors,
+): APIClient<ExchangeLib, TalerExchangeApi.ExchangeVersionResponse> {
+ const httpFetch = new BrowserFetchHttpLib({
+ enableThrottling: true,
+ requireTls: false,
+ });
+ const tracker = new ActiviyTracker<ObservabilityEvent>();
+
+ const httpLib = new ObservableHttpClientLibrary(httpFetch, {
+ observe(ev) {
+ tracker.notify(ev);
+ },
+ });
+
+ const ex = new TalerExchangeHttpClient(url.href, httpLib, evictors.exchange);
+
+ async function getRemoteConfig(): Promise<TalerExchangeApi.ExchangeVersionResponse> {
+ const resp = await ex.getConfig();
+ if (resp.type === "fail") {
+ throw TalerError.fromUncheckedDetail(resp.detail);
+ }
+ return resp.body;
+ }
+
+ return {
+ getRemoteConfig,
+ VERSION: ex.PROTOCOL_VERSION,
+ lib: {
+ exchange: ex,
+ },
+ onActivity: tracker.subscribe,
+ cancelRequest: httpLib.cancelRequest,
+ };
+}
+
+export const ExchangeApiProviderTesting = ({
+ children,
+ value,
+}: {
+ value: ExchangeContextType;
+ children: ComponentChildren;
+}): VNode => {
+ return h(ExchangeContext.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/web-util/src/context/index.ts b/packages/web-util/src/context/index.ts
index 0e28b844a..7e30ecd09 100644
--- a/packages/web-util/src/context/index.ts
+++ b/packages/web-util/src/context/index.ts
@@ -5,6 +5,8 @@ export {
useTranslationContext
} from "./translation.js";
export * from "./bank-api.js";
+export * from "./challenger-api.js";
export * from "./merchant-api.js";
+export * from "./exchange-api.js";
export * from "./navigation.js";
export * from "./wallet-integration.js";
diff --git a/packages/web-util/src/context/merchant-api.ts b/packages/web-util/src/context/merchant-api.ts
index 9998b3aeb..03c95d48e 100644
--- a/packages/web-util/src/context/merchant-api.ts
+++ b/packages/web-util/src/context/merchant-api.ts
@@ -69,7 +69,9 @@ enum VersionHint {
}
type Evictors = {
- management?: CacheEvictor<TalerMerchantManagementCacheEviction | TalerMerchantInstanceCacheEviction>;
+ management?: CacheEvictor<
+ TalerMerchantManagementCacheEviction | TalerMerchantInstanceCacheEviction
+ >;
};
type ConfigResult<T> =
@@ -81,7 +83,7 @@ export type ConfigResultFail<T> =
| { type: "incompatible"; result: T; supported: string }
| { type: "error"; error: TalerError };
-const CONFIG_FAIL_TRY_AGAIN_MS = 5000
+const CONFIG_FAIL_TRY_AGAIN_MS = 5000;
export const MerchantApiProvider = ({
baseUrl,
@@ -108,7 +110,7 @@ export const MerchantApiProvider = ({
let keepRetrying = true;
async function testConfig(): Promise<void> {
try {
- const config = await getRemoteConfig();
+ const config = await getRemoteConfig();
if (LibtoolVersion.compare(VERSION, config.version)) {
setChecked({ type: "ok", config, hints: [] });
} else {
@@ -122,7 +124,7 @@ export const MerchantApiProvider = ({
if (error instanceof TalerError) {
if (keepRetrying) {
setTimeout(() => {
- testConfig()
+ testConfig();
}, CONFIG_FAIL_TRY_AGAIN_MS);
}
setChecked({ type: "error", error });
@@ -135,7 +137,7 @@ export const MerchantApiProvider = ({
return () => {
// on unload, stop retry
keepRetrying = false;
- }
+ };
}, []);
if (!checked || checked.type !== "ok") {
@@ -183,30 +185,18 @@ function buildMerchantApiClient(
httpLib,
);
- // const instance = (instanceId: string): TalerMerchantInstanceHttpClient => {
- // return new TalerMerchantInstanceHttpClient(
- // management.getSubInstanceAPI(instanceId).href,
- // httpLib,
- // evictors.instance ? evictors.instance(instanceId) : undefined,
- // );
- // }
- // const impersonate = (instanceId: string): TalerAuthenticationHttpClient => {
- // return new TalerAuthenticationHttpClient(
- // instance(instanceId).getAuthenticationAPI().href,
- // httpLib,
- // );
- // }
- const rootUrl = url;
function getSubInstanceAPI(instanceId: string): MerchantLib {
- const newURL = new URL(`instance/${instanceId}/`, rootUrl);
- const api = buildMerchantApiClient(newURL, evictors);
+ const api = buildMerchantApiClient(
+ instance.getSubInstanceAPI(instanceId) as URL,
+ evictors,
+ );
return api.lib;
}
async function getRemoteConfig(): Promise<TalerMerchantApi.VersionResponse> {
const resp = await instance.getConfig();
if (resp.type === "fail") {
- throw TalerError.fromUncheckedDetail(resp.detail)
+ throw TalerError.fromUncheckedDetail(resp.detail);
}
return resp.body;
}
diff --git a/packages/web-util/src/context/navigation.ts b/packages/web-util/src/context/navigation.ts
index a2fe3ff12..c2f2bbbc1 100644
--- a/packages/web-util/src/context/navigation.ts
+++ b/packages/web-util/src/context/navigation.ts
@@ -16,17 +16,13 @@
import { ComponentChildren, createContext, h, VNode } from "preact";
import { useContext, useEffect, useState } from "preact/hooks";
-import { AppLocation, ObjectOf, Location, findMatch, RouteDefinition } from "../utils/route.js";
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>(
- pagesMap: T,
-): Location<T> | undefined {
- const pageList = Object.keys(pagesMap as object) as Array<keyof T>;
- const { path, params } = useNavigationContext();
-
- return findMatch(pagesMap, pageList, path, params);
-}
+import {
+ AppLocation,
+ ObjectOf,
+ Location,
+ findMatch,
+ RouteDefinition,
+} from "../utils/route.js";
/**
*
@@ -35,7 +31,7 @@ export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>(
export type Type = {
path: string;
- params: Record<string, string>;
+ params: Record<string, string[]>;
navigateTo: (path: AppLocation) => void;
// addNavigationListener: (listener: (path: string, params: Record<string, string>) => void) => (() => void);
};
@@ -45,13 +41,29 @@ const Context = createContext<Type>(undefined);
export const useNavigationContext = (): Type => useContext(Context);
-function getPathAndParamsFromWindow() {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>(
+ pagesMap: T,
+): Location<T> | undefined {
+ const pageList = Object.keys(pagesMap as object) as Array<keyof T>;
+ const { path, params } = useNavigationContext();
+
+ return findMatch(pagesMap, pageList, path, params);
+}
+
+function getPathAndParamsFromWindow(): {
+ path: string;
+ params: Record<string, string[]>;
+} {
const path =
typeof window !== "undefined" ? window.location.hash.substring(1) : "/";
- const params: Record<string, string> = {};
+ const params: Record<string, string[]> = {};
if (typeof window !== "undefined") {
for (const [key, value] of new URLSearchParams(window.location.search)) {
- params[key] = value;
+ if (!params[key]) {
+ params[key] = [];
+ }
+ params[key].push(value);
}
}
return { path, params };
@@ -80,14 +92,14 @@ export const BrowserHashNavigationProvider = ({
"Can't use BrowserHashNavigationProvider if there is no window object",
);
}
- function navigateTo(path: string) {
+ function navigateTo(path: string): void {
const { params } = getPathAndParamsFromWindow();
setState({ path, params });
window.location.href = path;
}
useEffect(() => {
- function eventListener() {
+ function eventListener(): void {
setState(getPathAndParamsFromWindow());
}
window.addEventListener(PopStateEventType, eventListener);
diff --git a/packages/web-util/src/forms/Calendar.tsx b/packages/web-util/src/forms/Calendar.tsx
index a0df639f3..b08129f56 100644
--- a/packages/web-util/src/forms/Calendar.tsx
+++ b/packages/web-util/src/forms/Calendar.tsx
@@ -1,20 +1,40 @@
-import { AbsoluteTime } from "@gnu-taler/taler-util"
-import { add as dateAdd, sub as dateSub, eachDayOfInterval, endOfMonth, endOfWeek, format, getMonth, getYear, isSameDay, isSameMonth, startOfDay, startOfMonth, startOfWeek } from "date-fns"
-import { VNode, h } from "preact"
-import { useState } from "preact/hooks"
-import { useTranslationContext } from "../index.browser.js"
+import { AbsoluteTime } from "@gnu-taler/taler-util";
+import {
+ add as dateAdd,
+ sub as dateSub,
+ eachDayOfInterval,
+ endOfMonth,
+ endOfWeek,
+ format,
+ getMonth,
+ getYear,
+ isSameDay,
+ isSameMonth,
+ startOfDay,
+ startOfMonth,
+ startOfWeek,
+} from "date-fns";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useTranslationContext } from "../index.browser.js";
-export function Calendar({ value, onChange }: { value: AbsoluteTime | undefined, onChange: (v: AbsoluteTime) => void }): VNode {
- const today = startOfDay(new Date())
- const selected = !value ? today : new Date(AbsoluteTime.toStampMs(value))
- const [showingDate, setShowingDate] = useState(selected)
- const month = getMonth(showingDate)
- const year = getYear(showingDate)
+export function Calendar({
+ value,
+ onChange,
+}: {
+ value: AbsoluteTime | undefined;
+ onChange: (v: AbsoluteTime) => void;
+}): VNode {
+ const today = startOfDay(new Date());
+ const selected = !value ? today : new Date(AbsoluteTime.toStampMs(value));
+ const [showingDate, setShowingDate] = useState(selected);
+ const month = getMonth(showingDate);
+ const year = getYear(showingDate);
const start = startOfWeek(startOfMonth(showingDate));
const end = endOfWeek(endOfMonth(showingDate));
const daysInMonth = eachDayOfInterval({ start, end });
- const { i18n } = useTranslationContext()
+ const { i18n } = useTranslationContext();
const monthNames = [
i18n.str`January`,
i18n.str`February`,
@@ -28,92 +48,139 @@ export function Calendar({ value, onChange }: { value: AbsoluteTime | undefined,
i18n.str`October`,
i18n.str`November`,
i18n.str`December`,
- ]
- return <div class="text-center p-2">
- <div class="flex items-center text-gray-900">
- <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm"
- onClick={() => {
- setShowingDate(dateSub(showingDate, { years: 1 }))
- }}>
- <span class="sr-only">
- {i18n.str`Previous year`}
- </span>
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
- <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
- </svg>
- </button>
- <div class="flex-auto text-sm font-semibold">{year}</div>
- <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm"
- onClick={() => {
- setShowingDate(dateAdd(showingDate, { years: 1 }))
- }}>
- <span class="sr-only">
- {i18n.str`Next year`}
- </span>
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
- <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
- </svg>
- </button>
- </div>
- <div class="mt-4 flex items-center text-gray-900">
- <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm"
- onClick={() => {
- setShowingDate(dateSub(showingDate, { months: 1 }))
- }}>
- <span class="sr-only">
- {i18n.str`Previous month`}
- </span>
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
- <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
- </svg>
- </button>
- <div class="flex-auto text-sm font-semibold">{monthNames[month]}</div>
- <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 rounded-sm "
- onClick={() => {
- setShowingDate(dateAdd(showingDate, { months: 1 }))
- }}>
- <span class="sr-only">
- {i18n.str`Next month`}
- </span>
- <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
- <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
- </svg>
- </button>
- </div>
- <div class="mt-6 grid grid-cols-7 text-xs leading-6 text-gray-500">
- <div>M</div>
- <div>T</div>
- <div>W</div>
- <div>T</div>
- <div>F</div>
- <div>S</div>
- <div>S</div>
- </div>
- <div class="isolate mt-2">
- <div class="grid grid-cols-7 gap-px rounded-lg bg-gray-200 text-sm shadow ring-1 ring-gray-200">
- {daysInMonth.map(current => (
- <button type="button"
- data-month={isSameMonth(current, showingDate)}
- data-today={isSameDay(current, today)}
- data-selected={isSameDay(current, selected)}
- onClick={() => {
- onChange(AbsoluteTime.fromStampMs(current.getTime()))
- }}
- class="text-gray-400 hover:bg-gray-700 focus:z-10 py-1.5
+ ];
+ return (
+ <div class="text-center p-2">
+ <div class="flex items-center text-gray-900">
+ <button
+ type="button"
+ class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm"
+ onClick={() => {
+ setShowingDate(dateSub(showingDate, { years: 1 }));
+ }}
+ >
+ <span class="sr-only">{i18n.str`Previous year`}</span>
+ <svg
+ class="h-5 w-5"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </button>
+ <div class="flex-auto text-sm font-semibold">{year}</div>
+ <button
+ type="button"
+ class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm"
+ onClick={() => {
+ setShowingDate(dateAdd(showingDate, { years: 1 }));
+ }}
+ >
+ <span class="sr-only">{i18n.str`Next year`}</span>
+ <svg
+ class="h-5 w-5"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </button>
+ </div>
+ <div class="mt-4 flex items-center text-gray-900">
+ <button
+ type="button"
+ class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm"
+ onClick={() => {
+ setShowingDate(dateSub(showingDate, { months: 1 }));
+ }}
+ >
+ <span class="sr-only">{i18n.str`Previous month`}</span>
+ <svg
+ class="h-5 w-5"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </button>
+ <div class="flex-auto text-sm font-semibold">{monthNames[month]}</div>
+ <button
+ type="button"
+ class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 rounded-sm "
+ onClick={() => {
+ setShowingDate(dateAdd(showingDate, { months: 1 }));
+ }}
+ >
+ <span class="sr-only">{i18n.str`Next month`}</span>
+ <svg
+ class="h-5 w-5"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </button>
+ </div>
+ <div class="mt-6 grid grid-cols-7 text-xs leading-6 text-gray-500">
+ <div>M</div>
+ <div>T</div>
+ <div>W</div>
+ <div>T</div>
+ <div>F</div>
+ <div>S</div>
+ <div>S</div>
+ </div>
+ <div class="isolate mt-2">
+ <div class="grid grid-cols-7 gap-px rounded-lg bg-gray-200 text-sm shadow ring-1 ring-gray-200">
+ {daysInMonth.map((current, idx) => (
+ <button
+ type="button"
+ key={idx}
+ data-month={isSameMonth(current, showingDate)}
+ data-today={isSameDay(current, today)}
+ data-selected={isSameDay(current, selected)}
+ onClick={() => {
+ onChange(AbsoluteTime.fromStampMs(current.getTime()));
+ }}
+ class="text-gray-400 hover:bg-gray-700 focus:z-10 py-1.5
data-[month=false]:bg-gray-100 data-[month=true]:bg-white
data-[today=true]:font-semibold
data-[month=true]:text-gray-900
data-[today=true]:bg-red-300 data-[today=true]:hover:bg-red-200
data-[month=true]:hover:bg-gray-200
- data-[selected=true]:!bg-blue-400 data-[selected=true]:hover:!bg-blue-300 ">
- <time dateTime={format(current, "yyyy-MM-dd")}
- class="mx-auto flex h-7 w-7 py-4 px-5 sm:px-8 items-center justify-center rounded-full">
- {format(current, "dd")}
- </time>
- </button>
- ))}
+ data-[selected=true]:!bg-blue-400 data-[selected=true]:hover:!bg-blue-300 "
+ >
+ <time
+ dateTime={format(current, "yyyy-MM-dd")}
+ class="mx-auto flex h-7 w-7 py-4 px-5 sm:px-8 items-center justify-center rounded-full"
+ >
+ {format(current, "dd")}
+ </time>
+ </button>
+ ))}
+ </div>
+ {daysInMonth.length < 40 ? <div class="w-7 h-7 m-1.5" /> : undefined}
</div>
- {daysInMonth.length < 40 ? <div class="w-7 h-7 m-1.5" /> : undefined}
</div>
- </div>
+ );
}
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 1155401f5..338460170 100644
--- a/packages/web-util/src/forms/DefaultForm.tsx
+++ b/packages/web-util/src/forms/DefaultForm.tsx
@@ -1,15 +1,16 @@
-import { Fragment, h } from "preact";
+import { Fragment, VNode, h } from "preact";
import { FormProvider, FormProviderProps, FormState } from "./FormProvider.js";
import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js";
import { TranslatedString } from "@gnu-taler/taler-util";
+// 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,20 +40,20 @@ export function DefaultForm<T extends object>({
onSubmit,
children,
readOnly,
-}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm<T> }) {
+}: 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) => {
if (!section) return <Fragment />;
return (
- <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
+ <div key={i} class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base font-semibold leading-7 text-gray-900">
{section.title}
diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx
index f4616525b..5e08efb32 100644
--- a/packages/web-util/src/forms/FormProvider.tsx
+++ b/packages/web-util/src/forms/FormProvider.tsx
@@ -4,10 +4,7 @@ import {
TranslatedString,
} from "@gnu-taler/taler-util";
import { ComponentChildren, VNode, createContext, h } from "preact";
-import {
- MutableRef,
- useState
-} from "preact/hooks";
+import { MutableRef, useState } from "preact/hooks";
export interface FormType<T extends object> {
value: MutableRef<Partial<T>>;
@@ -17,8 +14,7 @@ export interface FormType<T extends object> {
computeFormState?: (v: Partial<T>) => FormState<T>;
}
-//@ts-ignore
-export const FormContext = createContext<FormType<any>>({});
+export const FormContext = createContext<FormType<any>| undefined>(undefined);
/**
* Map of {[field]:FieldUIOptions}
@@ -26,29 +22,27 @@ export const FormContext = createContext<FormType<any>>({});
* - any native (string, number, etc...)
* - absoluteTime
* - amountJson
- *
- * except for:
+ *
+ * except for:
* - object => recurse into
* - array => behavior result and element field
*/
export type FormState<T extends object | undefined> = {
[field in keyof T]?: T[field] extends AbsoluteTime
- ? FieldUIOptions
- : T[field] extends AmountJson
- ? FieldUIOptions
- : T[field] extends Array<infer P extends object>
- ? InputArrayFieldState<P>
- : T[field] extends (object | undefined)
- ? FormState<T[field]>
- : FieldUIOptions;
+ ? FieldUIOptions
+ : T[field] extends AmountJson
+ ? FieldUIOptions
+ : T[field] extends Array<infer P extends object>
+ ? InputArrayFieldState<P>
+ : T[field] extends object | undefined
+ ? FormState<T[field]>
+ : FieldUIOptions;
};
/**
* Properties that can be defined by design or by computing state
*/
export type FieldUIOptions = {
- /* text to be shown next to the field */
- error?: TranslatedString;
/* instruction to be shown in the field */
placeholder?: TranslatedString;
/* long text help to be shown on demand */
@@ -63,13 +57,13 @@ export type FieldUIOptions = {
/* show a mark as required*/
required?: boolean;
-}
+};
/**
* properties only to be defined on design time
*/
-export interface UIFormProps<T extends object, K extends keyof T> extends FieldUIOptions {
-
+export interface UIFormProps<T extends object, K extends keyof T>
+ extends FieldUIOptions {
// property name of the object
name: K;
@@ -80,8 +74,17 @@ export interface UIFormProps<T extends object, K extends keyof T> extends FieldU
// converter to string and back
converter?: StringConverter<T[K]>;
+
+ handler?: UIFieldHandler;
}
+export type UIFieldHandler = {
+ value: string | undefined;
+ onChange: (s: string) => void;
+ state: FieldUIOptions;
+ error?: TranslatedString;
+};
+
export interface IconAddon {
type: "icon";
icon: VNode;
@@ -109,7 +112,7 @@ export interface InputArrayFieldState<P extends object> extends FieldUIOptions {
export type FormProviderProps<T extends object> = Omit<FormType<T>, "value"> & {
onSubmit?: (v: Partial<T>, s: FormState<T> | undefined) => void;
children?: ComponentChildren;
-}
+};
export function FormProvider<T extends object>({
children,
@@ -119,7 +122,6 @@ export function FormProvider<T extends object>({
computeFormState,
readOnly,
}: FormProviderProps<T>): VNode {
-
const [state, setState] = useState<Partial<T>>(initial ?? {});
const value = { current: state };
const onUpdate = (v: typeof state) => {
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 6245cf27c..6b792bfee 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,12 +43,12 @@ 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: [{
- type: "absoluteTime",
- props: {
+ type: "absoluteTimeText",
+ properties: {
label: "label of the field" as TranslatedString,
name: "today",
pattern: "dd/MM/yyyy HH:mm"
diff --git a/packages/web-util/src/forms/InputAbsoluteTime.tsx b/packages/web-util/src/forms/InputAbsoluteTime.tsx
index ee18e5592..f5fd4fc50 100644
--- a/packages/web-util/src/forms/InputAbsoluteTime.tsx
+++ b/packages/web-util/src/forms/InputAbsoluteTime.tsx
@@ -1,35 +1,50 @@
import { AbsoluteTime } from "@gnu-taler/taler-util";
-import { InputLine } from "./InputLine.js";
-import { Fragment, VNode, h } from "preact";
import { format, parse } from "date-fns";
-import { Dialog } from "./Dialog.js";
-import { Calendar } from "./Calendar.js";
+import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
-import { useField } from "./useField.js";
+import { Calendar } from "./Calendar.js";
+import { Dialog } from "./Dialog.js";
import { UIFormProps } from "./FormProvider.js";
-import { TimePicker } from "./TimePicker.js";
+import { InputLine } from "./InputLine.js";
+import { useField } from "./useField.js";
+import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
export function InputAbsoluteTime<T extends object, K extends keyof T>(
- props: { pattern?: string } & UIFormProps<T, K>,
+ properties: { pattern?: string } & UIFormProps<T, K>,
): VNode {
- const pattern = props.pattern ?? "dd/MM/yyyy";
- const [open, setOpen] = useState(false)
- const { value, onChange } = useField<T, K>(props.name);
+ const pattern = properties.pattern ?? "dd/MM/yyyy";
+ const [open, setOpen] = useState(false);
+
+ //FIXME: remove deprecated
+ const fieldCtx = useField<T, K>(properties.name);
+ const { value, onChange } =
+ properties.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(properties.name);
return (
<Fragment>
-
<InputLine<T, K>
type="text"
after={{
type: "button",
onClick: () => {
- setOpen(true)
+ setOpen(true);
},
// icon: <CalendarIcon class="h-6 w-6" />,
children: (
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
- <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
- </svg>)
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
+ />
+ </svg>
+ ),
}}
converter={{
//@ts-ignore
@@ -51,17 +66,19 @@ export function InputAbsoluteTime<T extends object, K extends keyof T>(
: format(v.t_ms, pattern);
},
}}
- {...props}
+ {...properties}
/>
- {open &&
+ {open && (
<Dialog onClose={() => setOpen(false)}>
- <Calendar value={value as AbsoluteTime ?? AbsoluteTime.now()}
+ <Calendar
+ value={(value as AbsoluteTime) ?? AbsoluteTime.now()}
onChange={(v) => {
- onChange(v as any)
- setOpen(false)
- }} />
+ onChange(v as any);
+ setOpen(false);
+ }}
+ />
</Dialog>
- }
+ )}
{/* {open &&
<Dialog onClose={() => setOpen(false)} >
<TimePicker value={value as AbsoluteTime ?? AbsoluteTime.now()}
diff --git a/packages/web-util/src/forms/InputAmount.stories.tsx b/packages/web-util/src/forms/InputAmount.stories.tsx
index c9f12a437..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,12 +43,12 @@ 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: [{
type: "amount",
- props: {
+ properties: {
label: "label of the field" as TranslatedString,
name: "amount",
},
diff --git a/packages/web-util/src/forms/InputAmount.tsx b/packages/web-util/src/forms/InputAmount.tsx
index 7a8c08f76..647d2c823 100644
--- a/packages/web-util/src/forms/InputAmount.tsx
+++ b/packages/web-util/src/forms/InputAmount.tsx
@@ -3,34 +3,41 @@ import { VNode, h } from "preact";
import { UIFormProps } from "./FormProvider.js";
import { InputLine } from "./InputLine.js";
import { useField } from "./useField.js";
+import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
export function InputAmount<T extends object, K extends keyof T>(
props: { currency?: string } & UIFormProps<T, K>,
): VNode {
- const { value } = useField<T, K>(props.name);
+ //FIXME: remove deprecated
+ const fieldCtx = useField<T, K>(props.name);
+ const { value } =
+ props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name);
const currency =
!value || !(value as any).currency
? props.currency
: (value as any).currency;
return (
<InputLine<T, K>
+ {...props}
type="text"
before={{
type: "text",
text: currency as TranslatedString,
}}
- converter={{
- //@ts-ignore
- fromStringUI: (v): AmountJson => {
-
- return Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency);
- },
- //@ts-ignore
- toStringUI: (v: AmountJson) => {
- return v === undefined ? "" : Amounts.stringifyValue(v);
- },
- }}
- {...props}
+ //@ts-ignore
+ converter={
+ props.converter ?? {
+ fromStringUI: (v): AmountJson => {
+ return (
+ Amounts.parse(`${currency}:${v}`) ??
+ Amounts.zeroOfCurrency(currency)
+ );
+ },
+ 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 8dbd3ff07..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,23 +49,23 @@ const initial: TargetObject = {
}]
}
-const form: FlexibleForm<TargetObject> = {
+const form: FlexibleForm_Deprecated<TargetObject> = {
design: [{
title: "this is a simple form" as TranslatedString,
fields: [{
type: "array",
- props: {
+ properties: {
label: "People" as TranslatedString,
name: "comment",
fields: [{
type: "text",
- props: {
+ properties: {
label: "the name" as TranslatedString,
name: "name",
}
}, {
type: "integer",
- props: {
+ properties: {
label: "the age" as TranslatedString,
name: "age",
}
diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx
index 7d9a1b378..d90028508 100644
--- a/packages/web-util/src/forms/InputArray.tsx
+++ b/packages/web-util/src/forms/InputArray.tsx
@@ -71,6 +71,14 @@ function Option({
);
}
+export function noHandlerPropsAndNoContextForField(
+ field: string | number | symbol,
+): never {
+ throw Error(
+ `Field ${field.toString()} doesn't have handler and is not in a form provider context.`,
+ );
+}
+
export function InputArray<T extends object, K extends keyof T>(
props: {
fields: UIFormField[];
@@ -78,12 +86,20 @@ export function InputArray<T extends object, K extends keyof T>(
} & UIFormProps<T, K>,
): VNode {
const { fields, labelField, name, label, required, tooltip } = props;
- const { value, onChange, state } = useField<T, K>(name);
+ // const { value, onChange, state } = useField<T, K>(name);
+ //FIXME: remove deprecated
+ const fieldCtx = useField<T, K>(props.name);
+ if (!props.handler && !fieldCtx) {
+ throw Error("");
+ }
+ const { value, onChange, state } =
+ props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name);
+
const list = (value ?? []) as Array<Record<string, string | undefined>>;
const [selectedIndex, setSelected] = useState<number | undefined>(undefined);
const selected =
selectedIndex === undefined ? undefined : list[selectedIndex];
-
+
return (
<div class="sm:col-span-6">
<LabelWithTooltipMaybeRequired
@@ -94,9 +110,11 @@ export function InputArray<T extends object, K extends keyof T>(
<div class="-space-y-px rounded-md bg-white ">
{list.map((v, idx) => {
+ const label = getValueDeeper(v, labelField.split("."))
return (
<Option
- label={v[labelField] as TranslatedString}
+ label={label as TranslatedString}
+ key={idx}
isSelected={selectedIndex === idx}
isLast={idx === list.length - 1}
disabled={selectedIndex !== undefined && selectedIndex !== idx}
@@ -107,7 +125,7 @@ export function InputArray<T extends object, K extends keyof T>(
/>
);
})}
- {!state.disabled &&
+ {!state.disabled && (
<div class="pt-2">
<Option
label={"Add..." as TranslatedString}
@@ -124,7 +142,7 @@ export function InputArray<T extends object, K extends keyof T>(
}}
/>
</div>
- }
+ )}
</div>
{selectedIndex !== undefined && (
/**
@@ -140,18 +158,19 @@ 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];
newValue.splice(selectedIndex, 1, v);
- onChange(newValue as T[K]);
+ onChange(newValue as any);
setSelected(undefined);
}}
onUpdate={(v) => {
const newValue = [...list];
newValue.splice(selectedIndex, 1, v);
- onChange(newValue as T[K]);
+ onChange(newValue as any);
}}
>
<div class="px-4 py-6">
@@ -170,7 +189,7 @@ export function InputArray<T extends object, K extends keyof T>(
onClick={() => {
const newValue = [...list];
newValue.splice(selectedIndex, 1);
- onChange(newValue as T[K]);
+ onChange(newValue as any);
setSelected(undefined);
}}
class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
@@ -184,3 +203,24 @@ export function InputArray<T extends object, K extends keyof T>(
</div>
);
}
+
+
+
+export function getValueDeeper(
+ object: Record<string, any>,
+ names: string[],
+): string {
+ if (names.length === 0) {
+ return object as any as string;
+ }
+ const [head, ...rest] = names;
+ if (!head) {
+ return getValueDeeper(object, rest);
+ }
+ if (object === undefined) {
+ return ""
+ }
+ return getValueDeeper(object[head], rest);
+}
+
+
diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx
index b950d3d02..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,12 +43,12 @@ const initial: TargetObject = {
comment: "0"
}
-const form: FlexibleForm<TargetObject> = {
+const form: FlexibleForm_Deprecated<TargetObject> = {
design: [{
title: "this is a simple form" as TranslatedString,
fields: [{
type: "choiceHorizontal",
- props: {
+ properties: {
label: "label of the field" as TranslatedString,
name: "comment",
choices: [{
diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.tsx
index 778b73c75..86d3aa926 100644
--- a/packages/web-util/src/forms/InputChoiceHorizontal.tsx
+++ b/packages/web-util/src/forms/InputChoiceHorizontal.tsx
@@ -3,6 +3,7 @@ import { Fragment, VNode, h } from "preact";
import { UIFormProps } from "./FormProvider.js";
import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
import { useField } from "./useField.js";
+import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
export interface ChoiceH<V> {
label: TranslatedString;
@@ -11,22 +12,14 @@ 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,
- name,
- label,
- tooltip,
- help,
- placeholder,
- required,
- before,
- after,
- converter,
- } = props;
- const { value, onChange, state, isDirty } = useField<T, K>(name);
+ const { choices, label, tooltip, help, required, converter } = props;
+ //FIXME: remove deprecated
+ const fieldCtx = useField<T, K>(props.name);
+ const { value, onChange, state } =
+ props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name);
if (state.hidden) {
return <Fragment />;
}
@@ -41,11 +34,12 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>(
<fieldset class="mt-2">
<div class="isolate inline-flex rounded-md shadow-sm">
{choices.map((choice, idx) => {
+ const convertedValue = converter?.fromStringUI(choice.value as any)
const isFirst = idx === 0;
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 (convertedValue !== undefined && convertedValue === value) {
clazz +=
" text-white bg-indigo-600 hover:bg-indigo-500 ring-2 ring-indigo-600 hover:ring-indigo-500";
} else {
@@ -62,12 +56,13 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>(
return (
<button
type="button"
+ key={idx}
disabled={state.disabled}
label={choice.label}
class={clazz}
onClick={(e) => {
onChange(
- (value === choice.value ? undefined : choice.value) as T[K],
+ (value === choice.value ? undefined : convertedValue) as any,
);
}}
>
diff --git a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx
index ed5170d17..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,12 +43,12 @@ 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: [{
type: "choiceStacked",
- props: {
+ properties: {
label: "label of the field" as TranslatedString,
name: "comment",
choices: [{
diff --git a/packages/web-util/src/forms/InputChoiceStacked.tsx b/packages/web-util/src/forms/InputChoiceStacked.tsx
index 234bb2255..1928f4365 100644
--- a/packages/web-util/src/forms/InputChoiceStacked.tsx
+++ b/packages/web-util/src/forms/InputChoiceStacked.tsx
@@ -3,6 +3,7 @@ import { Fragment, VNode, h } from "preact";
import { UIFormProps } from "./FormProvider.js";
import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
import { useField } from "./useField.js";
+import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
export interface ChoiceS<V> {
label: TranslatedString;
@@ -27,7 +28,12 @@ export function InputChoiceStacked<T extends object, K extends keyof T>(
after,
converter,
} = props;
- const { value, onChange, state, isDirty } = useField<T, K>(name);
+
+ //FIXME: remove deprecated
+ const fieldCtx = useField<T, K>(props.name);
+ const { value, onChange, state } =
+ props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name);
+
if (state.hidden) {
return <Fragment />;
}
@@ -41,7 +47,7 @@ export function InputChoiceStacked<T extends object, K extends keyof T>(
/>
<fieldset class="mt-2">
<div class="space-y-4">
- {choices.map((choice) => {
+ {choices.map((choice, idx) => {
// const currentValue = !converter
// ? choice.value
// : converter.fromStringUI(choice.value) ?? "";
@@ -56,7 +62,7 @@ export function InputChoiceStacked<T extends object, K extends keyof T>(
}
return (
- <label class={clazz}>
+ <label key={idx} class={clazz}>
<input
type="radio"
name="server-size"
@@ -71,7 +77,7 @@ export function InputChoiceStacked<T extends object, K extends keyof T>(
onChange(
(value === choice.value
? undefined
- : choice.value) as T[K],
+ : choice.value) as any,
);
}}
class="sr-only"
diff --git a/packages/web-util/src/forms/InputFile.stories.tsx b/packages/web-util/src/forms/InputFile.stories.tsx
index ba06debf9..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,12 +43,12 @@ 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: [{
type: "file",
- props: {
+ properties: {
label: "label of the field" as TranslatedString,
name: "comment",
required: true,
diff --git a/packages/web-util/src/forms/InputFile.tsx b/packages/web-util/src/forms/InputFile.tsx
index 6337d0902..cd0a96d1c 100644
--- a/packages/web-util/src/forms/InputFile.tsx
+++ b/packages/web-util/src/forms/InputFile.tsx
@@ -1,5 +1,6 @@
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";
@@ -7,20 +8,36 @@ 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,
maxBites,
accept,
} = props;
- const { value, onChange, state } = useField<T, K>(name);
- const help = propsHelp ?? state.help
+ //FIXME: remove deprecated
+ const fieldCtx = useField<T, K>(props.name);
+ const { value, onChange, state } =
+ props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name);
+
+ const help = propsHelp ?? state.help;
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
@@ -28,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
@@ -43,16 +60,15 @@ export function InputFile<T extends object, K extends keyof T>(
clip-rule="evenodd"
/>
</svg>
- {!state.disabled &&
+ {!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}
@@ -64,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(
@@ -71,24 +88,40 @@ 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,
+ );
+ }
});
}}
/>
</label>
{/* <p class="pl-1">or drag and drop</p> */}
</div>
- }
+ )}
</div>
</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 &&
+ {!state.disabled && (
<div
class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer "
onClick={() => {
@@ -97,7 +130,7 @@ export function InputFile<T extends object, K extends keyof T>(
>
Clear
</div>
- }
+ )}
</div>
)}
{help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>}
diff --git a/packages/web-util/src/forms/InputInteger.stories.tsx b/packages/web-util/src/forms/InputInteger.stories.tsx
index bd1a467ab..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,12 +38,12 @@ const initial: TargetObject = {
age: 5,
}
-const form: FlexibleForm<TargetObject> = {
+const form: FlexibleForm_Deprecated<TargetObject> = {
design: [{
title: "this is a simple form" as TranslatedString,
fields: [{
type: "integer",
- props: {
+ properties: {
label: "label of the field" as TranslatedString,
name: "age",
tooltip: "just numbers" as TranslatedString,
diff --git a/packages/web-util/src/forms/InputLine.stories.tsx b/packages/web-util/src/forms/InputLine.stories.tsx
index da41a221e..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,12 +43,12 @@ 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: [{
type: "text",
- props: {
+ properties: {
label: "label of the field" as TranslatedString,
name: "comment",
},
diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx
index b8879f9ec..eb3238ef9 100644
--- a/packages/web-util/src/forms/InputLine.tsx
+++ b/packages/web-util/src/forms/InputLine.tsx
@@ -1,7 +1,7 @@
import { TranslatedString } from "@gnu-taler/taler-util";
import { ComponentChildren, Fragment, VNode, h } from "preact";
-import { useEffect, useState } from "preact/hooks";
-import { UIFormProps } from "./FormProvider.js";
+import { Addon, UIFormProps } from "./FormProvider.js";
+import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
import { useField } from "./useField.js";
//@ts-ignore
@@ -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,
@@ -78,7 +109,11 @@ function InputWrapper<T extends object, K extends keyof T>({
error,
disabled,
required,
-}: { error?: string; disabled: boolean, children: ComponentChildren } & UIFormProps<T, K>): VNode {
+}: {
+ error?: string;
+ disabled: boolean;
+ children: ComponentChildren;
+} & UIFormProps<T, K>): VNode {
return (
<div class="sm:col-span-6">
<LabelWithTooltipMaybeRequired
@@ -87,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">
@@ -156,19 +155,22 @@ export function InputLine<T extends object, K extends keyof T>(
props: { type: InputType } & UIFormProps<T, K>,
): VNode {
const { name, placeholder, before, after, converter, type } = props;
- const { value, onChange, state, isDirty } = useField<T, K>(name);
+ //FIXME: remove deprecated
+ const fieldCtx = useField<T, K>(props.name);
+ const { value, onChange, state, error } =
+ props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name);
- const [text, setText] = useState("")
+ // const [text, setText] = useState("");
const fromString: (s: string) => any =
converter?.fromStringUI ?? defaultFromString;
const toString: (s: any) => string = converter?.toStringUI ?? defaultToString;
- useEffect(() => {
- const newValue = toString(value)
- if (newValue) {
- setText(newValue)
- }
- }, [value])
+ // useEffect(() => {
+ // const newValue = toString(value);
+ // if (newValue) {
+ // setText(newValue);
+ // }
+ // }, [value]);
if (state.hidden) return <div />;
@@ -206,7 +208,7 @@ export function InputLine<T extends object, K extends keyof T>(
}
}
}
- const showError = isDirty && state.error;
+ const showError = value !== undefined && error;
if (showError) {
clazz +=
" text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500";
@@ -221,7 +223,7 @@ export function InputLine<T extends object, K extends keyof T>(
{...props}
help={props.help ?? state.help}
disabled={state.disabled ?? false}
- error={showError ? state.error : undefined}
+ error={showError ? error : undefined}
>
<textarea
rows={4}
@@ -242,21 +244,23 @@ export function InputLine<T extends object, K extends keyof T>(
}
return (
- <InputWrapper<T, K> {...props}
+ <InputWrapper<T, K>
+ {...props}
help={props.help ?? state.help}
- disabled={state.disabled ?? false} error={showError ? state.error : undefined}
+ disabled={state.disabled ?? false}
+ error={showError ? error : undefined}
>
<input
name={String(name)}
type={type}
onChange={(e) => {
- setText(e.currentTarget.value)
+ onChange(fromString(e.currentTarget.value));
}}
placeholder={placeholder ? placeholder : undefined}
- value={text}
- onBlur={() => {
- onChange(fromString(text));
- }}
+ 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 6ce5445c0..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,12 +45,12 @@ const initial: TargetObject = {
things: [],
}
-const form: FlexibleForm<TargetObject> = {
+const form: FlexibleForm_Deprecated<TargetObject> = {
design: [{
title: "this is a simple form" as TranslatedString,
fields: [{
type: "selectMultiple",
- props: {
+ properties: {
label: "allow diplicates" as TranslatedString,
name: "pets",
placeholder: "search..." as TranslatedString,
@@ -67,7 +67,7 @@ const form: FlexibleForm<TargetObject> = {
},
}, {
type: "selectMultiple",
- props: {
+ properties: {
label: "unique values" as TranslatedString,
name: "things",
unique: true,
diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx b/packages/web-util/src/forms/InputSelectMultiple.tsx
index a67eb23b7..1bcf85061 100644
--- a/packages/web-util/src/forms/InputSelectMultiple.tsx
+++ b/packages/web-util/src/forms/InputSelectMultiple.tsx
@@ -1,6 +1,7 @@
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { UIFormProps } from "./FormProvider.js";
+import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
import { ChoiceS } from "./InputChoiceStacked.js";
import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
import { useField } from "./useField.js";
@@ -12,23 +13,28 @@ export function InputSelectMultiple<T extends object, K extends keyof T>(
max?: number;
} & UIFormProps<T, K>,
): VNode {
- const { name, label, choices, placeholder, tooltip, required, unique, max } =
- props;
- const { value, onChange, state } = useField<T, K>(name);
+ const { converter, label, choices, placeholder, tooltip, required, unique, max } = props;
+ //FIXME: remove deprecated
+ const fieldCtx = useField<T, K>(props.name);
+ const { value, onChange, state } =
+ props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name);
const [filter, setFilter] = useState<string | undefined>(undefined);
const regex = new RegExp(`.*${filter}.*`, "i");
- const choiceMap = choices.reduce((prev, curr) => {
- return { ...prev, [curr.value as string]: curr.label };
- }, {} as Record<string, string>);
+ const choiceMap = choices.reduce(
+ (prev, curr) => {
+ return { ...prev, [curr.value as string]: curr.label };
+ },
+ {} as Record<string, string>,
+ );
const list = (value ?? []) as string[];
const filteredChoices =
filter === undefined
? undefined
: choices.filter((v) => {
- return regex.test(v.label);
- });
+ return regex.test(v.label);
+ });
return (
<div class="sm:col-span-6">
<LabelWithTooltipMaybeRequired
@@ -38,7 +44,10 @@ export function InputSelectMultiple<T extends object, K extends keyof T>(
/>
{list.map((v, idx) => {
return (
- <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600">
+ <span
+ key={idx}
+ class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600"
+ >
{choiceMap[v]}
<button
type="button"
@@ -46,7 +55,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>(
onClick={() => {
const newValue = [...list];
newValue.splice(idx, 1);
- onChange(newValue as T[K]);
+ onChange(newValue as any);
setFilter(undefined);
}}
class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20"
@@ -64,91 +73,94 @@ export function InputSelectMultiple<T extends object, K extends keyof T>(
);
})}
- {!state.disabled && <div class="relative mt-2">
- <input
- id="combobox"
- type="text"
- value={filter ?? ""}
- onChange={(e) => {
- setFilter(e.currentTarget.value);
- }}
- placeholder={placeholder}
- class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
- role="combobox"
- aria-controls="options"
- aria-expanded="false"
- />
- <button
- type="button"
- disabled={state.disabled}
- onClick={() => {
- setFilter(filter === undefined ? "" : undefined);
- }}
- class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
- >
- <svg
- class="h-5 w-5 text-gray-400"
- viewBox="0 0 20 20"
- fill="currentColor"
- aria-hidden="true"
+ {!state.disabled && (
+ <div class="relative mt-2">
+ <input
+ id="combobox"
+ type="text"
+ value={filter ?? ""}
+ onChange={(e) => {
+ setFilter(e.currentTarget.value);
+ }}
+ placeholder={placeholder}
+ class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ role="combobox"
+ aria-controls="options"
+ aria-expanded="false"
+ />
+ <button
+ type="button"
+ disabled={state.disabled}
+ onClick={() => {
+ setFilter(filter === undefined ? "" : undefined);
+ }}
+ class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
- <path
- fill-rule="evenodd"
- d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
- clip-rule="evenodd"
- />
- </svg>
- </button>
+ <svg
+ class="h-5 w-5 text-gray-400"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </button>
- {filteredChoices !== undefined && (
- <ul
- class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
- id="options"
- role="listbox"
- >
- {filteredChoices.map((v, idx) => {
- return (
- <li
- class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"
- id="option-0"
- role="option"
- onClick={() => {
- setFilter(undefined);
- if (unique && list.indexOf(v.value as string) !== -1) {
- return;
- }
- if (max !== undefined && list.length >= max) {
- return;
- }
- const newValue = [...list];
- newValue.splice(0, 0, v.value as string);
- onChange(newValue as T[K]);
- }}
+ {filteredChoices !== undefined && (
+ <ul
+ class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
+ id="options"
+ role="listbox"
+ >
+ {filteredChoices.map((v, idx) => {
+ return (
+ <li
+ key={idx}
+ class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"
+ id="option-0"
+ role="option"
+ onClick={() => {
+ setFilter(undefined);
+ if (unique && list.indexOf(v.value as string) !== -1) {
+ return;
+ }
+ if (max !== undefined && list.length >= max) {
+ return;
+ }
+ const newValue = [...list];
+ newValue.splice(0, 0, v.value as string);
+ onChange(newValue as any);
+ }}
- // tabindex="-1"
- >
- {/* <!-- Selected: "font-semibold" --> */}
- <span class="block truncate">{v.label}</span>
+ // tabindex="-1"
+ >
+ {/* <!-- Selected: "font-semibold" --> */}
+ <span class="block truncate">{v.label}</span>
- {/* <!--
+ {/* <!--
Checkmark, only display for selected option.
Active: "text-white", Not Active: "text-indigo-600"
--> */}
- </li>
- );
- })}
+ </li>
+ );
+ })}
- {/* <!--
+ {/* <!--
Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation.
Active: "text-white bg-indigo-600", Not Active: "text-gray-900"
--> */}
- {/* <!-- More items... --> */}
- </ul>
- )}
- </div>}
+ {/* <!-- More items... --> */}
+ </ul>
+ )}
+ </div>
+ )}
</div>
);
}
diff --git a/packages/web-util/src/forms/InputSelectOne.stories.tsx b/packages/web-util/src/forms/InputSelectOne.stories.tsx
index 9e9029a77..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,12 +43,12 @@ const initial: TargetObject = {
things: "one"
}
-const form: FlexibleForm<TargetObject> = {
+const form: FlexibleForm_Deprecated<TargetObject> = {
design: [{
title: "this is a simple form" as TranslatedString,
fields: [{
type: "selectOne",
- props: {
+ properties: {
label: "label of the field" as TranslatedString,
name: "things",
placeholder: "search..." as TranslatedString,
diff --git a/packages/web-util/src/forms/InputSelectOne.tsx b/packages/web-util/src/forms/InputSelectOne.tsx
index d100b079d..26f887b08 100644
--- a/packages/web-util/src/forms/InputSelectOne.tsx
+++ b/packages/web-util/src/forms/InputSelectOne.tsx
@@ -4,27 +4,35 @@ import { UIFormProps } from "./FormProvider.js";
import { ChoiceS } from "./InputChoiceStacked.js";
import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
import { useField } from "./useField.js";
+import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
export function InputSelectOne<T extends object, K extends keyof T>(
props: {
choices: ChoiceS<T[K]>[];
} & UIFormProps<T, K>,
): VNode {
- const { name, label, choices, placeholder, tooltip, required } = props;
- const { value, onChange } = useField<T, K>(name);
+ const { label, choices, placeholder, tooltip, required } = props;
+ //FIXME: remove deprecated
+ const fieldCtx = useField<T, K>(props.name);
+ const { value, onChange } =
+ props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name);
+
const [filter, setFilter] = useState<string | undefined>(undefined);
const regex = new RegExp(`.*${filter}.*`, "i");
- const choiceMap = choices.reduce((prev, curr) => {
- return { ...prev, [curr.value as string]: curr.label };
- }, {} as Record<string, string>);
+ const choiceMap = choices.reduce(
+ (prev, curr) => {
+ return { ...prev, [curr.value as string]: curr.label };
+ },
+ {} as Record<string, string>,
+ );
const filteredChoices =
filter === undefined
? undefined
: choices.filter((v) => {
- return regex.test(v.label);
- });
+ return regex.test(v.label);
+ });
return (
<div class="sm:col-span-6">
<LabelWithTooltipMaybeRequired
@@ -97,15 +105,16 @@ export function InputSelectOne<T extends object, K extends keyof T>(
{filteredChoices.map((v, idx) => {
return (
<li
+ key={idx}
class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"
id="option-0"
role="option"
onClick={() => {
setFilter(undefined);
- onChange(v.value as T[K]);
+ onChange(v.value as any);
}}
- // tabindex="-1"
+ // tabindex="-1"
>
{/* <!-- Selected: "font-semibold" --> */}
<span class="block truncate">{v.label}</span>
diff --git a/packages/web-util/src/forms/InputText.stories.tsx b/packages/web-util/src/forms/InputText.stories.tsx
index 04ab8a1c6..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,12 +43,12 @@ 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: [{
type: "text",
- props: {
+ properties: {
label: "label of the field" as TranslatedString,
name: "comment",
},
diff --git a/packages/web-util/src/forms/InputTextArea.stories.tsx b/packages/web-util/src/forms/InputTextArea.stories.tsx
index c8c3eb088..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,12 +43,12 @@ 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: [{
type: "text",
- props: {
+ properties: {
label: "label of the field" as TranslatedString,
name: "comment",
},
diff --git a/packages/web-util/src/forms/InputToggle.stories.tsx b/packages/web-util/src/forms/InputToggle.stories.tsx
index ca6857618..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,12 +43,12 @@ 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: [{
type: "toggle",
- props: {
+ properties: {
label: "label of the field" as TranslatedString,
name: "comment",
},
diff --git a/packages/web-util/src/forms/InputToggle.tsx b/packages/web-util/src/forms/InputToggle.tsx
index 56b29c502..58386045c 100644
--- a/packages/web-util/src/forms/InputToggle.tsx
+++ b/packages/web-util/src/forms/InputToggle.tsx
@@ -1,5 +1,6 @@
import { VNode, h } from "preact";
import { UIFormProps } from "./FormProvider.js";
+import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
import { useField } from "./useField.js";
@@ -17,22 +18,39 @@ export function InputToggle<T extends object, K extends keyof T>(
after,
converter,
} = props;
- const { value, onChange, state, isDirty } = useField<T, K>(name);
+ //FIXME: remove deprecated
+ const fieldCtx = useField<T, K>(props.name);
+ const { value, onChange } =
+ props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name);
- const isOn = !!value
- return <div class="sm:col-span-6">
- <div class="flex items-center justify-between">
- <LabelWithTooltipMaybeRequired
- label={label}
- required={required}
- tooltip={tooltip}
- />
- <button type="button" data-enabled={isOn}
- class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
- role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
- onClick={() => { onChange(!isOn as any); }}>
- <span aria-hidden="true" data-enabled={isOn} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
- </button>
+ const isOn = !!value;
+ return (
+ <div class="sm:col-span-6">
+ <div class="flex items-center justify-between">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ <button
+ type="button"
+ data-enabled={isOn}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ onChange(!isOn as any);
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={isOn}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
</div>
- </div>
+ );
}
diff --git a/packages/web-util/src/forms/converter.ts b/packages/web-util/src/forms/converter.ts
new file mode 100644
index 000000000..eee891776
--- /dev/null
+++ b/packages/web-util/src/forms/converter.ts
@@ -0,0 +1,130 @@
+/*
+ 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}`);
+ }
+}
+
+const nullConverter: StringConverter<string> = {
+ fromStringUI(v: string | undefined): string {
+ return v ?? "";
+ },
+ toStringUI(v: unknown): string {
+ return v as string;
+ },
+};
+
+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 nullConverter as StringConverter<unknown>;
+}
diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts
index 3b8620bfb..4c5050830 100644
--- a/packages/web-util/src/forms/forms.ts
+++ b/packages/web-util/src/forms/forms.ts
@@ -1,6 +1,5 @@
import { h as create, Fragment, VNode } from "preact";
import { Caption } from "./Caption.js";
-import { FormProvider } from "./FormProvider.js";
import { Group } from "./Group.js";
import { InputAbsoluteTime } from "./InputAbsoluteTime.js";
import { InputAmount } from "./InputAmount.js";
@@ -9,13 +8,15 @@ import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js";
import { InputChoiceStacked } from "./InputChoiceStacked.js";
import { InputFile } from "./InputFile.js";
import { InputInteger } from "./InputInteger.js";
-import { InputLine } from "./InputLine.js";
import { InputSelectMultiple } from "./InputSelectMultiple.js";
import { InputSelectOne } from "./InputSelectOne.js";
import { InputText } from "./InputText.js";
import { InputTextArea } from "./InputTextArea.js";
import { InputToggle } from "./InputToggle.js";
-
+import { Addon, StringConverter, UIFieldHandler } from "./FormProvider.js";
+import { InternationalizationAPI, UIFieldElementDescription } from "../index.browser.js";
+import { assertUnreachable, TranslatedString } from "@gnu-taler/taler-util";
+import {UIFormFieldBaseConfig, UIFormElementConfig} from "./ui-form.js";
/**
* Constrain the type with the ui props
*/
@@ -30,7 +31,7 @@ type FieldType<T extends object = any, K extends keyof T = any> = {
textArea: Parameters<typeof InputTextArea<T, K>>[0];
choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0];
choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0];
- absoluteTime: Parameters<typeof InputAbsoluteTime<T, K>>[0];
+ absoluteTimeText: Parameters<typeof InputAbsoluteTime<T, K>>[0];
integer: Parameters<typeof InputInteger<T, K>>[0];
toggle: Parameters<typeof InputToggle<T, K>>[0];
amount: Parameters<typeof InputAmount<T, K>>[0];
@@ -40,20 +41,32 @@ type FieldType<T extends object = any, K extends keyof T = any> = {
* List all the form fields so typescript can type-check the form instance
*/
export type UIFormField =
- | { type: "group"; props: FieldType["group"] }
- | { type: "caption"; props: FieldType["caption"] }
- | { type: "array"; props: FieldType["array"] }
- | { type: "file"; props: FieldType["file"] }
- | { type: "amount"; props: FieldType["amount"] }
- | { type: "selectOne"; props: FieldType["selectOne"] }
- | { type: "selectMultiple"; props: FieldType["selectMultiple"] }
- | { type: "text"; props: FieldType["text"] }
- | { type: "textArea"; props: FieldType["textArea"] }
- | { type: "choiceStacked"; props: FieldType["choiceStacked"] }
- | { type: "choiceHorizontal"; props: FieldType["choiceHorizontal"] }
- | { type: "integer"; props: FieldType["integer"] }
- | { type: "toggle"; props: FieldType["toggle"] }
- | { type: "absoluteTime"; props: FieldType["absoluteTime"] };
+ | { type: "group"; properties: FieldType["group"] }
+ | { type: "caption"; properties: FieldType["caption"] }
+ | { type: "array"; properties: FieldType["array"] }
+ | { type: "file"; properties: FieldType["file"] }
+ | { type: "amount"; properties: FieldType["amount"] }
+ | { type: "selectOne"; properties: FieldType["selectOne"] }
+ | {
+ type: "selectMultiple";
+ properties: FieldType["selectMultiple"];
+ }
+ | { type: "text"; properties: FieldType["text"] }
+ | { type: "textArea"; properties: FieldType["textArea"] }
+ | {
+ type: "choiceStacked";
+ properties: FieldType["choiceStacked"];
+ }
+ | {
+ type: "choiceHorizontal";
+ properties: FieldType["choiceHorizontal"];
+ }
+ | { type: "integer"; properties: FieldType["integer"] }
+ | { type: "toggle"; properties: FieldType["toggle"] }
+ | {
+ type: "absoluteTimeText";
+ properties: FieldType["absoluteTimeText"];
+ };
type FieldComponentFunction<key extends keyof FieldType> = (
props: FieldType[key],
@@ -76,7 +89,7 @@ const UIFormConfiguration: UIFormFieldMap = {
file: InputFile,
textArea: InputTextArea,
//@ts-ignore
- absoluteTime: InputAbsoluteTime,
+ absoluteTimeText: InputAbsoluteTime,
//@ts-ignore
choiceStacked: InputChoiceStacked,
//@ts-ignore
@@ -104,31 +117,256 @@ export function RenderAllFieldsByUiConfig({
const Component = UIFormConfiguration[
field.type
] as FieldComponentFunction<any>;
- return Component(field.props);
+ return Component(field.properties);
}),
);
}
-type FormSet<T extends object> = {
- Provider: typeof FormProvider<T>;
- InputLine: <K extends keyof T>() => typeof InputLine<T, K>;
- InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal<T, K>;
-};
+// type FormSet<T extends object> = {
+// Provider: typeof FormProvider<T>;
+// InputLine: <K extends keyof T>() => typeof InputLine<T, K>;
+// InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal<T, K>;
+// };
/**
* Helper function that created a typed object.
+ *
+ * @returns
+ */
+// export function createNewForm<T extends object>() {
+// const res: FormSet<T> = {
+// Provider: FormProvider,
+// InputLine: () => InputLine,
+// InputChoiceHorizontal: () => InputChoiceHorizontal,
+// };
+// return {
+// Provider: res.Provider,
+// InputLine: res.InputLine(),
+// InputChoiceHorizontal: res.InputChoiceHorizontal(),
+// };
+// }
+
+/**
+ * convert field configuration to render function
*
+ * @param i18n_
+ * @param fieldConfig
+ * @param form
* @returns
*/
-export function createNewForm<T extends object>() {
- const res: FormSet<T> = {
- Provider: FormProvider,
- InputLine: () => InputLine,
- InputChoiceHorizontal: () => InputChoiceHorizontal,
+export function convertUiField(
+ i18n_: InternationalizationAPI,
+ fieldConfig: UIFormElementConfig[],
+ 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),
+ };
+ return resp;
+ }
+ case "group": {
+ const resp: UIFormField = {
+ type: config.type,
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ fields: convertUiField(i18n_, config.fields, form, getConverterById),
+ },
+ };
+ return resp;
+ }
+ }
+ // Input Fields
+ switch (config.type) {
+ case "array": {
+ return {
+ type: "array",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ labelField: config.labelFieldId,
+ fields: convertUiField(i18n_, config.fields, form, getConverterById),
+ },
+ } as UIFormField;
+ }
+ case "absoluteTimeText": {
+ return {
+ type: "absoluteTimeText",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ },
+ } as UIFormField;
+ }
+ case "amount": {
+ return {
+ type: "amount",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ currency: config.currency,
+ },
+ } as UIFormField;
+ }
+ case "choiceHorizontal": {
+ return {
+ type: "choiceHorizontal",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ choices: config.choices,
+ },
+ } as UIFormField;
+ }
+ case "choiceStacked": {
+ return {
+ type: "choiceStacked",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ choices: config.choices,
+
+ },
+ }as UIFormField;
+ }
+ case "file":{
+ return {
+ type: "file",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ accept: config.accept,
+ maxBites: config.maxBytes,
+ },
+ } as UIFormField;
+ }
+ case "integer":{
+ return {
+ type: "integer",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ },
+ } as UIFormField;
+ }
+ case "selectMultiple":{
+ return {
+ type: "selectMultiple",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ choices: config.choices,
+ },
+ } as UIFormField;
+ }
+ case "selectOne": {
+ return {
+ type: "selectOne",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ choices: config.choices,
+ },
+ } as UIFormField;
+ }
+ case "text": {
+ return {
+ type: "text",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ },
+ } as UIFormField;
+ }
+ case "textArea": {
+ return {
+ type: "text",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, getConverterById),
+ },
+ } as UIFormField;
+ }
+ case "toggle": {
+ return {
+ type: "toggle",
+ properties: {
+ ...converBaseFieldsProps(i18n_, config),
+ ...converInputFieldsProps(form, config, 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: UIFieldElementDescription,
+) {
return {
- Provider: res.Provider,
- InputLine: res.InputLine(),
- InputChoiceHorizontal: res.InputChoiceHorizontal(),
+ 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}`,
};
}
+
+export 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..012499d6d
--- /dev/null
+++ b/packages/web-util/src/forms/ui-form.ts
@@ -0,0 +1,363 @@
+import {
+ buildCodecForObject,
+ buildCodecForUnion,
+ Codec,
+ codecForBoolean,
+ codecForConstString,
+ codecForLazy,
+ codecForList,
+ codecForNumber,
+ codecForString,
+ codecForTimestamp,
+ codecOptional,
+ Integer,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+
+export type FormConfiguration = DoubleColumnForm;
+
+export type DoubleColumnForm = {
+ type: "double-column";
+ design: DoubleColumnFormSection[];
+ // behavior?: (form: Partial<T>) => FormState<T>;
+};
+
+export type DoubleColumnFormSection = {
+ title: string;
+ description?: string;
+ fields: UIFormElementConfig[];
+};
+
+// export interface BaseForm {
+// state: TalerExchangeApi.AmlState;
+// threshold: AmountJson;
+// }
+
+export type UIFormElementConfig =
+ | UIFormElementGroup
+ | UIFormElementCaption
+ | UIFormFieldAbsoluteTime
+ | UIFormFieldAmount
+ | UIFormFieldArray
+ | UIFormFieldChoiseHorizontal
+ | UIFormFieldChoiseStacked
+ | UIFormFieldFile
+ | UIFormFieldInteger
+ | UIFormFieldSelectMultiple
+ | UIFormFieldSelectOne
+ | UIFormFieldText
+ | UIFormFieldTextArea
+ | UIFormFieldToggle;
+
+type UIFormFieldAbsoluteTime = {
+ type: "absoluteTimeText";
+ max?: TalerProtocolTimestamp;
+ min?: TalerProtocolTimestamp;
+ pattern: string;
+} & UIFormFieldBaseConfig;
+
+type UIFormFieldAmount = {
+ type: "amount";
+ max?: Integer;
+ min?: Integer;
+ currency: string;
+} & UIFormFieldBaseConfig;
+
+type UIFormFieldArray = {
+ type: "array";
+ // id of the field shown when the array is collapsed
+ labelFieldId: UIHandlerId;
+ fields: UIFormElementConfig[];
+} & UIFormFieldBaseConfig;
+
+type UIFormElementCaption = { type: "caption" } & UIFieldElementDescription;
+
+type UIFormElementGroup = {
+ type: "group";
+ fields: UIFormElementConfig[];
+} & UIFieldElementDescription;
+
+type UIFormFieldChoiseHorizontal = {
+ type: "choiceHorizontal";
+ choices: Array<SelectUiChoice>;
+} & UIFormFieldBaseConfig;
+
+type UIFormFieldChoiseStacked = {
+ type: "choiceStacked";
+ choices: Array<SelectUiChoice>;
+} & UIFormFieldBaseConfig;
+
+type UIFormFieldFile = {
+ type: "file";
+ 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;
+} & UIFormFieldBaseConfig;
+
+type UIFormFieldInteger = {
+ type: "integer";
+ max?: Integer;
+ min?: Integer;
+} & UIFormFieldBaseConfig;
+
+interface SelectUiChoice {
+ label: string;
+ description?: string;
+ value: string;
+}
+
+type UIFormFieldSelectMultiple = {
+ type: "selectMultiple";
+ max?: Integer;
+ min?: Integer;
+ unique?: boolean;
+ choices: Array<SelectUiChoice>;
+} & UIFormFieldBaseConfig;
+
+type UIFormFieldSelectOne = {
+ type: "selectOne";
+ choices: Array<SelectUiChoice>;
+} & UIFormFieldBaseConfig;
+type UIFormFieldText = { type: "text" } & UIFormFieldBaseConfig;
+type UIFormFieldTextArea = { type: "textArea" } & UIFormFieldBaseConfig;
+type UIFormFieldToggle = { type: "toggle" } & UIFormFieldBaseConfig;
+
+export type UIFieldElementDescription = {
+ /* 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, usually below and dimmer*/
+ 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 = UIFieldElementDescription & {
+ /* 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 UIFieldElementDescription,
+>() =>
+ 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 codecForUiFormFieldAbsoluteTime = (): Codec<UIFormFieldAbsoluteTime> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldAbsoluteTime>()
+ .property("type", codecForConstString("absoluteTimeText"))
+ .property("pattern", codecForString())
+ .property("max", codecOptional(codecForTimestamp))
+ .property("min", codecOptional(codecForTimestamp))
+ .build("UIFormFieldAbsoluteTime");
+
+const codecForUiFormFieldAmount = (): Codec<UIFormFieldAmount> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldAmount>()
+ .property("type", codecForConstString("amount"))
+ .property("currency", codecForString())
+ .property("max", codecOptional(codecForNumber()))
+ .property("min", codecOptional(codecForNumber()))
+ .build("UIFormFieldAmount");
+
+const codecForUiFormFieldArray = (): Codec<UIFormFieldArray> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldArray>()
+ .property("type", codecForConstString("array"))
+ .property("labelFieldId", codecForUiFieldId())
+ .property("tooltip", codecOptional(codecForString()))
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ .property("fields", codecForList(codecForUiFormField()))
+ .build("UIFormFieldArray");
+
+const codecForUiFormFieldCaption = (): Codec<UIFormElementCaption> =>
+ codecForUIFormFieldBaseDescriptionTemplate<UIFormElementCaption>()
+ .property("type", codecForConstString("caption"))
+ .build("UIFormFieldCaption");
+
+const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> =>
+ buildCodecForObject<SelectUiChoice>()
+ .property("description", codecOptional(codecForString()))
+ .property("label", codecForString())
+ .property("value", codecForString())
+ .build("SelectUiChoice");
+
+const codecForUiFormFieldChoiceHorizontal =
+ (): Codec<UIFormFieldChoiseHorizontal> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseHorizontal>()
+ .property("type", codecForConstString("choiceHorizontal"))
+ .property("choices", codecForList(codecForUiFormSelectUiChoice()))
+ .build("UIFormFieldChoiseHorizontal");
+
+const codecForUiFormFieldChoiceStacked = (): Codec<UIFormFieldChoiseStacked> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseStacked>()
+ .property("type", codecForConstString("choiceStacked"))
+ .property("choices", codecForList(codecForUiFormSelectUiChoice()))
+ .build("UIFormFieldChoiseStacked");
+
+const codecForUiFormFieldFile = (): Codec<UIFormFieldFile> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldFile>()
+ .property("type", codecForConstString("file"))
+ .property("accept", codecOptional(codecForString()))
+ .property("maxBytes", codecOptional(codecForNumber()))
+ .property("minBytes", codecOptional(codecForNumber()))
+ .build("UIFormFieldFile");
+
+const codecForUiFormFieldGroup = (): Codec<UIFormElementGroup> =>
+ codecForUIFormFieldBaseDescriptionTemplate<UIFormElementGroup>()
+ .property("type", codecForConstString("group"))
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ .property("fields", codecForList(codecForUiFormField()))
+ .build("UiFormFieldGroup");
+
+const codecForUiFormFieldInteger = (): Codec<UIFormFieldInteger> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldInteger>()
+ .property("type", codecForConstString("integer"))
+ // .property("properties", codecForUIFormFieldBaseConfig())
+ .property("max", codecOptional(codecForNumber()))
+ .property("min", codecOptional(codecForNumber()))
+ .build("UIFormFieldInteger");
+
+const codecForUiFormFieldSelectMultiple =
+ (): Codec<UIFormFieldSelectMultiple> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectMultiple>()
+ .property("type", codecForConstString("selectMultiple"))
+ .property("max", codecOptional(codecForNumber()))
+ .property("min", codecOptional(codecForNumber()))
+ .property("unique", codecOptional(codecForBoolean()))
+ .property("choices", codecForList(codecForUiFormSelectUiChoice()))
+ .build("UiFormFieldSelectMultiple");
+
+const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldSelectOne> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectOne>()
+ .property("type", codecForConstString("selectOne"))
+ .property("choices", codecForList(codecForUiFormSelectUiChoice()))
+ .build("UIFormFieldSelectOne");
+
+const codecForUiFormFieldText = (): Codec<UIFormFieldText> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldText>()
+ .property("type", codecForConstString("text"))
+ .build("UIFormFieldText");
+
+const codecForUiFormFieldTextArea = (): Codec<UIFormFieldTextArea> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldTextArea>()
+ .property("type", codecForConstString("textArea"))
+ .build("UIFormFieldTextArea");
+
+const codecForUiFormFieldToggle = (): Codec<UIFormFieldToggle> =>
+ codecForUIFormFieldBaseConfigTemplate<UIFormFieldToggle>()
+ .property("type", codecForConstString("toggle"))
+ .build("UIFormFieldToggle");
+
+const codecForUiFormField = (): Codec<UIFormElementConfig> =>
+ buildCodecForUnion<UIFormElementConfig>()
+ .discriminateOn("type")
+ .alternative("array", codecForLazy(codecForUiFormFieldArray))
+ .alternative("group", codecForLazy(codecForUiFormFieldGroup))
+ .alternative("absoluteTimeText", 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 codecForFormConfiguration = (): Codec<FormConfiguration> =>
+ buildCodecForUnion<FormConfiguration>()
+ .discriminateOn("type")
+ .alternative("double-column", codecForDoubleColumnForm())
+ .build<FormConfiguration>("FormConfiguration");
+
+const codecForFormMetadata = (): Codec<FormMetadata> =>
+ buildCodecForObject<FormMetadata>()
+ .property("label", codecForString())
+ .property("id", codecForString())
+ .property("version", codecForNumber())
+ .property("config", codecForFormConfiguration())
+ .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: FormConfiguration;
+};
+
+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 eed8cebea..a250d3100 100644
--- a/packages/web-util/src/forms/useField.ts
+++ b/packages/web-util/src/forms/useField.ts
@@ -1,30 +1,40 @@
-import { useContext, useState } from "preact/compat";
+import { useContext } from "preact/compat";
import { FieldUIOptions, FormContext } from "./FormProvider.js";
+import { TranslatedString } from "@gnu-taler/taler-util";
export interface InputFieldHandler<Type> {
value: Type;
onChange: (s: Type) => void;
state: FieldUIOptions;
- isDirty: boolean;
+ error?: TranslatedString | undefined;
}
+/**
+ * @deprecated removing this so we don't depend on context to create a form
+ * @param name
+ * @returns
+ */
export function useField<T extends object, K extends keyof T>(
name: K,
-): InputFieldHandler<T[K]> {
+): InputFieldHandler<T[K]> | undefined {
+ const ctx = useContext(FormContext);
+ if (!ctx) {
+ //no context, can't be used
+ return undefined;
+ }
const {
value: formValue,
computeFormState,
onUpdate: notifyUpdate,
readOnly: readOnlyForm,
- } = useContext(FormContext);
+ } = ctx
type P = typeof name;
type V = T[P];
const formState = computeFormState ? computeFormState(formValue.current) : {};
const fieldValue = readField(formValue.current, String(name)) as V;
- // console.log("USE FIELD", String(name), formValue.current, fieldValue);
- const [currentValue, setCurrentValue] = useState<any | undefined>(fieldValue);
+
const fieldState =
readField<Partial<FieldUIOptions>>(formState, String(name)) ?? {};
@@ -32,13 +42,12 @@ export function useField<T extends object, K extends keyof T>(
const state = {
disabled: readOnlyForm ? true : (fieldState.disabled ?? false),
hidden: fieldState.hidden ?? false,
- error: fieldState.error,
help: fieldState.help,
elements: "elements" in fieldState ? fieldState.elements ?? [] : [],
};
function onChange(value: V): void {
- setCurrentValue(value);
+ // setCurrentValue(value);
formValue.current = setValueDeeper(
formValue.current,
String(name).split("."),
@@ -52,7 +61,6 @@ export function useField<T extends object, K extends keyof T>(
return {
value: fieldValue,
onChange,
- isDirty: currentValue !== undefined,
state,
};
}
@@ -67,18 +75,8 @@ export function useField<T extends object, K extends keyof T>(
function readField<T>(
object: any,
name: string,
- debug?: boolean,
): T | undefined {
return name.split(".").reduce((prev, current) => {
- if (debug) {
- console.log(
- "READ",
- name,
- prev,
- current,
- prev ? prev[current] : undefined,
- );
- }
return prev ? prev[current] : undefined;
}, object);
}
diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts
index 99f4f2699..103b88c86 100644
--- a/packages/web-util/src/hooks/useNotifications.ts
+++ b/packages/web-util/src/hooks/useNotifications.ts
@@ -1,6 +1,7 @@
import {
AbsoluteTime,
Duration,
+ OperationAlternative,
OperationFail,
OperationOk,
OperationResult,
@@ -9,7 +10,7 @@ import {
TranslatedString,
} from "@gnu-taler/taler-util";
import { useEffect, useState } from "preact/hooks";
-import { ButtonHandler } from "../components/Button.js";
+import { ButtonHandler, OnOperationFailReturnType, OnOperationSuccesReturnType } from "../components/Button.js";
import {
InternationalizationAPI,
memoryMap,
@@ -207,14 +208,8 @@ export function useLocalNotification(): [
type HandlerMaker = <T extends OperationResult<A, B>, A, B>(
onClick: () => Promise<T | undefined>,
- onOperationSuccess:
- | ((result: T extends OperationOk<any> ? T : never) => void)
- | ((
- result: T extends OperationOk<any> ? T : never,
- ) => TranslatedString | undefined),
- onOperationFail: (
- d: T extends OperationFail<any> ? T : never,
- ) => TranslatedString,
+ onOperationSuccess: OnOperationSuccesReturnType<T>,
+ onOperationFail?: OnOperationFailReturnType<T>,
onOperationComplete?: () => void,
) => ButtonHandler<T, A, B>;
@@ -235,14 +230,8 @@ export function useLocalNotificationHandler(): [
function makeHandler<T extends OperationResult<A, B>, A, B>(
onClick: () => Promise<T | undefined>,
- onOperationSuccess:
- | ((result: T extends OperationOk<any> ? T : never) => void)
- | ((
- result: T extends OperationOk<any> ? T : never,
- ) => TranslatedString | undefined),
- onOperationFail: (
- d: T extends OperationFail<any> ? T : never,
- ) => TranslatedString,
+ onOperationSuccess:OnOperationSuccesReturnType<T>,
+ onOperationFail?: OnOperationFailReturnType<T>,
onOperationComplete?: () => void,
): ButtonHandler<T, A, B> {
return {
diff --git a/packages/web-util/src/utils/http-impl.sw.ts b/packages/web-util/src/utils/http-impl.sw.ts
index 3c4b8b587..9c820bb4b 100644
--- a/packages/web-util/src/utils/http-impl.sw.ts
+++ b/packages/web-util/src/utils/http-impl.sw.ts
@@ -59,6 +59,7 @@ export class BrowserFetchHttpLib implements HttpRequestLibrary {
const requestTimeout =
options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS);
const requestCancel = options?.cancellationToken;
+ const requestRedirect = options?.redirect;
const parsedUrl = new URL(requestUrl);
if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) {
@@ -115,6 +116,7 @@ export class BrowserFetchHttpLib implements HttpRequestLibrary {
body: myBody,
method: requestMethod,
signal: controller.signal,
+ redirect: requestRedirect
});
if (timeoutId) {
diff --git a/packages/web-util/src/utils/route.ts b/packages/web-util/src/utils/route.ts
index 4f8a020f6..494a61efa 100644
--- a/packages/web-util/src/utils/route.ts
+++ b/packages/web-util/src/utils/route.ts
@@ -74,7 +74,7 @@ export function findMatch<T extends ObjectOf<RouteDefinition>>(
pagesMap: T,
pageList: Array<keyof T>,
path: string,
- params: Record<string, string>,
+ params: Record<string, string[]>,
): Location<T> | undefined {
for (let idx = 0; idx < pageList.length; idx++) {
const name = pageList[idx];
@@ -82,10 +82,6 @@ export function findMatch<T extends ObjectOf<RouteDefinition>>(
if (found !== null) {
const values = {} as Record<string, unknown>;
- Object.entries(params).forEach(([key, value]) => {
- values[key] = value;
- });
-
if (found.groups !== undefined) {
Object.entries(found.groups).forEach(([key, value]) => {
values[key] = value;
@@ -93,7 +89,7 @@ export function findMatch<T extends ObjectOf<RouteDefinition>>(
}
// @ts-expect-error values is a map string which is equivalent to the RouteParamsType
- return { name, parent: pagesMap, values };
+ return { name, parent: pagesMap, values, params };
}
}
return undefined;
@@ -117,6 +113,7 @@ type MapKeyValue<Type> = {
parent: Type;
name: Key;
values: RouteParamsType<Type, Key>;
+ params: Record<string, string[]>;
}
: never;
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b1c5511c8..3b56c5e64 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,4 +1,4 @@
-lockfileVersion: '6.1'
+lockfileVersion: '6.0'
settings:
autoInstallPeers: true
@@ -61,7 +61,7 @@ importers:
version: 2.2.2(react@18.2.0)
devDependencies:
'@gnu-taler/pogen':
- specifier: ^0.0.5
+ specifier: workspace:*
version: link:../pogen
'@tailwindcss/forms':
specifier: ^0.5.3
@@ -263,7 +263,7 @@ importers:
specifier: ^1.2.0
version: 1.2.0
'@gnu-taler/pogen':
- specifier: ^0.0.5
+ specifier: workspace:*
version: link:../pogen
'@types/chai':
specifier: ^4.3.0
@@ -378,7 +378,7 @@ importers:
version: 2.0.3(react@18.2.0)
devDependencies:
'@gnu-taler/pogen':
- specifier: ^0.0.5
+ specifier: workspace:*
version: link:../pogen
'@tailwindcss/forms':
specifier: ^0.5.3
@@ -436,37 +436,86 @@ importers:
version: 5.3.3
packages/challenger-ui:
- devDependencies:
- '@gnu-taler/pogen':
- specifier: ^0.0.5
- version: link:../pogen
+ dependencies:
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
'@gnu-taler/web-util':
specifier: workspace:*
version: link:../web-util
+ date-fns:
+ specifier: 2.29.3
+ version: 2.29.3
+ jed:
+ specifier: 1.1.1
+ version: 1.1.1
+ preact:
+ specifier: 10.11.3
+ version: 10.11.3
+ qrcode-generator:
+ specifier: ^1.4.4
+ version: 1.4.4
+ swr:
+ specifier: 2.0.3
+ version: 2.0.3(react@18.2.0)
+ devDependencies:
+ '@gnu-taler/pogen':
+ specifier: workspace:*
+ version: link:../pogen
'@tailwindcss/forms':
specifier: ^0.5.3
version: 0.5.3(tailwindcss@3.3.2)
'@tailwindcss/typography':
specifier: ^0.5.9
version: 0.5.9(tailwindcss@3.3.2)
+ '@types/chai':
+ specifier: ^4.3.0
+ version: 4.3.3
+ '@types/history':
+ specifier: ^4.7.8
+ version: 4.7.11
+ '@types/mocha':
+ specifier: ^10.0.1
+ version: 10.0.1
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^6.19.0
+ version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/parser':
+ specifier: ^6.19.0
+ version: 6.19.0(eslint@8.56.0)(typescript@5.3.3)
autoprefixer:
specifier: ^10.4.14
- version: 10.4.14(postcss@8.4.23)
+ version: 10.4.14(postcss@8.4.33)
+ chai:
+ specifier: ^4.3.6
+ version: 4.3.6
esbuild:
specifier: ^0.19.9
version: 0.19.9
+ eslint:
+ specifier: ^8.56.0
+ version: 8.56.0
+ eslint-config-prettier:
+ specifier: ^9.1.0
+ version: 9.1.0(eslint@8.56.0)
+ eslint-plugin-react:
+ specifier: ^7.33.2
+ version: 7.33.2(eslint@8.56.0)
+ mocha:
+ specifier: 9.2.0
+ version: 9.2.0
po2json:
specifier: ^0.4.5
version: 0.4.5
- postcss:
- specifier: ^8.4.23
- version: 8.4.23
- postcss-cli:
- specifier: ^10.1.0
- version: 10.1.0(postcss@8.4.23)
tailwindcss:
specifier: ^3.3.2
version: 3.3.2
+ typescript:
+ specifier: 5.3.3
+ version: 5.3.3
packages/idb-bridge:
dependencies:
@@ -510,7 +559,7 @@ importers:
specifier: 7.18.9
version: 7.18.9
'@gnu-taler/pogen':
- specifier: ^0.0.5
+ specifier: workspace:*
version: link:../pogen
'@linaria/babel-preset':
specifier: 3.0.0-beta.22
@@ -616,7 +665,7 @@ importers:
specifier: ^1.2.0
version: 1.2.0
'@gnu-taler/pogen':
- specifier: ^0.0.5
+ specifier: workspace:*
version: link:../pogen
'@types/chai':
specifier: ^4.3.0
@@ -5238,6 +5287,12 @@ packages:
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
dev: true
+ /@gnu-taler/pogen@0.0.5:
+ resolution: {integrity: sha512-hd+05sHcYySMY3DUKKxw1eyboWhwQpPr0puGqdsepqXfjAwPyyFzVzF1fnPFc5w/jbn5Wm8ByCB2jEiX24fOqg==}
+ dependencies:
+ '@types/node': 14.18.63
+ dev: true
+
/@headlessui/react@1.7.14(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-znzdq9PG8rkwcu9oQ2FwIy0ZFtP9Z7ycS+BAqJ3R5EIqC/0bJGvhT7193rFf+45i9nnPsYvCQVW4V/bB9Xc+gA==}
engines: {node: '>=10'}
@@ -6139,6 +6194,10 @@ packages:
resolution: {integrity: sha512-gFAlWL9Ik21nJioqjlGCnNYbf9zHi0sVbaZ/1hQEBcCEuxfLJDvz4bVJSV6v6CUaoLOz0XEIoP7mSrhJ6o237w==}
dev: true
+ /@types/node@14.18.63:
+ resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
+ dev: true
+
/@types/node@18.11.17:
resolution: {integrity: sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==}