Compare commits

...

14 Commits

60 changed files with 1202 additions and 958 deletions

@ -1 +1 @@
Subproject commit 001f5dd081fc8729ff8def90c4a1c3f93eb8689a Subproject commit 23538677f6c6be2a62f38dc6137ecdd1c76b7b15

29
ci/ci.sh Executable file
View File

@ -0,0 +1,29 @@
#!/bin/bash
set -evuo pipefail
# Use podman if available, otherwise use docker.
# Fails if neither is found in PATH
OCI_RUNTIME=$(which podman || which docker)
REPO_NAME=$(basename "${PWD}")
JOB_NAME="${1}"
JOB_CONTAINER=$((grep CONTAINER_NAME ci/jobs/${JOB_NAME}/config.ini | cut -d' ' -f 3) || echo "${REPO_NAME}")
echo "${JOB_CONTAINER}"
if [ "${JOB_CONTAINER}" = "${REPO_NAME}" ] ; then
"${OCI_RUNTIME}" build \
-t "${JOB_CONTAINER}" \
-f ci/Containerfile .
fi
"${OCI_RUNTIME}" run \
--rm \
-ti \
--volume "${PWD}":/workdir \
--workdir /workdir \
"${JOB_CONTAINER}" \
ci/jobs/"${JOB_NAME}"/job.sh
top_dir=$(dirname "${BASH_SOURCE[0]}")
#"${top_dir}"/build.sh

View File

@ -5,41 +5,16 @@
# 'foo' and you add 'Foo' _here_, codespell will continue to complain # 'foo' and you add 'Foo' _here_, codespell will continue to complain
# about 'Foo'. # about 'Foo'.
# #
BRE
ND
Nd
TE
TEH
UPDATEing
WAN
aci aci
acn
ba
bre
cant cant
complet
doas
ect ect
ehr
fo fo
hel
ifset
ist
keypair
nd
onl
openin
ot
ser
sie
som som
sover
te te
te
teh
tha
ths ths
updateing updateing
wan
wih
vie vie
zar
nam
pares
kwanza

View File

@ -3,4 +3,4 @@ set -exuo pipefail
job_dir=$(dirname "${BASH_SOURCE[0]}") job_dir=$(dirname "${BASH_SOURCE[0]}")
codespell -I "${job_dir}"/dictionary.txt -S "*.bib,*.bst,*.cls,*.json,*.png,*.svg,*.wav,*.gz,*/templating/test?/**,**/auditor/*.sql,**/templating/mustach**,*.fees,*key,*.tag,*.info,*.latexmkrc,*.ecc,*.jpg,*.zkey,*.sqlite,*/contrib/hellos/**,*/vpn/tests/**,*.priv,*.file,*.tgz,*.woff,*.gif,*.odt,*.fee,*.deflate,*.dat,*.jpeg,*.eps,*.odg,*/m4/ax_lib_postgresql.m4,*/m4/libgcrypt.m4,*.rpath,config.status,ABOUT-NLS,*/doc/texinfo.tex,*.PNG,*.??.json,*.docx,*.ods,*.doc,*.docx,*.xcf,*.xlsx,*.ecc,*.ttf,*.woff2,*.eot,*.ttf,*.eot,*.mp4,*.pptx,*.epgz,*.min.js,**/*.map,**/fonts/**,*.pack.js,*.po,*.bbl,*/afl-tests/*,*/.git/**,*.pdf,*.epub,**/signing-key.asc,**/pnpm-lock.yaml,**/*.svg,**/*.cls,**/rfc.bib,**/*.bst,*/cbdc-es.tex,*/cbdc-it.tex,**/ExchangeSelection/example.ts,*/testcurl/test_tricky.c,*/i18n/strings.ts,*/src/anastasis-data.ts,**/doc/flows/main.de.tex,*/vendor/**" codespell -q 0 -I "${job_dir}"/dictionary.txt -S "*.bib,*.bst,*.cls,*.json,*.png,*.svg,*.wav,*.gz,*/templating/test?/**,**/auditor/*.sql,**/templating/mustach**,*.fees,*key,*.tag,*.info,*.latexmkrc,*.ecc,*.jpg,*.zkey,*.sqlite,*/contrib/hellos/**,*/vpn/tests/**,*.priv,*.file,*.tgz,*.woff,*.gif,*.odt,*.fee,*.deflate,*.dat,*.jpeg,*.eps,*.odg,*/m4/ax_lib_postgresql.m4,*/m4/libgcrypt.m4,*.rpath,config.status,ABOUT-NLS,*/doc/texinfo.tex,*.PNG,*.??.json,*.docx,*.ods,*.doc,*.docx,*.xcf,*.xlsx,*.ecc,*.ttf,*.woff2,*.eot,*.ttf,*.eot,*.mp4,*.pptx,*.epgz,*.min.js,**/*.map,**/fonts/**,*.pack.js,*.po,*.bbl,*/afl-tests/*,*/.git/**,*.pdf,*.epub,**/signing-key.asc,**/pnpm-lock.yaml,**/*.svg,**/*.cls,**/rfc.bib,**/*.bst,*/cbdc-es.tex,*/cbdc-it.tex,**/ExchangeSelection/example.ts,*/testcurl/test_tricky.c,*/i18n/strings.ts,*/src/anastasis-data.ts,**/doc/flows/main.de.tex,*/vendor/**,*/node_modules/**,*.pnpm-store/**"

View File

@ -45,7 +45,7 @@ If the above parameters have an optimal assignment, then replacing
v'[x] := 0 v'[x] := 0
gives another optimal solution, as otherwise we'd get a better one for the first situation. gives another optimal solution, as otherwise we'd get a better one for the first situation.
There is however no assurence that t[x] = price mod v[x] for some x in D, so nievely such solutions give you running times like O(price * |D|), which kinda sucks actually. Just one simplified example : There is however no assurence that t[x] = price mod v[x] for some x in D, so naively such solutions give you running times like O(price * |D|), which kinda sucks actually. Just one simplified example :
http://www.codeproject.com/Articles/31002/Coin-Change-Problem-Using-Dynamic-Programming http://www.codeproject.com/Articles/31002/Coin-Change-Problem-Using-Dynamic-Programming

View File

@ -881,7 +881,7 @@ the page. Then the wallet inspects the response as it may contain
error reports about a failed payment which the wallet has to handle. error reports about a failed payment which the wallet has to handle.
By submitting the payment this way, we also ensure that this By submitting the payment this way, we also ensure that this
intermediate request does not require JavaScript and still does not intermediate request does not require JavaScript and still does not
interfer with navigation. Once the Web shop confirms the payment, the interfere with navigation. Once the Web shop confirms the payment, the
wallet causes the fulfillment URL to be reloaded. wallet causes the fulfillment URL to be reloaded.
If the contract hash does not match a payment which the user If the contract hash does not match a payment which the user
@ -937,7 +937,7 @@ it has the following key advantages:
other users has the expected behavior other users has the expected behavior
of asking the other user to pay for the resource. of asking the other user to pay for the resource.
\item Asynchronously transmitting coins from injected JavaScript costs \item Asynchronously transmitting coins from injected JavaScript costs
one roundtrip, but does not interfer with navigation and allows one roundtrip, but does not interfere with navigation and allows
proper error handling. proper error handling.
\item The different pages of the merchant have clear \item The different pages of the merchant have clear
delineations: the shopping pages conclude by making an offer, and delineations: the shopping pages conclude by making an offer, and

View File

@ -933,7 +933,7 @@ the page. Then the wallet inspects the response as it may contain
error reports about a failed payment which the wallet has to handle. error reports about a failed payment which the wallet has to handle.
By submitting the payment this way, we also ensure that this By submitting the payment this way, we also ensure that this
intermediate request does not require JavaScript and still does not intermediate request does not require JavaScript and still does not
interfer with navigation. Once the Web shop confirms the payment, the interfere with navigation. Once the Web shop confirms the payment, the
wallet causes the fulfillment URL to be reloaded. wallet causes the fulfillment URL to be reloaded.
If the contract hash does not match a payment which the user If the contract hash does not match a payment which the user
@ -989,7 +989,7 @@ it has the following key advantages:
other users has the expected behavior other users has the expected behavior
of asking the other user to pay for the resource. of asking the other user to pay for the resource.
\item Asynchronously transmitting coins from injected JavaScript costs \item Asynchronously transmitting coins from injected JavaScript costs
one roundtrip, but does not interfer with navigation and allows one roundtrip, but does not interfere with navigation and allows
proper error handling. proper error handling.
\item The different pages of the merchant have clear \item The different pages of the merchant have clear
delineations: the shopping pages conclude by making an offer, and delineations: the shopping pages conclude by making an offer, and

View File

