diff options
58 files changed, 1061 insertions, 862 deletions
diff --git a/build-system/taler-build-scripts b/build-system/taler-build-scripts -Subproject 001f5dd081fc8729ff8def90c4a1c3f93eb8689 +Subproject 23538677f6c6be2a62f38dc6137ecdd1c76b7b1 diff --git a/ci/ci.sh b/ci/ci.sh new file mode 100755 index 000000000..fc523d8f5 --- /dev/null +++ b/ci/ci.sh @@ -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 diff --git a/ci/jobs/0-codespell/dictionary.txt b/ci/jobs/0-codespell/dictionary.txt index 95f860504..aeace0e9e 100644 --- a/ci/jobs/0-codespell/dictionary.txt +++ b/ci/jobs/0-codespell/dictionary.txt @@ -5,41 +5,16 @@  # 'foo' and you add 'Foo' _here_, codespell will continue to complain  #  about 'Foo'.  # -BRE -ND -Nd -TE -TEH -UPDATEing -WAN  aci -acn -ba -bre  cant -complet -doas  ect -ehr  fo -hel -ifset -ist -keypair -nd -onl -openin -ot -ser -sie  som -sover  te -te -teh -tha  ths  updateing -wan -wih  vie +zar +nam +pares +kwanza diff --git a/ci/jobs/0-codespell/job.sh b/ci/jobs/0-codespell/job.sh index cb41388c3..9271343e6 100755 --- a/ci/jobs/0-codespell/job.sh +++ b/ci/jobs/0-codespell/job.sh @@ -3,4 +3,4 @@ set -exuo pipefail  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/**" diff --git a/contrib/articles/spending.txt b/contrib/articles/spending.txt index 7f2c716f8..887a80910 100644 --- a/contrib/articles/spending.txt +++ b/contrib/articles/spending.txt @@ -45,7 +45,7 @@ If the above parameters have an optimal assignment, then replacing    v'[x] := 0  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 diff --git a/contrib/articles/ui/ui-cameraready.tex b/contrib/articles/ui/ui-cameraready.tex index ef79d91ba..305b77258 100644 --- a/contrib/articles/ui/ui-cameraready.tex +++ b/contrib/articles/ui/ui-cameraready.tex @@ -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.  By submitting the payment this way, we also ensure that this  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.  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      of asking the other user to pay for the resource.    \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.    \item The different pages of the merchant have clear      delineations: the shopping pages conclude by making an offer, and diff --git a/contrib/articles/ui/ui.tex b/contrib/articles/ui/ui.tex index 37cd8b076..620dd5ecd 100644 --- a/contrib/articles/ui/ui.tex +++ b/contrib/articles/ui/ui.tex @@ -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.  By submitting the payment this way, we also ensure that this  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.  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      of asking the other user to pay for the resource.    \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.    \item The different pages of the merchant have clear      delineations: the shopping pages conclude by making an offer, and diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx index 3018f88dd..54bbc626d 100644 --- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx @@ -241,7 +241,7 @@ export function AuthenticationEditorScreen(): VNode {            </p>            {authAvailableSet.size > 0 && (              <p class="block"> -              We couldn't find provider for some of the authentication +              We couldn't find provider for some of the authentication                methods.              </p>            )} diff --git a/packages/demobank-ui/Makefile b/packages/demobank-ui/Makefile index fc570b270..8e41cc7c6 100644 --- a/packages/demobank-ui/Makefile +++ b/packages/demobank-ui/Makefile @@ -20,6 +20,7 @@ spa_dir=$(prefix)/share/taler/demobank-ui  .PHONY: deps  deps:  	pnpm install --frozen-lockfile --filter @gnu-taler/demobank-ui... +	pnpm run --filter @gnu-taler/demobank-ui... compile  	pnpm run check  	pnpm run build diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-reused.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-reused.test.ts index b13bd1fc3..e0e6c2bf8 100644 --- a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-reused.test.ts +++ b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-reused.test.ts @@ -26,7 +26,7 @@ test("WPT idbcursor-reused.htm", async (t) => {            case 0:              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;              e.target.custom_request_value = 2; @@ -34,7 +34,7 @@ test("WPT idbcursor-reused.htm", async (t) => {              break;            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(                e.target.custom_request_value, diff --git a/packages/idb-bridge/src/util/fakeDOMStringList.ts b/packages/idb-bridge/src/util/fakeDOMStringList.ts index 92785f9e1..24f5c96f4 100644 --- a/packages/idb-bridge/src/util/fakeDOMStringList.ts +++ b/packages/idb-bridge/src/util/fakeDOMStringList.ts @@ -21,7 +21,7 @@ export interface FakeDOMStringList extends Array<string> {    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 => {    const arr2 = arr.slice(); diff --git a/packages/merchant-backoffice-ui/src/Application.tsx b/packages/merchant-backoffice-ui/src/Application.tsx index 5e82821ae..1a7617643 100644 --- a/packages/merchant-backoffice-ui/src/Application.tsx +++ b/packages/merchant-backoffice-ui/src/Application.tsx @@ -41,7 +41,8 @@ import {  import { ConfigContextProvider } from "./context/config.js";  import { useBackendConfig } from "./hooks/backend.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 {    return ( @@ -59,25 +60,20 @@ export function Application(): VNode {   * @returns    */  function ApplicationStatusRoutes(): VNode { -  const { url, updateLoginStatus, triedToLog } = useBackendContext(); +  const { url: backendURL, updateToken, changeBackend } = useBackendContext();    const result = useBackendConfig();    const { i18n } = useTranslationContext(); -  const updateLoginInfoAndGoToRoot = (url: string, token?: string) => { -    updateLoginStatus(url, token); -    route("/"); -  }; -    const { currency, version } = result.ok      ? result.data      : { currency: "unknown", version: "unknown" };    const ctx = useMemo(() => ({ currency, version }), [currency, version]); -  if (!triedToLog) { +  if (!backendURL) {      return (        <Fragment>          <NotConnectedAppMenu title="Welcome!" /> -        <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> +        <ConnectionPage onConfirm={changeBackend} />        </Fragment>      );    } @@ -91,7 +87,7 @@ function ApplicationStatusRoutes(): VNode {        return (          <Fragment>            <NotConnectedAppMenu title="Login" /> -          <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> +          <ConnectionPage onConfirm={changeBackend} />          </Fragment>        );      } @@ -109,7 +105,7 @@ function ApplicationStatusRoutes(): VNode {                description: `Check your url`,              }}            /> -          <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> +          <ConnectionPage onConfirm={changeBackend} />          </Fragment>        );      } @@ -120,10 +116,10 @@ function ApplicationStatusRoutes(): VNode {            notification={{              message: i18n.str`Server response with an error code`,              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>;      }      if (result.type === ErrorType.UNREADABLE) { @@ -133,10 +129,10 @@ function ApplicationStatusRoutes(): VNode {            notification={{              message: i18n.str`Response from server is unreadable, http status: ${result.status}`,              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>;      }      return ( @@ -146,10 +142,10 @@ function ApplicationStatusRoutes(): VNode {            notification={{              message: i18n.str`Unexpected 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>      );    } @@ -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}`,          }}        /> -      <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> +      <ConnectionPage onConfirm={changeBackend} />      </Fragment>    } diff --git a/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx b/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx index 46dea98e3..8bfbdb076 100644 --- a/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx +++ b/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx @@ -18,22 +18,23 @@   *   * @author Sebastian Javier Marchano (sebasjm)   */ +import { HttpStatusCode } from "@gnu-taler/taler-util";  import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser";  import { createHashHistory } from "history"; -import { Fragment, h, VNode } from "preact"; -import { Router, Route, route } from "preact-router"; -import { useEffect, useState } from "preact/hooks"; +import { Fragment, VNode, h } from "preact"; +import { Route, Router, route } from "preact-router"; +import { useState } from "preact/hooks"; +import { InstanceRoutes } from "./InstanceRoutes.js";  import { -  NotificationCard,    NotYetReadyAppMenu, +  NotificationCard,  } from "./components/menu/index.js";  import { useBackendContext } from "./context/backend.js"; +import { LoginToken } from "./declaration.js";  import { useBackendInstancesTestForAdmin } from "./hooks/backend.js"; -import { InstanceRoutes } from "./InstanceRoutes.js"; -import LoginPage from "./paths/login/index.js"; -import { INSTANCE_ID_LOOKUP } from "./utils/constants.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { ConnectionPage, LoginPage } from "./paths/login/index.js";  import { Settings } from "./paths/settings/index.js"; +import { INSTANCE_ID_LOOKUP } from "./utils/constants.js";  /**   * Check if admin against /management/instances @@ -41,15 +42,14 @@ import { Settings } from "./paths/settings/index.js";   */  export function ApplicationReadyRoutes(): VNode {    const { i18n } = useTranslationContext(); +  const { url: backendURL, changeBackend } = useBackendContext()    const [unauthorized, setUnauthorized] = useState(false)    const { -    url: backendURL, -    updateLoginStatus: updateLoginStatus2, +    updateToken,    } = useBackendContext(); -  function updateLoginStatus(url: string, token: string | undefined) { -    console.log("updateing", url, token) -    updateLoginStatus2(url, token) +  function updateLoginStatus(token: LoginToken | undefined) { +    updateToken(token)      setUnauthorized(false)    } @@ -59,15 +59,15 @@ export function ApplicationReadyRoutes(): VNode {      route("/");    };    const [showSettings, setShowSettings] = useState(false) -  // useEffect(() => { -  //   setUnauthorized(FF) -  // }, [FF]) -  const unauthorizedAdmin = !result.loading && !result.ok && result.type === ErrorType.CLIENT && result.status === HttpStatusCode.Unauthorized +  const unauthorizedAdmin = !result.loading +    && !result.ok +    && result.type === ErrorType.CLIENT +    && result.status === HttpStatusCode.Unauthorized;    if (showSettings) {      return <Fragment>        <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> -      <Settings /> +      <Settings onClose={() => setShowSettings(false)} />      </Fragment>    } @@ -100,7 +100,7 @@ export function ApplicationReadyRoutes(): VNode {                type: "ERROR",              }}            /> -          <LoginPage onConfirm={updateLoginStatus} /> +          <ConnectionPage onConfirm={changeBackend} />          </Fragment>        );      } @@ -108,14 +108,13 @@ export function ApplicationReadyRoutes(): VNode {      instanceNameByBackendURL = match[1];    } -  console.log(unauthorized, unauthorizedAdmin)    if (unauthorized || unauthorizedAdmin) {      return <Fragment>        <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />        <NotificationCard          notification={{            message: i18n.str`Access denied`, -          description: i18n.str`Check your token is valid`, +          description: i18n.str`Check your token is valid 1`,            type: "ERROR",          }}        /> @@ -132,7 +131,6 @@ export function ApplicationReadyRoutes(): VNode {          admin={admin}          onUnauthorized={() => setUnauthorized(true)}          onLoginPass={() => { -          console.log("ahora si")            setUnauthorized(false)          }}          instanceNameByBackendURL={instanceNameByBackendURL} diff --git a/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx b/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx index ee8db9a9f..c2a9d3b18 100644 --- a/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx +++ b/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx @@ -35,7 +35,7 @@ import { InstanceContextProvider } from "./context/instance.js";  import {    useBackendDefaultToken,    useBackendInstanceToken, -  useLocalStorage, +  useSimpleLocalStorage,  } from "./hooks/index.js";  import { useInstanceKYCDetails } from "./hooks/instance.js";  import InstanceCreatePage from "./paths/admin/create/index.js"; @@ -71,10 +71,10 @@ import InstanceUpdatePage, {    AdminUpdate as InstanceAdminUpdatePage,    Props as InstanceUpdatePageProps,  } 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 { Notification } from "./utils/types.js"; -import { MerchantBackend } from "./declaration.js"; +import { LoginToken, MerchantBackend } from "./declaration.js";  import { Settings } from "./paths/settings/index.js";  import { dateFormatForSettings, useSettings } from "./hooks/useSettings.js"; @@ -143,7 +143,7 @@ export function InstanceRoutes({    id,    admin,    path, -  onUnauthorized, +  // onUnauthorized,    onLoginPass,    setInstanceName,  }: Props): VNode { @@ -155,7 +155,7 @@ export function InstanceRoutes({    const [globalNotification, setGlobalNotification] =      useState<GlobalNotifState>(undefined); -  const changeToken = (token?: string) => { +  const changeToken = (token?: LoginToken) => {      if (admin) {        updateToken(token);      } else { @@ -201,14 +201,17 @@ export function InstanceRoutes({    // const LoginPageAccessDeniend = onUnauthorized    const LoginPageAccessDenied = () => { -    onUnauthorized() -    return <NotificationCard -      notification={{ -        message: i18n.str`Access denied`, -        description: i18n.str`Redirecting to login page.`, -        type: "ERROR", -      }} -    /> +    return <Fragment> +      <NotificationCard +        notification={{ +          message: i18n.str`Access denied`, +          description: i18n.str`Redirecting to login page.`, +          type: "ERROR", +        }} +      /> +      <LoginPage onConfirm={changeToken} /> +    </Fragment> +    }    function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) { @@ -687,9 +690,7 @@ function AdminInstanceUpdatePage({    ...rest  }: { id: string } & InstanceUpdatePageProps): VNode {    const [token, changeToken] = useBackendInstanceToken(id); -  const { updateLoginStatus: changeBackend } = useBackendContext(); -  const updateLoginStatus = (url: string, token?: string): void => { -    changeBackend(url); +  const updateLoginStatus = (token?: LoginToken): void => {      changeToken(token);    };    const value = useMemo( @@ -752,7 +753,7 @@ function KycBanner(): VNode {    const { i18n } = useTranslationContext();    const [settings] = useSettings();    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 needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect";    if (hasBeenHidden || !needsToBeShown) return <Fragment />; diff --git a/packages/merchant-backoffice-ui/src/components/exception/login.tsx b/packages/merchant-backoffice-ui/src/components/exception/login.tsx deleted file mode 100644 index 4fa440fc7..000000000 --- a/packages/merchant-backoffice-ui/src/components/exception/login.tsx +++ /dev/null @@ -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> -} diff --git a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx index b75dc83b3..6f5881fc0 100644 --- a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx +++ b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx @@ -40,13 +40,13 @@ export function DefaultInstanceFormFields({    showId: boolean;  }): VNode {    const { i18n } = useTranslationContext(); -  const backend = useBackendContext(); +  const { url: backendURL } = useBackendContext()    return (      <Fragment>        {showId && (          <InputWithAddon<Entity>            name="id" -          addonBefore={`${backend.url}/instances/`} +          addonBefore={`${backendURL}/instances/`}            readonly={readonlyId}            label={i18n.str`Identifier`}            tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`} diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx index be2f8dde5..3d5f20c85 100644 --- a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx +++ b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -25,7 +25,6 @@ import { useBackendContext } from "../../context/backend.js";  import { useConfigContext } from "../../context/config.js";  import { useInstanceKYCDetails } from "../../hooks/instance.js";  import { LangSelector } from "./LangSelector.js"; -import { useCredentialsChecker } from "../../hooks/backend.js";  const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;  const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; @@ -50,7 +49,7 @@ export function Sidebar({    isPasswordOk  }: Props): VNode {    const config = useConfigContext(); -  const backend = useBackendContext(); +  const { url: backendURL } = useBackendContext()    const { i18n } = useTranslationContext();    const kycStatus = useInstanceKYCDetails();    const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; @@ -230,7 +229,7 @@ export function Sidebar({                  <i class="mdi mdi-web" />                </span>                <span class="menu-item-label"> -                {new URL(backend.url).hostname} +                {new URL(backendURL).hostname}                </span>              </div>            </li> diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx index 726a94f5e..8bebbd298 100644 --- a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx +++ b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx @@ -114,7 +114,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {      onSubscribe(hasErrors ? undefined : submit);    }, [submit, hasErrors]); -  const backend = useBackendContext(); +  const { url: backendURL } = useBackendContext()    const { i18n } = useTranslationContext();    return ( @@ -128,7 +128,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {          {alreadyExist ? undefined : (            <InputWithAddon<Entity>              name="product_id" -            addonBefore={`${backend.url}/product/`} +            addonBefore={`${backendURL}/product/`}              label={i18n.str`ID`}              tooltip={i18n.str`product identification to use in URLs (for internal use only)`}            /> diff --git a/packages/merchant-backoffice-ui/src/context/backend.test.ts b/packages/merchant-backoffice-ui/src/context/backend.test.ts index cb0010c4b..b042d5a25 100644 --- a/packages/merchant-backoffice-ui/src/context/backend.test.ts +++ b/packages/merchant-backoffice-ui/src/context/backend.test.ts @@ -21,7 +21,7 @@  import * as tests from "@gnu-taler/web-util/testing";  import { ComponentChildren, h, VNode } from "preact"; -import { MerchantBackend } from "../declaration.js"; +import { AccessToken, MerchantBackend } from "../declaration.js";  import {    useAdminAPI,    useInstanceAPI, @@ -64,7 +64,7 @@ describe("backend context api ", () => {              } as MerchantBackend.Instances.QueryInstancesResponse,            }); -          management.setNewToken("another_token"); +          management.setNewToken("another_token" as AccessToken);          },          ({ instance, management, admin }) => {            expect(env.assertJustExpectedRequestWereMade()).deep.eq({ @@ -113,7 +113,7 @@ describe("backend context api ", () => {                name: "instance_name",              } as MerchantBackend.Instances.QueryInstancesResponse,            }); -          instance.setNewToken("another_token"); +          instance.setNewToken("another_token" as AccessToken);          },          ({ instance, management, admin }) => {            expect(env.assertJustExpectedRequestWereMade()).deep.eq({ diff --git a/packages/merchant-backoffice-ui/src/context/backend.ts b/packages/merchant-backoffice-ui/src/context/backend.ts index 43e9e4d27..056f9a192 100644 --- a/packages/merchant-backoffice-ui/src/context/backend.ts +++ b/packages/merchant-backoffice-ui/src/context/backend.ts @@ -20,90 +20,46 @@   */  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";  interface BackendContextType { -  url: string; -  token?: string; -  triedToLog: boolean; -  resetBackend: () => void; -  // clearAllTokens: () => void; -  // addTokenCleaner: (c: () => void) => void; -  updateLoginStatus: (url: string, token?: string) => void; -  updateToken: (token?: string) => void; +  url: string, +  token?: LoginToken; +  updateToken: (token: LoginToken | undefined) => void; +  changeBackend: (url: string) => void;  }  const BackendContext = createContext<BackendContextType>({    url: "",    token: undefined, -  triedToLog: false, -  resetBackend: () => null, -  // clearAllTokens: () => null, -  // addTokenCleaner: () => null, -  updateLoginStatus: () => null,    updateToken: () => null, +  changeBackend: () => null,  });  function useBackendContextState(    defaultUrl?: string, -  initialToken?: string,  ): BackendContextType { -  const [url, triedToLog, changeBackend, resetBackend] = -    useBackendURL(defaultUrl); -  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); -  }; +  const [url, changeBackend] = useBackendURL(defaultUrl); +  const [token, updateToken] = useBackendDefaultToken();    return {      url,      token, -    triedToLog, -    updateLoginStatus, -    resetBackend, -    // clearAllTokens,      updateToken, -    // addTokenCleaner: addTokenCleanerMemo, +    changeBackend    };  }  export const BackendContextProvider = ({    children,    defaultUrl, -  initialToken,  }: {    children: any;    defaultUrl?: string; -  initialToken?: string;  }): VNode => { -  const value = useBackendContextState(defaultUrl, initialToken); +  const value = useBackendContextState(defaultUrl);    return h(BackendContext.Provider, { value, children });  }; diff --git a/packages/merchant-backoffice-ui/src/context/instance.ts b/packages/merchant-backoffice-ui/src/context/instance.ts index 9a25fe80c..3c6cc2b63 100644 --- a/packages/merchant-backoffice-ui/src/context/instance.ts +++ b/packages/merchant-backoffice-ui/src/context/instance.ts @@ -21,12 +21,13 @@  import { createContext } from "preact";  import { useContext } from "preact/hooks"; +import { LoginToken } from "../declaration.js";  interface Type {    id: string; -  token?: string; +  token?: LoginToken;    admin?: boolean; -  changeToken: (t?: string) => void; +  changeToken: (t?: LoginToken) => void;  }  const Context = createContext<Type>({} as any); diff --git a/packages/merchant-backoffice-ui/src/declaration.d.ts b/packages/merchant-backoffice-ui/src/declaration.d.ts index 5ca9c1e09..c3e6ea3da 100644 --- a/packages/merchant-backoffice-ui/src/declaration.d.ts +++ b/packages/merchant-backoffice-ui/src/declaration.d.ts @@ -107,6 +107,16 @@ interface RegexAccountRestriction {    // human hints.    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 {    interface WireResponse { @@ -491,6 +501,35 @@ export namespace MerchantBackend {        };      }      //   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 { diff --git a/packages/merchant-backoffice-ui/src/hooks/backend.ts b/packages/merchant-backoffice-ui/src/hooks/backend.ts index ecd34df6d..fe4155788 100644 --- a/packages/merchant-backoffice-ui/src/hooks/backend.ts +++ b/packages/merchant-backoffice-ui/src/hooks/backend.ts @@ -19,19 +19,21 @@   * @author Sebastian Javier Marchano (sebasjm)   */ -import { useSWRConfig } from "swr"; -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 { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util";  import {    ErrorType, +  HttpError,    HttpResponse,    HttpResponseOk,    RequestError,    RequestOptions, +  useApiContext,  } 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(): ( @@ -85,6 +87,9 @@ export function useBackendInstancesTestForAdmin(): HttpResponse<    return result;  } +const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000; +const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000; +  export function useBackendConfig(): HttpResponse<    MerchantBackend.VersionResponse,    RequestError<MerchantBackend.ErrorDetail> @@ -92,18 +97,33 @@ export function useBackendConfig(): HttpResponse<    const { request } = useBackendBaseRequest();    type Type = MerchantBackend.VersionResponse; - -  const [result, setResult] = useState< -    HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>> -  >({ loading: true }); +  type State = { data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>, timer: number } +  const [result, setResult] = useState<State>({ data: { loading: true }, timer: 0 });    useEffect(() => { -    request<Type>(`/config`) -      .then((data) => setResult(data)) -      .catch((error) => setResult(error)); +    if (result.timer) { +      clearTimeout(result.timer) +    } +    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]); -  return result; +  return result.data;  }  interface useBackendInstanceRequestType { @@ -149,32 +169,86 @@ interface useBackendBaseRequestType {  }  type YesOrNo = "yes" | "no"; +type LoginResult = { +  valid: true; +  token: string; +  expiration: Timestamp; +} | { +  valid: false; +  cause: HttpError<{}>; +}  export function useCredentialsChecker() {    const { request } = useApiContext();    //check against instance details endpoint    //while merchant backend doesn't have a login endpoint -  async function testLogin( -    instance: string, -    token: string, -  ): Promise<{ -    valid: boolean; -    cause?: ErrorType; -  }> { +  async function requestNewLoginToken( +    baseUrl: string, +    token: AccessToken, +  ): Promise<LoginResult> { +    const data: MerchantBackend.Instances.LoginTokenRequest = { +      scope: "write", +      duration: { +        d_us: "forever" +      }, +      refreshable: true, +    }      try { -      const response = await request(instance, `/private/`, { +      const response = await request<MerchantBackend.Instances.LoginTokenSuccessResponse>(baseUrl, `/private/token`, { +        method: "POST",          token, +        data        }); -      return { valid: true }; +      return { valid: true, token: response.data.token, expiration: response.data.expiration };      } catch (error) {        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   */  export function useBackendBaseRequest(): useBackendBaseRequestType { -  const { url: backend, token } = useBackendContext(); +  const { url: backend, token: loginToken } = useBackendContext();    const { request: requestHandler } = useApiContext(); +  const token = loginToken?.token;    const request = useCallback(      function requestImpl<T>(        endpoint: string,        options: RequestOptions = {},      ): 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],    ); @@ -204,10 +283,12 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {    const { token: instanceToken, id, admin } = useInstanceContext();    const { request: requestHandler } = useApiContext(); -  const { baseUrl, token } = !admin +  const { baseUrl, token: loginToken } = !admin      ? { baseUrl: rootBackendUrl, token: rootToken }      : { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken }; +  const token = loginToken?.token; +    const request = useCallback(      function requestImpl<T>(        endpoint: string, diff --git a/packages/merchant-backoffice-ui/src/hooks/index.ts b/packages/merchant-backoffice-ui/src/hooks/index.ts index 79b22304a..ee696779f 100644 --- a/packages/merchant-backoffice-ui/src/hooks/index.ts +++ b/packages/merchant-backoffice-ui/src/hooks/index.ts @@ -19,9 +19,11 @@   * @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 { useMemoryStorage } from "@gnu-taler/web-util/browser";  import { useMatchMutate } from "./backend.js";  const calculateRootPath = () => { @@ -32,53 +34,55 @@ const calculateRootPath = () => {    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(    url?: string, -): [string, boolean, StateUpdater<string>, () => void] { -  const [value, setter] = useNotNullLocalStorage( +): [string, StateUpdater<string>] { +  const [value, setter] = useSimpleLocalStorage(      "backend-url",      url || calculateRootPath(),    ); -  const [triedToLog, setTriedToLog] = useLocalStorage("tried-login");    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 = () => { -    setTriedToLog(undefined); -  }; -  return [value, !!triedToLog, checkedSetter, resetBackend]; +  return [value!, checkedSetter];  }  export function useBackendDefaultToken( -  initialValue?: string, -): [string | undefined, ((d: string | undefined) => void)] { -  // uncomment for testing -  initialValue = "secret-token:secret" as string | undefined -  const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token`, initialValue) +): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] { +  const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {}) + +  const tokenOfDefaultInstance = tokenMap["default"]    const clearCache = useMatchMutate()    useEffect(() => {      clearCache() -  }, [token]) +  }, [tokenOfDefaultInstance])    function updateToken( -    value: (string | undefined) +    value: (LoginToken | undefined)    ): void {      if (value === undefined) {        reset()      } else { -      setToken(value) +      const res = { ...tokenMap, "default": value } +      setToken(res)      }    } -  return [token, updateToken]; +  return [tokenMap["default"], updateToken];  }  export function useBackendInstanceToken(    id: string, -): [string | undefined, ((d: string | undefined) => void)] { -  const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token-${id}`) +): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] { +  const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})    const [defaultToken, defaultSetToken] = useBackendDefaultToken();    // instance named 'default' use the default token @@ -86,16 +90,17 @@ export function useBackendInstanceToken(      return [defaultToken, defaultSetToken];    }    function updateToken( -    value: (string | undefined) +    value: (LoginToken | undefined)    ): void {      if (value === undefined) {        reset()      } 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>] { @@ -104,10 +109,10 @@ export function useLang(initial?: string): [string, StateUpdater<string>] {        ? navigator.language || (navigator as any).userLanguage        : undefined;    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,    initialValue?: string,  ): [string | undefined, StateUpdater<string | undefined>] { @@ -137,28 +142,3 @@ export function useLocalStorage(    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]; -} diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.test.ts b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts index d15b3f6d7..a7b8d047c 100644 --- a/packages/merchant-backoffice-ui/src/hooks/instance.test.ts +++ b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts @@ -21,7 +21,7 @@  import * as tests from "@gnu-taler/web-util/testing";  import { expect } from "chai"; -import { MerchantBackend } from "../declaration.js"; +import { AccessToken, MerchantBackend } from "../declaration.js";  import {    useAdminAPI,    useBackendInstances, @@ -158,7 +158,7 @@ describe("instance api interaction with details", () => {                },              } as MerchantBackend.Instances.QueryInstancesResponse,            }); -          api.setNewToken("secret"); +          api.setNewToken("secret" as AccessToken);          },          ({ query, api }) => {            expect(env.assertJustExpectedRequestWereMade()).deep.eq({ diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts index 32ed30c6f..50f9487a3 100644 --- a/packages/merchant-backoffice-ui/src/hooks/instance.ts +++ b/packages/merchant-backoffice-ui/src/hooks/instance.ts @@ -19,10 +19,11 @@ import {    RequestError,  } from "@gnu-taler/web-util/browser";  import { useBackendContext } from "../context/backend.js"; -import { MerchantBackend } from "../declaration.js"; +import { AccessToken, MerchantBackend } from "../declaration.js";  import {    useBackendBaseRequest,    useBackendInstanceRequest, +  useCredentialsChecker,    useMatchMutate,  } from "./backend.js"; @@ -36,7 +37,7 @@ interface InstanceAPI {    ) => Promise<void>;    deleteInstance: () => Promise<void>;    clearToken: () => Promise<void>; -  setNewToken: (token: string) => Promise<void>; +  setNewToken: (token: AccessToken) => Promise<void>;  }  export function useAdminAPI(): AdminAPI { @@ -86,8 +87,10 @@ export interface AdminAPI {  export function useManagementAPI(instanceId: string): InstanceAPI {    const mutateAll = useMatchMutate(); +  const { url: backendURL } = useBackendContext()    const { updateToken } = useBackendContext();    const { request } = useBackendBaseRequest(); +  const { requestNewLoginToken } = useCredentialsChecker()    const updateInstance = async (      instance: MerchantBackend.Instances.InstanceReconfigurationMessage, @@ -117,13 +120,20 @@ export function useManagementAPI(instanceId: string): InstanceAPI {      mutateAll(/\/management\/instances/);    }; -  const setNewToken = async (newToken: string): Promise<void> => { +  const setNewToken = async (newToken: AccessToken): Promise<void> => {      await request(`/management/instances/${instanceId}/auth`, {        method: "POST",        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/);    }; @@ -132,12 +142,13 @@ export function useManagementAPI(instanceId: string): InstanceAPI {  export function useInstanceAPI(): InstanceAPI {    const { mutate } = useSWRConfig(); +  const { url: backendURL, updateToken } = useBackendContext() +    const { -    url: baseUrl,      token: adminToken, -    updateLoginStatus,    } = useBackendContext();    const { request } = useBackendInstanceRequest(); +  const { requestNewLoginToken } = useCredentialsChecker()    const updateInstance = async (      instance: MerchantBackend.Instances.InstanceReconfigurationMessage, @@ -147,7 +158,7 @@ export function useInstanceAPI(): InstanceAPI {        data: instance,      }); -    if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null); +    if (adminToken) mutate(["/private/instances", adminToken, backendURL], null);      mutate([`/private/`], null);    }; @@ -157,7 +168,7 @@ export function useInstanceAPI(): InstanceAPI {        // token: adminToken,      }); -    if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null); +    if (adminToken) mutate(["/private/instances", adminToken, backendURL], null);      mutate([`/private/`], null);    }; @@ -170,13 +181,20 @@ export function useInstanceAPI(): InstanceAPI {      mutate([`/private/`], null);    }; -  const setNewToken = async (newToken: string): Promise<void> => { +  const setNewToken = async (newToken: AccessToken): Promise<void> => {      await request(`/private/auth`, {        method: "POST",        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);    }; diff --git a/packages/merchant-backoffice-ui/src/hooks/testing.tsx b/packages/merchant-backoffice-ui/src/hooks/testing.tsx index ebbc6f64a..847d512b0 100644 --- a/packages/merchant-backoffice-ui/src/hooks/testing.tsx +++ b/packages/merchant-backoffice-ui/src/hooks/testing.tsx @@ -90,10 +90,7 @@ export class ApiMockEnvironment extends MockEnvironment {        const SC: any = SWRConfig;        return ( -        <BackendContextProvider -          defaultUrl="http://backend" -          initialToken={undefined} -        > +        <BackendContextProvider defaultUrl="http://backend">            <InstanceContextProvider              value={{                token: undefined, diff --git a/packages/merchant-backoffice-ui/src/hooks/useSettings.ts b/packages/merchant-backoffice-ui/src/hooks/useSettings.ts index 7dee9f896..8c1ebd9f6 100644 --- a/packages/merchant-backoffice-ui/src/hooks/useSettings.ts +++ b/packages/merchant-backoffice-ui/src/hooks/useSettings.ts @@ -24,15 +24,6 @@ import {    codecForString,  } 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 {    advanceOrderMode: boolean;    dateFormat: "ymd" | "dmy" | "mdy"; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx index e42adc2ff..1cfbec29b 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx @@ -22,7 +22,7 @@  import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util";  import { useTranslationContext } from "@gnu-taler/web-util/browser";  import { format, formatDistance } from "date-fns"; -import { Fragment, h, VNode } from "preact"; +import { Fragment, VNode, h } from "preact";  import { useState } from "preact/hooks";  import { FormProvider } from "../../../../components/form/FormProvider.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 { useBackendContext } from "../../../../context/backend.js";  import { MerchantBackend } from "../../../../declaration.js"; +import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";  import { mergeRefunds } from "../../../../utils/amount.js";  import { RefundModal } from "../list/Table.js";  import { Event, Timeline } from "./Timeline.js"; -import { dateFormatForSettings, datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";  type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse;  type CT = MerchantBackend.ContractTerms; @@ -416,9 +416,9 @@ function PaidPage({    })    const [value, valueHandler] = useState<Partial<Paid>>(order); -  const { url } = useBackendContext(); +  const { url: backendURL } = useBackendContext()    const refundurl = stringifyRefundUri({ -    merchantBaseUrl: url, +    merchantBaseUrl: backendURL,      orderId: order.contract_terms.order_id    })    const refundable = diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx index 57a051ed7..780068a91 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx @@ -13,12 +13,12 @@   You should have received a copy of the GNU General Public License along with   GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>   */ +import { stringifyRewardUri } from "@gnu-taler/taler-util";  import { format } from "date-fns";  import { Fragment, h, VNode } from "preact";  import { useBackendContext } from "../../../../context/backend.js";  import { MerchantBackend } from "../../../../declaration.js";  import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; -import { stringifyRewardUri } from "@gnu-taler/taler-util";  type Entity = MerchantBackend.Rewards.RewardDetails; @@ -29,9 +29,9 @@ interface Props {  }  export function RewardInfo({ id: merchantRewardId, amount, entity }: Props): VNode { -  const { url: merchantBaseUrl } = useBackendContext(); +  const { url: backendURL } = useBackendContext()    const [settings] = useSettings(); -  const rewardURL = stringifyRewardUri({ merchantBaseUrl, merchantRewardId }) +  const rewardURL = stringifyRewardUri({ merchantBaseUrl: backendURL, merchantRewardId })    return (      <Fragment>        <div class="field is-horizontal"> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx index 8629d8dee..78ea07477 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx @@ -35,16 +35,12 @@ import { Input } from "../../../../components/form/Input.js";  import { InputCurrency } from "../../../../components/form/InputCurrency.js";  import { InputDuration } from "../../../../components/form/InputDuration.js";  import { InputNumber } from "../../../../components/form/InputNumber.js"; +import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js";  import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";  import { useBackendContext } from "../../../../context/backend.js"; -import { useInstanceContext } from "../../../../context/instance.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 { undefinedIfEmpty } from "../../../../utils/table.js";  type Entity = MerchantBackend.Template.TemplateAddDetails; @@ -55,7 +51,7 @@ interface Props {  export function CreatePage({ onCreate, onBack }: Props): VNode {    const { i18n } = useTranslationContext(); -  const backend = useBackendContext(); +  const { url: backendURL } = useBackendContext()    const devices = useInstanceOtpDevices()    const [state, setState] = useState<Partial<Entity>>({ @@ -128,7 +124,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {              >                <InputWithAddon<Entity>                  name="template_id" -                help={`${backend.url}/templates/${state.template_id ?? ""}`} +                help={`${backendURL}/templates/${state.template_id ?? ""}`}                  label={i18n.str`Identifier`}                  tooltip={i18n.str`Name of the template in URLs.`}                /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx index c65cf6a19..5140aae3a 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx @@ -19,8 +19,9 @@   * @author Sebastian Javier Marchano (sebasjm)   */ -import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; +import { stringifyPayTemplateUri } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact";  import { useState } from "preact/hooks";  import { QR } from "../../../../components/exception/QR.js";  import { @@ -29,14 +30,10 @@ import {  } from "../../../../components/form/FormProvider.js";  import { Input } from "../../../../components/form/Input.js";  import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { ConfirmModal } from "../../../../components/modal/index.js";  import { useBackendContext } from "../../../../context/backend.js";  import { useConfigContext } from "../../../../context/config.js";  import { useInstanceContext } from "../../../../context/instance.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; @@ -48,7 +45,7 @@ interface Props {  export function QrPage({ contract, id: templateId, onBack }: Props): VNode {    const { i18n } = useTranslationContext(); -  const { url: backendUrl } = useBackendContext(); +  const { url: backendURL } = useBackendContext()    const { id: instanceId } = useInstanceContext();    const config = useConfigContext(); @@ -75,7 +72,7 @@ export function QrPage({ contract, id: templateId, onBack }: Props): VNode {      templateParams.summary = state.summary ?? ""    } -  const merchantBaseUrl = new URL(backendUrl).href; +  const merchantBaseUrl = new URL(backendURL).href;    const payTemplateUri = stringifyPayTemplateUri({      merchantBaseUrl, @@ -84,7 +81,7 @@ export function QrPage({ contract, id: templateId, onBack }: Props): VNode {    })    const issuer = encodeURIComponent( -    `${new URL(backendUrl).host}/${instanceId}`, +    `${new URL(backendURL).host}/${instanceId}`,    );    return ( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx index 30d47385c..82b74e1fa 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx @@ -24,7 +24,7 @@ import {    MerchantTemplateContractDetails,  } from "@gnu-taler/taler-util";  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 { AsyncButton } from "../../../../components/exception/AsyncButton.js";  import { @@ -35,17 +35,10 @@ import { Input } from "../../../../components/form/Input.js";  import { InputCurrency } from "../../../../components/form/InputCurrency.js";  import { InputDuration } from "../../../../components/form/InputDuration.js";  import { InputNumber } from "../../../../components/form/InputNumber.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js";  import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";  import { useBackendContext } from "../../../../context/backend.js";  import { MerchantBackend, WithId } from "../../../../declaration.js"; -import { -  isBase32RFC3548Charset, -  randomBase32Key, -} from "../../../../utils/crypto.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; @@ -55,12 +48,9 @@ interface Props {    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 {    const { i18n } = useTranslationContext(); -  const backend = useBackendContext(); +  const { url: backendURL } = useBackendContext()    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-item">                    <span class="is-size-4"> -                    {backend.url}/templates/{template.id} +                    {backendURL}/templates/{template.id}                    </span>                  </div>                </div> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx index 984880752..4b0db200a 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx @@ -26,12 +26,13 @@ import { AsyncButton } from "../../../components/exception/AsyncButton.js";  import { FormProvider } from "../../../components/form/FormProvider.js";  import { Input } from "../../../components/form/Input.js";  import { useInstanceContext } from "../../../context/instance.js"; +import { AccessToken } from "../../../declaration.js";  interface Props {    instanceId: string;    currentToken: string | undefined;    onClearToken: () => void; -  onNewToken: (s: string) => void; +  onNewToken: (s: AccessToken) => void;    onBack?: () => void;  } @@ -71,7 +72,8 @@ export function DetailPage({ instanceId, currentToken: oldToken, onBack, onNewTo    async function submitForm() {      if (hasErrors) return; -    onNewToken(form.new_token as any) +    const nt = `secret-token:${form.new_token}` as AccessToken; +    onNewToken(nt)    }    return ( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx index d5910361b..0a49448f8 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx @@ -17,7 +17,7 @@ import { HttpStatusCode } from "@gnu-taler/taler-util";  import { ErrorType, HttpError, useTranslationContext } from "@gnu-taler/web-util/browser";  import { Fragment, VNode, h } from "preact";  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 { DetailPage } from "./DetailPage.js";  import { useInstanceContext } from "../../../context/instance.js"; @@ -49,13 +49,13 @@ export default function Token({    const { token: instanceToken, id, admin } = useInstanceContext();    const currentToken = !admin ? rootToken : instanceToken -  const hasPrefix = currentToken !== undefined && currentToken.startsWith(PREFIX) +  const hasPrefix = currentToken !== undefined && currentToken.token.startsWith(PREFIX)    return (      <Fragment>        <NotificationCard notification={notif} />        <DetailPage          instanceId={id} -        currentToken={hasPrefix ? currentToken.substring(PREFIX.length) : currentToken} +        currentToken={hasPrefix ? currentToken.token.substring(PREFIX.length) : currentToken?.token}          onClearToken={async (): Promise<void> => {            try {              await clearToken(); @@ -72,7 +72,7 @@ export default function Token({          }}          onNewToken={async (newToken): Promise<void> => {            try { -            await setNewToken(`secret-token:${newToken}`); +            await setNewToken(newToken);              onChange();            } catch (error) {              if (error instanceof Error) { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx index 4a8162611..6c5e7a514 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx @@ -13,18 +13,19 @@   You should have received a copy of the GNU General Public License along with   GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>   */ +import { HttpStatusCode } from "@gnu-taler/taler-util";  import {    ErrorType,    HttpError,    HttpResponse,    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 { Loading } from "../../../components/exception/loading.js";  import { NotificationCard } from "../../../components/menu/index.js";  import { useInstanceContext } from "../../../context/instance.js"; -import { MerchantBackend } from "../../../declaration.js"; +import { AccessToken, MerchantBackend } from "../../../declaration.js";  import {    useInstanceAPI,    useInstanceDetails, @@ -33,7 +34,6 @@ import {  } from "../../../hooks/instance.js";  import { Notification } from "../../../utils/types.js";  import { UpdatePage } from "./UpdatePage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util";  export interface Props {    onBack: () => void; @@ -73,10 +73,9 @@ function CommonUpdate(      MerchantBackend.ErrorDetail    >,    updateInstance: any, -  clearToken: any, -  setNewToken: any, +  clearToken: () => Promise<void>, +  setNewToken: (t: AccessToken) => Promise<void>,  ): VNode { -  const { changeToken } = useInstanceContext();    const [notif, setNotif] = useState<Notification | undefined>(undefined);    const { i18n } = useTranslationContext(); @@ -119,11 +118,8 @@ function CommonUpdate(            d: MerchantBackend.Instances.InstanceAuthConfigurationMessage,          ): Promise<void> => {            const apiCall = -            d.method === "external" ? clearToken() : setNewToken(d.token!); -          return apiCall -            .then(() => changeToken(d.token)) -            .then(onConfirm) -            .catch(onUpdateError); +            d.method === "external" ? clearToken() : setNewToken(d.token! as AccessToken); +          return apiCall.then(onConfirm).catch(onUpdateError);          }}        />      </Fragment> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx index 3ad3cb3a3..22ae55677 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx @@ -18,9 +18,9 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser";  import { Fragment, VNode, h } from "preact";  import { QR } from "../../../../components/exception/QR.js";  import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; -import { useBackendContext } from "../../../../context/backend.js";  import { useInstanceContext } from "../../../../context/instance.js";  import { MerchantBackend } from "../../../../declaration.js"; +import { useBackendContext } from "../../../../context/backend.js";  type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; @@ -38,9 +38,9 @@ export function CreatedSuccessfully({    onConfirm,  }: Props): VNode {    const { i18n } = useTranslationContext(); -  const backend = useBackendContext(); +  const { url: backendURL } = useBackendContext()    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 qrTextSafe = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`; diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx index caa63c714..9948307e4 100644 --- a/packages/merchant-backoffice-ui/src/paths/login/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -18,12 +18,301 @@   *   * @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 { -  onConfirm: (url: string, token?: string) => void; +  onConfirm: (token: LoginToken | undefined) => void; +} + +function getTokenValuePart(t: string): string { +  if (!t) return t; +  const match = /secret-token:(.*)/.exec(t); +  if (!match || !match[1]) return ""; +  return match[1];  } -export default function LoginPage({ onConfirm }: Props): VNode { -  return <LoginModal onConfirm={onConfirm} />; + +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> +  ); +}
\ No newline at end of file diff --git a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx index 0d514f2df..87bd2fa39 100644 --- a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx @@ -13,7 +13,7 @@ function getBrowserLang(): string | undefined {    return undefined;  } -export function Settings(): VNode { +export function Settings({ onClose }: { onClose?: () => void }): VNode {    const { i18n } = useTranslationContext()    const borwserLang = getBrowserLang()    const { update } = useLang() @@ -94,11 +94,19 @@ export function Settings(): VNode {                />              </FormProvider>            </div> - -          </div>          <div class="column" />        </div> -    </section> -  </div> +    </section > +    {onClose && +      <section class="section is-main-section"> +        <button +          class="button" +          onClick={onClose} +        > +          <i18n.Translate>Close</i18n.Translate> +        </button> +      </section> +    } +  </div >  }
\ No newline at end of file diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts index 24e42099e..0c3c367af 100644 --- a/packages/taler-harness/src/harness/harness.ts +++ b/packages/taler-harness/src/harness/harness.ts @@ -565,7 +565,7 @@ class BankServiceBase {      protected globalTestState: GlobalTestState,      protected bankConfig: BankConfig,      protected configFile: string, -  ) {} +  ) { }  }  export interface HarnessExchangeBankAccount { @@ -580,8 +580,7 @@ export interface HarnessExchangeBankAccount {   */  export class FakebankService    extends BankServiceBase -  implements BankServiceHandle -{ +  implements BankServiceHandle {    proc: ProcessWrapper | undefined;    http = createPlatformHttpLib({ enableThrottling: false }); @@ -790,7 +789,7 @@ export class ExchangeService implements ExchangeServiceInterface {    async runWirewatchOnce() {      if (useLibeufinBank) { -      // Not even 2 secods showed to be enough! +      // Not even 2 seconds showed to be enough!        await waitMs(4000);      }      await runCommand( @@ -1013,7 +1012,7 @@ export class ExchangeService implements ExchangeServiceInterface {      private exchangeConfig: ExchangeConfig,      private configFilename: string,      private keyPair: EddsaKeyPair, -  ) {} +  ) { }    get name() {      return this.exchangeConfig.name; @@ -1369,7 +1368,7 @@ export class MerchantService implements MerchantServiceInterface {      private globalState: GlobalTestState,      private merchantConfig: MerchantConfig,      private configFilename: string, -  ) {} +  ) { }    private currentTimetravelOffsetMs: number | undefined; @@ -1707,7 +1706,7 @@ export class WalletService {    constructor(      private globalState: GlobalTestState,      private opts: WalletServiceOptions, -  ) {} +  ) { }    get socketPath() {      const unixPath = path.join( @@ -1816,7 +1815,7 @@ export class WalletClient {      return client.call(operation, payload);    } -  constructor(private args: WalletClientArgs) {} +  constructor(private args: WalletClientArgs) { }    async connect(): Promise<void> {      const waiter = this.waiter; @@ -1883,11 +1882,9 @@ export class WalletCli {            ? `--crypto-worker=${cliOpts.cryptoWorkerType}`            : "";          const logName = `wallet-${self.name}`; -        const command = `taler-wallet-cli ${ -          self.timetravelArg ?? "" -        } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${ -          self.dbfile -        }' api '${op}' ${shellWrap(JSON.stringify(payload))}`; +        const command = `taler-wallet-cli ${self.timetravelArg ?? "" +          } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${self.dbfile +          }' api '${op}' ${shellWrap(JSON.stringify(payload))}`;          const resp = await sh(self.globalTestState, logName, command);          logger.info("--- wallet core response ---");          logger.info(resp); diff --git a/packages/taler-harness/src/harness/libeufin.ts b/packages/taler-harness/src/harness/libeufin.ts index 9f3e7a5a0..caeea85ae 100644 --- a/packages/taler-harness/src/harness/libeufin.ts +++ b/packages/taler-harness/src/harness/libeufin.ts @@ -251,7 +251,7 @@ export interface NexusTask {    taskCronSpec: string;    // Only meaningful for "fetch" types.    taskParams: FetchParams; -  // Timestamp in secons when the next iteration will run. +  // Timestamp in seconds when the next iteration will run.    nextScheduledExecutionSec: number;    // Timestamp in seconds when the previous iteration ran.    prevScheduledExecutionSec: number; @@ -618,9 +618,9 @@ export class LibeufinCli {        this.globalTestState,        "libeufin-cli-createebicssubscriber",        "libeufin-cli sandbox ebicssubscriber create" + -        ` --host-id=${details.hostId}` + -        ` --partner-id=${details.partnerId}` + -        ` --user-id=${details.userId}`, +      ` --host-id=${details.hostId}` + +      ` --partner-id=${details.partnerId}` + +      ` --user-id=${details.userId}`,        this.env(),      );      console.log(stdout); @@ -634,13 +634,13 @@ export class LibeufinCli {        this.globalTestState,        "libeufin-cli-createebicsbankaccount",        "libeufin-cli sandbox ebicsbankaccount create" + -        ` --iban=${bankAccountDetails.iban}` + -        ` --bic=${bankAccountDetails.bic}` + -        ` --person-name='${bankAccountDetails.personName}'` + -        ` --account-name=${bankAccountDetails.accountName}` + -        ` --ebics-host-id=${sd.hostId}` + -        ` --ebics-partner-id=${sd.partnerId}` + -        ` --ebics-user-id=${sd.userId}`, +      ` --iban=${bankAccountDetails.iban}` + +      ` --bic=${bankAccountDetails.bic}` + +      ` --person-name='${bankAccountDetails.personName}'` + +      ` --account-name=${bankAccountDetails.accountName}` + +      ` --ebics-host-id=${sd.hostId}` + +      ` --ebics-partner-id=${sd.partnerId}` + +      ` --ebics-user-id=${sd.userId}`,        this.env(),      );      console.log(stdout); @@ -673,11 +673,11 @@ export class LibeufinCli {        this.globalTestState,        "libeufin-cli-createebicsconnection",        `libeufin-cli connections new-ebics-connection` + -        ` --ebics-url=${connectionDetails.ebicsUrl}` + -        ` --host-id=${connectionDetails.subscriberDetails.hostId}` + -        ` --partner-id=${connectionDetails.subscriberDetails.partnerId}` + -        ` --ebics-user-id=${connectionDetails.subscriberDetails.userId}` + -        ` ${connectionDetails.connectionName}`, +      ` --ebics-url=${connectionDetails.ebicsUrl}` + +      ` --host-id=${connectionDetails.subscriberDetails.hostId}` + +      ` --partner-id=${connectionDetails.subscriberDetails.partnerId}` + +      ` --ebics-user-id=${connectionDetails.subscriberDetails.userId}` + +      ` ${connectionDetails.connectionName}`,        {          ...process.env,          LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, @@ -693,9 +693,9 @@ export class LibeufinCli {        this.globalTestState,        "libeufin-cli-createbackupfile",        `libeufin-cli connections export-backup` + -        ` --passphrase=${details.passphrase}` + -        ` --output-file=${details.outputFile}` + -        ` ${details.connectionName}`, +      ` --passphrase=${details.passphrase}` + +      ` --output-file=${details.outputFile}` + +      ` ${details.connectionName}`,        {          ...process.env,          LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, @@ -711,7 +711,7 @@ export class LibeufinCli {        this.globalTestState,        "libeufin-cli-createkeyletter",        `libeufin-cli connections get-key-letter` + -        ` ${details.connectionName} ${details.outputFile}`, +      ` ${details.connectionName} ${details.outputFile}`,        {          ...process.env,          LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, @@ -774,9 +774,9 @@ export class LibeufinCli {        this.globalTestState,        "libeufin-cli-importbankaccount",        "libeufin-cli connections import-bank-account" + -        ` --offered-account-id=${importDetails.offeredBankAccountName}` + -        ` --nexus-bank-account-id=${importDetails.nexusBankAccountName}` + -        ` ${importDetails.connectionName}`, +      ` --offered-account-id=${importDetails.offeredBankAccountName}` + +      ` --nexus-bank-account-id=${importDetails.nexusBankAccountName}` + +      ` ${importDetails.connectionName}`,        {          ...process.env,          LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, @@ -822,12 +822,12 @@ export class LibeufinCli {        this.globalTestState,        "libeufin-cli-preparepayment",        `libeufin-cli accounts prepare-payment` + -        ` --creditor-iban=${details.creditorIban}` + -        ` --creditor-bic=${details.creditorBic}` + -        ` --creditor-name='${details.creditorName}'` + -        ` --payment-subject='${details.subject}'` + -        ` --payment-amount=${details.currency}:${details.amount}` + -        ` ${details.nexusBankAccountName}`, +      ` --creditor-iban=${details.creditorIban}` + +      ` --creditor-bic=${details.creditorBic}` + +      ` --creditor-name='${details.creditorName}'` + +      ` --payment-subject='${details.subject}'` + +      ` --payment-amount=${details.currency}:${details.amount}` + +      ` ${details.nexusBankAccountName}`,        {          ...process.env,          LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, @@ -846,8 +846,8 @@ export class LibeufinCli {        this.globalTestState,        "libeufin-cli-submitpayments",        `libeufin-cli accounts submit-payments` + -        ` --payment-uuid=${paymentUuid}` + -        ` ${details.nexusBankAccountName}`, +      ` --payment-uuid=${paymentUuid}` + +      ` ${details.nexusBankAccountName}`,        {          ...process.env,          LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, @@ -863,9 +863,9 @@ export class LibeufinCli {        this.globalTestState,        "libeufin-cli-new-anastasis-facade",        `libeufin-cli facades new-anastasis-facade` + -        ` --currency ${req.currency}` + -        ` --facade-name ${req.facadeName}` + -        ` ${req.connectionName} ${req.accountName}`, +      ` --currency ${req.currency}` + +      ` --facade-name ${req.facadeName}` + +      ` ${req.connectionName} ${req.accountName}`,        {          ...process.env,          LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, @@ -881,9 +881,9 @@ export class LibeufinCli {        this.globalTestState,        "libeufin-cli-new-taler-wire-gateway-facade",        `libeufin-cli facades new-taler-wire-gateway-facade` + -        ` --currency ${req.currency}` + -        ` --facade-name ${req.facadeName}` + -        ` ${req.connectionName} ${req.accountName}`, +      ` --currency ${req.currency}` + +      ` --facade-name ${req.facadeName}` + +      ` ${req.connectionName} ${req.accountName}`,        {          ...process.env,          LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl, diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances.ts index fd7a8ca3a..27de8a0a0 100644 --- a/packages/taler-harness/src/integrationtests/test-merchant-instances.ts +++ b/packages/taler-harness/src/integrationtests/test-merchant-instances.ts @@ -193,7 +193,6 @@ export async function runMerchantInstancesTest(t: GlobalTestState) {      });      console.log(exc);      t.assertTrue(exc.errorDetail.httpStatusCode === 401); -    t.assertDeepEqual(exc.response?.status, 401);    }  } diff --git a/packages/taler-util/src/MerchantApiClient.ts b/packages/taler-util/src/MerchantApiClient.ts index cbdcb9fdf..ccbbf79b3 100644 --- a/packages/taler-util/src/MerchantApiClient.ts +++ b/packages/taler-util/src/MerchantApiClient.ts @@ -14,6 +14,7 @@   GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>   */ +import { codecForAny } from "./codec.js";  import {    createPlatformHttpLib,    expectSuccessResponseOrThrow, @@ -221,7 +222,7 @@ export class MerchantApiClient {      const resp = await this.httpClient.fetch(url.href, {        headers: this.makeAuthHeader(),      }); -    return resp.json(); +    return readSuccessResponseJsonOrThrow(resp, codecForAny());    }    async getInstanceFullDetails(instanceId: string): Promise<any> { diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts index cc9c706ba..de5be71a1 100644 --- a/packages/taler-util/src/taler-crypto.ts +++ b/packages/taler-util/src/taler-crypto.ts @@ -392,7 +392,7 @@ function csKdfMod(  // Newer versions of node have TextEncoder and TextDecoder as a global,  // just like modern browsers.  // 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.  let encoder: any; @@ -693,7 +693,7 @@ export async function csBlind(   * Unblind operation to unblind the signature   * @param bseed seed to derive secrets   * @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 csSig blinded signature   * @returns unblinded signature @@ -721,7 +721,7 @@ export async function csUnblind(   * Verification algorithm for CS signatures   * @param hm message signed   * @param csSig unblinded signature - * @param csPub denomination publick key + * @param csPub denomination public key   * @returns true if valid, false if invalid   */  export async function csVerify( @@ -844,8 +844,7 @@ export function hashDenomPub(pub: DenominationPubKey): Uint8Array {      return hash(uint8ArrayBuf);    } else {      throw Error( -      `unsupported cipher (${ -        (pub as DenominationPubKey).cipher +      `unsupported cipher (${(pub as DenominationPubKey).cipher        }), unable to hash`,      );    } @@ -1023,7 +1022,7 @@ export enum WalletAccountMergeFlags {  export class SignaturePurposeBuilder {    private chunks: Uint8Array[] = []; -  constructor(private purposeNum: number) {} +  constructor(private purposeNum: number) { }    put(bytes: Uint8Array): SignaturePurposeBuilder {      this.chunks.push(Uint8Array.from(bytes)); diff --git a/packages/taler-util/src/taler-types.ts b/packages/taler-util/src/taler-types.ts index 17900129c..8a0608008 100644 --- a/packages/taler-util/src/taler-types.ts +++ b/packages/taler-util/src/taler-types.ts @@ -1972,42 +1972,58 @@ export interface ExchangeAgeWithdrawRevealResponse {    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    // associated with this transaction.  If not given,    // 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 -  // for /coins/, i.e. for load balancing.  Clients SHOULD -  // respect the transaction_base_url if provided.  Any HTTP server +  // Can be used if the base URL for ``/transactions/`` differs from that +  // for ``/coins/``, i.e. for load balancing.  Clients SHOULD +  // respect the ``transaction_base_url`` if provided.  Any HTTP server    // belonging to an exchange MUST generate a 307 or 308 redirection    // to the correct base URL should a client uses the wrong base    // URL, or if the base URL has changed since the deposit.    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; -  // the EdDSA signature of TALER_DepositConfirmationPS using a current -  // 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 +  // `Public EdDSA key of the exchange <sign-key-pub>` that was used to    // 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    // 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> => -  buildCodecForObject<DepositSuccess>() +export const codecForDepositConfirmationSignature = +  (): Codec<DepositConfirmationSignature> => +    buildCodecForObject<DepositConfirmationSignature>() +      .property("exchange_sig", codecForString()) +      .build("DepositConfirmationSignature"); + +export const codecForBatchDepositSuccess = (): Codec<BatchDepositSuccess> => +  buildCodecForObject<BatchDepositSuccess>()      .property("exchange_pub", codecForString()) -    .property("exchange_sig", codecForString()) +    .property( +      "exchange_sigs", +      codecForList(codecForDepositConfirmationSignature()), +    )      .property("exchange_timestamp", codecForTimestamp)      .property("transaction_base_url", codecOptional(codecForString())) -    .build("DepositSuccess"); +    .build("BatchDepositSuccess");  export interface TrackTransactionWired {    // Raw wire transfer identifier of the deposit. @@ -2231,6 +2247,9 @@ export interface ExchangePurseDeposits {    deposits: PurseDeposit[];  } +/** + * @deprecated batch deposit should be used. + */  export interface ExchangeDepositRequest {    // Amount to be deposited, can be a fraction of the    // coin's total value. @@ -2293,6 +2312,67 @@ export interface ExchangeDepositRequest {    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 {    // UUID that the wallet should use when initiating    // the KYC check. diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index c6f19c73f..f7bd3d120 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -57,7 +57,9 @@ import {    DenomKeyType,    DenominationPubKey,    ExchangeAuditor, +  InternationalizedString,    MerchantContractTerms, +  MerchantInfo,    PeerContractTerms,    UnblindedSignature,    codecForMerchantContractTerms, @@ -2667,3 +2669,49 @@ export const codecForTestingSetTimetravelRequest =      buildCodecForObject<TestingSetTimetravelRequest>()        .property("offsetMs", codecForNumber())        .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; +} diff --git a/packages/taler-wallet-cli/Makefile b/packages/taler-wallet-cli/Makefile index 388401eae..6d695e9c1 100644 --- a/packages/taler-wallet-cli/Makefile +++ b/packages/taler-wallet-cli/Makefile @@ -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  deps:  	pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-cli... +	pnpm run --filter @gnu-taler/taler-wallet-cli... compile  install:  	$(MAKE) deps  	$(MAKE) install-nodeps diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 25757ef25..abfb02445 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1042,52 +1042,6 @@ export enum RefundReason {    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 {    /**     * Not downloaded yet. @@ -1710,6 +1664,8 @@ export interface DepositGroupRecord {    /**     * Verbatim contract terms. +   *  +   * FIXME: Move this to the contract terms object store!     */    contractTermsRaw: MerchantContractTerms; diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts index a20ded2af..8034f78ea 100644 --- a/packages/taler-wallet-core/src/operations/balance.ts +++ b/packages/taler-wallet-core/src/operations/balance.ts @@ -50,6 +50,8 @@   * Imports.   */  import { +  AllowedAuditorInfo, +  AllowedExchangeInfo,    AmountJson,    Amounts,    BalancesResponse, @@ -60,17 +62,15 @@ import {    ScopeType,  } from "@gnu-taler/taler-util";  import { -  AllowedAuditorInfo, -  AllowedExchangeInfo,    RefreshGroupRecord,    WalletStoresV1,    WithdrawalGroupStatus,  } from "../db.js";  import { InternalWalletState } from "../internal-wallet-state.js"; +import { assertUnreachable } from "../util/assertUnreachable.js";  import { checkLogicInvariant } from "../util/invariants.js";  import { GetReadOnlyAccess } from "../util/query.js";  import { getExchangeDetails } from "./exchanges.js"; -import { assertUnreachable } from "../util/assertUnreachable.js";  /**   * Logger. diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index 8ea792d91..a3483a332 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -21,70 +21,69 @@ import {    AbsoluteTime,    AmountJson,    Amounts, +  BatchDepositRequestCoin,    CancellationToken, -  canonicalJson, -  codecForDepositSuccess, -  codecForTackTransactionAccepted, -  codecForTackTransactionWired,    CoinRefreshRequest,    CreateDepositGroupRequest,    CreateDepositGroupResponse,    DepositGroupFees, -  durationFromSpec, -  encodeCrock, -  ExchangeDepositRequest, +  Duration, +  ExchangeBatchDepositRequest,    ExchangeRefundRequest, -  getRandomBytes, -  hashTruncate32, -  hashWire,    HttpStatusCode, -  j2s,    Logger,    MerchantContractTerms,    NotificationType, -  parsePaytoUri,    PayCoinSelection,    PrepareDepositRequest,    PrepareDepositResponse,    RefreshReason, -  stringToBytes, +  TalerError,    TalerErrorCode, -  TalerProtocolTimestamp,    TalerPreciseTimestamp, +  TalerProtocolTimestamp,    TrackTransaction, +  TransactionAction,    TransactionMajorState,    TransactionMinorState,    TransactionState,    TransactionType,    URL,    WireFee, -  TransactionAction, -  Duration, +  canonicalJson, +  codecForBatchDepositSuccess, +  codecForTackTransactionAccepted, +  codecForTackTransactionWired, +  durationFromSpec, +  encodeCrock, +  getRandomBytes, +  hashTruncate32, +  hashWire, +  j2s, +  parsePaytoUri, +  stringToBytes,  } from "@gnu-taler/taler-util"; +import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { DepositElementStatus, DepositGroupRecord } from "../db.js";  import { -  DenominationRecord, -  DepositGroupRecord, -  DepositElementStatus, -} from "../db.js"; -import { TalerError } from "@gnu-taler/taler-util"; -import { -  createRefreshGroup,    DepositOperationStatus,    DepositTrackingInfo, -  getTotalRefreshCost,    KycPendingInfo, -  KycUserType,    PendingTaskType,    RefreshOperationStatus, +  createRefreshGroup, +  getTotalRefreshCost,  } from "../index.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 { -  constructTaskIdentifier,    TaskRunResult, +  TombstoneTag, +  constructTaskIdentifier,    runLongpollAsync,    spendCoins, -  TombstoneTag,  } from "./common.js";  import { getExchangeDetails } from "./exchanges.js";  import { @@ -92,15 +91,12 @@ import {    generateDepositPermissions,    getTotalPaymentCost,  } from "./pay-merchant.js"; -import { selectPayCoinsNew } from "../util/coinSelection.js";  import {    constructTransactionIdentifier,    notifyTransition,    parseTransactionIdentifier,    stopLongpolling,  } from "./transactions.js"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; -import { assertUnreachable } from "../util/assertUnreachable.js";  /**   * 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(    dg: DepositGroupRecord,  ): 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(    ws: InternalWalletState,    depositGroupId: string, @@ -407,46 +412,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   * aborting deposit group is done.   * @@ -940,38 +905,58 @@ async function processDepositGroupPendingDeposit(      contractData,    ); -  for (let i = 0; i < depositPermissions.length; i++) { -    const perm = depositPermissions[i]; +  // Exchanges involved in the deposit +  const exchanges: Set<string> = new Set(); -    if (depositGroup.statusPerCoin[i] !== DepositElementStatus.DepositPending) { -      continue; -    } +  for (const dp of depositPermissions) { +    exchanges.add(dp.exchange_url); +  } -    const requestBody: ExchangeDepositRequest = { -      contribution: Amounts.stringify(perm.contribution), -      merchant_payto_uri: depositGroup.wire.payto_uri, -      wire_salt: depositGroup.wire.salt, +  // 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, -      ub_sig: perm.ub_sig, +      merchant_payto_uri: depositGroup.wire.payto_uri, +      merchant_pub: depositGroup.contractTermsRaw.merchant_pub,        timestamp: depositGroup.contractTermsRaw.timestamp, +      wire_salt: depositGroup.wire.salt,        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,      }; + +    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); +    } +      // Check for cancellation before making network request.      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}`);      const httpResp = await ws.http.fetch(url.href, {        method: "POST", -      body: requestBody, +      body: batchReq,        cancellationToken: cancellationToken,      }); -    await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess()); +    await readSuccessResponseJsonOrThrow( +      httpResp, +      codecForBatchDepositSuccess(), +    );      await ws.db        .mktx((x) => [x.depositGroups]) @@ -980,11 +965,13 @@ async function processDepositGroupPendingDeposit(          if (!dg) {            return;          } -        const coinStatus = dg.statusPerCoin[i]; -        switch (coinStatus) { -          case DepositElementStatus.DepositPending: -            dg.statusPerCoin[i] = DepositElementStatus.Tracking; -            await tx.depositGroups.put(dg); +        for (const batchIndex of batchIndexes) { +          const coinStatus = dg.statusPerCoin[batchIndex]; +          switch (coinStatus) { +            case DepositElementStatus.DepositPending: +              dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking; +              await tx.depositGroups.put(dg); +          }          }        });    } @@ -1538,10 +1525,7 @@ async function getTotalFeesForDepositAmount(          const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl            .iter(coin.exchangeBaseUrl)            .filter((x) => -            Amounts.isSameCurrency( -              x.value, -              pcs.coinContributions[i], -            ), +            Amounts.isSameCurrency(x.value, pcs.coinContributions[i]),            );          const amountLeft = Amounts.sub(            denom.value, diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index 57367bb20..fe0cbeda0 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -63,7 +63,6 @@ import {    RefreshReason,    SharePaymentResult,    StartRefundQueryForUriResponse, -  stringifyPaytoUri,    stringifyPayUri,    stringifyTalerUri,    TalerError, @@ -78,6 +77,7 @@ import {    TransactionState,    TransactionType,    URL, +  WalletContractData,  } from "@gnu-taler/taler-util";  import {    getHttpResponseErrorDetails, @@ -95,7 +95,6 @@ import {    PurchaseRecord,    PurchaseStatus,    RefundReason, -  WalletContractData,    WalletStoresV1,  } from "../db.js";  import { @@ -115,15 +114,13 @@ import { checkDbInvariant } from "../util/invariants.js";  import { GetReadOnlyAccess } from "../util/query.js";  import {    constructTaskIdentifier, -  TaskRunResult, -  TaskRunResultType,    RetryInfo, -  TaskIdentifiers, -} from "./common.js"; -import {    runLongpollAsync,    runTaskWithErrorReporting,    spendCoins, +  TaskIdentifiers, +  TaskRunResult, +  TaskRunResultType,  } from "./common.js";  import {    calculateRefreshOutput, @@ -173,10 +170,7 @@ export async function getTotalPaymentCost(          const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl            .iter(coin.exchangeBaseUrl)            .filter((x) => -            Amounts.isSameCurrency( -              x.value, -              pcs.coinContributions[i], -            ), +            Amounts.isSameCurrency(x.value, pcs.coinContributions[i]),            );          const amountLeft = Amounts.sub(            denom.value, diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 7bdb9af5b..31655ad71 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -41,6 +41,7 @@ import {    TransactionsResponse,    TransactionState,    TransactionType, +  WalletContractData,    WithdrawalType,  } from "@gnu-taler/taler-util";  import { @@ -60,7 +61,6 @@ import {    RefreshOperationStatus,    RefundGroupRecord,    RewardRecord, -  WalletContractData,    WithdrawalGroupRecord,    WithdrawalGroupStatus,    WithdrawalRecordType, diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index ef2f85789..6fd0f1b86 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -28,21 +28,20 @@ import {    AbsoluteTime,    AgeCommitmentProof,    AgeRestriction, +  AllowedAuditorInfo, +  AllowedExchangeInfo,    AmountJson,    AmountLike, -  AmountResponse,    Amounts,    AmountString,    CoinPublicKeyString,    CoinStatus, -  ConvertAmountRequest,    DenominationInfo,    DenominationPubKey,    DenomSelectionState,    Duration,    ForcedCoinSel,    ForcedDenomSel, -  GetAmountRequest,    j2s,    Logger,    parsePaytoUri, @@ -50,24 +49,13 @@ import {    PayMerchantInsufficientBalanceDetails,    PayPeerInsufficientBalanceDetails,    strcmp, -  TransactionAmountMode, -  TransactionType,    UnblindedSignature,  } from "@gnu-taler/taler-util"; +import { DenominationRecord } from "../db.js";  import { -  AllowedAuditorInfo, -  AllowedExchangeInfo, -  DenominationRecord, -} from "../db.js"; -import { -  DbReadOnlyTransaction,    getExchangeDetails, -  GetReadOnlyAccess, -  GetReadWriteAccess,    isWithdrawableDenom, -  StoreNames,    WalletDbReadOnlyTransaction, -  WalletStoresV1,  } from "../index.js";  import { InternalWalletState } from "../internal-wallet-state.js";  import { diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index d89ad257a..67c05a42f 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -38,7 +38,6 @@ import {    ApplyDevExperimentRequest,    BackupRecovery,    BalancesResponse, -  FailTransactionRequest,    CheckPeerPullCreditRequest,    CheckPeerPullCreditResponse,    CheckPeerPushDebitRequest, @@ -51,14 +50,19 @@ import {    ConvertAmountRequest,    CreateDepositGroupRequest,    CreateDepositGroupResponse, +  CreateStoredBackupResponse, +  DeleteStoredBackupRequest,    DeleteTransactionRequest,    ExchangeDetailedResponse,    ExchangesListResponse, +  FailTransactionRequest,    ForceRefreshRequest,    ForgetKnownBankAccountsRequest,    GetAmountRequest,    GetBalanceDetailRequest,    GetContractTermsDetailsRequest, +  GetCurrencyInfoRequest, +  GetCurrencyInfoResponse,    GetExchangeTosRequest,    GetExchangeTosResult,    GetPlanForOperationRequest, @@ -85,16 +89,21 @@ import {    PreparePeerPushCreditRequest,    PreparePeerPushCreditResponse,    PrepareRefundRequest, -  PrepareRewardRequest as PrepareRewardRequest, +  PrepareRewardRequest,    PrepareTipResult as PrepareRewardResult, +  RecoverStoredBackupRequest,    RecoveryLoadRequest,    RetryTransactionRequest,    SetCoinSuspendedRequest,    SetWalletDeviceIdRequest, +  SharePaymentRequest, +  SharePaymentResult,    StartRefundQueryForUriResponse,    StartRefundQueryRequest, +  StoredBackupList,    TestPayArgs,    TestPayResult, +  TestingSetTimetravelRequest,    Transaction,    TransactionByIdRequest,    TransactionsRequest, @@ -106,22 +115,13 @@ import {    UserAttentionsResponse,    ValidateIbanRequest,    ValidateIbanResponse, +  WalletContractData,    WalletCoreVersion,    WalletCurrencyInfo,    WithdrawFakebankRequest,    WithdrawTestBalanceRequest,    WithdrawUriInfoResponse, -  SharePaymentRequest, -  SharePaymentResult, -  GetCurrencyInfoRequest, -  GetCurrencyInfoResponse, -  StoredBackupList, -  CreateStoredBackupResponse, -  RecoverStoredBackupRequest, -  DeleteStoredBackupRequest, -  TestingSetTimetravelRequest,  } from "@gnu-taler/taler-util"; -import { WalletContractData } from "./db.js";  import {    AddBackupProviderRequest,    AddBackupProviderResponse, diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 1a60b148c..2d0878afc 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -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.   */ @@ -923,9 +923,9 @@ async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {            ageCommitmentProof: c.ageCommitmentProof,            spend_allocation: c.spendAllocation              ? { -                amount: c.spendAllocation.amount, -                id: c.spendAllocation.id, -              } +              amount: c.spendAllocation.amount, +              id: c.spendAllocation.id, +            }              : undefined,          });        } diff --git a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx index 74c92cbc6..1b1802b8c 100644 --- a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx +++ b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx @@ -18,8 +18,6 @@   *   * @author Sebastian Javier Marchano (sebasjm)   */ - -import { WalletContractData } from "@gnu-taler/taler-wallet-core";  import * as tests from "@gnu-taler/web-util/testing";  import {    ErrorView, @@ -27,6 +25,7 @@ import {    LoadingView,    ShowView,  } from "./ShowFullContractTermPopup.js"; +import { WalletContractData } from "@gnu-taler/taler-util";  export default {    title: "ShowFullContractTermPopup", diff --git a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx index db9b6ebcd..0b3cca0b2 100644 --- a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx +++ b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx @@ -13,11 +13,13 @@   You should have received a copy of the GNU General Public License along with   GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>   */ -import { AbsoluteTime, Duration, Location } from "@gnu-taler/taler-util";  import { -  WalletApiOperation, +  AbsoluteTime, +  Duration, +  Location,    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 { Fragment, h, VNode } from "preact";  import { useState } from "preact/hooks"; @@ -334,8 +336,8 @@ export function ShowView({ contractTerms, hideHandler }: States.Show): VNode {                      !contractTerms.autoRefund                        ? Duration.getZero()                        : Duration.fromTalerProtocolDuration( -                        contractTerms.autoRefund, -                      ), +                          contractTerms.autoRefund, +                        ),                    )}                    format="dd MMMM yyyy, HH:mm"                  /> diff --git a/packages/web-util/src/utils/request.ts b/packages/web-util/src/utils/request.ts index 1464eca98..8ce21b0e1 100644 --- a/packages/web-util/src/utils/request.ts +++ b/packages/web-util/src/utils/request.ts @@ -25,6 +25,8 @@ export enum ErrorType {    UNEXPECTED,  } + +  /**   *   * @param baseUrl URL where the service is located @@ -60,10 +62,27 @@ export async function defaultRequestHandler<T>(    const requestPreventCache = options.preventCache ?? 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]) => { -    _url.searchParams.set(key, String(value)); +    validURL.searchParams.set(key, String(value));    });    let payload: BodyInit | undefined = undefined; @@ -77,7 +96,20 @@ export async function defaultRequestHandler<T>(      } else if (typeof requestBody === "object") {        payload = JSON.stringify(requestBody);      } 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;    try { -    response = await fetch(_url.href, { +    response = await fetch(validURL.href, {        headers: requestHeaders,        method: requestMethod,        credentials: "omit", @@ -100,15 +132,29 @@ export async function defaultRequestHandler<T>(    } catch (ex) {      const info: RequestInfo = {        payload, -      url: _url.href, +      url: validURL.href,        hasToken: !!options.token,        status: 0,        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, -      type: ErrorType.TIMEOUT, -      message: "Request timeout", +      type: ErrorType.UNEXPECTED, +      exception: ex, +      loading: false, +      message: (ex instanceof Error ? ex.message : ""),      };      throw new RequestError(error);    } @@ -124,7 +170,7 @@ export async function defaultRequestHandler<T>(    if (response.ok) {      const result = await buildRequestOk<T>(        response, -      _url.href, +      validURL.href,        payload,        !!options.token,        options, @@ -133,7 +179,7 @@ export async function defaultRequestHandler<T>(    } else {      const dataTxt = await response.text();      const error = buildRequestFailed( -      _url.href, +      validURL.href,        dataTxt,        response.status,        payload, @@ -377,3 +423,12 @@ export function buildRequestFailed<ErrorDetail>(      return error;    }  } + +function validateURL(baseUrl: string, endpoint: string): URL | undefined { +  try { +    return new URL(`${baseUrl}${endpoint}`) +  } catch (ex) { +    return undefined +  } + +}
\ No newline at end of file  | 
