diff options
| author | Sebastian <sebasjm@gmail.com> | 2023-09-11 15:07:55 -0300 | 
|---|---|---|
| committer | Sebastian <sebasjm@gmail.com> | 2023-09-11 15:08:08 -0300 | 
| commit | 8c20f4b27946679267bb44255721a9f14ae1077a (patch) | |
| tree | 389d7dac804af0e17652240094947e710f503dd3 /packages | |
| parent | e2422b68ebb2a29fb2e4d86f8a8cf9ec2a33e099 (diff) | |
new login token
Diffstat (limited to 'packages')
29 files changed, 697 insertions, 551 deletions
| 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/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 | 