@ -241,7 +241,7 @@ export function AuthenticationEditorScreen(): VNode {
</p> </p>
{authAvailableSet.size > 0 && ( {authAvailableSet.size > 0 && (
<p class="block"> <p class="block">
We couldn&apos;t find provider for some of the authentication We couldn't find provider for some of the authentication
methods. methods.
</p> </p>
)} )}

View File

@ -20,6 +20,7 @@ spa_dir=$(prefix)/share/taler/demobank-ui
.PHONY: deps .PHONY: deps
deps: deps:
pnpm install --frozen-lockfile --filter @gnu-taler/demobank-ui... pnpm install --frozen-lockfile --filter @gnu-taler/demobank-ui...
pnpm run --filter @gnu-taler/demobank-ui... compile
pnpm run check pnpm run check
pnpm run build pnpm run build

View File

@ -26,7 +26,7 @@ test("WPT idbcursor-reused.htm", async (t) => {
case 0: case 0:
cursor = e.target.result; cursor = e.target.result;
t.deepEqual(cursor.value, "data", "prequisite cursor.value"); t.deepEqual(cursor.value, "data", "prerequisite cursor.value");
cursor.custom_cursor_value = 1; cursor.custom_cursor_value = 1;
e.target.custom_request_value = 2; e.target.custom_request_value = 2;
@ -34,7 +34,7 @@ test("WPT idbcursor-reused.htm", async (t) => {
break; break;
case 1: case 1:
t.deepEqual(cursor.value, "data2", "prequisite cursor.value"); t.deepEqual(cursor.value, "data2", "prerequisite cursor.value");
t.deepEqual(cursor.custom_cursor_value, 1, "custom cursor value"); t.deepEqual(cursor.custom_cursor_value, 1, "custom cursor value");
t.deepEqual( t.deepEqual(
e.target.custom_request_value, e.target.custom_request_value,

View File

@ -21,7 +21,7 @@ export interface FakeDOMStringList extends Array<string> {
item: (i: number) => string | null; item: (i: number) => string | null;
} }
// Would be nicer to sublcass Array, but I'd have to sacrifice Node 4 support to do that. // Would be nicer to subclass Array, but I'd have to sacrifice Node 4 support to do that.
export const fakeDOMStringList = (arr: string[]): FakeDOMStringList => { export const fakeDOMStringList = (arr: string[]): FakeDOMStringList => {
const arr2 = arr.slice(); const arr2 = arr.slice();

View File

@ -41,7 +41,8 @@ import {
import { ConfigContextProvider } from "./context/config.js"; import { ConfigContextProvider } from "./context/config.js";
import { useBackendConfig } from "./hooks/backend.js"; import { useBackendConfig } from "./hooks/backend.js";
import { strings } from "./i18n/strings.js"; import { strings } from "./i18n/strings.js";
import LoginPage from "./paths/login/index.js"; import { ConnectionPage, LoginPage } from "./paths/login/index.js";
import { LoginToken } from "./declaration.js";
export function Application(): VNode { export function Application(): VNode {
return ( return (
@ -59,25 +60,20 @@ export function Application(): VNode {
* @returns * @returns
*/ */
function ApplicationStatusRoutes(): VNode { function ApplicationStatusRoutes(): VNode {
const { url, updateLoginStatus, triedToLog } = useBackendContext(); const { url: backendURL, updateToken, changeBackend } = useBackendContext();
const result = useBackendConfig(); const result = useBackendConfig();
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const updateLoginInfoAndGoToRoot = (url: string, token?: string) => {
updateLoginStatus(url, token);
route("/");
};
const { currency, version } = result.ok const { currency, version } = result.ok
? result.data ? result.data
: { currency: "unknown", version: "unknown" }; : { currency: "unknown", version: "unknown" };
const ctx = useMemo(() => ({ currency, version }), [currency, version]); const ctx = useMemo(() => ({ currency, version }), [currency, version]);
if (!triedToLog) { if (!backendURL) {
return ( return (
<Fragment> <Fragment>
<NotConnectedAppMenu title="Welcome!" /> <NotConnectedAppMenu title="Welcome!" />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
); );
} }
@ -91,7 +87,7 @@ function ApplicationStatusRoutes(): VNode {
return ( return (
<Fragment> <Fragment>
<NotConnectedAppMenu title="Login" /> <NotConnectedAppMenu title="Login" />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
); );
} }
@ -109,7 +105,7 @@ function ApplicationStatusRoutes(): VNode {
description: `Check your url`, description: `Check your url`,
}} }}
/> />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
); );
} }
@ -120,10 +116,10 @@ function ApplicationStatusRoutes(): VNode {
notification={{ notification={{
message: i18n.str`Server response with an error code`, message: i18n.str`Server response with an error code`,
type: "ERROR", type: "ERROR",
description: i18n.str`Got message ${result.message} from ${result.info?.url}`, description: i18n.str`Got message "${result.message}" from ${result.info?.url}`,
}} }}
/> />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment>; </Fragment>;
} }
if (result.type === ErrorType.UNREADABLE) { if (result.type === ErrorType.UNREADABLE) {
@ -133,10 +129,10 @@ function ApplicationStatusRoutes(): VNode {
notification={{ notification={{
message: i18n.str`Response from server is unreadable, http status: ${result.status}`, message: i18n.str`Response from server is unreadable, http status: ${result.status}`,
type: "ERROR", type: "ERROR",
description: i18n.str`Got message ${result.message} from ${result.info?.url}`, description: i18n.str`Got message "${result.message}" from ${result.info?.url}`,
}} }}
/> />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment>; </Fragment>;
} }
return ( return (
@ -146,10 +142,10 @@ function ApplicationStatusRoutes(): VNode {
notification={{ notification={{
message: i18n.str`Unexpected Error`, message: i18n.str`Unexpected Error`,
type: "ERROR", type: "ERROR",
description: i18n.str`Got message ${result.message} from ${result.info?.url}`, description: i18n.str`Got message "${result.message}" from ${result.info?.url}`,
}} }}
/> />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
); );
} }
@ -168,7 +164,7 @@ function ApplicationStatusRoutes(): VNode {
description: i18n.str`Merchant backend server version ${result.data.version} is not compatible with the supported version ${SUPPORTED_VERSION}`, description: i18n.str`Merchant backend server version ${result.data.version} is not compatible with the supported version ${SUPPORTED_VERSION}`,
}} }}
/> />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
} }

View File

@ -18,22 +18,23 @@
* *
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { HttpStatusCode } from "@gnu-taler/taler-util";
import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser";
import { createHashHistory } from "history"; import { createHashHistory } from "history";
import { Fragment, h, VNode } from "preact"; import { Fragment, VNode, h } from "preact";
import { Router, Route, route } from "preact-router"; import { Route, Router, route } from "preact-router";
import { useEffect, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { InstanceRoutes } from "./InstanceRoutes.js";
import { import {
NotificationCard,
NotYetReadyAppMenu, NotYetReadyAppMenu,
NotificationCard,
} from "./components/menu/index.js"; } from "./components/menu/index.js";
import { useBackendContext } from "./context/backend.js"; import { useBackendContext } from "./context/backend.js";
import { LoginToken } from "./declaration.js";
import { useBackendInstancesTestForAdmin } from "./hooks/backend.js"; import { useBackendInstancesTestForAdmin } from "./hooks/backend.js";
import { InstanceRoutes } from "./InstanceRoutes.js"; import { ConnectionPage, LoginPage } from "./paths/login/index.js";
import LoginPage from "./paths/login/index.js";
import { INSTANCE_ID_LOOKUP } from "./utils/constants.js";
import { HttpStatusCode } from "@gnu-taler/taler-util";
import { Settings } from "./paths/settings/index.js"; import { Settings } from "./paths/settings/index.js";
import { INSTANCE_ID_LOOKUP } from "./utils/constants.js";
/** /**
* Check if admin against /management/instances * Check if admin against /management/instances
@ -41,15 +42,14 @@ import { Settings } from "./paths/settings/index.js";
*/ */
export function ApplicationReadyRoutes(): VNode { export function ApplicationReadyRoutes(): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const { url: backendURL, changeBackend } = useBackendContext()
const [unauthorized, setUnauthorized] = useState(false) const [unauthorized, setUnauthorized] = useState(false)
const { const {
url: backendURL, updateToken,
updateLoginStatus: updateLoginStatus2,
} = useBackendContext(); } = useBackendContext();
function updateLoginStatus(url: string, token: string | undefined) { function updateLoginStatus(token: LoginToken | undefined) {
console.log("updateing", url, token) updateToken(token)
updateLoginStatus2(url, token)
setUnauthorized(false) setUnauthorized(false)
} }
@ -59,15 +59,15 @@ export function ApplicationReadyRoutes(): VNode {
route("/"); route("/");
}; };
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
// useEffect(() => { const unauthorizedAdmin = !result.loading
// setUnauthorized(FF) && !result.ok
// }, [FF]) && result.type === ErrorType.CLIENT
const unauthorizedAdmin = !result.loading && !result.ok && result.type === ErrorType.CLIENT && result.status === HttpStatusCode.Unauthorized && result.status === HttpStatusCode.Unauthorized;
if (showSettings) { if (showSettings) {
return <Fragment> return <Fragment>
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />
<Settings /> <Settings onClose={() => setShowSettings(false)} />
</Fragment> </Fragment>
} }
@ -100,7 +100,7 @@ export function ApplicationReadyRoutes(): VNode {
type: "ERROR", type: "ERROR",
}} }}
/> />
<LoginPage onConfirm={updateLoginStatus} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
); );
} }
@ -108,14 +108,13 @@ export function ApplicationReadyRoutes(): VNode {
instanceNameByBackendURL = match[1]; instanceNameByBackendURL = match[1];
} }
console.log(unauthorized, unauthorizedAdmin)
if (unauthorized || unauthorizedAdmin) { if (unauthorized || unauthorizedAdmin) {
return <Fragment> return <Fragment>
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />
<NotificationCard <NotificationCard
notification={{ notification={{
message: i18n.str`Access denied`, message: i18n.str`Access denied`,
description: i18n.str`Check your token is valid`, description: i18n.str`Check your token is valid 1`,
type: "ERROR", type: "ERROR",
}} }}
/> />
@ -132,7 +131,6 @@ export function ApplicationReadyRoutes(): VNode {
admin={admin} admin={admin}
onUnauthorized={() => setUnauthorized(true)} onUnauthorized={() => setUnauthorized(true)}
onLoginPass={() => { onLoginPass={() => {
console.log("ahora si")
setUnauthorized(false) setUnauthorized(false)
}} }}
instanceNameByBackendURL={instanceNameByBackendURL} instanceNameByBackendURL={instanceNameByBackendURL}

View File

@ -35,7 +35,7 @@ import { InstanceContextProvider } from "./context/instance.js";
import { import {
useBackendDefaultToken, useBackendDefaultToken,
useBackendInstanceToken, useBackendInstanceToken,
useLocalStorage, useSimpleLocalStorage,
} from "./hooks/index.js"; } from "./hooks/index.js";
import { useInstanceKYCDetails } from "./hooks/instance.js"; import { useInstanceKYCDetails } from "./hooks/instance.js";
import InstanceCreatePage from "./paths/admin/create/index.js"; import InstanceCreatePage from "./paths/admin/create/index.js";
@ -71,10 +71,10 @@ import InstanceUpdatePage, {
AdminUpdate as InstanceAdminUpdatePage, AdminUpdate as InstanceAdminUpdatePage,
Props as InstanceUpdatePageProps, Props as InstanceUpdatePageProps,
} from "./paths/instance/update/index.js"; } from "./paths/instance/update/index.js";
import LoginPage from "./paths/login/index.js"; import { LoginPage } from "./paths/login/index.js";
import NotFoundPage from "./paths/notfound/index.js"; import NotFoundPage from "./paths/notfound/index.js";
import { Notification } from "./utils/types.js"; import { Notification } from "./utils/types.js";
import { MerchantBackend } from "./declaration.js"; import { LoginToken, MerchantBackend } from "./declaration.js";
import { Settings } from "./paths/settings/index.js"; import { Settings } from "./paths/settings/index.js";
import { dateFormatForSettings, useSettings } from "./hooks/useSettings.js"; import { dateFormatForSettings, useSettings } from "./hooks/useSettings.js";
@ -143,7 +143,7 @@ export function InstanceRoutes({
id, id,
admin, admin,
path, path,
onUnauthorized, // onUnauthorized,
onLoginPass, onLoginPass,
setInstanceName, setInstanceName,
}: Props): VNode { }: Props): VNode {
@ -155,7 +155,7 @@ export function InstanceRoutes({
const [globalNotification, setGlobalNotification] = const [globalNotification, setGlobalNotification] =
useState<GlobalNotifState>(undefined); useState<GlobalNotifState>(undefined);
const changeToken = (token?: string) => { const changeToken = (token?: LoginToken) => {
if (admin) { if (admin) {
updateToken(token); updateToken(token);
} else { } else {
@ -201,14 +201,17 @@ export function InstanceRoutes({
// const LoginPageAccessDeniend = onUnauthorized // const LoginPageAccessDeniend = onUnauthorized
const LoginPageAccessDenied = () => { const LoginPageAccessDenied = () => {
onUnauthorized() return <Fragment>
return <NotificationCard <NotificationCard
notification={{ notification={{
message: i18n.str`Access denied`, message: i18n.str`Access denied`,
description: i18n.str`Redirecting to login page.`, description: i18n.str`Redirecting to login page.`,
type: "ERROR", type: "ERROR",
}} }}
/> />
<LoginPage onConfirm={changeToken} />
</Fragment>
} }
function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) { function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) {
@ -687,9 +690,7 @@ function AdminInstanceUpdatePage({
...rest ...rest
}: { id: string } & InstanceUpdatePageProps): VNode { }: { id: string } & InstanceUpdatePageProps): VNode {
const [token, changeToken] = useBackendInstanceToken(id); const [token, changeToken] = useBackendInstanceToken(id);
const { updateLoginStatus: changeBackend } = useBackendContext(); const updateLoginStatus = (token?: LoginToken): void => {
const updateLoginStatus = (url: string, token?: string): void => {
changeBackend(url);
changeToken(token); changeToken(token);
}; };
const value = useMemo( const value = useMemo(
@ -752,7 +753,7 @@ function KycBanner(): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings] = useSettings(); const [settings] = useSettings();
const today = format(new Date(), dateFormatForSettings(settings)); const today = format(new Date(), dateFormatForSettings(settings));
const [lastHide, setLastHide] = useLocalStorage("kyc-last-hide"); const [lastHide, setLastHide] = useSimpleLocalStorage("kyc-last-hide");
const hasBeenHidden = today === lastHide; const hasBeenHidden = today === lastHide;
const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect"; const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect";
if (hasBeenHidden || !needsToBeShown) return <Fragment />; if (hasBeenHidden || !needsToBeShown) return <Fragment />;

View File

@ -1,244 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser";
import { ComponentChildren, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { useBackendContext } from "../../context/backend.js";
import { useInstanceContext } from "../../context/instance.js";
import { useCredentialsChecker } from "../../hooks/backend.js";
import { Notification } from "../../utils/types.js";
interface Props {
withMessage?: Notification;
onConfirm: (backend: string, token?: string) => void;
}
function getTokenValuePart(t: string): string {
if (!t) return t;
const match = /secret-token:(.*)/.exec(t);
if (!match || !match[1]) return "";
return match[1];
}
function normalizeToken(r: string): string {
return `secret-token:${r}`;
}
function cleanUp(s: string): string {
let result = s;
if (result.indexOf("webui/") !== -1) {
result = result.substring(0, result.indexOf("webui/"));
}
return result;
}
export function LoginModal({ onConfirm, withMessage }: Props): VNode {
const { url: backendUrl, token: baseToken } = useBackendContext();
const { admin, token: instanceToken, id } = useInstanceContext();
const testLogin = useCredentialsChecker();
const currentToken = getTokenValuePart(
(!admin ? baseToken : instanceToken) ?? "",
);
const [token, setToken] = useState(currentToken);
const [url, setURL] = useState(cleanUp(backendUrl));
const { i18n } = useTranslationContext();
if (admin && id !== "default") {
//admin trying to access another instance
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.</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
? onConfirm(url, normalizeToken(token))
: 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={async () => {
const secretToken = normalizeToken(token);
const { valid, cause } = await testLogin(`${url}/instances/${id}`, secretToken);
if (valid) {
onConfirm(url, secretToken);
} else {
onConfirm(url);
}
}}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>)
}
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 }}
>
<i18n.Translate>Please enter your access token.</i18n.Translate>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">URL</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="text"
placeholder="set new url"
name="id"
value={url}
onKeyPress={(e) =>
e.keyCode === 13
? onConfirm(url, normalizeToken(token))
: null
}
onInput={(e): void => setURL(e?.currentTarget.value)}
/>
</p>
</div>
</div>
</div>
<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
? onConfirm(url, normalizeToken(token))
: 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={async () => {
const secretToken = normalizeToken(token);
const { valid, cause } = await testLogin(url, secretToken);
if (valid) {
onConfirm(url, secretToken);
} else {
onConfirm(url);
}
}}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>
);
}
function AsyncButton({ onClick, children }: { onClick: () => Promise<void>, children: ComponentChildren }): VNode {
const [running, setRunning] = useState(false)
return <button class="button is-info" disabled={running} onClick={() => {
setRunning(true)
onClick().then(() => {
setRunning(false)
}).catch(() => {
setRunning(false)
})
}}>
{children}
</button>
}

View File

@ -40,13 +40,13 @@ export function DefaultInstanceFormFields({
showId: boolean; showId: boolean;
}): VNode { }): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
return ( return (
<Fragment> <Fragment>
{showId && ( {showId && (
<InputWithAddon<Entity> <InputWithAddon<Entity>
name="id" name="id"
addonBefore={`${backend.url}/instances/`} addonBefore={`${backendURL}/instances/`}
readonly={readonlyId} readonly={readonlyId}
label={i18n.str`Identifier`} 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.`} tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`}

View File

@ -25,7 +25,6 @@ import { useBackendContext } from "../../context/backend.js";
import { useConfigContext } from "../../context/config.js"; import { useConfigContext } from "../../context/config.js";
import { useInstanceKYCDetails } from "../../hooks/instance.js"; import { useInstanceKYCDetails } from "../../hooks/instance.js";
import { LangSelector } from "./LangSelector.js"; import { LangSelector } from "./LangSelector.js";
import { useCredentialsChecker } from "../../hooks/backend.js";
const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
@ -50,7 +49,7 @@ export function Sidebar({
isPasswordOk isPasswordOk
}: Props): VNode { }: Props): VNode {
const config = useConfigContext(); const config = useConfigContext();
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const kycStatus = useInstanceKYCDetails(); const kycStatus = useInstanceKYCDetails();
const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; const needKYC = kycStatus.ok && kycStatus.data.type === "redirect";
@ -230,7 +229,7 @@ export function Sidebar({
<i class="mdi mdi-web" /> <i class="mdi mdi-web" />
</span> </span>
<span class="menu-item-label"> <span class="menu-item-label">
{new URL(backend.url).hostname} {new URL(backendURL).hostname}
</span> </span>
</div> </div>
</li> </li>

View File

@ -114,7 +114,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
onSubscribe(hasErrors ? undefined : submit); onSubscribe(hasErrors ? undefined : submit);
}, [submit, hasErrors]); }, [submit, hasErrors]);
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
return ( return (
@ -128,7 +128,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
{alreadyExist ? undefined : ( {alreadyExist ? undefined : (
<InputWithAddon<Entity> <InputWithAddon<Entity>
name="product_id" name="product_id"
addonBefore={`${backend.url}/product/`} addonBefore={`${backendURL}/product/`}
label={i18n.str`ID`} label={i18n.str`ID`}
tooltip={i18n.str`product identification to use in URLs (for internal use only)`} tooltip={i18n.str`product identification to use in URLs (for internal use only)`}
/> />

View File

@ -21,7 +21,7 @@
import * as tests from "@gnu-taler/web-util/testing"; import * as tests from "@gnu-taler/web-util/testing";
import { ComponentChildren, h, VNode } from "preact"; import { ComponentChildren, h, VNode } from "preact";
import { MerchantBackend } from "../declaration.js"; import { AccessToken, MerchantBackend } from "../declaration.js";
import { import {
useAdminAPI, useAdminAPI,
useInstanceAPI, useInstanceAPI,
@ -64,7 +64,7 @@ describe("backend context api ", () => {
} as MerchantBackend.Instances.QueryInstancesResponse, } as MerchantBackend.Instances.QueryInstancesResponse,
}); });
management.setNewToken("another_token"); management.setNewToken("another_token" as AccessToken);
}, },
({ instance, management, admin }) => { ({ instance, management, admin }) => {
expect(env.assertJustExpectedRequestWereMade()).deep.eq({ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
@ -113,7 +113,7 @@ describe("backend context api ", () => {
name: "instance_name", name: "instance_name",
} as MerchantBackend.Instances.QueryInstancesResponse, } as MerchantBackend.Instances.QueryInstancesResponse,
}); });
instance.setNewToken("another_token"); instance.setNewToken("another_token" as AccessToken);
}, },
({ instance, management, admin }) => { ({ instance, management, admin }) => {
expect(env.assertJustExpectedRequestWereMade()).deep.eq({ expect(env.assertJustExpectedRequestWereMade()).deep.eq({

View File

@ -20,90 +20,46 @@
*/ */
import { createContext, h, VNode } from "preact"; import { createContext, h, VNode } from "preact";
import { useCallback, useContext, useState } from "preact/hooks"; import { useContext } from "preact/hooks";
import { LoginToken } from "../declaration.js";
import { useBackendDefaultToken, useBackendURL } from "../hooks/index.js"; import { useBackendDefaultToken, useBackendURL } from "../hooks/index.js";
interface BackendContextType { interface BackendContextType {
url: string; url: string,
token?: string; token?: LoginToken;
triedToLog: boolean; updateToken: (token: LoginToken | undefined) => void;
resetBackend: () => void; changeBackend: (url: string) => void;
// clearAllTokens: () => void;
// addTokenCleaner: (c: () => void) => void;
updateLoginStatus: (url: string, token?: string) => void;
updateToken: (token?: string) => void;
} }
const BackendContext = createContext<BackendContextType>({ const BackendContext = createContext<BackendContextType>({
url: "", url: "",
token: undefined, token: undefined,
triedToLog: false,
resetBackend: () => null,
// clearAllTokens: () => null,
// addTokenCleaner: () => null,
updateLoginStatus: () => null,
updateToken: () => null, updateToken: () => null,
changeBackend: () => null,
}); });
function useBackendContextState( function useBackendContextState(
defaultUrl?: string, defaultUrl?: string,
initialToken?: string,
): BackendContextType { ): BackendContextType {
const [url, triedToLog, changeBackend, resetBackend] = const [url, changeBackend] = useBackendURL(defaultUrl);
useBackendURL(defaultUrl); const [token, updateToken] = useBackendDefaultToken();
const [token, _updateToken] = useBackendDefaultToken(initialToken);
const updateToken = (t?: string) => {
_updateToken(t);
};
// const tokenCleaner = useCallback(() => {
// updateToken(undefined);
// }, []);
// const [cleaners, setCleaners] = useState([tokenCleaner]);
// const addTokenCleaner = (c: () => void) => setCleaners((cs) => [...cs, c]);
// const addTokenCleanerMemo = useCallback(
// (c: () => void) => {
// addTokenCleaner(c);
// },
// [tokenCleaner],
// );
// const clearAllTokens = () => {
// cleaners.forEach((c) => c());
// for (let i = 0; i < localStorage.length; i++) {
// const k = localStorage.key(i);
// if (k && /^backend-token/.test(k)) localStorage.removeItem(k);
// }
// resetBackend();
// };
const updateLoginStatus = (url: string, token?: string) => {
changeBackend(url);
updateToken(token);
};
return { return {
url, url,
token, token,
triedToLog,
updateLoginStatus,
resetBackend,
// clearAllTokens,
updateToken, updateToken,
// addTokenCleaner: addTokenCleanerMemo, changeBackend
}; };
} }
export const BackendContextProvider = ({ export const BackendContextProvider = ({
children, children,
defaultUrl, defaultUrl,
initialToken,
}: { }: {
children: any; children: any;
defaultUrl?: string; defaultUrl?: string;
initialToken?: string;
}): VNode => { }): VNode => {
const value = useBackendContextState(defaultUrl, initialToken); const value = useBackendContextState(defaultUrl);
return h(BackendContext.Provider, { value, children }); return h(BackendContext.Provider, { value, children });
}; };

View File

@ -21,12 +21,13 @@
import { createContext } from "preact"; import { createContext } from "preact";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { LoginToken } from "../declaration.js";
interface Type { interface Type {
id: string; id: string;
token?: string; token?: LoginToken;
admin?: boolean; admin?: boolean;
changeToken: (t?: string) => void; changeToken: (t?: LoginToken) => void;
} }
const Context = createContext<Type>({} as any); const Context = createContext<Type>({} as any);

View File

@ -107,6 +107,16 @@ interface RegexAccountRestriction {
// human hints. // human hints.
human_hint_i18n?: { [lang_tag: string]: string }; human_hint_i18n?: { [lang_tag: string]: string };
} }
interface LoginToken {
token: string,
expiration: Timestamp,
}
// token used to get loginToken
// must forget after used
declare const __ac_token: unique symbol;
type AccessToken = string & {
[__ac_token]: true;
};
export namespace ExchangeBackend { export namespace ExchangeBackend {
interface WireResponse { interface WireResponse {
@ -491,6 +501,35 @@ export namespace MerchantBackend {
}; };
} }
// DELETE /private/instances/$INSTANCE // DELETE /private/instances/$INSTANCE
interface LoginTokenRequest {
// Scope of the token (which kinds of operations it will allow)
scope: "readonly" | "write";
// Server may impose its own upper bound
// on the token validity duration
duration?: RelativeTime;
// Can this token be refreshed?
// Defaults to false.
refreshable?: boolean;
}
interface LoginTokenSuccessResponse {
// The login token that can be used to access resources
// that are in scope for some time. Must be prefixed
// with "Bearer " when used in the "Authorization" HTTP header.
// Will already begin with the RFC 8959 prefix.
token: string;
// Scope of the token (which kinds of operations it will allow)
scope: "readonly" | "write";
// Server may impose its own upper bound
// on the token validity duration
expiration: Timestamp;
// Can this token be refreshed?
refreshable: boolean;
}
} }
namespace KYC { namespace KYC {

View File

@ -19,19 +19,21 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { useSWRConfig } from "swr"; import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util";
import { MerchantBackend } from "../declaration.js";
import { useBackendContext } from "../context/backend.js";
import { useCallback, useEffect, useState } from "preact/hooks";
import { useInstanceContext } from "../context/instance.js";
import { import {
ErrorType, ErrorType,
HttpError,
HttpResponse, HttpResponse,
HttpResponseOk, HttpResponseOk,
RequestError, RequestError,
RequestOptions, RequestOptions,
useApiContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { useApiContext } from "@gnu-taler/web-util/browser"; import { useCallback, useEffect, useState } from "preact/hooks";
import { useSWRConfig } from "swr";
import { useBackendContext } from "../context/backend.js";
import { useInstanceContext } from "../context/instance.js";
import { AccessToken, LoginToken, MerchantBackend, Timestamp } from "../declaration.js";
export function useMatchMutate(): ( export function useMatchMutate(): (
@ -85,6 +87,9 @@ export function useBackendInstancesTestForAdmin(): HttpResponse<
return result; return result;
} }
const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000;
const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000;
export function useBackendConfig(): HttpResponse< export function useBackendConfig(): HttpResponse<
MerchantBackend.VersionResponse, MerchantBackend.VersionResponse,
RequestError<MerchantBackend.ErrorDetail> RequestError<MerchantBackend.ErrorDetail>
@ -92,18 +97,33 @@ export function useBackendConfig(): HttpResponse<
const { request } = useBackendBaseRequest(); const { request } = useBackendBaseRequest();
type Type = MerchantBackend.VersionResponse; type Type = MerchantBackend.VersionResponse;
type State = { data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>, timer: number }
const [result, setResult] = useState< const [result, setResult] = useState<State>({ data: { loading: true }, timer: 0 });
HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>
>({ loading: true });
useEffect(() => { useEffect(() => {
request<Type>(`/config`) if (result.timer) {
.then((data) => setResult(data)) clearTimeout(result.timer)
.catch((error) => setResult(error)); }
function tryConfig(): void {
request<Type>(`/config`)
.then((data) => {
const timer: any = setTimeout(() => {
tryConfig()
}, CHECK_CONFIG_INTERVAL_OK)
setResult({ data, timer })
})
.catch((error) => {
const timer: any = setTimeout(() => {
tryConfig()
}, CHECK_CONFIG_INTERVAL_FAIL)
const data = error.cause
setResult({ data, timer })
});
}
tryConfig()
}, [request]); }, [request]);
return result; return result.data;
} }
interface useBackendInstanceRequestType { interface useBackendInstanceRequestType {
@ -149,32 +169,86 @@ interface useBackendBaseRequestType {
} }
type YesOrNo = "yes" | "no"; type YesOrNo = "yes" | "no";
type LoginResult = {
valid: true;
token: string;
expiration: Timestamp;
} | {
valid: false;
cause: HttpError<{}>;
}
export function useCredentialsChecker() { export function useCredentialsChecker() {
const { request } = useApiContext(); const { request } = useApiContext();
//check against instance details endpoint //check against instance details endpoint
//while merchant backend doesn't have a login endpoint //while merchant backend doesn't have a login endpoint
async function testLogin( async function requestNewLoginToken(
instance: string, baseUrl: string,
token: string, token: AccessToken,
): Promise<{ ): Promise<LoginResult> {
valid: boolean; const data: MerchantBackend.Instances.LoginTokenRequest = {
cause?: ErrorType; scope: "write",
}> { duration: {
d_us: "forever"
},
refreshable: true,
}
try { try {
const response = await request(instance, `/private/`, { const response = await request<MerchantBackend.Instances.LoginTokenSuccessResponse>(baseUrl, `/private/token`, {
method: "POST",
token, token,
data
}); });
return { valid: true }; return { valid: true, token: response.data.token, expiration: response.data.expiration };
} catch (error) { } catch (error) {
if (error instanceof RequestError) { if (error instanceof RequestError) {
return { valid: false, cause: error.cause.type }; return { valid: false, cause: error.cause };
} }
return { valid: false, cause: ErrorType.UNEXPECTED }; return {
valid: false, cause: {
type: ErrorType.UNEXPECTED,
loading: false,
info: {
hasToken: true,
status: 0,
options: {},
url: `/private/token`,
payload: {}
},
exception: error,
message: (error instanceof Error ? error.message : "unpexepected error")
}
};
} }
}; };
return testLogin
async function refreshLoginToken(
baseUrl: string,
token: LoginToken
): Promise<LoginResult> {
if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) {
return {
valid: false, cause: {
type: ErrorType.CLIENT,
status: HttpStatusCode.Unauthorized,
message: "login token expired, login again.",
info: {
hasToken: true,
status: 401,
options: {},
url: `/private/token`,
payload: {}
},
payload: {}
},
}
}
return requestNewLoginToken(baseUrl, token.token as AccessToken)
}
return { requestNewLoginToken, refreshLoginToken }
} }
/** /**
@ -183,15 +257,20 @@ export function useCredentialsChecker() {
* @returns request handler to * @returns request handler to
*/ */
export function useBackendBaseRequest(): useBackendBaseRequestType { export function useBackendBaseRequest(): useBackendBaseRequestType {
const { url: backend, token } = useBackendContext(); const { url: backend, token: loginToken } = useBackendContext();
const { request: requestHandler } = useApiContext(); const { request: requestHandler } = useApiContext();
const token = loginToken?.token;
const request = useCallback( const request = useCallback(
function requestImpl<T>( function requestImpl<T>(
endpoint: string, endpoint: string,
options: RequestOptions = {}, options: RequestOptions = {},
): Promise<HttpResponseOk<T>> { ): Promise<HttpResponseOk<T>> {
return requestHandler<T>(backend, endpoint, { token, ...options }); return requestHandler<T>(backend, endpoint, { token, ...options }).then(res => {
return res
}).catch(err => {
throw err
});
}, },
[backend, token], [backend, token],
); );
@ -204,10 +283,12 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
const { token: instanceToken, id, admin } = useInstanceContext(); const { token: instanceToken, id, admin } = useInstanceContext();
const { request: requestHandler } = useApiContext(); const { request: requestHandler } = useApiContext();
const { baseUrl, token } = !admin const { baseUrl, token: loginToken } = !admin
? { baseUrl: rootBackendUrl, token: rootToken } ? { baseUrl: rootBackendUrl, token: rootToken }
: { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken }; : { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken };
const token = loginToken?.token;
const request = useCallback( const request = useCallback(
function requestImpl<T>( function requestImpl<T>(
endpoint: string, endpoint: string,

View File

@ -19,9 +19,11 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { StateUpdater, useCallback, useEffect, useState } from "preact/hooks"; import { buildCodecForObject, codecForMap, codecForString, codecForTimestamp } from "@gnu-taler/taler-util";
import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
import { StateUpdater, useEffect, useState } from "preact/hooks";
import { LoginToken } from "../declaration.js";
import { ValueOrFunction } from "../utils/types.js"; import { ValueOrFunction } from "../utils/types.js";
import { useMemoryStorage } from "@gnu-taler/web-util/browser";
import { useMatchMutate } from "./backend.js"; import { useMatchMutate } from "./backend.js";
const calculateRootPath = () => { const calculateRootPath = () => {
@ -32,53 +34,55 @@ const calculateRootPath = () => {
return rootPath; return rootPath;
}; };
const loginTokenCodec = buildCodecForObject<LoginToken>()
.property("token", codecForString())
.property("expiration", codecForTimestamp)
.build("loginToken")
const TOKENS_KEY = buildStorageKey("backend-token", codecForMap(loginTokenCodec));
export function useBackendURL( export function useBackendURL(
url?: string, url?: string,
): [string, boolean, StateUpdater<string>, () => void] { ): [string, StateUpdater<string>] {
const [value, setter] = useNotNullLocalStorage( const [value, setter] = useSimpleLocalStorage(
"backend-url", "backend-url",
url || calculateRootPath(), url || calculateRootPath(),
); );
const [triedToLog, setTriedToLog] = useLocalStorage("tried-login");
const checkedSetter = (v: ValueOrFunction<string>) => { const checkedSetter = (v: ValueOrFunction<string>) => {
setTriedToLog("yes"); return setter((p) => (v instanceof Function ? v(p ?? "") : v).replace(/\/$/, ""));
return setter((p) => (v instanceof Function ? v(p) : v).replace(/\/$/, ""));
}; };
const resetBackend = () => { return [value!, checkedSetter];
setTriedToLog(undefined);
};
return [value, !!triedToLog, checkedSetter, resetBackend];
} }
export function useBackendDefaultToken( export function useBackendDefaultToken(
initialValue?: string, ): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] {
): [string | undefined, ((d: string | undefined) => void)] { const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})
// uncomment for testing
initialValue = "secret-token:secret" as string | undefined const tokenOfDefaultInstance = tokenMap["default"]
const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token`, initialValue)
const clearCache = useMatchMutate() const clearCache = useMatchMutate()
useEffect(() => { useEffect(() => {
clearCache() clearCache()
}, [token]) }, [tokenOfDefaultInstance])
function updateToken( function updateToken(
value: (string | undefined) value: (LoginToken | undefined)
): void { ): void {
if (value === undefined) { if (value === undefined) {
reset() reset()
} else { } else {
setToken(value) const res = { ...tokenMap, "default": value }
setToken(res)
} }
} }
return [token, updateToken]; return [tokenMap["default"], updateToken];
} }
export function useBackendInstanceToken( export function useBackendInstanceToken(
id: string, id: string,
): [string | undefined, ((d: string | undefined) => void)] { ): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] {
const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token-${id}`) const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})
const [defaultToken, defaultSetToken] = useBackendDefaultToken(); const [defaultToken, defaultSetToken] = useBackendDefaultToken();
// instance named 'default' use the default token // instance named 'default' use the default token
@ -86,16 +90,17 @@ export function useBackendInstanceToken(
return [defaultToken, defaultSetToken]; return [defaultToken, defaultSetToken];
} }
function updateToken( function updateToken(
value: (string | undefined) value: (LoginToken | undefined)
): void { ): void {
if (value === undefined) { if (value === undefined) {
reset() reset()
} else { } else {
setToken(value) const res = { ...tokenMap, [id]: value }
setToken(res)
} }
} }
return [token, updateToken]; return [tokenMap[id], updateToken];
} }
export function useLang(initial?: string): [string, StateUpdater<string>] { export function useLang(initial?: string): [string, StateUpdater<string>] {
@ -104,10 +109,10 @@ export function useLang(initial?: string): [string, StateUpdater<string>] {
? navigator.language || (navigator as any).userLanguage ? navigator.language || (navigator as any).userLanguage
: undefined; : undefined;
const defaultLang = (browserLang || initial || "en").substring(0, 2); const defaultLang = (browserLang || initial || "en").substring(0, 2);
return useNotNullLocalStorage("lang-preference", defaultLang); return useSimpleLocalStorage("lang-preference", defaultLang) as [string, StateUpdater<string>];
} }
export function useLocalStorage( export function useSimpleLocalStorage(
key: string, key: string,
initialValue?: string, initialValue?: string,
): [string | undefined, StateUpdater<string | undefined>] { ): [string | undefined, StateUpdater<string | undefined>] {
@ -137,28 +142,3 @@ export function useLocalStorage(
return [storedValue, setValue]; return [storedValue, setValue];
} }
export function useNotNullLocalStorage(
key: string,
initialValue: string,
): [string, StateUpdater<string>] {
const [storedValue, setStoredValue] = useState<string>((): string => {
return typeof window !== "undefined"
? window.localStorage.getItem(key) || initialValue
: initialValue;
});
const setValue = (value: string | ((val: string) => string)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== "undefined") {
if (!valueToStore) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, valueToStore);
}
}
};
return [storedValue, setValue];
}

View File

@ -21,7 +21,7 @@
import * as tests from "@gnu-taler/web-util/testing"; import * as tests from "@gnu-taler/web-util/testing";
import { expect } from "chai"; import { expect } from "chai";
import { MerchantBackend } from "../declaration.js"; import { AccessToken, MerchantBackend } from "../declaration.js";
import { import {
useAdminAPI, useAdminAPI,
useBackendInstances, useBackendInstances,
@ -158,7 +158,7 @@ describe("instance api interaction with details", () => {
}, },
} as MerchantBackend.Instances.QueryInstancesResponse, } as MerchantBackend.Instances.QueryInstancesResponse,
}); });
api.setNewToken("secret"); api.setNewToken("secret" as AccessToken);
}, },
({ query, api }) => { ({ query, api }) => {
expect(env.assertJustExpectedRequestWereMade()).deep.eq({ expect(env.assertJustExpectedRequestWereMade()).deep.eq({

View File

@ -19,10 +19,11 @@ import {
RequestError, RequestError,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { useBackendContext } from "../context/backend.js"; import { useBackendContext } from "../context/backend.js";
import { MerchantBackend } from "../declaration.js"; import { AccessToken, MerchantBackend } from "../declaration.js";
import { import {
useBackendBaseRequest, useBackendBaseRequest,
useBackendInstanceRequest, useBackendInstanceRequest,
useCredentialsChecker,
useMatchMutate, useMatchMutate,
} from "./backend.js"; } from "./backend.js";
@ -36,7 +37,7 @@ interface InstanceAPI {
) => Promise<void>; ) => Promise<void>;
deleteInstance: () => Promise<void>; deleteInstance: () => Promise<void>;
clearToken: () => Promise<void>; clearToken: () => Promise<void>;
setNewToken: (token: string) => Promise<void>; setNewToken: (token: AccessToken) => Promise<void>;
} }
export function useAdminAPI(): AdminAPI { export function useAdminAPI(): AdminAPI {
@ -86,8 +87,10 @@ export interface AdminAPI {
export function useManagementAPI(instanceId: string): InstanceAPI { export function useManagementAPI(instanceId: string): InstanceAPI {
const mutateAll = useMatchMutate(); const mutateAll = useMatchMutate();
const { url: backendURL } = useBackendContext()
const { updateToken } = useBackendContext(); const { updateToken } = useBackendContext();
const { request } = useBackendBaseRequest(); const { request } = useBackendBaseRequest();
const { requestNewLoginToken } = useCredentialsChecker()
const updateInstance = async ( const updateInstance = async (
instance: MerchantBackend.Instances.InstanceReconfigurationMessage, instance: MerchantBackend.Instances.InstanceReconfigurationMessage,
@ -117,13 +120,20 @@ export function useManagementAPI(instanceId: string): InstanceAPI {
mutateAll(/\/management\/instances/); mutateAll(/\/management\/instances/);
}; };
const setNewToken = async (newToken: string): Promise<void> => { const setNewToken = async (newToken: AccessToken): Promise<void> => {
await request(`/management/instances/${instanceId}/auth`, { await request(`/management/instances/${instanceId}/auth`, {
method: "POST", method: "POST",
data: { method: "token", token: newToken }, data: { method: "token", token: newToken },
}); });
updateToken(newToken); const resp = await requestNewLoginToken(backendURL, newToken)
if (resp.valid) {
const { token, expiration } = resp
updateToken({ token, expiration });
} else {
updateToken(undefined)
}
mutateAll(/\/management\/instances/); mutateAll(/\/management\/instances/);
}; };
@ -132,12 +142,13 @@ export function useManagementAPI(instanceId: string): InstanceAPI {
export function useInstanceAPI(): InstanceAPI { export function useInstanceAPI(): InstanceAPI {
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const { url: backendURL, updateToken } = useBackendContext()
const { const {
url: baseUrl,
token: adminToken, token: adminToken,
updateLoginStatus,
} = useBackendContext(); } = useBackendContext();
const { request } = useBackendInstanceRequest(); const { request } = useBackendInstanceRequest();
const { requestNewLoginToken } = useCredentialsChecker()
const updateInstance = async ( const updateInstance = async (
instance: MerchantBackend.Instances.InstanceReconfigurationMessage, instance: MerchantBackend.Instances.InstanceReconfigurationMessage,
@ -147,7 +158,7 @@ export function useInstanceAPI(): InstanceAPI {
data: instance, data: instance,
}); });
if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null); if (adminToken) mutate(["/private/instances", adminToken, backendURL], null);
mutate([`/private/`], null); mutate([`/private/`], null);
}; };
@ -157,7 +168,7 @@ export function useInstanceAPI(): InstanceAPI {
// token: adminToken, // token: adminToken,
}); });
if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null); if (adminToken) mutate(["/private/instances", adminToken, backendURL], null);
mutate([`/private/`], null); mutate([`/private/`], null);
}; };
@ -170,13 +181,20 @@ export function useInstanceAPI(): InstanceAPI {
mutate([`/private/`], null); mutate([`/private/`], null);
}; };
const setNewToken = async (newToken: string): Promise<void> => { const setNewToken = async (newToken: AccessToken): Promise<void> => {
await request(`/private/auth`, { await request(`/private/auth`, {
method: "POST", method: "POST",
data: { method: "token", token: newToken }, data: { method: "token", token: newToken },
}); });
updateLoginStatus(baseUrl, newToken); const resp = await requestNewLoginToken(backendURL, newToken)
if (resp.valid) {
const { token, expiration } = resp
updateToken({ token, expiration });
} else {
updateToken(undefined)
}
mutate([`/private/`], null); mutate([`/private/`], null);
}; };

View File

@ -90,10 +90,7 @@ export class ApiMockEnvironment extends MockEnvironment {
const SC: any = SWRConfig; const SC: any = SWRConfig;
return ( return (
<BackendContextProvider <BackendContextProvider defaultUrl="http://backend">
defaultUrl="http://backend"
initialToken={undefined}
>
<InstanceContextProvider <InstanceContextProvider
value={{ value={{
token: undefined, token: undefined,

View File

@ -24,15 +24,6 @@ import {
codecForString, codecForString,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
function parse_json_or_undefined<T>(str: string | undefined): T | undefined {
if (str === undefined) return undefined;
try {
return JSON.parse(str);
} catch {
return undefined;
}
}
export interface Settings { export interface Settings {
advanceOrderMode: boolean; advanceOrderMode: boolean;
dateFormat: "ymd" | "dmy" | "mdy"; dateFormat: "ymd" | "dmy" | "mdy";

View File

@ -22,7 +22,7 @@
import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util"; import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { format, formatDistance } from "date-fns"; import { format, formatDistance } from "date-fns";
import { Fragment, h, VNode } from "preact"; import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { FormProvider } from "../../../../components/form/FormProvider.js"; import { FormProvider } from "../../../../components/form/FormProvider.js";
import { Input } from "../../../../components/form/Input.js"; import { Input } from "../../../../components/form/Input.js";
@ -35,10 +35,10 @@ import { TextField } from "../../../../components/form/TextField.js";
import { ProductList } from "../../../../components/product/ProductList.js"; import { ProductList } from "../../../../components/product/ProductList.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
import { mergeRefunds } from "../../../../utils/amount.js"; import { mergeRefunds } from "../../../../utils/amount.js";
import { RefundModal } from "../list/Table.js"; import { RefundModal } from "../list/Table.js";
import { Event, Timeline } from "./Timeline.js"; import { Event, Timeline } from "./Timeline.js";
import { dateFormatForSettings, datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse; type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse;
type CT = MerchantBackend.ContractTerms; type CT = MerchantBackend.ContractTerms;
@ -416,9 +416,9 @@ function PaidPage({
}) })
const [value, valueHandler] = useState<Partial<Paid>>(order); const [value, valueHandler] = useState<Partial<Paid>>(order);
const { url } = useBackendContext(); const { url: backendURL } = useBackendContext()
const refundurl = stringifyRefundUri({ const refundurl = stringifyRefundUri({
merchantBaseUrl: url, merchantBaseUrl: backendURL,
orderId: order.contract_terms.order_id orderId: order.contract_terms.order_id
}) })
const refundable = const refundable =

View File

@ -13,12 +13,12 @@
You should have received a copy of the GNU General Public License along with 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/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { stringifyRewardUri } from "@gnu-taler/taler-util";
import { format } from "date-fns"; import { format } from "date-fns";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
import { stringifyRewardUri } from "@gnu-taler/taler-util";
type Entity = MerchantBackend.Rewards.RewardDetails; type Entity = MerchantBackend.Rewards.RewardDetails;
@ -29,9 +29,9 @@ interface Props {
} }
export function RewardInfo({ id: merchantRewardId, amount, entity }: Props): VNode { export function RewardInfo({ id: merchantRewardId, amount, entity }: Props): VNode {
const { url: merchantBaseUrl } = useBackendContext(); const { url: backendURL } = useBackendContext()
const [settings] = useSettings(); const [settings] = useSettings();
const rewardURL = stringifyRewardUri({ merchantBaseUrl, merchantRewardId }) const rewardURL = stringifyRewardUri({ merchantBaseUrl: backendURL, merchantRewardId })
return ( return (
<Fragment> <Fragment>
<div class="field is-horizontal"> <div class="field is-horizontal">

View File

@ -35,16 +35,12 @@ import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputDuration } from "../../../../components/form/InputDuration.js";
import { InputNumber } from "../../../../components/form/InputNumber.js"; import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import {
isBase32RFC3548Charset
} from "../../../../utils/crypto.js";
import { undefinedIfEmpty } from "../../../../utils/table.js";
import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js";
import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; import { useInstanceOtpDevices } from "../../../../hooks/otp.js";
import { undefinedIfEmpty } from "../../../../utils/table.js";
type Entity = MerchantBackend.Template.TemplateAddDetails; type Entity = MerchantBackend.Template.TemplateAddDetails;
@ -55,7 +51,7 @@ interface Props {
export function CreatePage({ onCreate, onBack }: Props): VNode { export function CreatePage({ onCreate, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
const devices = useInstanceOtpDevices() const devices = useInstanceOtpDevices()
const [state, setState] = useState<Partial<Entity>>({ const [state, setState] = useState<Partial<Entity>>({
@ -128,7 +124,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
> >
<InputWithAddon<Entity> <InputWithAddon<Entity>
name="template_id" name="template_id"
help={`${backend.url}/templates/${state.template_id ?? ""}`} help={`${backendURL}/templates/${state.template_id ?? ""}`}
label={i18n.str`Identifier`} label={i18n.str`Identifier`}
tooltip={i18n.str`Name of the template in URLs.`} tooltip={i18n.str`Name of the template in URLs.`}
/> />

View File

@ -19,8 +19,9 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { stringifyPayTemplateUri } from "@gnu-taler/taler-util";
import { h, VNode } from "preact"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { QR } from "../../../../components/exception/QR.js"; import { QR } from "../../../../components/exception/QR.js";
import { import {
@ -29,14 +30,10 @@ import {
} from "../../../../components/form/FormProvider.js"; } from "../../../../components/form/FormProvider.js";
import { Input } from "../../../../components/form/Input.js"; import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { ConfirmModal } from "../../../../components/modal/index.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { useConfigContext } from "../../../../context/config.js"; import { useConfigContext } from "../../../../context/config.js";
import { useInstanceContext } from "../../../../context/instance.js"; import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { stringifyPayTemplateUri } from "@gnu-taler/taler-util";
import { useOtpDeviceDetails } from "../../../../hooks/otp.js";
import { Loading } from "../../../../components/exception/loading.js";
type Entity = MerchantBackend.Template.UsingTemplateDetails; type Entity = MerchantBackend.Template.UsingTemplateDetails;
@ -48,7 +45,7 @@ interface Props {
export function QrPage({ contract, id: templateId, onBack }: Props): VNode { export function QrPage({ contract, id: templateId, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const { url: backendUrl } = useBackendContext(); const { url: backendURL } = useBackendContext()
const { id: instanceId } = useInstanceContext(); const { id: instanceId } = useInstanceContext();
const config = useConfigContext(); const config = useConfigContext();
@ -75,7 +72,7 @@ export function QrPage({ contract, id: templateId, onBack }: Props): VNode {
templateParams.summary = state.summary ?? "" templateParams.summary = state.summary ?? ""
} }
const merchantBaseUrl = new URL(backendUrl).href; const merchantBaseUrl = new URL(backendURL).href;
const payTemplateUri = stringifyPayTemplateUri({ const payTemplateUri = stringifyPayTemplateUri({
merchantBaseUrl, merchantBaseUrl,
@ -84,7 +81,7 @@ export function QrPage({ contract, id: templateId, onBack }: Props): VNode {
}) })
const issuer = encodeURIComponent( const issuer = encodeURIComponent(
`${new URL(backendUrl).host}/${instanceId}`, `${new URL(backendURL).host}/${instanceId}`,
); );
return ( return (

View File

@ -24,7 +24,7 @@ import {
MerchantTemplateContractDetails, MerchantTemplateContractDetails,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { 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 { useState } from "preact/hooks";
import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
import { import {
@ -35,17 +35,10 @@ import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputDuration } from "../../../../components/form/InputDuration.js";
import { InputNumber } from "../../../../components/form/InputNumber.js"; import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend, WithId } from "../../../../declaration.js"; import { MerchantBackend, WithId } from "../../../../declaration.js";
import {
isBase32RFC3548Charset,
randomBase32Key,
} from "../../../../utils/crypto.js";
import { undefinedIfEmpty } from "../../../../utils/table.js"; import { undefinedIfEmpty } from "../../../../utils/table.js";
import { QR } from "../../../../components/exception/QR.js";
import { useInstanceContext } from "../../../../context/instance.js";
type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId; type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId;
@ -55,12 +48,9 @@ interface Props {
template: Entity; template: Entity;
} }
const algorithms = [0, 1, 2];
const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"];
export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
const [state, setState] = useState<Partial<Entity>>(template); const [state, setState] = useState<Partial<Entity>>(template);
@ -115,7 +105,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">
<span class="is-size-4"> <span class="is-size-4">
{backend.url}/templates/{template.id} {backendURL}/templates/{template.id}
</span> </span>
</div> </div>
</div> </div>

View File

@ -26,12 +26,13 @@ import { AsyncButton } from "../../../components/exception/AsyncButton.js";
import { FormProvider } from "../../../components/form/FormProvider.js"; import { FormProvider } from "../../../components/form/FormProvider.js";
import { Input } from "../../../components/form/Input.js"; import { Input } from "../../../components/form/Input.js";
import { useInstanceContext } from "../../../context/instance.js"; import { useInstanceContext } from "../../../context/instance.js";
import { AccessToken } from "../../../declaration.js";
interface Props { interface Props {
instanceId: string; instanceId: string;
currentToken: string | undefined; currentToken: string | undefined;
onClearToken: () => void; onClearToken: () => void;
onNewToken: (s: string) => void; onNewToken: (s: AccessToken) => void;
onBack?: () => void; onBack?: () => void;
} }
@ -71,7 +72,8 @@ export function DetailPage({ instanceId, currentToken: oldToken, onBack, onNewTo
async function submitForm() { async function submitForm() {
if (hasErrors) return; if (hasErrors) return;
onNewToken(form.new_token as any) const nt = `secret-token:${form.new_token}` as AccessToken;
onNewToken(nt)
} }
return ( return (

View File

@ -17,7 +17,7 @@ import { HttpStatusCode } from "@gnu-taler/taler-util";
import { ErrorType, HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ErrorType, HttpError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { Loading } from "../../../components/exception/loading.js"; import { Loading } from "../../../components/exception/loading.js";
import { MerchantBackend } from "../../../declaration.js"; import { AccessToken, MerchantBackend } from "../../../declaration.js";
import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js"; import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js";
import { DetailPage } from "./DetailPage.js"; import { DetailPage } from "./DetailPage.js";
import { useInstanceContext } from "../../../context/instance.js"; import { useInstanceContext } from "../../../context/instance.js";
@ -49,13 +49,13 @@ export default function Token({
const { token: instanceToken, id, admin } = useInstanceContext(); const { token: instanceToken, id, admin } = useInstanceContext();
const currentToken = !admin ? rootToken : instanceToken const currentToken = !admin ? rootToken : instanceToken
const hasPrefix = currentToken !== undefined && currentToken.startsWith(PREFIX) const hasPrefix = currentToken !== undefined && currentToken.token.startsWith(PREFIX)
return ( return (
<Fragment> <Fragment>
<NotificationCard notification={notif} /> <NotificationCard notification={notif} />
<DetailPage <DetailPage
instanceId={id} instanceId={id}
currentToken={hasPrefix ? currentToken.substring(PREFIX.length) : currentToken} currentToken={hasPrefix ? currentToken.token.substring(PREFIX.length) : currentToken?.token}
onClearToken={async (): Promise<void> => { onClearToken={async (): Promise<void> => {
try { try {
await clearToken(); await clearToken();
@ -72,7 +72,7 @@ export default function Token({
}} }}
onNewToken={async (newToken): Promise<void> => { onNewToken={async (newToken): Promise<void> => {
try { try {
await setNewToken(`secret-token:${newToken}`); await setNewToken(newToken);
onChange(); onChange();
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {

View File

@ -13,18 +13,19 @@
You should have received a copy of the GNU General Public License along with 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/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { HttpStatusCode } from "@gnu-taler/taler-util";
import { import {
ErrorType, ErrorType,
HttpError, HttpError,
HttpResponse, HttpResponse,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact"; import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Loading } from "../../../components/exception/loading.js"; import { Loading } from "../../../components/exception/loading.js";
import { NotificationCard } from "../../../components/menu/index.js"; import { NotificationCard } from "../../../components/menu/index.js";
import { useInstanceContext } from "../../../context/instance.js"; import { useInstanceContext } from "../../../context/instance.js";
import { MerchantBackend } from "../../../declaration.js"; import { AccessToken, MerchantBackend } from "../../../declaration.js";
import { import {
useInstanceAPI, useInstanceAPI,
useInstanceDetails, useInstanceDetails,
@ -33,7 +34,6 @@ import {
} from "../../../hooks/instance.js"; } from "../../../hooks/instance.js";
import { Notification } from "../../../utils/types.js"; import { Notification } from "../../../utils/types.js";
import { UpdatePage } from "./UpdatePage.js"; import { UpdatePage } from "./UpdatePage.js";
import { HttpStatusCode } from "@gnu-taler/taler-util";
export interface Props { export interface Props {
onBack: () => void; onBack: () => void;
@ -73,10 +73,9 @@ function CommonUpdate(
MerchantBackend.ErrorDetail MerchantBackend.ErrorDetail
>, >,
updateInstance: any, updateInstance: any,
clearToken: any, clearToken: () => Promise<void>,
setNewToken: any, setNewToken: (t: AccessToken) => Promise<void>,
): VNode { ): VNode {
const { changeToken } = useInstanceContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined); const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -119,11 +118,8 @@ function CommonUpdate(
d: MerchantBackend.Instances.InstanceAuthConfigurationMessage, d: MerchantBackend.Instances.InstanceAuthConfigurationMessage,
): Promise<void> => { ): Promise<void> => {
const apiCall = const apiCall =
d.method === "external" ? clearToken() : setNewToken(d.token!); d.method === "external" ? clearToken() : setNewToken(d.token! as AccessToken);
return apiCall return apiCall.then(onConfirm).catch(onUpdateError);
.then(() => changeToken(d.token))
.then(onConfirm)
.catch(onUpdateError);
}} }}
/> />
</Fragment> </Fragment>

View File

@ -18,9 +18,9 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { QR } from "../../../../components/exception/QR.js"; import { QR } from "../../../../components/exception/QR.js";
import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
import { useBackendContext } from "../../../../context/backend.js";
import { useInstanceContext } from "../../../../context/instance.js"; import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { useBackendContext } from "../../../../context/backend.js";
type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; type Entity = MerchantBackend.OTP.OtpDeviceAddDetails;
@ -38,9 +38,9 @@ export function CreatedSuccessfully({
onConfirm, onConfirm,
}: Props): VNode { }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
const { id: instanceId } = useInstanceContext(); const { id: instanceId } = useInstanceContext();
const issuer = new URL(backend.url).hostname; const issuer = new URL(backendURL).hostname;
const qrText = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`; const qrText = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`;
const qrTextSafe = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`; const qrTextSafe = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`;

View File

@ -18,12 +18,301 @@
* *
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { h, VNode } from "preact";
import { LoginModal } from "../../components/exception/login.js"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { ComponentChildren, h, VNode } from "preact";
import { useCallback, useEffect, useState } from "preact/hooks";
import { useBackendContext } from "../../context/backend.js";
import { useInstanceContext } from "../../context/instance.js";
import { AccessToken, LoginToken } from "../../declaration.js";
import { useCredentialsChecker } from "../../hooks/backend.js";
import { useBackendURL } from "../../hooks/index.js";
interface Props { interface Props {
onConfirm: (url: string, token?: string) => void; onConfirm: (token: LoginToken | undefined) => void;
} }
export default function LoginPage({ onConfirm }: Props): VNode {
return <LoginModal onConfirm={onConfirm} />; function getTokenValuePart(t: string): string {
if (!t) return t;
const match = /secret-token:(.*)/.exec(t);
if (!match || !match[1]) return "";
return match[1];
} }
function normalizeToken(r: string): AccessToken {
return `secret-token:${r}` as AccessToken;
}
function cleanUp(s: string): string {
let result = s;
if (result.indexOf("webui/") !== -1) {
result = result.substring(0, result.indexOf("webui/"));
}
return result;
}
export function LoginPage({ onConfirm }: Props): VNode {
const { url: backendURL, changeBackend } = useBackendContext();
const { admin, id } = useInstanceContext();
const { requestNewLoginToken } = useCredentialsChecker();
const [token, setToken] = useState("");
const { i18n } = useTranslationContext();
const doLogin = useCallback(async function doLoginImpl() {
const secretToken = normalizeToken(token);
const baseUrl = id === undefined ? backendURL : `${backendURL}/instances/${id}`
const result = await requestNewLoginToken(baseUrl, secretToken);
if (result.valid) {
const { token, expiration } = result
onConfirm({ token, expiration });
} else {
onConfirm(undefined);
}
}, [backendURL, id, token])
async function changeServer() {
changeBackend("")
}
console.log(admin, id)
if (admin && id !== "default") {
//admin trying to access another instance
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.</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
? doLogin()
: 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={doLogin}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>)
}
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 }}
>
<i18n.Translate>Please enter your access token.</i18n.Translate>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">URL</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="text"
placeholder="set new url"
name="id"
value={backendURL}
disabled
readOnly
/>
</p>
</div>
</div>
</div>
<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
? doLogin()
: null
}
value={token}
onInput={(e): void => setToken(e?.currentTarget.value)}
/>
</p>
</div>
</div>
</div>
</section>
<footer
class="modal-card-foot "
style={{
justifyContent: "space-between",
border: "1px solid",
borderTop: 0,
}}
>
<AsyncButton
onClick={changeServer}
>
<i18n.Translate>Change server</i18n.Translate>
</AsyncButton>
<AsyncButton
type="is-info"
onClick={doLogin}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>
);
}
function AsyncButton({ onClick, disabled, type = "", children }: { type?: string, disabled?: boolean, onClick: () => Promise<void>, children: ComponentChildren }): VNode {
const [running, setRunning] = useState(false)
return <button class={"button " + type} disabled={disabled || running} onClick={() => {
setRunning(true)
onClick().then(() => {
setRunning(false)
}).catch(() => {
setRunning(false)
})
}}>
{children}
</button>
}
export function ConnectionPage({ onConfirm }: { onConfirm: (s: string) => void }): VNode {
const { url: backendURL } = useBackendContext()
const [url, setURL] = useState(cleanUp(backendURL));
const { i18n } = useTranslationContext();
async function doConnect() {
onConfirm(url)
}
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`Connect to backend`}</p>
</header>
<section
class="modal-card-body"
style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
>
<i18n.Translate>Location of the backend server</i18n.Translate>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">URL</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="text"
placeholder="set new url"
name="id"
value={url ?? ""}
onKeyPress={(e) =>
e.keyCode === 13
? doConnect()
: null
}
onInput={(e): void => setURL(e?.currentTarget.value)}
/>
</p>
</div>
</div>
</div>
</section>
<footer
class="modal-card-foot "
style={{
justifyContent: "flex-end",
border: "1px solid",
borderTop: 0,
}}
>
<AsyncButton
disabled={backendURL === url}
onClick={doConnect}
>
<i18n.Translate>Try again</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>
);
}

View File

@ -13,7 +13,7 @@ function getBrowserLang(): string | undefined {
return undefined; return undefined;
} }
export function Settings(): VNode { export function Settings({ onClose }: { onClose?: () => void }): VNode {
const { i18n } = useTranslationContext() const { i18n } = useTranslationContext()
const borwserLang = getBrowserLang() const borwserLang = getBrowserLang()
const { update } = useLang() const { update } = useLang()
@ -94,11 +94,19 @@ export function Settings(): VNode {
/> />
</FormProvider> </FormProvider>
</div> </div>
</div> </div>
<div class="column" /> <div class="column" />
</div> </div>
</section> </section >
</div> {onClose &&
<section class="section is-main-section">
<button
class="button"
onClick={onClose}
>
<i18n.Translate>Close</i18n.Translate>
</button>
</section>
}
</div >
} }

View File

@ -565,7 +565,7 @@ class BankServiceBase {
protected globalTestState: GlobalTestState, protected globalTestState: GlobalTestState,
protected bankConfig: BankConfig, protected bankConfig: BankConfig,
protected configFile: string, protected configFile: string,
) {} ) { }
} }
export interface HarnessExchangeBankAccount { export interface HarnessExchangeBankAccount {
@ -580,8 +580,7 @@ export interface HarnessExchangeBankAccount {
*/ */
export class FakebankService export class FakebankService
extends BankServiceBase extends BankServiceBase
implements BankServiceHandle implements BankServiceHandle {
{
proc: ProcessWrapper | undefined; proc: ProcessWrapper | undefined;
http = createPlatformHttpLib({ enableThrottling: false }); http = createPlatformHttpLib({ enableThrottling: false });
@ -790,7 +789,7 @@ export class ExchangeService implements ExchangeServiceInterface {
async runWirewatchOnce() { async runWirewatchOnce() {
if (useLibeufinBank) { if (useLibeufinBank) {
// Not even 2 secods showed to be enough! // Not even 2 seconds showed to be enough!
await waitMs(4000); await waitMs(4000);
} }
await runCommand( await runCommand(
@ -1013,7 +1012,7 @@ export class ExchangeService implements ExchangeServiceInterface {
private exchangeConfig: ExchangeConfig, private exchangeConfig: ExchangeConfig,
private configFilename: string, private configFilename: string,
private keyPair: EddsaKeyPair, private keyPair: EddsaKeyPair,
) {} ) { }
get name() { get name() {
return this.exchangeConfig.name; return this.exchangeConfig.name;
@ -1369,7 +1368,7 @@ export class MerchantService implements MerchantServiceInterface {
private globalState: GlobalTestState, private globalState: GlobalTestState,
private merchantConfig: MerchantConfig, private merchantConfig: MerchantConfig,
private configFilename: string, private configFilename: string,
) {} ) { }
private currentTimetravelOffsetMs: number | undefined; private currentTimetravelOffsetMs: number | undefined;
@ -1707,7 +1706,7 @@ export class WalletService {
constructor( constructor(
private globalState: GlobalTestState, private globalState: GlobalTestState,
private opts: WalletServiceOptions, private opts: WalletServiceOptions,
) {} ) { }
get socketPath() { get socketPath() {
const unixPath = path.join( const unixPath = path.join(
@ -1816,7 +1815,7 @@ export class WalletClient {
return client.call(operation, payload); return client.call(operation, payload);
} }
constructor(private args: WalletClientArgs) {} constructor(private args: WalletClientArgs) { }
async connect(): Promise<void> { async connect(): Promise<void> {
const waiter = this.waiter; const waiter = this.waiter;
@ -1883,11 +1882,9 @@ export class WalletCli {
? `--crypto-worker=${cliOpts.cryptoWorkerType}` ? `--crypto-worker=${cliOpts.cryptoWorkerType}`
: ""; : "";
const logName = `wallet-${self.name}`; const logName = `wallet-${self.name}`;
const command = `taler-wallet-cli ${ const command = `taler-wallet-cli ${self.timetravelArg ?? ""
self.timetravelArg ?? "" } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${self.dbfile
} ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${ }' api '${op}' ${shellWrap(JSON.stringify(payload))}`;
self.dbfile
}' api '${op}' ${shellWrap(JSON.stringify(payload))}`;
const resp = await sh(self.globalTestState, logName, command); const resp = await sh(self.globalTestState, logName, command);
logger.info("--- wallet core response ---"); logger.info("--- wallet core response ---");
logger.info(resp); logger.info(resp);

View File

@ -251,7 +251,7 @@ export interface NexusTask {
taskCronSpec: string; taskCronSpec: string;
// Only meaningful for "fetch" types. // Only meaningful for "fetch" types.
taskParams: FetchParams; taskParams: FetchParams;
// Timestamp in secons when the next iteration will run. // Timestamp in seconds when the next iteration will run.
nextScheduledExecutionSec: number; nextScheduledExecutionSec: number;
// Timestamp in seconds when the previous iteration ran. // Timestamp in seconds when the previous iteration ran.
prevScheduledExecutionSec: number; prevScheduledExecutionSec: number;
@ -618,9 +618,9 @@ export class LibeufinCli {
this.globalTestState, this.globalTestState,
"libeufin-cli-createebicssubscriber", "libeufin-cli-createebicssubscriber",
"libeufin-cli sandbox ebicssubscriber create" + "libeufin-cli sandbox ebicssubscriber create" +
` --host-id=${details.hostId}` + ` --host-id=${details.hostId}` +
` --partner-id=${details.partnerId}` + ` --partner-id=${details.partnerId}` +
` --user-id=${details.userId}`, ` --user-id=${details.userId}`,
this.env(), this.env(),
); );
console.log(stdout); console.log(stdout);
@ -634,13 +634,13 @@ export class LibeufinCli {
this.globalTestState, this.globalTestState,
"libeufin-cli-createebicsbankaccount", "libeufin-cli-createebicsbankaccount",
"libeufin-cli sandbox ebicsbankaccount create" + "libeufin-cli sandbox ebicsbankaccount create" +
` --iban=${bankAccountDetails.iban}` + ` --iban=${bankAccountDetails.iban}` +
` --bic=${bankAccountDetails.bic}` + ` --bic=${bankAccountDetails.bic}` +
` --person-name='${bankAccountDetails.personName}'` + ` --person-name='${bankAccountDetails.personName}'` +
` --account-name=${bankAccountDetails.accountName}` + ` --account-name=${bankAccountDetails.accountName}` +
` --ebics-host-id=${sd.hostId}` + ` --ebics-host-id=${sd.hostId}` +
` --ebics-partner-id=${sd.partnerId}` + ` --ebics-partner-id=${sd.partnerId}` +
` --ebics-user-id=${sd.userId}`, ` --ebics-user-id=${sd.userId}`,
this.env(), this.env(),
); );
console.log(stdout); console.log(stdout);
@ -673,11 +673,11 @@ export class LibeufinCli {
this.globalTestState, this.globalTestState,
"libeufin-cli-createebicsconnection", "libeufin-cli-createebicsconnection",
`libeufin-cli connections new-ebics-connection` + `libeufin-cli connections new-ebics-connection` +
` --ebics-url=${connectionDetails.ebicsUrl}` + ` --ebics-url=${connectionDetails.ebicsUrl}` +
` --host-id=${connectionDetails.subscriberDetails.hostId}` + ` --host-id=${connectionDetails.subscriberDetails.hostId}` +
` --partner-id=${connectionDetails.subscriberDetails.partnerId}` + ` --partner-id=${connectionDetails.subscriberDetails.partnerId}` +
` --ebics-user-id=${connectionDetails.subscriberDetails.userId}` + ` --ebics-user-id=${connectionDetails.subscriberDetails.userId}` +
` ${connectionDetails.connectionName}`, ` ${connectionDetails.connectionName}`,
{ {
...process.env, ...process.env,
LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
@ -693,9 +693,9 @@ export class LibeufinCli {
this.globalTestState, this.globalTestState,
"libeufin-cli-createbackupfile", "libeufin-cli-createbackupfile",
`libeufin-cli connections export-backup` + `libeufin-cli connections export-backup` +
` --passphrase=${details.passphrase}` + ` --passphrase=${details.passphrase}` +
` --output-file=${details.outputFile}` + ` --output-file=${details.outputFile}` +
` ${details.connectionName}`, ` ${details.connectionName}`,
{ {
...process.env, ...process.env,
LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
@ -711,7 +711,7 @@ export class LibeufinCli {
this.globalTestState, this.globalTestState,
"libeufin-cli-createkeyletter", "libeufin-cli-createkeyletter",
`libeufin-cli connections get-key-letter` + `libeufin-cli connections get-key-letter` +
` ${details.connectionName} ${details.outputFile}`, ` ${details.connectionName} ${details.outputFile}`,
{ {
...process.env, ...process.env,
LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
@ -774,9 +774,9 @@ export class LibeufinCli {
this.globalTestState, this.globalTestState,
"libeufin-cli-importbankaccount", "libeufin-cli-importbankaccount",
"libeufin-cli connections import-bank-account" + "libeufin-cli connections import-bank-account" +
` --offered-account-id=${importDetails.offeredBankAccountName}` + ` --offered-account-id=${importDetails.offeredBankAccountName}` +
` --nexus-bank-account-id=${importDetails.nexusBankAccountName}` + ` --nexus-bank-account-id=${importDetails.nexusBankAccountName}` +
` ${importDetails.connectionName}`, ` ${importDetails.connectionName}`,
{ {
...process.env, ...process.env,
LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
@ -822,12 +822,12 @@ export class LibeufinCli {
this.globalTestState, this.globalTestState,
"libeufin-cli-preparepayment", "libeufin-cli-preparepayment",
`libeufin-cli accounts prepare-payment` + `libeufin-cli accounts prepare-payment` +
` --creditor-iban=${details.creditorIban}` + ` --creditor-iban=${details.creditorIban}` +
` --creditor-bic=${details.creditorBic}` + ` --creditor-bic=${details.creditorBic}` +
` --creditor-name='${details.creditorName}'` + ` --creditor-name='${details.creditorName}'` +
` --payment-subject='${details.subject}'` + ` --payment-subject='${details.subject}'` +
` --payment-amount=${details.currency}:${details.amount}` + ` --payment-amount=${details.currency}:${details.amount}` +
` ${details.nexusBankAccountName}`, ` ${details.nexusBankAccountName}`,
{ {
...process.env, ...process.env,
LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
@ -846,8 +846,8 @@ export class LibeufinCli {
this.globalTestState, this.globalTestState,
"libeufin-cli-submitpayments", "libeufin-cli-submitpayments",
`libeufin-cli accounts submit-payments` + `libeufin-cli accounts submit-payments` +
` --payment-uuid=${paymentUuid}` + ` --payment-uuid=${paymentUuid}` +
` ${details.nexusBankAccountName}`, ` ${details.nexusBankAccountName}`,
{ {
...process.env, ...process.env,
LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
@ -863,9 +863,9 @@ export class LibeufinCli {
this.globalTestState, this.globalTestState,
"libeufin-cli-new-anastasis-facade", "libeufin-cli-new-anastasis-facade",
`libeufin-cli facades new-anastasis-facade` + `libeufin-cli facades new-anastasis-facade` +
` --currency ${req.currency}` + ` --currency ${req.currency}` +
` --facade-name ${req.facadeName}` + ` --facade-name ${req.facadeName}` +
` ${req.connectionName} ${req.accountName}`, ` ${req.connectionName} ${req.accountName}`,
{ {
...process.env, ...process.env,
LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
@ -881,9 +881,9 @@ export class LibeufinCli {
this.globalTestState, this.globalTestState,
"libeufin-cli-new-taler-wire-gateway-facade", "libeufin-cli-new-taler-wire-gateway-facade",
`libeufin-cli facades new-taler-wire-gateway-facade` + `libeufin-cli facades new-taler-wire-gateway-facade` +
` --currency ${req.currency}` + ` --currency ${req.currency}` +
` --facade-name ${req.facadeName}` + ` --facade-name ${req.facadeName}` +
` ${req.connectionName} ${req.accountName}`, ` ${req.connectionName} ${req.accountName}`,
{ {
...process.env, ...process.env,
LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,

View File

@ -193,7 +193,6 @@ export async function runMerchantInstancesTest(t: GlobalTestState) {
}); });
console.log(exc); console.log(exc);
t.assertTrue(exc.errorDetail.httpStatusCode === 401); t.assertTrue(exc.errorDetail.httpStatusCode === 401);
t.assertDeepEqual(exc.response?.status, 401);
} }
} }

View File

@ -14,6 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { codecForAny } from "./codec.js";
import { import {
createPlatformHttpLib, createPlatformHttpLib,
expectSuccessResponseOrThrow, expectSuccessResponseOrThrow,
@ -221,7 +222,7 @@ export class MerchantApiClient {
const resp = await this.httpClient.fetch(url.href, { const resp = await this.httpClient.fetch(url.href, {
headers: this.makeAuthHeader(), headers: this.makeAuthHeader(),
}); });
return resp.json(); return readSuccessResponseJsonOrThrow(resp, codecForAny());
} }
async getInstanceFullDetails(instanceId: string): Promise<any> { async getInstanceFullDetails(instanceId: string): Promise<any> {

View File

@ -392,7 +392,7 @@ function csKdfMod(
// Newer versions of node have TextEncoder and TextDecoder as a global, // Newer versions of node have TextEncoder and TextDecoder as a global,
// just like modern browsers. // just like modern browsers.
// In older versions of node or environments that do not have these // In older versions of node or environments that do not have these
// globals, they must be polyfilled (by adding them to globa/globalThis) // globals, they must be polyfilled (by adding them to global/globalThis)
// before stringToBytes or bytesToString is called the first time. // before stringToBytes or bytesToString is called the first time.
let encoder: any; let encoder: any;
@ -693,7 +693,7 @@ export async function csBlind(
* Unblind operation to unblind the signature * Unblind operation to unblind the signature
* @param bseed seed to derive secrets * @param bseed seed to derive secrets
* @param rPub public R received from /csr * @param rPub public R received from /csr
* @param csPub denomination publick key * @param csPub denomination public key
* @param b returned from exchange to select c * @param b returned from exchange to select c
* @param csSig blinded signature * @param csSig blinded signature
* @returns unblinded signature * @returns unblinded signature
@ -721,7 +721,7 @@ export async function csUnblind(
* Verification algorithm for CS signatures * Verification algorithm for CS signatures
* @param hm message signed * @param hm message signed
* @param csSig unblinded signature * @param csSig unblinded signature
* @param csPub denomination publick key * @param csPub denomination public key
* @returns true if valid, false if invalid * @returns true if valid, false if invalid
*/ */
export async function csVerify( export async function csVerify(
@ -844,8 +844,7 @@ export function hashDenomPub(pub: DenominationPubKey): Uint8Array {
return hash(uint8ArrayBuf); return hash(uint8ArrayBuf);
} else { } else {
throw Error( throw Error(
`unsupported cipher (${ `unsupported cipher (${(pub as DenominationPubKey).cipher
(pub as DenominationPubKey).cipher
}), unable to hash`, }), unable to hash`,
); );
} }
@ -1023,7 +1022,7 @@ export enum WalletAccountMergeFlags {
export class SignaturePurposeBuilder { export class SignaturePurposeBuilder {
private chunks: Uint8Array[] = []; private chunks: Uint8Array[] = [];
constructor(private purposeNum: number) {} constructor(private purposeNum: number) { }
put(bytes: Uint8Array): SignaturePurposeBuilder { put(bytes: Uint8Array): SignaturePurposeBuilder {
this.chunks.push(Uint8Array.from(bytes)); this.chunks.push(Uint8Array.from(bytes));

View File

@ -1972,42 +1972,58 @@ export interface ExchangeAgeWithdrawRevealResponse {
ev_sigs : BlindedDenominationSignature[]; ev_sigs : BlindedDenominationSignature[];
} }
export interface DepositSuccess { interface DepositConfirmationSignature {
// The EdDSA signature of `TALER_DepositConfirmationPS` using a current
// `signing key of the exchange <sign-key-priv>` affirming the successful
// deposit and that the exchange will transfer the funds after the refund
// deadline, or as soon as possible if the refund deadline is zero.
exchange_sig: EddsaSignatureString;
}
export interface BatchDepositSuccess {
// Optional base URL of the exchange for looking up wire transfers // Optional base URL of the exchange for looking up wire transfers
// associated with this transaction. If not given, // associated with this transaction. If not given,
// the base URL is the same as the one used for this request. // the base URL is the same as the one used for this request.
// Can be used if the base URL for /transactions/ differs from that // Can be used if the base URL for ``/transactions/`` differs from that
// for /coins/, i.e. for load balancing. Clients SHOULD // for ``/coins/``, i.e. for load balancing. Clients SHOULD
// respect the transaction_base_url if provided. Any HTTP server // respect the ``transaction_base_url`` if provided. Any HTTP server
// belonging to an exchange MUST generate a 307 or 308 redirection // belonging to an exchange MUST generate a 307 or 308 redirection
// to the correct base URL should a client uses the wrong base // to the correct base URL should a client uses the wrong base
// URL, or if the base URL has changed since the deposit. // URL, or if the base URL has changed since the deposit.
transaction_base_url?: string; transaction_base_url?: string;
// timestamp when the deposit was received by the exchange. // Timestamp when the deposit was received by the exchange.
exchange_timestamp: TalerProtocolTimestamp; exchange_timestamp: TalerProtocolTimestamp;
// the EdDSA signature of TALER_DepositConfirmationPS using a current // `Public EdDSA key of the exchange <sign-key-pub>` that was used to
// signing key of the exchange affirming the successful
// deposit and that the exchange will transfer the funds after the refund
// deadline, or as soon as possible if the refund deadline is zero.
exchange_sig: string;
// public EdDSA key of the exchange that was used to
// generate the signature. // generate the signature.
// Should match one of the exchange's signing keys from /keys. It is given // Should match one of the exchange's signing keys from ``/keys``. It is given
// explicitly as the client might otherwise be confused by clock skew as to // explicitly as the client might otherwise be confused by clock skew as to
// which signing key was used. // which signing key was used.
exchange_pub: string; exchange_pub: EddsaPublicKeyString;
// Array of deposit confirmation signatures from the exchange
// Entries must be in the same order the coins were given
// in the batch deposit request.
exchange_sigs: DepositConfirmationSignature[];
} }
export const codecForDepositSuccess = (): Codec<DepositSuccess> => export const codecForDepositConfirmationSignature =
buildCodecForObject<DepositSuccess>() (): Codec<DepositConfirmationSignature> =>
buildCodecForObject<DepositConfirmationSignature>()
.property("exchange_sig", codecForString())
.build("DepositConfirmationSignature");
export const codecForBatchDepositSuccess = (): Codec<BatchDepositSuccess> =>
buildCodecForObject<BatchDepositSuccess>()
.property("exchange_pub", codecForString()) .property("exchange_pub", codecForString())
.property("exchange_sig", codecForString()) .property(
"exchange_sigs",
codecForList(codecForDepositConfirmationSignature()),
)
.property("exchange_timestamp", codecForTimestamp) .property("exchange_timestamp", codecForTimestamp)
.property("transaction_base_url", codecOptional(codecForString())) .property("transaction_base_url", codecOptional(codecForString()))
.build("DepositSuccess"); .build("BatchDepositSuccess");
export interface TrackTransactionWired { export interface TrackTransactionWired {
// Raw wire transfer identifier of the deposit. // Raw wire transfer identifier of the deposit.
@ -2231,6 +2247,9 @@ export interface ExchangePurseDeposits {
deposits: PurseDeposit[]; deposits: PurseDeposit[];
} }
/**
* @deprecated batch deposit should be used.
*/
export interface ExchangeDepositRequest { export interface ExchangeDepositRequest {
// Amount to be deposited, can be a fraction of the // Amount to be deposited, can be a fraction of the
// coin's total value. // coin's total value.
@ -2293,6 +2312,67 @@ export interface ExchangeDepositRequest {
h_age_commitment?: string; h_age_commitment?: string;
} }
export type WireSalt = string;
export interface ExchangeBatchDepositRequest {
// The merchant's account details.
merchant_payto_uri: string;
// The salt is used to hide the ``payto_uri`` from customers
// when computing the ``h_wire`` of the merchant.
wire_salt: WireSalt;
// SHA-512 hash of the contract of the merchant with the customer. Further
// details are never disclosed to the exchange.
h_contract_terms: HashCodeString;
// The list of coins that are going to be deposited with this Request.
coins: BatchDepositRequestCoin[];
// Timestamp when the contract was finalized.
timestamp: TalerProtocolTimestamp;
// Indicative time by which the exchange undertakes to transfer the funds to
// the merchant, in case of successful payment. A wire transfer deadline of 'never'
// is not allowed.
wire_transfer_deadline: TalerProtocolTimestamp;
// EdDSA `public key of the merchant <merchant-pub>`, so that the client can identify the
// merchant for refund requests.
merchant_pub: EddsaPublicKeyString;
// Date until which the merchant can issue a refund to the customer via the
// exchange, to be omitted if refunds are not allowed.
//
// THIS FIELD WILL BE DEPRICATED, once the refund mechanism becomes a
// policy via extension.
refund_deadline?: TalerProtocolTimestamp;
// CAVEAT: THIS IS WORK IN PROGRESS
// (Optional) policy for the batch-deposit.
// This might be a refund, auction or escrow policy.
policy?: any;
}
export interface BatchDepositRequestCoin {
// EdDSA public key of the coin being deposited.
coin_pub: EddsaPublicKeyString;
// Hash of denomination RSA key with which the coin is signed.
denom_pub_hash: HashCodeString;
// Exchange's unblinded RSA signature of the coin.
ub_sig: UnblindedSignature;
// Amount to be deposited, can be a fraction of the
// coin's total value.
contribution: Amounts;
// Signature over `TALER_DepositRequestPS`, made by the customer with the
// `coin's private key <coin-priv>`.
coin_sig: EddsaSignatureString;
}
export interface WalletKycUuid { export interface WalletKycUuid {
// UUID that the wallet should use when initiating // UUID that the wallet should use when initiating
// the KYC check. // the KYC check.

View File

@ -57,7 +57,9 @@ import {
DenomKeyType, DenomKeyType,
DenominationPubKey, DenominationPubKey,
ExchangeAuditor, ExchangeAuditor,
InternationalizedString,
MerchantContractTerms, MerchantContractTerms,
MerchantInfo,
PeerContractTerms, PeerContractTerms,
UnblindedSignature, UnblindedSignature,
codecForMerchantContractTerms, codecForMerchantContractTerms,
@ -2667,3 +2669,49 @@ export const codecForTestingSetTimetravelRequest =
buildCodecForObject<TestingSetTimetravelRequest>() buildCodecForObject<TestingSetTimetravelRequest>()
.property("offsetMs", codecForNumber()) .property("offsetMs", codecForNumber())
.build("TestingSetTimetravelRequest"); .build("TestingSetTimetravelRequest");
export interface AllowedAuditorInfo {
auditorBaseUrl: string;
auditorPub: string;
}
export interface AllowedExchangeInfo {
exchangeBaseUrl: string;
exchangePub: string;
}
/**
* Data extracted from the contract terms that is relevant for payment
* processing in the wallet.
*/
export interface WalletContractData {
/**
* Fulfillment URL, or the empty string if the order has no fulfillment URL.
*
* Stored as a non-nullable string as we use this field for IndexedDB indexing.
*/
fulfillmentUrl: string;
contractTermsHash: string;
fulfillmentMessage?: string;
fulfillmentMessageI18n?: InternationalizedString;
merchantSig: string;
merchantPub: string;
merchant: MerchantInfo;
amount: AmountString;
orderId: string;
merchantBaseUrl: string;
summary: string;
summaryI18n: { [lang_tag: string]: string } | undefined;
autoRefund: TalerProtocolDuration | undefined;
maxWireFee: AmountString;
wireFeeAmortization: number;
payDeadline: TalerProtocolTimestamp;
refundDeadline: TalerProtocolTimestamp;
allowedExchanges: AllowedExchangeInfo[];
timestamp: TalerProtocolTimestamp;
wireMethod: string;
wireInfoHash: string;
maxDepositFee: AmountString;
minimumAge?: number;
}

View File

@ -39,6 +39,7 @@ install-nodeps:
ln -sf $(install_target)/node_modules/taler-wallet-cli/bin/taler-wallet-cli.mjs $(prefix)/bin/taler-wallet-cli ln -sf $(install_target)/node_modules/taler-wallet-cli/bin/taler-wallet-cli.mjs $(prefix)/bin/taler-wallet-cli
deps: deps:
pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-cli... pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-cli...
pnpm run --filter @gnu-taler/taler-wallet-cli... compile
install: install:
$(MAKE) deps $(MAKE) deps
$(MAKE) install-nodeps $(MAKE) install-nodeps

View File

@ -28,6 +28,7 @@ import {
} from "@gnu-taler/idb-bridge"; } from "@gnu-taler/idb-bridge";
import { import {
AgeCommitmentProof, AgeCommitmentProof,
AmountJson,
AmountString, AmountString,
Amounts, Amounts,
AttentionInfo, AttentionInfo,
@ -1042,52 +1043,6 @@ export enum RefundReason {
AbortRefund = "abort-pay-refund", AbortRefund = "abort-pay-refund",
} }
export interface AllowedAuditorInfo {
auditorBaseUrl: string;
auditorPub: string;
}
export interface AllowedExchangeInfo {
exchangeBaseUrl: string;
exchangePub: string;
}
/**
* Data extracted from the contract terms that is relevant for payment
* processing in the wallet.
*/
export interface WalletContractData {
/**
* Fulfillment URL, or the empty string if the order has no fulfillment URL.
*
* Stored as a non-nullable string as we use this field for IndexedDB indexing.
*/
fulfillmentUrl: string;
contractTermsHash: string;
fulfillmentMessage?: string;
fulfillmentMessageI18n?: InternationalizedString;
merchantSig: string;
merchantPub: string;
merchant: MerchantInfo;
amount: AmountString;
orderId: string;
merchantBaseUrl: string;
summary: string;
summaryI18n: { [lang_tag: string]: string } | undefined;
autoRefund: TalerProtocolDuration | undefined;
maxWireFee: AmountString;
wireFeeAmortization: number;
payDeadline: TalerProtocolTimestamp;
refundDeadline: TalerProtocolTimestamp;
allowedExchanges: AllowedExchangeInfo[];
timestamp: TalerProtocolTimestamp;
wireMethod: string;
wireInfoHash: string;
maxDepositFee: AmountString;
minimumAge?: number;
}
export enum PurchaseStatus { export enum PurchaseStatus {
/** /**
* Not downloaded yet. * Not downloaded yet.
@ -1693,6 +1648,15 @@ export interface DepositTrackingInfo {
export interface DepositGroupRecord { export interface DepositGroupRecord {
depositGroupId: string; depositGroupId: string;
currency: string;
/**
* Instructed amount.
*/
amount: AmountString;
wireTransferDeadline: TalerProtocolTimestamp;
merchantPub: string; merchantPub: string;
merchantPriv: string; merchantPriv: string;
@ -1708,11 +1672,6 @@ export interface DepositGroupRecord {
salt: string; salt: string;
}; };
/**
* Verbatim contract terms.
*/
contractTermsRaw: MerchantContractTerms;
contractTermsHash: string; contractTermsHash: string;
payCoinSelection: PayCoinSelection; payCoinSelection: PayCoinSelection;
@ -2032,7 +1991,9 @@ export interface PeerPullPaymentIncomingRecord {
exchangeBaseUrl: string; exchangeBaseUrl: string;
contractTerms: PeerContractTerms; amount: AmountString;
contractTermsHash: string;
timestampCreated: TalerPreciseTimestamp; timestampCreated: TalerPreciseTimestamp;

View File

@ -33,12 +33,13 @@ import {
AmountString, AmountString,
codecForAny, codecForAny,
codecForBankWithdrawalOperationPostResponse, codecForBankWithdrawalOperationPostResponse,
codecForDepositSuccess, codecForBatchDepositSuccess,
codecForExchangeMeltResponse, codecForExchangeMeltResponse,
codecForExchangeRevealResponse, codecForExchangeRevealResponse,
codecForWithdrawResponse, codecForWithdrawResponse,
DenominationPubKey, DenominationPubKey,
encodeCrock, encodeCrock,
ExchangeBatchDepositRequest,
ExchangeMeltRequest, ExchangeMeltRequest,
ExchangeProtocolVersion, ExchangeProtocolVersion,
ExchangeWithdrawRequest, ExchangeWithdrawRequest,
@ -256,22 +257,27 @@ export async function depositCoin(args: {
refundDeadline: refundDeadline, refundDeadline: refundDeadline,
wireInfoHash: hashWire(depositPayto, wireSalt), wireInfoHash: hashWire(depositPayto, wireSalt),
}); });
const requestBody = { const requestBody: ExchangeBatchDepositRequest = {
contribution: Amounts.stringify(dp.contribution), coins: [
{
contribution: Amounts.stringify(dp.contribution),
coin_pub: dp.coin_pub,
coin_sig: dp.coin_sig,
denom_pub_hash: dp.h_denom,
ub_sig: dp.ub_sig,
},
],
merchant_payto_uri: depositPayto, merchant_payto_uri: depositPayto,
wire_salt: wireSalt, wire_salt: wireSalt,
h_contract_terms: contractTermsHash, h_contract_terms: contractTermsHash,
ub_sig: coin.denomSig,
timestamp: depositTimestamp, timestamp: depositTimestamp,
wire_transfer_deadline: wireTransferDeadline, wire_transfer_deadline: wireTransferDeadline,
refund_deadline: refundDeadline, refund_deadline: refundDeadline,
coin_sig: dp.coin_sig,
denom_pub_hash: dp.h_denom,
merchant_pub: merchantPub, merchant_pub: merchantPub,
}; };
const url = new URL(`coins/${dp.coin_pub}/deposit`, dp.exchange_url); const url = new URL(`batch-deposit`, dp.exchange_url);
const httpResp = await http.postJson(url.href, requestBody); const httpResp = await http.fetch(url.href, { body: requestBody });
await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess()); await readSuccessResponseJsonOrThrow(httpResp, codecForBatchDepositSuccess());
} }
export async function refreshCoin(req: { export async function refreshCoin(req: {

View File

@ -50,6 +50,8 @@
* Imports. * Imports.
*/ */
import { import {
AllowedAuditorInfo,
AllowedExchangeInfo,
AmountJson, AmountJson,
Amounts, Amounts,
BalancesResponse, BalancesResponse,
@ -60,17 +62,15 @@ import {
ScopeType, ScopeType,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
AllowedAuditorInfo,
AllowedExchangeInfo,
RefreshGroupRecord, RefreshGroupRecord,
WalletStoresV1, WalletStoresV1,
WithdrawalGroupStatus, WithdrawalGroupStatus,
} from "../db.js"; } from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
import { checkLogicInvariant } from "../util/invariants.js"; import { checkLogicInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js"; import { GetReadOnlyAccess } from "../util/query.js";
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
/** /**
* Logger. * Logger.

View File

@ -21,70 +21,69 @@ import {
AbsoluteTime, AbsoluteTime,
AmountJson, AmountJson,
Amounts, Amounts,
BatchDepositRequestCoin,
CancellationToken, CancellationToken,
canonicalJson,
codecForDepositSuccess,
codecForTackTransactionAccepted,
codecForTackTransactionWired,
CoinRefreshRequest, CoinRefreshRequest,
CreateDepositGroupRequest, CreateDepositGroupRequest,
CreateDepositGroupResponse, CreateDepositGroupResponse,
DepositGroupFees, DepositGroupFees,
durationFromSpec, Duration,
encodeCrock, ExchangeBatchDepositRequest,
ExchangeDepositRequest,
ExchangeRefundRequest, ExchangeRefundRequest,
getRandomBytes,
hashTruncate32,
hashWire,
HttpStatusCode, HttpStatusCode,
j2s,
Logger, Logger,
MerchantContractTerms, MerchantContractTerms,
NotificationType, NotificationType,
parsePaytoUri,
PayCoinSelection, PayCoinSelection,
PrepareDepositRequest, PrepareDepositRequest,
PrepareDepositResponse, PrepareDepositResponse,
RefreshReason, RefreshReason,
stringToBytes, TalerError,
TalerErrorCode, TalerErrorCode,
TalerProtocolTimestamp,
TalerPreciseTimestamp, TalerPreciseTimestamp,
TalerProtocolTimestamp,
TrackTransaction, TrackTransaction,
TransactionAction,
TransactionMajorState, TransactionMajorState,
TransactionMinorState, TransactionMinorState,
TransactionState, TransactionState,
TransactionType, TransactionType,
URL, URL,
WireFee, WireFee,
TransactionAction, canonicalJson,
Duration, codecForBatchDepositSuccess,
codecForTackTransactionAccepted,
codecForTackTransactionWired,
durationFromSpec,
encodeCrock,
getRandomBytes,
hashTruncate32,
hashWire,
j2s,
parsePaytoUri,
stringToBytes,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import { DepositElementStatus, DepositGroupRecord } from "../db.js";
import { import {
DenominationRecord,
DepositGroupRecord,
DepositElementStatus,
} from "../db.js";
import { TalerError } from "@gnu-taler/taler-util";
import {
createRefreshGroup,
DepositOperationStatus, DepositOperationStatus,
DepositTrackingInfo, DepositTrackingInfo,
getTotalRefreshCost,
KycPendingInfo, KycPendingInfo,
KycUserType,
PendingTaskType, PendingTaskType,
RefreshOperationStatus, RefreshOperationStatus,
createRefreshGroup,
getTotalRefreshCost,
} from "../index.js"; } from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; import { assertUnreachable } from "../util/assertUnreachable.js";
import { selectPayCoinsNew } from "../util/coinSelection.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { import {
constructTaskIdentifier,
TaskRunResult, TaskRunResult,
TombstoneTag,
constructTaskIdentifier,
runLongpollAsync, runLongpollAsync,
spendCoins, spendCoins,
TombstoneTag,
} from "./common.js"; } from "./common.js";
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
import { import {
@ -92,15 +91,12 @@ import {
generateDepositPermissions, generateDepositPermissions,
getTotalPaymentCost, getTotalPaymentCost,
} from "./pay-merchant.js"; } from "./pay-merchant.js";
import { selectPayCoinsNew } from "../util/coinSelection.js";
import { import {
constructTransactionIdentifier, constructTransactionIdentifier,
notifyTransition, notifyTransition,
parseTransactionIdentifier, parseTransactionIdentifier,
stopLongpolling, stopLongpolling,
} from "./transactions.js"; } from "./transactions.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
/** /**
* Logger. * Logger.
@ -169,6 +165,10 @@ export function computeDepositTransactionStatus(
} }
} }
/**
* Compute the possible actions possible on a deposit transaction
* based on the current transaction state.
*/
export function computeDepositTransactionActions( export function computeDepositTransactionActions(
dg: DepositGroupRecord, dg: DepositGroupRecord,
): TransactionAction[] { ): TransactionAction[] {
@ -200,6 +200,11 @@ export function computeDepositTransactionActions(
} }
} }
/**
* Put a deposit group in a suspended state.
* While the deposit group is suspended, no network requests
* will be made to advance the transaction status.
*/
export async function suspendDepositGroup( export async function suspendDepositGroup(
ws: InternalWalletState, ws: InternalWalletState,
depositGroupId: string, depositGroupId: string,
@ -406,46 +411,6 @@ export async function deleteDepositGroup(
}); });
} }
/**
* Check KYC status with the exchange, throw an appropriate exception when KYC
* is required.
*
* FIXME: Why does this throw an exception when KYC is required?
* Should we not return some proper result record here?
*/
async function checkDepositKycStatus(
ws: InternalWalletState,
exchangeUrl: string,
kycInfo: KycPendingInfo,
userType: KycUserType,
): Promise<void> {
const url = new URL(
`kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
exchangeUrl,
);
logger.info(`kyc url ${url.href}`);
const kycStatusReq = await ws.http.fetch(url.href, {
method: "GET",
});
if (kycStatusReq.status === HttpStatusCode.Ok) {
logger.warn("kyc requested, but already fulfilled");
return;
} else if (kycStatusReq.status === HttpStatusCode.Accepted) {
const kycStatus = await kycStatusReq.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
// FIXME: This error code is totally wrong
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
{
kycUrl: kycStatus.kyc_url,
},
`KYC check required for deposit`,
);
} else {
throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`);
}
}
/** /**
* Check whether the refresh associated with the * Check whether the refresh associated with the
* aborting deposit group is done. * aborting deposit group is done.
@ -919,8 +884,18 @@ async function processDepositGroupPendingDeposit(
): Promise<TaskRunResult> { ): Promise<TaskRunResult> {
logger.info("processing deposit group in pending(deposit)"); logger.info("processing deposit group in pending(deposit)");
const depositGroupId = depositGroup.depositGroupId; const depositGroupId = depositGroup.depositGroupId;
const contractTermsRec = await ws.db
.mktx((x) => [x.contractTerms])
.runReadOnly(async (tx) => {
return tx.contractTerms.get(depositGroup.contractTermsHash);
});
if (!contractTermsRec) {
throw Error("contract terms for deposit not found in database");
}
const contractTerms: MerchantContractTerms =
contractTermsRec.contractTermsRaw;
const contractData = extractContractData( const contractData = extractContractData(
depositGroup.contractTermsRaw, contractTermsRec.contractTermsRaw,
depositGroup.contractTermsHash, depositGroup.contractTermsHash,
"", "",
); );
@ -940,38 +915,57 @@ async function processDepositGroupPendingDeposit(
contractData, contractData,
); );
for (let i = 0; i < depositPermissions.length; i++) { // Exchanges involved in the deposit
const perm = depositPermissions[i]; const exchanges: Set<string> = new Set();
if (depositGroup.statusPerCoin[i] !== DepositElementStatus.DepositPending) { for (const dp of depositPermissions) {
continue; exchanges.add(dp.exchange_url);
}
// We need to do one batch per exchange.
for (const exchangeUrl of exchanges.values()) {
const coins: BatchDepositRequestCoin[] = [];
const batchIndexes: number[] = [];
const batchReq: ExchangeBatchDepositRequest = {
coins,
h_contract_terms: depositGroup.contractTermsHash,
merchant_payto_uri: depositGroup.wire.payto_uri,
merchant_pub: contractTerms.merchant_pub,
timestamp: contractTerms.timestamp,
wire_salt: depositGroup.wire.salt,
wire_transfer_deadline: contractTerms.wire_transfer_deadline,
refund_deadline: contractTerms.refund_deadline,
};
for (let i = 0; i < depositPermissions.length; i++) {
const perm = depositPermissions[i];
if (perm.exchange_url != exchangeUrl) {
continue;
}
coins.push({
coin_pub: perm.coin_pub,
coin_sig: perm.coin_sig,
contribution: Amounts.stringify(perm.contribution),
denom_pub_hash: perm.h_denom,
ub_sig: perm.ub_sig,
});
batchIndexes.push(i);
} }
const requestBody: ExchangeDepositRequest = {
contribution: Amounts.stringify(perm.contribution),
merchant_payto_uri: depositGroup.wire.payto_uri,
wire_salt: depositGroup.wire.salt,
h_contract_terms: depositGroup.contractTermsHash,
ub_sig: perm.ub_sig,
timestamp: depositGroup.contractTermsRaw.timestamp,
wire_transfer_deadline:
depositGroup.contractTermsRaw.wire_transfer_deadline,
refund_deadline: depositGroup.contractTermsRaw.refund_deadline,
coin_sig: perm.coin_sig,
denom_pub_hash: perm.h_denom,
merchant_pub: depositGroup.merchantPub,
h_age_commitment: perm.h_age_commitment,
};
// Check for cancellation before making network request. // Check for cancellation before making network request.
cancellationToken?.throwIfCancelled(); cancellationToken?.throwIfCancelled();
const url = new URL(`coins/${perm.coin_pub}/deposit`, perm.exchange_url); const url = new URL(`batch-deposit`, exchangeUrl);
logger.info(`depositing to ${url}`); logger.info(`depositing to ${url}`);
const httpResp = await ws.http.fetch(url.href, { const httpResp = await ws.http.fetch(url.href, {
method: "POST", method: "POST",
body: requestBody, body: batchReq,
cancellationToken: cancellationToken, cancellationToken: cancellationToken,
}); });
await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess()); await readSuccessResponseJsonOrThrow(
httpResp,
codecForBatchDepositSuccess(),
);
await ws.db await ws.db
.mktx((x) => [x.depositGroups]) .mktx((x) => [x.depositGroups])
@ -980,11 +974,13 @@ async function processDepositGroupPendingDeposit(
if (!dg) { if (!dg) {
return; return;
} }
const coinStatus = dg.statusPerCoin[i]; for (const batchIndex of batchIndexes) {
switch (coinStatus) { const coinStatus = dg.statusPerCoin[batchIndex];
case DepositElementStatus.DepositPending: switch (coinStatus) {
dg.statusPerCoin[i] = DepositElementStatus.Tracking; case DepositElementStatus.DepositPending:
await tx.depositGroups.put(dg); dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking;
await tx.depositGroups.put(dg);
}
} }
}); });
} }
@ -1099,7 +1095,10 @@ async function trackDeposit(
coinPub: string, coinPub: string,
exchangeUrl: string, exchangeUrl: string,
): Promise<TrackTransaction> { ): Promise<TrackTransaction> {
const wireHash = depositGroup.contractTermsRaw.h_wire; const wireHash = hashWire(
depositGroup.wire.payto_uri,
depositGroup.wire.salt,
);
const url = new URL( const url = new URL(
`deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`, `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`,
@ -1371,8 +1370,9 @@ export async function createDepositGroup(
const depositGroup: DepositGroupRecord = { const depositGroup: DepositGroupRecord = {
contractTermsHash, contractTermsHash,
contractTermsRaw: contractTerms,
depositGroupId, depositGroupId,
currency: Amounts.currencyOf(totalDepositCost),
amount: contractData.amount,
noncePriv: noncePair.priv, noncePriv: noncePair.priv,
noncePub: noncePair.pub, noncePub: noncePair.pub,
timestampCreated: AbsoluteTime.toPreciseTimestamp(now), timestampCreated: AbsoluteTime.toPreciseTimestamp(now),
@ -1388,6 +1388,7 @@ export async function createDepositGroup(
counterpartyEffectiveDepositAmount: Amounts.stringify( counterpartyEffectiveDepositAmount: Amounts.stringify(
counterpartyEffectiveDepositAmount, counterpartyEffectiveDepositAmount,
), ),
wireTransferDeadline: contractTerms.wire_transfer_deadline,
wire: { wire: {
payto_uri: req.depositPaytoUri, payto_uri: req.depositPaytoUri,
salt: wireSalt, salt: wireSalt,
@ -1408,6 +1409,7 @@ export async function createDepositGroup(
x.denominations, x.denominations,
x.refreshGroups, x.refreshGroups,
x.coinAvailability, x.coinAvailability,
x.contractTerms,
]) ])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
await spendCoins(ws, tx, { await spendCoins(ws, tx, {
@ -1419,6 +1421,10 @@ export async function createDepositGroup(
refreshReason: RefreshReason.PayDeposit, refreshReason: RefreshReason.PayDeposit,
}); });
await tx.depositGroups.put(depositGroup); await tx.depositGroups.put(depositGroup);
await tx.contractTerms.put({
contractTermsRaw: contractTerms,
h: contractTermsHash,
});
return computeDepositTransactionStatus(depositGroup); return computeDepositTransactionStatus(depositGroup);
}); });
@ -1538,10 +1544,7 @@ async function getTotalFeesForDepositAmount(
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl) .iter(coin.exchangeBaseUrl)
.filter((x) => .filter((x) =>
Amounts.isSameCurrency( Amounts.isSameCurrency(x.value, pcs.coinContributions[i]),
x.value,
pcs.coinContributions[i],
),
); );
const amountLeft = Amounts.sub( const amountLeft = Amounts.sub(
denom.value, denom.value,

View File

@ -63,7 +63,6 @@ import {
RefreshReason, RefreshReason,
SharePaymentResult, SharePaymentResult,
StartRefundQueryForUriResponse, StartRefundQueryForUriResponse,
stringifyPaytoUri,
stringifyPayUri, stringifyPayUri,
stringifyTalerUri, stringifyTalerUri,
TalerError, TalerError,
@ -78,6 +77,7 @@ import {
TransactionState, TransactionState,
TransactionType, TransactionType,
URL, URL,
WalletContractData,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
getHttpResponseErrorDetails, getHttpResponseErrorDetails,
@ -95,7 +95,6 @@ import {
PurchaseRecord, PurchaseRecord,
PurchaseStatus, PurchaseStatus,
RefundReason, RefundReason,
WalletContractData,
WalletStoresV1, WalletStoresV1,
} from "../db.js"; } from "../db.js";
import { import {
@ -115,15 +114,13 @@ import { checkDbInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js"; import { GetReadOnlyAccess } from "../util/query.js";
import { import {
constructTaskIdentifier, constructTaskIdentifier,
TaskRunResult,
TaskRunResultType,
RetryInfo, RetryInfo,
TaskIdentifiers,
} from "./common.js";
import {
runLongpollAsync, runLongpollAsync,
runTaskWithErrorReporting, runTaskWithErrorReporting,
spendCoins, spendCoins,
TaskIdentifiers,
TaskRunResult,
TaskRunResultType,
} from "./common.js"; } from "./common.js";
import { import {
calculateRefreshOutput, calculateRefreshOutput,
@ -173,10 +170,7 @@ export async function getTotalPaymentCost(
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl) .iter(coin.exchangeBaseUrl)
.filter((x) => .filter((x) =>
Amounts.isSameCurrency( Amounts.isSameCurrency(x.value, pcs.coinContributions[i]),
x.value,
pcs.coinContributions[i],
),
); );
const amountLeft = Amounts.sub( const amountLeft = Amounts.sub(
denom.value, denom.value,

View File

@ -19,6 +19,7 @@ import {
Amounts, Amounts,
CoinRefreshRequest, CoinRefreshRequest,
ConfirmPeerPullDebitRequest, ConfirmPeerPullDebitRequest,
ContractTermsUtil,
ExchangePurseDeposits, ExchangePurseDeposits,
HttpStatusCode, HttpStatusCode,
Logger, Logger,
@ -103,9 +104,7 @@ async function handlePurseCreationConflict(
throw new TalerProtocolViolationError(); throw new TalerProtocolViolationError();
} }
const instructedAmount = Amounts.parseOrThrow( const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
peerPullInc.contractTerms.amount,
);
const sel = peerPullInc.coinSel; const sel = peerPullInc.coinSel;
if (!sel) { if (!sel) {
@ -142,9 +141,7 @@ async function handlePurseCreationConflict(
await ws.db await ws.db
.mktx((x) => [x.peerPullDebit]) .mktx((x) => [x.peerPullDebit])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const myPpi = await tx.peerPullDebit.get( const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
peerPullInc.peerPullDebitId,
);
if (!myPpi) { if (!myPpi) {
return; return;
} }
@ -220,9 +217,7 @@ async function processPeerPullDebitPendingDeposit(
const transitionInfo = await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.peerPullDebit]) .mktx((x) => [x.peerPullDebit])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const pi = await tx.peerPullDebit.get( const pi = await tx.peerPullDebit.get(peerPullDebitId);
peerPullDebitId,
);
if (!pi) { if (!pi) {
throw Error("peer pull payment not found anymore"); throw Error("peer pull payment not found anymore");
} }
@ -248,9 +243,7 @@ async function processPeerPullDebitPendingDeposit(
x.coins, x.coins,
]) ])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const pi = await tx.peerPullDebit.get( const pi = await tx.peerPullDebit.get(peerPullDebitId);
peerPullDebitId,
);
if (!pi) { if (!pi) {
throw Error("peer pull payment not found anymore"); throw Error("peer pull payment not found anymore");
} }
@ -335,9 +328,7 @@ async function processPeerPullDebitAbortingRefresh(
} }
} }
if (newOpState) { if (newOpState) {
const newDg = await tx.peerPullDebit.get( const newDg = await tx.peerPullDebit.get(peerPullDebitId);
peerPullDebitId,
);
if (!newDg) { if (!newDg) {
return; return;
} }
@ -391,9 +382,7 @@ export async function confirmPeerPullDebit(
} else if (req.peerPullDebitId) { } else if (req.peerPullDebitId) {
peerPullDebitId = req.peerPullDebitId; peerPullDebitId = req.peerPullDebitId;
} else { } else {
throw Error( throw Error("invalid request, transactionId or peerPullDebitId required");
"invalid request, transactionId or peerPullDebitId required",
);
} }
const peerPullInc = await ws.db const peerPullInc = await ws.db
@ -408,9 +397,7 @@ export async function confirmPeerPullDebit(
); );
} }
const instructedAmount = Amounts.parseOrThrow( const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
peerPullInc.contractTerms.amount,
);
const coinSelRes = await selectPeerCoins(ws, { instructedAmount }); const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`); logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
@ -454,9 +441,7 @@ export async function confirmPeerPullDebit(
refreshReason: RefreshReason.PayPeerPull, refreshReason: RefreshReason.PayPeerPull,
}); });
const pi = await tx.peerPullDebit.get( const pi = await tx.peerPullDebit.get(peerPullDebitId);
peerPullDebitId,
);
if (!pi) { if (!pi) {
throw Error(); throw Error();
} }
@ -498,27 +483,36 @@ export async function preparePeerPullDebit(
throw Error("got invalid taler://pay-pull URI"); throw Error("got invalid taler://pay-pull URI");
} }
const existingPullIncomingRecord = await ws.db const existing = await ws.db
.mktx((x) => [x.peerPullDebit]) .mktx((x) => [x.peerPullDebit, x.contractTerms])
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
return tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([ const peerPullDebitRecord =
uri.exchangeBaseUrl, await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
uri.contractPriv, uri.exchangeBaseUrl,
]); uri.contractPriv,
]);
if (!peerPullDebitRecord) {
return;
}
const contractTerms = await tx.contractTerms.get(
peerPullDebitRecord.contractTermsHash,
);
if (!contractTerms) {
return;
}
return { peerPullDebitRecord, contractTerms };
}); });
if (existingPullIncomingRecord) { if (existing) {
return { return {
amount: existingPullIncomingRecord.contractTerms.amount, amount: existing.peerPullDebitRecord.amount,
amountRaw: existingPullIncomingRecord.contractTerms.amount, amountRaw: existing.peerPullDebitRecord.amount,
amountEffective: existingPullIncomingRecord.totalCostEstimated, amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
contractTerms: existingPullIncomingRecord.contractTerms, contractTerms: existing.contractTerms.contractTermsRaw,
peerPullDebitId: peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
existingPullIncomingRecord.peerPullDebitId,
transactionId: constructTransactionIdentifier({ transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit, tag: TransactionType.PeerPullDebit,
peerPullDebitId: peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
existingPullIncomingRecord.peerPullDebitId,
}), }),
}; };
} }
@ -566,6 +560,8 @@ export async function preparePeerPullDebit(
throw Error("pull payments without contract terms not supported yet"); throw Error("pull payments without contract terms not supported yet");
} }
const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
// FIXME: Why don't we compute the totalCost here?! // FIXME: Why don't we compute the totalCost here?!
const instructedAmount = Amounts.parseOrThrow(contractTerms.amount); const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
@ -588,18 +584,23 @@ export async function preparePeerPullDebit(
); );
await ws.db await ws.db
.mktx((x) => [x.peerPullDebit]) .mktx((x) => [x.peerPullDebit, x.contractTerms])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
await tx.peerPullDebit.add({ await tx.contractTerms.put({
peerPullDebitId, h: contractTermsHash,
contractPriv: contractPriv, contractTermsRaw: contractTerms,
exchangeBaseUrl: exchangeBaseUrl, }),
pursePub: pursePub, await tx.peerPullDebit.add({
timestampCreated: TalerPreciseTimestamp.now(), peerPullDebitId,
contractTerms, contractPriv: contractPriv,
status: PeerPullDebitRecordStatus.DialogProposed, exchangeBaseUrl: exchangeBaseUrl,
totalCostEstimated: Amounts.stringify(totalAmount), pursePub: pursePub,
}); timestampCreated: TalerPreciseTimestamp.now(),
contractTermsHash,
amount: contractTerms.amount,
status: PeerPullDebitRecordStatus.DialogProposed,
totalCostEstimated: Amounts.stringify(totalAmount),
});
}); });
return { return {
@ -631,9 +632,7 @@ export async function suspendPeerPullDebitTransaction(
const transitionInfo = await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.peerPullDebit]) .mktx((x) => [x.peerPullDebit])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const pullDebitRec = await tx.peerPullDebit.get( const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
peerPullDebitId,
);
if (!pullDebitRec) { if (!pullDebitRec) {
logger.warn(`peer pull debit ${peerPullDebitId} not found`); logger.warn(`peer pull debit ${peerPullDebitId} not found`);
return; return;
@ -692,9 +691,7 @@ export async function abortPeerPullDebitTransaction(
const transitionInfo = await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.peerPullDebit]) .mktx((x) => [x.peerPullDebit])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const pullDebitRec = await tx.peerPullDebit.get( const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
peerPullDebitId,
);
if (!pullDebitRec) { if (!pullDebitRec) {
logger.warn(`peer pull debit ${peerPullDebitId} not found`); logger.warn(`peer pull debit ${peerPullDebitId} not found`);
return; return;
@ -753,9 +750,7 @@ export async function failPeerPullDebitTransaction(
const transitionInfo = await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.peerPullDebit]) .mktx((x) => [x.peerPullDebit])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const pullDebitRec = await tx.peerPullDebit.get( const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
peerPullDebitId,
);
if (!pullDebitRec) { if (!pullDebitRec) {
logger.warn(`peer pull debit ${peerPullDebitId} not found`); logger.warn(`peer pull debit ${peerPullDebitId} not found`);
return; return;
@ -814,9 +809,7 @@ export async function resumePeerPullDebitTransaction(
const transitionInfo = await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.peerPullDebit]) .mktx((x) => [x.peerPullDebit])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const pullDebitRec = await tx.peerPullDebit.get( const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
peerPullDebitId,
);
if (!pullDebitRec) { if (!pullDebitRec) {
logger.warn(`peer pull debit ${peerPullDebitId} not found`); logger.warn(`peer pull debit ${peerPullDebitId} not found`);
return; return;

View File

@ -41,6 +41,7 @@ import {
TransactionsResponse, TransactionsResponse,
TransactionState, TransactionState,
TransactionType, TransactionType,
WalletContractData,
WithdrawalType, WithdrawalType,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
@ -60,7 +61,6 @@ import {
RefreshOperationStatus, RefreshOperationStatus,
RefundGroupRecord, RefundGroupRecord,
RewardRecord, RewardRecord,
WalletContractData,
WithdrawalGroupRecord, WithdrawalGroupRecord,
WithdrawalGroupStatus, WithdrawalGroupStatus,
WithdrawalRecordType, WithdrawalRecordType,
@ -346,11 +346,19 @@ export async function getTransactionById(
} }
case TransactionType.PeerPullDebit: { case TransactionType.PeerPullDebit: {
return await ws.db return await ws.db
.mktx((x) => [x.peerPullDebit]) .mktx((x) => [x.peerPullDebit, x.contractTerms])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId); const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId);
if (!debit) throw Error("not found"); if (!debit) throw Error("not found");
return buildTransactionForPullPaymentDebit(debit); const contractTermsRec = await tx.contractTerms.get(
debit.contractTermsHash,
);
if (!contractTermsRec)
throw Error("contract terms for peer-pull-debit not found");
return buildTransactionForPullPaymentDebit(
debit,
contractTermsRec.contractTermsRaw,
);
}); });
} }
@ -477,6 +485,7 @@ function buildTransactionForPushPaymentDebit(
function buildTransactionForPullPaymentDebit( function buildTransactionForPullPaymentDebit(
pi: PeerPullPaymentIncomingRecord, pi: PeerPullPaymentIncomingRecord,
contractTerms: PeerContractTerms,
ort?: OperationRetryRecord, ort?: OperationRetryRecord,
): Transaction { ): Transaction {
return { return {
@ -485,12 +494,12 @@ function buildTransactionForPullPaymentDebit(
txActions: computePeerPullDebitTransactionActions(pi), txActions: computePeerPullDebitTransactionActions(pi),
amountEffective: pi.coinSel?.totalCost amountEffective: pi.coinSel?.totalCost
? pi.coinSel?.totalCost ? pi.coinSel?.totalCost
: Amounts.stringify(pi.contractTerms.amount), : Amounts.stringify(pi.amount),
amountRaw: Amounts.stringify(pi.contractTerms.amount), amountRaw: Amounts.stringify(pi.amount),
exchangeBaseUrl: pi.exchangeBaseUrl, exchangeBaseUrl: pi.exchangeBaseUrl,
info: { info: {
expiration: pi.contractTerms.purse_expiration, expiration: contractTerms.purse_expiration,
summary: pi.contractTerms.summary, summary: contractTerms.summary,
}, },
timestamp: pi.timestampCreated, timestamp: pi.timestampCreated,
transactionId: constructTransactionIdentifier({ transactionId: constructTransactionIdentifier({
@ -805,7 +814,7 @@ function buildTransactionForDeposit(
amountEffective: Amounts.stringify(dg.totalPayCost), amountEffective: Amounts.stringify(dg.totalPayCost),
timestamp: dg.timestampCreated, timestamp: dg.timestampCreated,
targetPaytoUri: dg.wire.payto_uri, targetPaytoUri: dg.wire.payto_uri,
wireTransferDeadline: dg.contractTermsRaw.wire_transfer_deadline, wireTransferDeadline: dg.wireTransferDeadline,
transactionId: constructTransactionIdentifier({ transactionId: constructTransactionIdentifier({
tag: TransactionType.Deposit, tag: TransactionType.Deposit,
depositGroupId: dg.depositGroupId, depositGroupId: dg.depositGroupId,
@ -980,7 +989,7 @@ export async function getTransactions(
}); });
await iterRecordsForPeerPullDebit(tx, filter, async (pi) => { await iterRecordsForPeerPullDebit(tx, filter, async (pi) => {
const amount = Amounts.parseOrThrow(pi.contractTerms.amount); const amount = Amounts.parseOrThrow(pi.amount);
if (shouldSkipCurrency(transactionsRequest, amount.currency)) { if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
return; return;
} }
@ -991,10 +1000,23 @@ export async function getTransactions(
pi.status !== PeerPullDebitRecordStatus.PendingDeposit && pi.status !== PeerPullDebitRecordStatus.PendingDeposit &&
pi.status !== PeerPullDebitRecordStatus.Done pi.status !== PeerPullDebitRecordStatus.Done
) { ) {
// FIXME: Why?!
return; return;
} }
transactions.push(buildTransactionForPullPaymentDebit(pi)); const contractTermsRec = await tx.contractTerms.get(
pi.contractTermsHash,
);
if (!contractTermsRec) {
return;
}
transactions.push(
buildTransactionForPullPaymentDebit(
pi,
contractTermsRec.contractTermsRaw,
),
);
}); });
await iterRecordsForPeerPushCredit(tx, filter, async (pi) => { await iterRecordsForPeerPushCredit(tx, filter, async (pi) => {
@ -1158,7 +1180,7 @@ export async function getTransactions(
}); });
await iterRecordsForDeposit(tx, filter, async (dg) => { await iterRecordsForDeposit(tx, filter, async (dg) => {
const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount); const amount = Amounts.parseOrThrow(dg.amount);
if (shouldSkipCurrency(transactionsRequest, amount.currency)) { if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
return; return;
} }

View File

@ -28,21 +28,20 @@ import {
AbsoluteTime, AbsoluteTime,
AgeCommitmentProof, AgeCommitmentProof,
AgeRestriction, AgeRestriction,
AllowedAuditorInfo,
AllowedExchangeInfo,
AmountJson, AmountJson,
AmountLike, AmountLike,
AmountResponse,
Amounts, Amounts,
AmountString, AmountString,
CoinPublicKeyString, CoinPublicKeyString,
CoinStatus, CoinStatus,
ConvertAmountRequest,
DenominationInfo, DenominationInfo,
DenominationPubKey, DenominationPubKey,
DenomSelectionState, DenomSelectionState,
Duration, Duration,
ForcedCoinSel, ForcedCoinSel,
ForcedDenomSel, ForcedDenomSel,
GetAmountRequest,
j2s, j2s,
Logger, Logger,
parsePaytoUri, parsePaytoUri,
@ -50,24 +49,13 @@ import {
PayMerchantInsufficientBalanceDetails, PayMerchantInsufficientBalanceDetails,
PayPeerInsufficientBalanceDetails, PayPeerInsufficientBalanceDetails,
strcmp, strcmp,
TransactionAmountMode,
TransactionType,
UnblindedSignature, UnblindedSignature,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { DenominationRecord } from "../db.js";
import { import {
AllowedAuditorInfo,
AllowedExchangeInfo,
DenominationRecord,
} from "../db.js";
import {
DbReadOnlyTransaction,
getExchangeDetails, getExchangeDetails,
GetReadOnlyAccess,
GetReadWriteAccess,
isWithdrawableDenom, isWithdrawableDenom,
StoreNames,
WalletDbReadOnlyTransaction, WalletDbReadOnlyTransaction,
WalletStoresV1,
} from "../index.js"; } from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { import {

View File

@ -38,7 +38,6 @@ import {
ApplyDevExperimentRequest, ApplyDevExperimentRequest,
BackupRecovery, BackupRecovery,
BalancesResponse, BalancesResponse,
FailTransactionRequest,
CheckPeerPullCreditRequest, CheckPeerPullCreditRequest,
CheckPeerPullCreditResponse, CheckPeerPullCreditResponse,
CheckPeerPushDebitRequest, CheckPeerPushDebitRequest,
@ -51,14 +50,19 @@ import {
ConvertAmountRequest, ConvertAmountRequest,
CreateDepositGroupRequest, CreateDepositGroupRequest,
CreateDepositGroupResponse, CreateDepositGroupResponse,
CreateStoredBackupResponse,
DeleteStoredBackupRequest,
DeleteTransactionRequest, DeleteTransactionRequest,
ExchangeDetailedResponse, ExchangeDetailedResponse,
ExchangesListResponse, ExchangesListResponse,
FailTransactionRequest,
ForceRefreshRequest, ForceRefreshRequest,
ForgetKnownBankAccountsRequest, ForgetKnownBankAccountsRequest,
GetAmountRequest, GetAmountRequest,
GetBalanceDetailRequest, GetBalanceDetailRequest,
GetContractTermsDetailsRequest, GetContractTermsDetailsRequest,
GetCurrencyInfoRequest,
GetCurrencyInfoResponse,
GetExchangeTosRequest, GetExchangeTosRequest,
GetExchangeTosResult, GetExchangeTosResult,
GetPlanForOperationRequest, GetPlanForOperationRequest,
@ -85,16 +89,21 @@ import {
PreparePeerPushCreditRequest, PreparePeerPushCreditRequest,
PreparePeerPushCreditResponse, PreparePeerPushCreditResponse,
PrepareRefundRequest, PrepareRefundRequest,
PrepareRewardRequest as PrepareRewardRequest, PrepareRewardRequest,
PrepareTipResult as PrepareRewardResult, PrepareTipResult as PrepareRewardResult,
RecoverStoredBackupRequest,
RecoveryLoadRequest, RecoveryLoadRequest,
RetryTransactionRequest, RetryTransactionRequest,
SetCoinSuspendedRequest, SetCoinSuspendedRequest,
SetWalletDeviceIdRequest, SetWalletDeviceIdRequest,
SharePaymentRequest,
SharePaymentResult,
StartRefundQueryForUriResponse, StartRefundQueryForUriResponse,
StartRefundQueryRequest, StartRefundQueryRequest,
StoredBackupList,
TestPayArgs, TestPayArgs,
TestPayResult, TestPayResult,
TestingSetTimetravelRequest,
Transaction, Transaction,
TransactionByIdRequest, TransactionByIdRequest,
TransactionsRequest, TransactionsRequest,
@ -106,22 +115,13 @@ import {
UserAttentionsResponse, UserAttentionsResponse,
ValidateIbanRequest, ValidateIbanRequest,
ValidateIbanResponse, ValidateIbanResponse,
WalletContractData,
WalletCoreVersion, WalletCoreVersion,
WalletCurrencyInfo, WalletCurrencyInfo,
WithdrawFakebankRequest, WithdrawFakebankRequest,
WithdrawTestBalanceRequest, WithdrawTestBalanceRequest,
WithdrawUriInfoResponse, WithdrawUriInfoResponse,
SharePaymentRequest,
SharePaymentResult,
GetCurrencyInfoRequest,
GetCurrencyInfoResponse,
StoredBackupList,
CreateStoredBackupResponse,
RecoverStoredBackupRequest,
DeleteStoredBackupRequest,
TestingSetTimetravelRequest,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { WalletContractData } from "./db.js";
import { import {
AddBackupProviderRequest, AddBackupProviderRequest,
AddBackupProviderResponse, AddBackupProviderResponse,

View File

@ -15,7 +15,7 @@
*/ */
/** /**
* High-level wallet operations that should be indepentent from the underlying * High-level wallet operations that should be independent from the underlying
* browser extension interface. * browser extension interface.
*/ */
@ -923,9 +923,9 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
ageCommitmentProof: c.ageCommitmentProof, ageCommitmentProof: c.ageCommitmentProof,
spend_allocation: c.spendAllocation spend_allocation: c.spendAllocation
? { ? {
amount: c.spendAllocation.amount, amount: c.spendAllocation.amount,
id: c.spendAllocation.id, id: c.spendAllocation.id,
} }
: undefined, : undefined,
}); });
} }

View File

@ -18,8 +18,6 @@
* *
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { WalletContractData } from "@gnu-taler/taler-wallet-core";
import * as tests from "@gnu-taler/web-util/testing"; import * as tests from "@gnu-taler/web-util/testing";
import { import {
ErrorView, ErrorView,
@ -27,6 +25,7 @@ import {
LoadingView, LoadingView,
ShowView, ShowView,
} from "./ShowFullContractTermPopup.js"; } from "./ShowFullContractTermPopup.js";
import { WalletContractData } from "@gnu-taler/taler-util";
export default { export default {
title: "ShowFullContractTermPopup", title: "ShowFullContractTermPopup",

View File

@ -13,11 +13,13 @@
You should have received a copy of the GNU General Public License along with 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/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { AbsoluteTime, Duration, Location } from "@gnu-taler/taler-util";
import { import {
WalletApiOperation, AbsoluteTime,
Duration,
Location,
WalletContractData, WalletContractData,
} from "@gnu-taler/taler-wallet-core"; } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { styled } from "@linaria/react"; import { styled } from "@linaria/react";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
@ -334,8 +336,8 @@ export function ShowView({ contractTerms, hideHandler }: States.Show): VNode {
!contractTerms.autoRefund !contractTerms.autoRefund
? Duration.getZero() ? Duration.getZero()
: Duration.fromTalerProtocolDuration( : Duration.fromTalerProtocolDuration(
contractTerms.autoRefund, contractTerms.autoRefund,
), ),
)} )}
format="dd MMMM yyyy, HH:mm" format="dd MMMM yyyy, HH:mm"
/> />

View File

@ -25,6 +25,8 @@ export enum ErrorType {
UNEXPECTED, UNEXPECTED,
} }
/** /**
* *
* @param baseUrl URL where the service is located * @param baseUrl URL where the service is located
@ -60,10 +62,27 @@ export async function defaultRequestHandler<T>(
const requestPreventCache = options.preventCache ?? false; const requestPreventCache = options.preventCache ?? false;
const requestPreventCors = options.preventCors ?? false; const requestPreventCors = options.preventCors ?? false;
const _url = new URL(`${baseUrl}${endpoint}`); const validURL = validateURL(baseUrl, endpoint);
if (!validURL) {
const error: HttpResponseUnexpectedError = {
info: {
url: `${baseUrl}${endpoint}`,
payload: {},
hasToken: !!options.token,
status: 0,
options,
},
type: ErrorType.UNEXPECTED,
exception: undefined,
loading: false,
message: `invalid URL: "${validURL}"`,
};
throw new RequestError(error)
}
Object.entries(requestParams).forEach(([key, value]) => { Object.entries(requestParams).forEach(([key, value]) => {
_url.searchParams.set(key, String(value)); validURL.searchParams.set(key, String(value));
}); });
let payload: BodyInit | undefined = undefined; let payload: BodyInit | undefined = undefined;
@ -77,7 +96,20 @@ export async function defaultRequestHandler<T>(
} else if (typeof requestBody === "object") { } else if (typeof requestBody === "object") {
payload = JSON.stringify(requestBody); payload = JSON.stringify(requestBody);
} else { } else {
throw Error("unsupported request body type"); const error: HttpResponseUnexpectedError = {
info: {
url: validURL.href,
payload: {},
hasToken: !!options.token,
status: 0,
options,
},
type: ErrorType.UNEXPECTED,
exception: undefined,
loading: false,
message: `unsupported request body type: "${typeof requestBody}"`,
};
throw new RequestError(error)
} }
} }
@ -88,7 +120,7 @@ export async function defaultRequestHandler<T>(
let response; let response;
try { try {
response = await fetch(_url.href, { response = await fetch(validURL.href, {
headers: requestHeaders, headers: requestHeaders,
method: requestMethod, method: requestMethod,
credentials: "omit", credentials: "omit",
@ -100,15 +132,29 @@ export async function defaultRequestHandler<T>(
} catch (ex) { } catch (ex) {
const info: RequestInfo = { const info: RequestInfo = {
payload, payload,
url: _url.href, url: validURL.href,
hasToken: !!options.token, hasToken: !!options.token,
status: 0, status: 0,
options, options,
}; };
const error: HttpRequestTimeoutError = {
if (ex instanceof Error) {
if (ex.message === "HTTP_REQUEST_TIMEOUT") {
const error: HttpRequestTimeoutError = {
info,
type: ErrorType.TIMEOUT,
message: "request timeout",
};
throw new RequestError(error);
}
}
const error: HttpResponseUnexpectedError = {
info, info,
type: ErrorType.TIMEOUT, type: ErrorType.UNEXPECTED,
message: "Request timeout", exception: ex,
loading: false,
message: (ex instanceof Error ? ex.message : ""),
}; };
throw new RequestError(error); throw new RequestError(error);
} }
@ -124,7 +170,7 @@ export async function defaultRequestHandler<T>(
if (response.ok) { if (response.ok) {
const result = await buildRequestOk<T>( const result = await buildRequestOk<T>(
response, response,
_url.href, validURL.href,
payload, payload,
!!options.token, !!options.token,
options, options,
@ -133,7 +179,7 @@ export async function defaultRequestHandler<T>(
} else { } else {
const dataTxt = await response.text(); const dataTxt = await response.text();
const error = buildRequestFailed( const error = buildRequestFailed(
_url.href, validURL.href,
dataTxt, dataTxt,
response.status, response.status,
payload, payload,
@ -377,3 +423,12 @@ export function buildRequestFailed<ErrorDetail>(
return error; return error;
} }
} }
function validateURL(baseUrl: string, endpoint: string): URL | undefined {
try {
return new URL(`${baseUrl}${endpoint}`)
} catch (ex) {
return undefined
}
}