diff options
author | Sebastian <sebasjm@gmail.com> | 2024-03-22 13:56:16 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2024-03-26 16:57:58 -0300 |
commit | e2bfbced7ab027c901913e83ff7dd82240661990 (patch) | |
tree | 33752605ccaf19498f8c2a64e0117db16f22ce26 | |
parent | 0c265558c4b7b78a13272abf1c4c84e3cf93c987 (diff) | |
download | wallet-core-e2bfbced7ab027c901913e83ff7dd82240661990.tar.xz |
work in progress, new api being used. merchant now should move into using the full API
43 files changed, 1264 insertions, 1106 deletions
diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx index 50a91c060..4ed5850e7 100644 --- a/packages/merchant-backoffice-ui/src/Routing.tsx +++ b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -39,7 +39,10 @@ import { MerchantBackend } from "./declaration.js"; import { useInstanceBankAccounts } from "./hooks/bank.js"; import { useInstanceKYCDetails } from "./hooks/instance.js"; import { usePreference } from "./hooks/preference.js"; -import { DEFAULT_ADMIN_USERNAME, useSessionState } from "./hooks/session.js"; +import { + DEFAULT_ADMIN_USERNAME, + useSessionContext, +} from "./context/session.js"; import InstanceCreatePage from "./paths/admin/create/index.js"; import InstanceListPage from "./paths/admin/list/index.js"; import BankAccountCreatePage from "./paths/instance/accounts/create/index.js"; @@ -74,6 +77,7 @@ import { LoginPage } from "./paths/login/index.js"; import NotFoundPage from "./paths/notfound/index.js"; import { Settings } from "./paths/settings/index.js"; import { Notification } from "./utils/types.js"; +import { createHashHistory } from "history"; export enum InstancePaths { error = "/error", @@ -138,9 +142,10 @@ export const publicPages = { go: urlPattern(/\/home/, () => "#/home"), }; +const history = createHashHistory(); export function Routing(_p: Props): VNode { const { i18n } = useTranslationContext(); - const { state } = useSessionState(); + const { state } = useSessionContext(); type GlobalNotifState = | (Notification & { to: string | undefined }) @@ -152,8 +157,10 @@ export function Routing(_p: Props): VNode { const instance = useInstanceBankAccounts(); const accounts = !instance.ok ? undefined : instance.data.accounts; - const shouldWarnAboutMissingBankAccounts = !state.isAdmin && accounts !== undefined && accounts.length < 1 - const shouldLogin = state.status === "loggedOut" || state.status === "expired"; + const shouldWarnAboutMissingBankAccounts = + !state.isAdmin && accounts !== undefined && accounts.length < 1; + const shouldLogin = + state.status === "loggedOut" || state.status === "expired"; function ServerErrorRedirectTo(to: InstancePaths | AdminPaths) { return function ServerErrorRedirectToImpl( @@ -275,6 +282,7 @@ export function Routing(_p: Props): VNode { )} <Router + history={history} onChange={(e) => { const movingOutFromNotification = globalNotification && e.url !== globalNotification.to; diff --git a/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx index c1359e641..f60508504 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx @@ -18,8 +18,8 @@ * * @author Sebastian Javier Marchano (sebasjm) */ +import { useMerchantApiContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, h, VNode } from "preact"; -import { useConfigContext } from "../../context/config.js"; import { Amount } from "../../declaration.js"; import { InputWithAddon } from "./InputWithAddon.js"; import { InputProps } from "./useField.js"; @@ -43,7 +43,7 @@ export function InputCurrency<T>({ children, side, }: Props<keyof T>): VNode { - const config = useConfigContext(); + const { config } = useMerchantApiContext(); return ( <InputWithAddon<T> name={name} diff --git a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx index e36549e76..cb4442897 100644 --- a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx +++ b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx @@ -19,9 +19,11 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { useBackendContext } from "../../context/backend.js"; +import { + useTranslationContext +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useSessionContext } from "../../context/session.js"; import { Entity } from "../../paths/admin/create/CreatePage.js"; import { Input } from "../form/Input.js"; import { InputDuration } from "../form/InputDuration.js"; @@ -40,13 +42,15 @@ export function DefaultInstanceFormFields({ showId: boolean; }): VNode { const { i18n } = useTranslationContext(); - const { url: backendURL } = useBackendContext() + const { + state: { backendUrl }, + } = useSessionContext(); return ( <Fragment> {showId && ( <InputWithAddon<Entity> name="id" - addonBefore={`${backendURL}/instances/`} + addonBefore={new URL("instances/", backendUrl).href} readonly={readonlyId} label={i18n.str`Identifier`} tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`} diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx index a9b9618bb..adc47b216 100644 --- a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx +++ b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -19,12 +19,14 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { useMerchantApiContext, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { useConfigContext } from "../../context/config.js"; +import { + useMerchantApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useSessionContext } from "../../context/session.js"; import { useInstanceKYCDetails } from "../../hooks/instance.js"; import { LangSelector } from "./LangSelector.js"; -import { useSessionState } from "../../hooks/session.js"; // const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; @@ -33,20 +35,21 @@ interface Props { mobile?: boolean; } -export function Sidebar({ - mobile, -}: Props): VNode { - const config = useConfigContext(); - // const { url: backendURL } = useBackendContext() +export function Sidebar({ mobile }: Props): VNode { const { i18n } = useTranslationContext(); const kycStatus = useInstanceKYCDetails(); const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; - const { state, logOut } = useSessionState(); - const { url } = useMerchantApiContext(); - const isLoggedIn = state.status === "loggedIn" || state.status === "impersonate" - const hasToken = isLoggedIn && state.token !== undefined + const { state, logOut } = useSessionContext(); + const isLoggedIn = state.status === "loggedIn"; + const hasToken = isLoggedIn && state.token !== undefined; + const backendURL = state.backendUrl; + const { config } = useMerchantApiContext(); + return ( - <aside class="aside is-placed-left is-expanded" style={{ overflowY: "scroll" }}> + <aside + class="aside is-placed-left is-expanded" + style={{ overflowY: "scroll" }} + > {mobile && ( <div class="footer" @@ -187,9 +190,10 @@ export function Sidebar({ </p> <ul class="menu-list"> <li> - <a class="has-icon is-state-info is-hoverable" + <a + class="has-icon is-state-info is-hoverable" onClick={(e): void => { - e.preventDefault() + e.preventDefault(); }} > <span class="icon"> @@ -206,7 +210,7 @@ export function Sidebar({ <i class="mdi mdi-web" /> </span> <span class="menu-item-label"> - {url.hostname} + {new URL(backendURL).hostname} </span> </div> </li> @@ -215,12 +219,10 @@ export function Sidebar({ <span style={{ width: "3rem" }} class="icon"> ID </span> - <span class="menu-item-label"> - {state.instance} - </span> + <span class="menu-item-label">{state.instance}</span> </div> </li> - {state.isAdmin && state.status !== "impersonate" && ( + {state.isAdmin && ( <Fragment> <p class="menu-label"> <i18n.Translate>Instances</i18n.Translate> @@ -247,12 +249,12 @@ export function Sidebar({ </li> </Fragment> )} - {hasToken ? + {hasToken ? ( <li> <a class="has-icon is-state-info is-hoverable" onClick={(e): void => { - logOut() + logOut(); e.preventDefault(); }} > @@ -263,8 +265,8 @@ export function Sidebar({ <i18n.Translate>Log out</i18n.Translate> </span> </a> - </li> : undefined - } + </li> + ) : undefined} </ul> </div> </aside> diff --git a/packages/merchant-backoffice-ui/src/components/menu/index.tsx b/packages/merchant-backoffice-ui/src/components/menu/index.tsx index fa2de563e..aa955db4e 100644 --- a/packages/merchant-backoffice-ui/src/components/menu/index.tsx +++ b/packages/merchant-backoffice-ui/src/components/menu/index.tsx @@ -21,7 +21,7 @@ import { InstancePaths } from "../../Routing.js"; import { Notification } from "../../utils/types.js"; import { NavigationBar } from "./NavigationBar.js"; import { Sidebar } from "./SideBar.js"; -import { useSessionState } from "../../hooks/session.js"; +import { useSessionContext } from "../../context/session.js"; import { useNavigationContext } from "@gnu-taler/web-util/browser"; function getInstanceTitle(path: string, id: string): string { @@ -97,15 +97,14 @@ function WithTitle({ export function Menu(_p: MenuProps): VNode { const [mobileOpen, setMobileOpen] = useState(false); - const { state, logIn } = useSessionState(); + const { state, deImpersonate } = useSessionContext(); const { path } = useNavigationContext(); const titleWithSubtitle = !state.isAdmin ? getInstanceTitle(path, state.instance) : getAdminTitle(path, state.instance); - const isLoggedIn = - state.status === "loggedIn" || state.status === "impersonate"; + const isLoggedIn =state.status === "loggedIn"; return ( <WithTitle title={titleWithSubtitle}> @@ -119,10 +118,10 @@ export function Menu(_p: MenuProps): VNode { /> {isLoggedIn && ( - <Sidebar mobile={mobileOpen} mimic={state.status === "impersonate"} /> + <Sidebar mobile={mobileOpen} /> )} - {state.status === "impersonate" && ( + {state.status !== "loggedOut" && state.impersonate !== undefined && ( <nav class="level" style={{ @@ -139,10 +138,7 @@ export function Menu(_p: MenuProps): VNode { <a href="#/instances" onClick={(e) => { - logIn({ - instance: state.originalInstance, - token: state.originalToken, - }); + deImpersonate(); e.preventDefault(); }} > @@ -227,14 +223,13 @@ export function NotConnectedAppMenu({ export function NotYetReadyAppMenu({ title }: NotYetReadyAppMenuProps): VNode { const [mobileOpen, setMobileOpen] = useState(false); - const { state } = useSessionState(); + const { state } = useSessionContext(); useEffect(() => { document.title = `Taler Backoffice: ${title}`; }, [title]); - const isLoggedIn = - state.status === "loggedIn" || state.status === "impersonate"; + const isLoggedIn = state.status === "loggedIn"; return ( <div diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx index c684ba7a3..1335d0f77 100644 --- a/packages/merchant-backoffice-ui/src/components/modal/index.tsx +++ b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -22,11 +22,11 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { useInstanceContext } from "../../context/instance.js"; import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js"; import { Spinner } from "../exception/loading.js"; import { FormProvider } from "../form/FormProvider.js"; import { Input } from "../form/Input.js"; +import { useSessionContext } from "../../context/session.js"; interface Props { active?: boolean; @@ -298,8 +298,8 @@ export function UpdateTokenModal({ new_token: !form.new_token ? i18n.str`cannot be empty` : form.new_token === form.old_token - ? i18n.str`cannot be the same as the old token` - : undefined, + ? i18n.str`cannot be the same as the old token` + : undefined, repeat_token: form.new_token !== form.repeat_token ? i18n.str`is not the same` @@ -310,9 +310,9 @@ export function UpdateTokenModal({ (k) => (errors as any)[k] !== undefined, ); - const instance = useInstanceContext(); + const { state } = useSessionContext(); - const text = i18n.str`You are updating the access token from instance with id ${instance.id}`; + const text = i18n.str`You are updating the access token from instance with id ${state.instance}`; return ( <ClearConfirmModal @@ -374,8 +374,8 @@ export function SetTokenNewInstanceModal({ new_token: !form.new_token ? i18n.str`cannot be empty` : form.new_token === form.old_token - ? i18n.str`cannot be the same as the old access token` - : undefined, + ? i18n.str`cannot be the same as the old access token` + : undefined, repeat_token: form.new_token !== form.repeat_token ? i18n.str`is not the same` diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx index 47e3431e2..11344cde3 100644 --- a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx +++ b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx @@ -19,11 +19,13 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useMerchantApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { h } from "preact"; import { useCallback, useEffect, useState } from "preact/hooks"; import * as yup from "yup"; -import { useBackendContext } from "../../context/backend.js"; import { MerchantBackend } from "../../declaration.js"; import { ProductCreateSchema as createSchema, @@ -37,6 +39,7 @@ import { InputNumber } from "../form/InputNumber.js"; import { InputStock, Stock } from "../form/InputStock.js"; import { InputTaxes } from "../form/InputTaxes.js"; import { InputWithAddon } from "../form/InputWithAddon.js"; +import { useSessionContext } from "../../context/session.js"; type Entity = MerchantBackend.Products.ProductDetail & { product_id: string }; @@ -58,12 +61,12 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { !initial || initial.total_stock === -1 ? undefined : { - current: initial.total_stock || 0, - lost: initial.total_lost || 0, - sold: initial.total_sold || 0, - address: initial.address, - nextRestock: initial.next_restock, - }, + current: initial.total_stock || 0, + lost: initial.total_lost || 0, + sold: initial.total_sold || 0, + address: initial.address, + nextRestock: initial.next_restock, + }, }); let errors: FormErrors<Entity> = {}; @@ -114,7 +117,9 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { onSubscribe(hasErrors ? undefined : submit); }, [submit, hasErrors]); - const { url: backendURL } = useBackendContext() + const { + state: { backendUrl }, + } = useSessionContext(); const { i18n } = useTranslationContext(); return ( @@ -128,7 +133,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { {alreadyExist ? undefined : ( <InputWithAddon<Entity> name="product_id" - addonBefore={`${backendURL}/product/`} + addonBefore={new URL("product/", backendUrl).href} 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 deleted file mode 100644 index 74530e750..000000000 --- a/packages/merchant-backoffice-ui/src/context/backend.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import * as tests from "@gnu-taler/web-util/testing"; -import { ComponentChildren, h, VNode } from "preact"; -import { AccessToken, MerchantBackend } from "../declaration.js"; -import { - useAdminAPI, - useInstanceAPI, - useManagementAPI, -} from "../hooks/instance.js"; -import { expect } from "chai"; -import { ApiMockEnvironment } from "../hooks/testing.js"; -import { - API_CREATE_INSTANCE, - API_NEW_LOGIN, - API_UPDATE_CURRENT_INSTANCE_AUTH, - API_UPDATE_INSTANCE_AUTH_BY_ID, -} from "../hooks/urls.js"; - -interface TestingContextProps { - children?: ComponentChildren; -} - -describe("backend context api ", () => { - it("should use new token after updating the instance token in the settings as user", async () => { - const env = new ApiMockEnvironment(); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const instance = useInstanceAPI(); - const management = useManagementAPI("default"); - const admin = useAdminAPI(); - - return { instance, management, admin }; - }, - {}, - [ - ({ instance, management, admin }) => { - env.addRequestExpectation(API_UPDATE_INSTANCE_AUTH_BY_ID("default"), { - request: { - method: "token", - token: "another_token", - }, - response: { - name: "instance_name", - } as MerchantBackend.Instances.QueryInstancesResponse, - }); - env.addRequestExpectation(API_NEW_LOGIN, { - auth: "another_token", - request: { - scope: "write", - duration: { - "d_us": "forever", - }, - refreshable: true, - }, - - }); - - management.setNewAccessToken(undefined,"another_token" as AccessToken); - }, - ({ instance, management, admin }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - - env.addRequestExpectation(API_CREATE_INSTANCE, { - // auth: "another_token", - request: { - id: "new_instance_id", - } as MerchantBackend.Instances.InstanceConfigurationMessage, - }); - - admin.createInstance({ - id: "new_instance_id", - } as MerchantBackend.Instances.InstanceConfigurationMessage); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); - - it("should use new token after updating the instance token in the settings as admin", async () => { - const env = new ApiMockEnvironment(); - - const hookBehavior = await tests.hookBehaveLikeThis( - () => { - const instance = useInstanceAPI(); - const management = useManagementAPI("default"); - const admin = useAdminAPI(); - - return { instance, management, admin }; - }, - {}, - [ - ({ instance, management, admin }) => { - env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, { - request: { - method: "token", - token: "another_token", - }, - response: { - name: "instance_name", - } as MerchantBackend.Instances.QueryInstancesResponse, - }); - env.addRequestExpectation(API_NEW_LOGIN, { - auth: "another_token", - request: { - scope: "write", - duration: { - "d_us": "forever", - }, - refreshable: true, - }, - }); - instance.setNewAccessToken(undefined, "another_token" as AccessToken); - }, - ({ instance, management, admin }) => { - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ - result: "ok", - }); - - env.addRequestExpectation(API_CREATE_INSTANCE, { - // auth: "another_token", - request: { - id: "new_instance_id", - } as MerchantBackend.Instances.InstanceConfigurationMessage, - }); - - admin.createInstance({ - id: "new_instance_id", - } as MerchantBackend.Instances.InstanceConfigurationMessage); - }, - ], - env.buildTestingContext(), - ); - - expect(hookBehavior).deep.eq({ result: "ok" }); - expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); - }); -}); diff --git a/packages/merchant-backoffice-ui/src/context/backend.ts b/packages/merchant-backoffice-ui/src/context/backend.ts deleted file mode 100644 index f78236216..000000000 --- a/packages/merchant-backoffice-ui/src/context/backend.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { useMemoryStorage } from "@gnu-taler/web-util/browser"; -import { createContext, h, VNode } from "preact"; -import { useContext } from "preact/hooks"; -import { LoginToken } from "../declaration.js"; -import { useBackendDefaultToken, useBackendURL } from "../hooks/index.js"; - -interface BackendContextType { - url: string, - alreadyTriedLogin: boolean; - token?: LoginToken; - updateToken: (token: LoginToken | undefined) => void; -} - -const BackendContext = createContext<BackendContextType>({ - url: "", - alreadyTriedLogin: false, - token: undefined, - updateToken: () => null, -}); - -function useBackendContextState( - defaultUrl?: string, -): BackendContextType { - const [url] = useBackendURL(defaultUrl); - const [token, updateToken] = useBackendDefaultToken(); - - return { - url, - token, - alreadyTriedLogin: token !== undefined, - updateToken, - }; -} - -const BackendContextProvider = ({ - children, - defaultUrl, -}: { - children: any; - defaultUrl?: string; -}): VNode => { - const value = useBackendContextState(defaultUrl); - - return h(BackendContext.Provider, { value, children }); -}; - -const useBackendContext = (): BackendContextType => - useContext(BackendContext); diff --git a/packages/merchant-backoffice-ui/src/context/config.ts b/packages/merchant-backoffice-ui/src/context/config.ts deleted file mode 100644 index 8c562b3c1..000000000 --- a/packages/merchant-backoffice-ui/src/context/config.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { createContext } from "preact"; -import { useContext } from "preact/hooks"; -import { MerchantBackend } from "../declaration.js"; - -const Context = createContext<MerchantBackend.VersionResponse>(null!); - -export const ConfigContextProvider = Context.Provider; -export const useConfigContext = (): MerchantBackend.VersionResponse => useContext(Context); diff --git a/packages/merchant-backoffice-ui/src/hooks/session.ts b/packages/merchant-backoffice-ui/src/context/session.ts index 8bf075e94..83f3f113a 100644 --- a/packages/merchant-backoffice-ui/src/hooks/session.ts +++ b/packages/merchant-backoffice-ui/src/context/session.ts @@ -21,40 +21,45 @@ import { buildCodecForUnion, codecForBoolean, codecForConstString, - codecForConstTrue, codecForString, codecOptional, } from "@gnu-taler/taler-util"; -import { buildStorageKey, useLocalStorage, useMerchantApiContext } from "@gnu-taler/web-util/browser"; +import { + buildStorageKey, + useLocalStorage, + useMerchantApiContext, +} from "@gnu-taler/web-util/browser"; import { mutate } from "swr"; /** * Has the information to reach and * authenticate at the bank's backend. */ -export type SessionState = LoggedIn | LoggedOut | Expired | Impersonate; +export type SessionState = LoggedIn | LoggedOut | Expired; interface LoggedIn { status: "loggedIn"; - instance: string; + backendUrl: string; isAdmin: boolean; + instance: string; token: AccessToken | undefined; + impersonate: Impersonate | undefined; +} +interface Impersonate { + originalInstance: string; + originalToken: AccessToken | undefined; + originalBackendUrl: string; } interface Expired { status: "expired"; - instance: string; + backendUrl: string; isAdmin: boolean; -} -interface Impersonate { - status: "impersonate"; instance: string; - isAdmin: true; - token: AccessToken | undefined; - originalInstance: string; - originalToken: AccessToken | undefined; + impersonate: Impersonate | undefined; } interface LoggedOut { status: "loggedOut"; + backendUrl: string; instance: string; isAdmin: boolean; } @@ -62,7 +67,9 @@ interface LoggedOut { export const codecForSessionStateLoggedIn = (): Codec<LoggedIn> => buildCodecForObject<LoggedIn>() .property("status", codecForConstString("loggedIn")) + .property("backendUrl", codecForString()) .property("instance", codecForString()) + .property("impersonate", codecOptional(codecForImpresonate())) .property("token", codecOptional(codecForString() as Codec<AccessToken>)) .property("isAdmin", codecForBoolean()) .build("SessionState.LoggedIn"); @@ -70,54 +77,87 @@ export const codecForSessionStateLoggedIn = (): Codec<LoggedIn> => export const codecForSessionStateExpired = (): Codec<Expired> => buildCodecForObject<Expired>() .property("status", codecForConstString("expired")) + .property("backendUrl", codecForString()) .property("instance", codecForString()) + .property("impersonate", codecOptional(codecForImpresonate())) .property("isAdmin", codecForBoolean()) .build("SessionState.Expired"); export const codecForSessionStateLoggedOut = (): Codec<LoggedOut> => buildCodecForObject<LoggedOut>() .property("status", codecForConstString("loggedOut")) + .property("backendUrl", codecForString()) .property("instance", codecForString()) .property("isAdmin", codecForBoolean()) .build("SessionState.LoggedOut"); -export const codecForSessionStateImpresonate = (): Codec<Impersonate> => +export const codecForImpresonate = (): Codec<Impersonate> => buildCodecForObject<Impersonate>() - .property("status", codecForConstString("impersonate")) - .property("instance", codecForString()) - .property("isAdmin", codecForConstTrue()) - .property("token", codecOptional(codecForString() as Codec<AccessToken>)) .property("originalInstance", codecForString()) - .property("originalToken", codecOptional(codecForString() as Codec<AccessToken>)) + .property( + "originalToken", + codecOptional(codecForString() as Codec<AccessToken>), + ) + .property("originalBackendUrl", codecForString()) .build("SessionState.Impersonate"); export const codecForSessionState = (): Codec<SessionState> => buildCodecForUnion<SessionState>() .discriminateOn("status") .alternative("loggedIn", codecForSessionStateLoggedIn()) - .alternative("impersonate", codecForSessionStateImpresonate()) .alternative("loggedOut", codecForSessionStateLoggedOut()) .alternative("expired", codecForSessionStateExpired()) .build("SessionState"); -export const defaultState = (instance: string): SessionState => ({ - status: "loggedIn", - instance, - isAdmin: instance === DEFAULT_ADMIN_USERNAME, - token: undefined, -}); +function inferInstanceName(url: URL) { + const match = INSTANCE_ID_LOOKUP.exec(url.href); + return !match || !match[1] ? DEFAULT_ADMIN_USERNAME : match[1]; +} + +export const defaultState = (url: URL): SessionState => { + const instance = inferInstanceName(url); + return { + status: "loggedIn", + instance, + backendUrl: url.href, + isAdmin: instance === DEFAULT_ADMIN_USERNAME, + token: undefined, + impersonate: undefined, + }; +}; export interface SessionStateHandler { state: SessionState; + /** + * from every state to logout state + */ logOut(): void; + /** + * from impersonate to loggedIn + */ + deImpersonate(): void; + /** + * from non-loggedOut state to expired + */ expired(): void; - logIn(info: { instance: string; token?: AccessToken }): void; + /** + * from any to loggedIn + * @param info + */ + logIn(info: { token?: AccessToken }): void; + /** + * from loggedIn to impersonate + * @param info + */ impersonate(info: { instance: string; token?: AccessToken }): void; } -const SESSION_STATE_KEY = buildStorageKey("merchant-session", codecForSessionState()); +const SESSION_STATE_KEY = buildStorageKey( + "merchant-session", + codecForSessionState(), +); -export const DEFAULT_ADMIN_USERNAME = "default" +export const DEFAULT_ADMIN_USERNAME = "default"; export const INSTANCE_ID_LOOKUP = /\/instances\/([^/]*)\/?$/; @@ -126,21 +166,43 @@ export const INSTANCE_ID_LOOKUP = /\/instances\/([^/]*)\/?$/; * login credentials and backend's * base URL. */ -export function useSessionState(): SessionStateHandler { +export function useSessionContext(): SessionStateHandler { const { url } = useMerchantApiContext(); - const match = INSTANCE_ID_LOOKUP.exec(url.href); - const instanceName = !match || !match[1] ? DEFAULT_ADMIN_USERNAME : match[1]; - const { value: state, update } = useLocalStorage( SESSION_STATE_KEY, - defaultState(instanceName), + defaultState(url), ); return { state, logOut() { - update(defaultState(instanceName)); + const instance = inferInstanceName(url); + const nextState: SessionState = { + status: "loggedOut", + backendUrl: url.href, + instance, + isAdmin: instance === DEFAULT_ADMIN_USERNAME, + }; + update(nextState); + }, + deImpersonate() { + if (state.status === "loggedOut" || state.status === "expired") { + // can't impersonate if not loggedin + return; + } + if (state.impersonate === undefined) { + return; + } + const nextState: SessionState = { + status: "loggedIn", + backendUrl: state.impersonate.originalBackendUrl, + isAdmin: state.impersonate.originalInstance === DEFAULT_ADMIN_USERNAME, + instance: state.impersonate.originalInstance, + token: state.impersonate.originalToken, + impersonate: undefined, + }; + update(nextState); }, impersonate(info) { if (state.status === "loggedOut" || state.status === "expired") { @@ -148,31 +210,36 @@ export function useSessionState(): SessionStateHandler { return; } const nextState: SessionState = { - status: "impersonate", - originalToken: state.token, - originalInstance: state.instance, - isAdmin: true, + status: "loggedIn", + backendUrl: new URL(`instances/${info.instance}`, state.backendUrl) + .href, + isAdmin: info.instance === DEFAULT_ADMIN_USERNAME, instance: info.instance, token: info.token, + impersonate: { + originalBackendUrl: state.backendUrl, + originalToken: state.token, + originalInstance: state.instance, + }, }; update(nextState); }, expired() { if (state.status === "loggedOut") return; + const nextState: SessionState = { + ...state, status: "expired", - instance: state.instance, - isAdmin: state.instance === DEFAULT_ADMIN_USERNAME, }; update(nextState); }, logIn(info) { // admin is defined by the username const nextState: SessionState = { + impersonate: undefined, + ...state, status: "loggedIn", - instance: info.instance, token: info.token, - isAdmin: state.instance === DEFAULT_ADMIN_USERNAME, }; update(nextState); cleanAllCache(); diff --git a/packages/merchant-backoffice-ui/src/declaration.d.ts b/packages/merchant-backoffice-ui/src/declaration.d.ts index ff526282a..e39257a79 100644 --- a/packages/merchant-backoffice-ui/src/declaration.d.ts +++ b/packages/merchant-backoffice-ui/src/declaration.d.ts @@ -117,9 +117,6 @@ interface LoginToken { // 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 { diff --git a/packages/merchant-backoffice-ui/src/hooks/backend.ts b/packages/merchant-backoffice-ui/src/hooks/backend.ts index 4305a9309..37dfd8fd6 100644 --- a/packages/merchant-backoffice-ui/src/hooks/backend.ts +++ b/packages/merchant-backoffice-ui/src/hooks/backend.ts @@ -19,8 +19,13 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util"; import { + AbsoluteTime, + AccessToken, + HttpStatusCode, +} from "@gnu-taler/taler-util"; +import { + EmptyObject, ErrorType, HttpError, HttpResponse, @@ -31,10 +36,8 @@ import { } 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"; - +import { useSessionContext } from "../context/session.js"; +import { LoginToken, MerchantBackend, Timestamp } from "../declaration.js"; export function useMatchMutate(): ( re?: RegExp, @@ -49,18 +52,22 @@ export function useMatchMutate(): ( } return function matchRegexMutate(re?: RegExp) { - return mutate((key) => { - // evict if no key or regex === all - if (!key || !re) return true - // match string - if (typeof key === 'string' && re.test(key)) return true - // record or object have the path at [0] - if (typeof key === 'object' && re.test(key[0])) return true - //key didn't match regex - return false - }, undefined, { - revalidate: true, - }); + return mutate( + (key) => { + // evict if no key or regex === all + if (!key || !re) return true; + // match string + if (typeof key === "string" && re.test(key)) return true; + // record or object have the path at [0] + if (typeof key === "object" && re.test(key[0])) return true; + //key didn't match regex + return false; + }, + undefined, + { + revalidate: true, + }, + ); }; } @@ -97,30 +104,36 @@ export function useBackendConfig(): HttpResponse< const { request } = useBackendBaseRequest(); type Type = MerchantBackend.VersionResponse; - type State = { data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>, timer: number } - const [result, setResult] = useState<State>({ data: { loading: true }, timer: 0 }); + type State = { + data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>; + timer: number; + }; + const [result, setResult] = useState<State>({ + data: { loading: true }, + timer: 0, + }); useEffect(() => { if (result.timer) { - clearTimeout(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 }) + 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(); + }, CHECK_CONFIG_INTERVAL_FAIL); + const data = error.cause; + setResult({ data, timer }); }); } - tryConfig() + tryConfig(); }, [request]); return result.data; @@ -134,29 +147,29 @@ interface useBackendInstanceRequestType { fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; multiFetcher: <T>(params: [url: string[]]) => Promise<HttpResponseOk<T>[]>; orderFetcher: <T>( - params: [endpoint: string, + params: [ + endpoint: string, paid?: YesOrNo, refunded?: YesOrNo, wired?: YesOrNo, searchDate?: Date, - delta?: number,] + delta?: number, + ], ) => Promise<HttpResponseOk<T>>; transferFetcher: <T>( - params: [endpoint: string, + params: [ + endpoint: string, payto_uri?: string, verified?: string, position?: string, - delta?: number,] + delta?: number, + ], ) => Promise<HttpResponseOk<T>>; templateFetcher: <T>( - params: [endpoint: string, - position?: string, - delta?: number] + params: [endpoint: string, position?: string, delta?: number], ) => Promise<HttpResponseOk<T>>; webhookFetcher: <T>( - params: [endpoint: string, - position?: string, - delta?: number] + params: [endpoint: string, position?: string, delta?: number], ) => Promise<HttpResponseOk<T>>; } interface useBackendBaseRequestType { @@ -167,14 +180,16 @@ interface useBackendBaseRequestType { } type YesOrNo = "yes" | "no"; -type LoginResult = { - valid: true; - token: string; - expiration: Timestamp; -} | { - valid: false; - cause: HttpError<{}>; -} +type LoginResult = + | { + valid: true; + token: string; + expiration: Timestamp; + } + | { + valid: false; + cause: HttpError<EmptyObject>; + }; export function useCredentialsChecker() { const { request } = useApiContext(); @@ -187,24 +202,34 @@ export function useCredentialsChecker() { const data: MerchantBackend.Instances.LoginTokenRequest = { scope: "write", duration: { - d_us: "forever" + d_us: "forever", }, refreshable: true, - } + }; try { - const response = await request<MerchantBackend.Instances.LoginTokenSuccessResponse>(baseUrl, `/private/token`, { - method: "POST", - token, - data - }); - return { valid: true, token: response.data.token, expiration: response.data.expiration }; + const response = + await request<MerchantBackend.Instances.LoginTokenSuccessResponse>( + baseUrl, + `/private/token`, + { + method: "POST", + token, + data, + }, + ); + return { + valid: true, + token: response.data.token, + expiration: response.data.expiration, + }; } catch (error) { if (error instanceof RequestError) { return { valid: false, cause: error.cause }; } return { - valid: false, cause: { + valid: false, + cause: { type: ErrorType.UNEXPECTED, loading: false, info: { @@ -212,23 +237,28 @@ export function useCredentialsChecker() { status: 0, options: {}, url: `/private/token`, - payload: {} + payload: {}, }, exception: error, - message: (error instanceof Error ? error.message : "unpexepected error") - } + message: + error instanceof Error ? error.message : "unpexepected error", + }, }; } - }; + } async function refreshLoginToken( baseUrl: string, - token: LoginToken + token: LoginToken, ): Promise<LoginResult> { - - if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) { + if ( + AbsoluteTime.isExpired( + AbsoluteTime.fromProtocolTimestamp(token.expiration), + ) + ) { return { - valid: false, cause: { + valid: false, + cause: { type: ErrorType.CLIENT, status: HttpStatusCode.Unauthorized, message: "login token expired, login again.", @@ -237,16 +267,16 @@ export function useCredentialsChecker() { status: 401, options: {}, url: `/private/token`, - payload: {} + payload: {}, }, - payload: {} + payload: {}, }, - } + }; } - return requestNewLoginToken(baseUrl, token.token as AccessToken) + return requestNewLoginToken(baseUrl, token.token as AccessToken); } - return { requestNewLoginToken, refreshLoginToken } + return { requestNewLoginToken, refreshLoginToken }; } /** @@ -255,37 +285,36 @@ export function useCredentialsChecker() { * @returns request handler to */ export function useBackendBaseRequest(): useBackendBaseRequestType { - const { url: backend, token: loginToken } = useBackendContext(); const { request: requestHandler } = useApiContext(); - const token = loginToken?.token; + const { state } = useSessionContext(); + const token = state.status === "loggedIn" ? state.token : undefined; + const baseUrl = state.backendUrl; const request = useCallback( function requestImpl<T>( endpoint: string, options: RequestOptions = {}, ): Promise<HttpResponseOk<T>> { - return requestHandler<T>(backend, endpoint, { ...options, token }).then(res => { - return res - }).catch(err => { - throw err - }); + return requestHandler<T>(baseUrl, endpoint, { ...options, token }) + .then((res) => { + return res; + }) + .catch((err) => { + throw err; + }); }, - [backend, token], + [baseUrl, token], ); return { request }; } export function useBackendInstanceRequest(): useBackendInstanceRequestType { - const { url: rootBackendUrl, token: rootToken } = useBackendContext(); - const { token: instanceToken, admin } = useInstanceContext(); const { request: requestHandler } = useApiContext(); - const { baseUrl, token: loginToken } = !admin - ? { baseUrl: rootBackendUrl, token: rootToken } - : { baseUrl: rootBackendUrl, token: instanceToken }; - - const token = loginToken?.token; + const { state } = useSessionContext(); + const token = state.status === "loggedIn" ? state.token : undefined; + const baseUrl = state.backendUrl; const request = useCallback( function requestImpl<T>( @@ -301,7 +330,7 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { function multiFetcherImpl<T>( args: [endpoints: string[]], ): Promise<HttpResponseOk<T>[]> { - const [endpoints] = args + const [endpoints] = args; return Promise.all( endpoints.map((endpoint) => requestHandler<T>(baseUrl, endpoint, { token }), @@ -320,18 +349,22 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const orderFetcher = useCallback( function orderFetcherImpl<T>( - args: [endpoint: string, + args: [ + endpoint: string, paid?: YesOrNo, refunded?: YesOrNo, wired?: YesOrNo, searchDate?: Date, - delta?: number,] + delta?: number, + ], ): Promise<HttpResponseOk<T>> { - const [endpoint, paid, refunded, wired, searchDate, delta] = args + const [endpoint, paid, refunded, wired, searchDate, delta] = args; const date_s = delta && delta < 0 && searchDate ? Math.floor(searchDate.getTime() / 1000) + 1 - : searchDate !== undefined ? Math.floor(searchDate.getTime() / 1000) : undefined; + : searchDate !== undefined + ? Math.floor(searchDate.getTime() / 1000) + : undefined; const params: any = {}; if (paid !== undefined) params.paid = paid; if (delta !== undefined) params.delta = delta; @@ -339,12 +372,12 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { if (wired !== undefined) params.wired = wired; if (date_s !== undefined) params.date_s = date_s; if (delta === 0) { - //in this case we can already assume the response + //in this case we can already assume the response //and avoid network return Promise.resolve({ ok: true, data: { orders: [] } as T, - }) + }); } return requestHandler<T>(baseUrl, endpoint, { params, token }); }, @@ -353,23 +386,25 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const transferFetcher = useCallback( function transferFetcherImpl<T>( - args: [endpoint: string, + args: [ + endpoint: string, payto_uri?: string, verified?: string, position?: string, - delta?: number,] + delta?: number, + ], ): Promise<HttpResponseOk<T>> { - const [endpoint, payto_uri, verified, position, delta] = args + const [endpoint, payto_uri, verified, position, delta] = args; const params: any = {}; if (payto_uri !== undefined) params.payto_uri = payto_uri; if (verified !== undefined) params.verified = verified; if (delta === 0) { - //in this case we can already assume the response + //in this case we can already assume the response //and avoid network return Promise.resolve({ ok: true, data: { transfers: [] } as T, - }) + }); } if (delta !== undefined) { params.limit = delta; @@ -383,19 +418,17 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const templateFetcher = useCallback( function templateFetcherImpl<T>( - args: [endpoint: string, - position?: string, - delta?: number,] + args: [endpoint: string, position?: string, delta?: number], ): Promise<HttpResponseOk<T>> { - const [endpoint, position, delta] = args + const [endpoint, position, delta] = args; const params: any = {}; if (delta === 0) { - //in this case we can already assume the response + //in this case we can already assume the response //and avoid network return Promise.resolve({ ok: true, data: { templates: [] } as T, - }) + }); } if (delta !== undefined) { params.limit = delta; @@ -409,19 +442,17 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const webhookFetcher = useCallback( function webhookFetcherImpl<T>( - args: [endpoint: string, - position?: string, - delta?: number,] + args: [endpoint: string, position?: string, delta?: number], ): Promise<HttpResponseOk<T>> { - const [endpoint, position, delta] = args + const [endpoint, position, delta] = args; const params: any = {}; if (delta === 0) { - //in this case we can already assume the response + //in this case we can already assume the response //and avoid network return Promise.resolve({ ok: true, data: { webhooks: [] } as T, - }) + }); } if (delta !== undefined) { params.limit = delta; diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.test.ts b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts index 4f6cabc9e..a1bb3d5d4 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 { AccessToken, MerchantBackend } from "../declaration.js"; +import { MerchantBackend } from "../declaration.js"; import { useAdminAPI, useBackendInstances, @@ -40,6 +40,7 @@ import { API_UPDATE_CURRENT_INSTANCE_AUTH, API_UPDATE_INSTANCE_BY_ID, } from "./urls.js"; +import { AccessToken } from "@gnu-taler/taler-util"; describe("instance api interaction with details", () => { it("should evict cache when updating an instance", async () => { diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts index 352f54982..dfe97fd61 100644 --- a/packages/merchant-backoffice-ui/src/hooks/instance.ts +++ b/packages/merchant-backoffice-ui/src/hooks/instance.ts @@ -17,9 +17,9 @@ import { HttpResponse, HttpResponseOk, RequestError, + useMerchantApiContext, } from "@gnu-taler/web-util/browser"; -import { useBackendContext } from "../context/backend.js"; -import { AccessToken, MerchantBackend } from "../declaration.js"; +import { MerchantBackend } from "../declaration.js"; import { useBackendBaseRequest, useBackendInstanceRequest, @@ -29,6 +29,8 @@ import { // FIX default import https://github.com/microsoft/TypeScript/issues/49189 import _useSWR, { SWRHook, useSWRConfig } from "swr"; +import { useSessionContext } from "../context/session.js"; +import { AccessToken } from "@gnu-taler/taler-util"; const useSWR = _useSWR as unknown as SWRHook; interface InstanceAPI { @@ -37,7 +39,10 @@ interface InstanceAPI { ) => Promise<void>; deleteInstance: () => Promise<void>; clearAccessToken: (currentToken: AccessToken | undefined) => Promise<void>; - setNewAccessToken: (currentToken: AccessToken | undefined, token: AccessToken) => Promise<void>; + setNewAccessToken: ( + currentToken: AccessToken | undefined, + token: AccessToken, + ) => Promise<void>; } export function useAdminAPI(): AdminAPI { @@ -87,10 +92,13 @@ export interface AdminAPI { export function useManagementAPI(instanceId: string): InstanceAPI { const mutateAll = useMatchMutate(); - const { url: backendURL } = useBackendContext() - const { updateToken } = useBackendContext(); + const { + state: { backendUrl }, + logIn, + logOut, + } = useSessionContext(); const { request } = useBackendBaseRequest(); - const { requestNewLoginToken } = useCredentialsChecker() + const { requestNewLoginToken } = useCredentialsChecker(); const updateInstance = async ( instance: MerchantBackend.Instances.InstanceReconfigurationMessage, @@ -111,7 +119,9 @@ export function useManagementAPI(instanceId: string): InstanceAPI { mutateAll(/\/management\/instances/); }; - const clearAccessToken = async (currentToken: AccessToken | undefined): Promise<void> => { + const clearAccessToken = async ( + currentToken: AccessToken | undefined, + ): Promise<void> => { await request(`/management/instances/${instanceId}/auth`, { method: "POST", token: currentToken, @@ -121,36 +131,46 @@ export function useManagementAPI(instanceId: string): InstanceAPI { mutateAll(/\/management\/instances/); }; - const setNewAccessToken = async (currentToken: AccessToken | undefined, newToken: AccessToken): Promise<void> => { + const setNewAccessToken = async ( + currentToken: AccessToken | undefined, + newToken: AccessToken, + ): Promise<void> => { await request(`/management/instances/${instanceId}/auth`, { method: "POST", token: currentToken, data: { method: "token", token: newToken }, }); - const resp = await requestNewLoginToken(backendURL, newToken) + const resp = await requestNewLoginToken(backendUrl, newToken); if (resp.valid) { - const { token, expiration } = resp - updateToken({ token, expiration }); + logIn({ token: resp.token as AccessToken }); } else { - updateToken(undefined) + logOut(); } mutateAll(/\/management\/instances/); }; - return { updateInstance, deleteInstance, setNewAccessToken, clearAccessToken }; + return { + updateInstance, + deleteInstance, + setNewAccessToken, + clearAccessToken, + }; } export function useInstanceAPI(): InstanceAPI { const { mutate } = useSWRConfig(); - const { url: backendURL, updateToken } = useBackendContext() - const { - token: adminToken, - } = useBackendContext(); + state: { backendUrl }, + } = useSessionContext(); + const { request } = useBackendInstanceRequest(); - const { requestNewLoginToken } = useCredentialsChecker() + const { requestNewLoginToken } = useCredentialsChecker(); + const { state, logIn, logOut } = useSessionContext(); + + const adminToken = + state.status === "loggedIn" && state.isAdmin ? state.token : undefined; const updateInstance = async ( instance: MerchantBackend.Instances.InstanceReconfigurationMessage, @@ -160,7 +180,9 @@ export function useInstanceAPI(): InstanceAPI { data: instance, }); - if (adminToken) mutate(["/private/instances", adminToken, backendURL], null); + if (adminToken) { + mutate(["/private/instances", adminToken, backendUrl], null); + } mutate([`/private/`], null); }; @@ -170,11 +192,15 @@ export function useInstanceAPI(): InstanceAPI { // token: adminToken, }); - if (adminToken) mutate(["/private/instances", adminToken, backendURL], null); + if (adminToken) { + mutate(["/private/instances", adminToken, backendUrl], null); + } mutate([`/private/`], null); }; - const clearAccessToken = async (currentToken: AccessToken | undefined): Promise<void> => { + const clearAccessToken = async ( + currentToken: AccessToken | undefined, + ): Promise<void> => { await request(`/private/auth`, { method: "POST", token: currentToken, @@ -184,25 +210,32 @@ export function useInstanceAPI(): InstanceAPI { mutate([`/private/`], null); }; - const setNewAccessToken = async (currentToken: AccessToken | undefined, newToken: AccessToken): Promise<void> => { + const setNewAccessToken = async ( + currentToken: AccessToken | undefined, + newToken: AccessToken, + ): Promise<void> => { await request(`/private/auth`, { method: "POST", token: currentToken, data: { method: "token", token: newToken }, }); - const resp = await requestNewLoginToken(backendURL, newToken) + const resp = await requestNewLoginToken(backendUrl, newToken); if (resp.valid) { - const { token, expiration } = resp - updateToken({ token, expiration }); + logIn({ token: resp.token as AccessToken }); } else { - updateToken(undefined) + logOut(); } mutate([`/private/`], null); }; - return { updateInstance, deleteInstance, setNewAccessToken, clearAccessToken }; + return { + updateInstance, + deleteInstance, + setNewAccessToken, + clearAccessToken, + }; } export function useInstanceDetails(): HttpResponse< diff --git a/packages/merchant-backoffice-ui/src/hooks/preference.ts b/packages/merchant-backoffice-ui/src/hooks/preference.ts index 4570ff679..5a50eb378 100644 --- a/packages/merchant-backoffice-ui/src/hooks/preference.ts +++ b/packages/merchant-backoffice-ui/src/hooks/preference.ts @@ -59,6 +59,7 @@ const PREFERENCES_KEY = buildStorageKey( export function usePreference(): [ Readonly<Preferences>, <T extends keyof Preferences>(key: T, value: Preferences[T]) => void, + (s: Preferences) => void, ] { const { value, update } = useLocalStorage(PREFERENCES_KEY, defaultSettings); function updateField<T extends keyof Preferences>(k: T, v: Preferences[T]) { @@ -66,7 +67,7 @@ export function usePreference(): [ update(newValue); } - return [value, updateField]; + return [value, updateField, update]; } export function dateFormatForSettings(s: Preferences): string { diff --git a/packages/merchant-backoffice-ui/src/hooks/testing.tsx b/packages/merchant-backoffice-ui/src/hooks/testing.tsx index d9a70e794..bebf7716b 100644 --- a/packages/merchant-backoffice-ui/src/hooks/testing.tsx +++ b/packages/merchant-backoffice-ui/src/hooks/testing.tsx @@ -24,8 +24,6 @@ import { ComponentChildren, FunctionalComponent, h, VNode } from "preact"; import { HttpRequestLibrary, HttpRequestOptions, HttpResponse } from "@gnu-taler/taler-util/http"; import { SWRConfig } from "swr"; import { ApiContextProvider } from "@gnu-taler/web-util/browser"; -import { BackendContextProvider } from "../context/backend.js"; -import { InstanceContextProvider } from "../context/instance.js"; import { HttpResponseOk, RequestOptions } from "@gnu-taler/web-util/browser"; import { TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient } from "@gnu-taler/taler-util"; @@ -149,15 +147,15 @@ export class ApiMockEnvironment extends MockEnvironment { const bankWire = new TalerWireGatewayHttpClient(bankCore.getWireGatewayAPI("b").href, "b", mockHttpClient) return ( - <BackendContextProvider defaultUrl="http://backend"> - <InstanceContextProvider - value={{ - token: undefined, - id: "default", - admin: true, - changeToken: () => null, - }} - > + // <BackendContextProvider defaultUrl="http://backend"> + // <InstanceContextProvider + // value={{ + // token: undefined, + // id: "default", + // admin: true, + // changeToken: () => null, + // }} + // > <ApiContextProvider value={{ request, bankCore, bankIntegration, bankRevenue, bankWire }}> <SC value={{ @@ -172,8 +170,8 @@ export class ApiMockEnvironment extends MockEnvironment { {children} </SC> </ApiContextProvider> - </InstanceContextProvider> - </BackendContextProvider> + // </InstanceContextProvider> + // </BackendContextProvider> ); }; } diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/Create.stories.tsx index ec54dc150..39fdb6bdc 100644 --- a/packages/merchant-backoffice-ui/src/paths/admin/create/Create.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/admin/create/Create.stories.tsx @@ -20,8 +20,8 @@ */ import { h, VNode, FunctionalComponent } from "preact"; -import { ConfigContextProvider } from "../../../context/config.js"; import { CreatePage as TestedComponent } from "./CreatePage.js"; +import { MerchantApiProviderTesting } from "@gnu-taler/web-util/browser"; export default { title: "Pages/Instance/Create", @@ -37,19 +37,32 @@ function createExample<Props>( props: Partial<Props>, ) { const r = (args: any) => ( - <ConfigContextProvider + <MerchantApiProviderTesting value={{ - currency: "ARS", - version: "1", - currencies: { - currency: "TESTKUDOS" + cancelRequest: () => {}, + config: { + currency: "ARS", + version: "1", + currencies: { + "ASD": { + name: "testkudos", + alt_unit_names: {}, + num_fractional_input_digits: 1, + num_fractional_normal_digits: 1, + num_fractional_trailing_zero_digits: 1, + } + }, + exchanges: [], + name: "taler-merchant" }, - exchanges: [], - name: "taler-merchant" + hints: [], + lib: {} as any, + onActivity: (() => {}) as any, + url: new URL("asdasd"), }} > <Component {...args} /> - </ConfigContextProvider> + </MerchantApiProviderTesting> ); r.args = props; return r; diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx index cbda65bfe..440cd2b07 100644 --- a/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx @@ -17,16 +17,18 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { + useMerchantApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { NotificationCard } from "../../../components/menu/index.js"; -import { AccessToken, MerchantBackend } from "../../../declaration.js"; -import { useAdminAPI, useInstanceAPI } from "../../../hooks/instance.js"; +import { MerchantBackend } from "../../../declaration.js"; +import { useAdminAPI } from "../../../hooks/instance.js"; +import { useSessionContext } from "../../../context/session.js"; import { Notification } from "../../../utils/types.js"; import { CreatePage } from "./CreatePage.js"; -import { useCredentialsChecker } from "../../../hooks/backend.js"; -import { useBackendContext } from "../../../context/backend.js"; interface Props { onBack?: () => void; @@ -39,8 +41,8 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { const { createInstance } = useAdminAPI(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); - const { requestNewLoginToken } = useCredentialsChecker() - const { url: backendURL, updateToken } = useBackendContext() + const { lib } = useMerchantApiContext(); + const { state, logIn } = useSessionContext(); return ( <Fragment> @@ -53,15 +55,29 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { d: MerchantBackend.Instances.InstanceConfigurationMessage, ) => { try { - await createInstance(d) + await createInstance(d); if (d.auth.token) { - const resp = await requestNewLoginToken(backendURL, d.auth.token as AccessToken) - if (resp.valid) { - const { token, expiration } = resp - updateToken({ token, expiration }); - } else { - updateToken(undefined) + const result = await lib.authenticate.createAccessToken( + d.auth.token, + { + scope: "write", + duration: { + d_us: "forever", + }, + refreshable: true, + }, + ); + if (result.type === "ok") { + const { access_token } = result.body; + logIn({ token: access_token }); } + // const resp = await requestNewLoginToken(backendURL.href, d.auth.token as AccessToken) + // if (resp.valid) { + // const { token, expiration } = resp + // updateToken({ token, expiration }); + // } else { + // updateToken(undefined) + // } } onConfirm(); } catch (ex) { @@ -72,7 +88,7 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { description: ex.message, }); } else { - console.error(ex) + console.error(ex); } } }} diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/stories.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/stories.tsx index 9a947c9d5..8166dc739 100644 --- a/packages/merchant-backoffice-ui/src/paths/admin/create/stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/admin/create/stories.tsx @@ -19,9 +19,9 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { h, VNode, FunctionalComponent } from "preact"; -import { ConfigContextProvider } from "../../../context/config.js"; +import { FunctionalComponent, h } from "preact"; import { CreatePage as TestedComponent } from "./CreatePage.js"; +import { MerchantApiProviderTesting } from "@gnu-taler/web-util/browser"; export default { title: "Pages/Instance/Create", @@ -37,19 +37,32 @@ function createExample<Props>( props: Partial<Props>, ) { const component = (args: any) => ( - <ConfigContextProvider + <MerchantApiProviderTesting value={{ - currency: "TESTKUDOS", - version: "1", - currencies: { - currency: "TESTKUDOS" + cancelRequest: () => {}, + config: { + currency: "ARS", + version: "1", + currencies: { + "ASD": { + name: "testkudos", + alt_unit_names: {}, + num_fractional_input_digits: 1, + num_fractional_normal_digits: 1, + num_fractional_trailing_zero_digits: 1, + } + }, + exchanges: [], + name: "taler-merchant" }, - exchanges: [], - name: "taler-merchant" + hints: [], + lib: {} as any, + onActivity: (() => {}) as any, + url: new URL("asdasd"), }} > <Internal {...(props as any)} /> - </ConfigContextProvider> + </MerchantApiProviderTesting> ); return { component, props }; } diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx index 711a5a4f0..bc18bb352 100644 --- a/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx +++ b/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx @@ -23,7 +23,7 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { StateUpdater, useEffect, useState } from "preact/hooks"; import { MerchantBackend } from "../../../declaration.js"; -import { useSessionState } from "../../../hooks/session.js"; +import { useSessionContext } from "../../../context/session.js"; interface Props { instances: MerchantBackend.Instances.Instance[]; @@ -149,7 +149,7 @@ function Table({ onPurge, }: TableProps): VNode { const { i18n } = useTranslationContext(); - const { impersonate } = useSessionState() + const { impersonate } = useSessionContext() return ( <div class="table-container"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx index 13dd3a2f6..2a37ee588 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx @@ -18,11 +18,11 @@ import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { Loading } from "../../../components/exception/loading.js"; import { DeleteModal } from "../../../components/modal/index.js"; -import { useInstanceContext } from "../../../context/instance.js"; import { MerchantBackend } from "../../../declaration.js"; import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js"; import { DetailPage } from "./DetailPage.js"; import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { useSessionContext } from "../../../context/session.js"; interface Props { onUnauthorized: () => VNode; @@ -39,7 +39,7 @@ export default function Detail({ onDelete, onNotFound, }: Props): VNode { - const { id } = useInstanceContext(); + const { state } = useSessionContext(); const result = useInstanceDetails(); const [deleting, setDeleting] = useState<boolean>(false); @@ -69,7 +69,7 @@ export default function Detail({ /> {deleting && ( <DeleteModal - element={{ name: result.data.name, id }} + element={{ name: result.data.name, id: state.instance }} onCancel={() => setDeleting(false)} onConfirm={async (): Promise<void> => { try { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx index aabe67e00..94e19bb6e 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx @@ -19,8 +19,8 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { h, VNode, FunctionalComponent } from "preact"; -import { ConfigContextProvider } from "../../../context/config.js"; +import { MerchantApiProviderTesting } from "@gnu-taler/web-util/browser"; +import { FunctionalComponent, h } from "preact"; import { DetailPage as TestedComponent } from "./DetailPage.js"; export default { @@ -37,19 +37,32 @@ function createExample<Props>( props: Partial<Props>, ) { const component = (args: any) => ( - <ConfigContextProvider + <MerchantApiProviderTesting value={{ - currency: "TESTKUDOS", - version: "1", - currencies: { - currency: "TESTKUDOS" + cancelRequest: () => {}, + config: { + currency: "ARS", + version: "1", + currencies: { + "ASD": { + name: "testkudos", + alt_unit_names: {}, + num_fractional_input_digits: 1, + num_fractional_normal_digits: 1, + num_fractional_trailing_zero_digits: 1, + } + }, + exchanges: [], + name: "taler-merchant" }, - exchanges: [], - name: "taler-merchant" + hints: [], + lib: {} as any, + onActivity: (() => {}) as any, + url: new URL("asdasd"), }} > <Internal {...(props as any)} /> - </ConfigContextProvider> + </MerchantApiProviderTesting> ); return { component, props }; } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx index 5633d93ab..fca123773 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx @@ -19,10 +19,18 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { AbsoluteTime, Amounts, Duration, TalerProtocolDuration } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + AbsoluteTime, + Amounts, + Duration, + TalerProtocolDuration, +} from "@gnu-taler/taler-util"; +import { + useMerchantApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { format, isFuture } from "date-fns"; -import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { FormErrors, @@ -39,10 +47,8 @@ import { InputToggle } from "../../../../components/form/InputToggle.js"; import { InventoryProductForm } from "../../../../components/product/InventoryProductForm.js"; import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm.js"; import { ProductList } from "../../../../components/product/ProductList.js"; -import { useConfigContext } from "../../../../context/config.js"; import { MerchantBackend, WithId } from "../../../../declaration.js"; import { usePreference } from "../../../../hooks/preference.js"; -import { OrderCreateSchema as schema } from "../../../../schemas/index.js"; import { rate } from "../../../../utils/amount.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; @@ -58,9 +64,16 @@ interface InstanceConfig { default_wire_transfer_delay: TalerProtocolDuration; } -function with_defaults(config: InstanceConfig, currency: string): Partial<Entity> { - const defaultPayDeadline = Duration.fromTalerProtocolDuration(config.default_pay_delay); - const defaultWireDeadline = Duration.fromTalerProtocolDuration(config.default_wire_transfer_delay); +function with_defaults( + config: InstanceConfig, + _currency: string, +): Partial<Entity> { + const defaultPayDeadline = Duration.fromTalerProtocolDuration( + config.default_pay_delay, + ); + const defaultWireDeadline = Duration.fromTalerProtocolDuration( + config.default_wire_transfer_delay, + ); return { inventoryProducts: {}, @@ -69,9 +82,9 @@ function with_defaults(config: InstanceConfig, currency: string): Partial<Entity payments: { max_fee: undefined, createToken: true, - pay_deadline: (defaultPayDeadline), - refund_deadline: (defaultPayDeadline), - wire_transfer_deadline: (defaultWireDeadline), + pay_deadline: defaultPayDeadline, + refund_deadline: defaultPayDeadline, + wire_transfer_deadline: defaultWireDeadline, }, shipping: {}, extra: {}, @@ -114,26 +127,17 @@ interface Entity { extra: Record<string, string>; } -const stringIsValidJSON = (value: string) => { - try { - JSON.parse(value.trim()); - return true; - } catch { - return false; - } -}; - export function CreatePage({ onCreate, onBack, instanceConfig, instanceInventory, }: Props): VNode { - const config = useConfigContext(); - const instance_default = with_defaults(instanceConfig, config.currency) + const { config } = useMerchantApiContext(); + const instance_default = with_defaults(instanceConfig, config.currency); const [value, valueHandler] = useState(instance_default); const zero = Amounts.zeroOfCurrency(config.currency); - const [settings, updateSettings] = usePreference() + const [settings, updateSettings] = usePreference(); const inventoryList = Object.values(value.inventoryProducts || {}); const productList = Object.values(value.products || {}); @@ -158,22 +162,25 @@ export function CreatePage({ refund_deadline: !value.payments?.refund_deadline ? undefined : value.payments.pay_deadline && - Duration.cmp(value.payments.refund_deadline, value.payments.pay_deadline) === -1 - ? i18n.str`refund deadline cannot be before pay deadline` - : value.payments.wire_transfer_deadline && Duration.cmp( - value.payments.wire_transfer_deadline, value.payments.refund_deadline, + value.payments.pay_deadline, ) === -1 + ? i18n.str`refund deadline cannot be before pay deadline` + : value.payments.wire_transfer_deadline && + Duration.cmp( + value.payments.wire_transfer_deadline, + value.payments.refund_deadline, + ) === -1 ? i18n.str`wire transfer deadline cannot be before refund deadline` : undefined, pay_deadline: !value.payments?.pay_deadline ? i18n.str`required` : value.payments.wire_transfer_deadline && - Duration.cmp( - value.payments.wire_transfer_deadline, - value.payments.pay_deadline, - ) === -1 + Duration.cmp( + value.payments.wire_transfer_deadline, + value.payments.pay_deadline, + ) === -1 ? i18n.str`wire transfer deadline cannot be before pay deadline` : undefined, wire_transfer_deadline: !value.payments?.wire_transfer_deadline @@ -184,12 +191,11 @@ export function CreatePage({ : !value.payments?.refund_deadline ? i18n.str`should have a refund deadline` : Duration.cmp( - value.payments.refund_deadline, - value.payments.auto_refund_deadline, - ) == -1 + value.payments.refund_deadline, + value.payments.auto_refund_deadline, + ) == -1 ? i18n.str`auto refund cannot be after refund deadline` : undefined, - }), shipping: undefinedIfEmpty({ delivery_date: !value.shipping?.delivery_date @@ -214,18 +220,34 @@ export function CreatePage({ summary: order.pricing.summary, products: productList, extra: undefinedIfEmpty(value.extra), - pay_deadline: !value.payments.pay_deadline ? - i18n.str`required` : - AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.pay_deadline)) - ,// : undefined, + pay_deadline: !value.payments.pay_deadline + ? i18n.str`required` + : AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + value.payments.pay_deadline, + ), + ), // : undefined, wire_transfer_deadline: value.payments.wire_transfer_deadline - ? AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.wire_transfer_deadline)) + ? AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + value.payments.wire_transfer_deadline, + ), + ) : undefined, refund_deadline: value.payments.refund_deadline - ? AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.refund_deadline)) + ? AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + value.payments.refund_deadline, + ), + ) : undefined, auto_refund: value.payments.auto_refund_deadline - ? Duration.toTalerProtocolDuration(value.payments.auto_refund_deadline) + ? Duration.toTalerProtocolDuration( + value.payments.auto_refund_deadline, + ) : undefined, max_fee: value.payments.max_fee as string, @@ -301,7 +323,7 @@ export function CreatePage({ const totalAsString = Amounts.stringify(totalPrice.amount); const allProducts = productList.concat(inventoryList.map(asProduct)); - const [newField, setNewField] = useState("") + const [newField, setNewField] = useState(""); useEffect(() => { valueHandler((v) => { @@ -328,37 +350,43 @@ export function CreatePage({ ); // if there is no default pay deadline - const noDefault_payDeadline = !instance_default.payments || !instance_default.payments.pay_deadline + const noDefault_payDeadline = + !instance_default.payments || !instance_default.payments.pay_deadline; // and there is no default wire deadline - const noDefault_wireDeadline = !instance_default.payments || !instance_default.payments.wire_transfer_deadline + const noDefault_wireDeadline = + !instance_default.payments || + !instance_default.payments.wire_transfer_deadline; // user required to set the taler options - const requiresSomeTalerOptions = noDefault_payDeadline || noDefault_wireDeadline - + const requiresSomeTalerOptions = + noDefault_payDeadline || noDefault_wireDeadline; return ( <div> - <section class="section is-main-section"> <div class="tabs is-toggle is-fullwidth is-small"> <ul> - <li class={!settings.advanceOrderMode ? "is-active" : ""} onClick={() => { - updateSettings({ - ...settings, - advanceOrderMode: false - }) - }}> - <a > - <span><i18n.Translate>Simple</i18n.Translate></span> + <li + class={!settings.advanceOrderMode ? "is-active" : ""} + onClick={() => { + updateSettings("advanceOrderMode", false); + }} + > + <a> + <span> + <i18n.Translate>Simple</i18n.Translate> + </span> </a> </li> - <li class={settings.advanceOrderMode ? "is-active" : ""} onClick={() => { - updateSettings({ - ...settings, - advanceOrderMode: true - }) - }}> - <a > - <span><i18n.Translate>Advanced</i18n.Translate></span> + <li + class={settings.advanceOrderMode ? "is-active" : ""} + onClick={() => { + updateSettings("advanceOrderMode", true); + }} + > + <a> + <span> + <i18n.Translate>Advanced</i18n.Translate> + </span> </a> </li> </ul> @@ -386,7 +414,7 @@ export function CreatePage({ inventory={instanceInventory} /> - {settings.advanceOrderMode && + {settings.advanceOrderMode && ( <NonInventoryProductFrom productToEdit={editingProduct} onAddProduct={(p) => { @@ -394,7 +422,7 @@ export function CreatePage({ return addNewProduct(p); }} /> - } + )} {allProducts.length > 0 && ( <ProductList @@ -437,8 +465,8 @@ export function CreatePage({ discountOrRise > 0 && (discountOrRise < 1 ? `discount of %${Math.round( - (1 - discountOrRise) * 100, - )}` + (1 - discountOrRise) * 100, + )}` : `rise of %${Math.round((discountOrRise - 1) * 100)}`) } tooltip={i18n.str`Amount to be paid by the customer`} @@ -459,7 +487,7 @@ export function CreatePage({ tooltip={i18n.str`Title of the order to be shown to the customer`} /> - {settings.advanceOrderMode && + {settings.advanceOrderMode && ( <InputGroup name="shipping" label={i18n.str`Shipping and Fulfillment`} @@ -485,146 +513,201 @@ export function CreatePage({ tooltip={i18n.str`URL to which the user will be redirected after successful payment.`} /> </InputGroup> - } + )} - {(settings.advanceOrderMode || requiresSomeTalerOptions) && + {(settings.advanceOrderMode || requiresSomeTalerOptions) && ( <InputGroup name="payments" label={i18n.str`Taler payment options`} tooltip={i18n.str`Override default Taler payment settings for this order`} > - {(settings.advanceOrderMode || noDefault_payDeadline) && <InputDuration - name="payments.pay_deadline" - label={i18n.str`Payment time`} - help={<DeadlineHelp duration={value.payments?.pay_deadline} />} - withForever - withoutClear - tooltip={i18n.str`Time for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline. Time start to run after the order is created.`} - side={ - <span> - <button class="button" onClick={() => { - const c = { - ...value, - payments: { - ...(value.payments ?? {}), - pay_deadline: instance_default.payments?.pay_deadline - } - } - valueHandler(c) - }}> - <i18n.Translate>default</i18n.Translate> - </button> - </span> - } - />} - {settings.advanceOrderMode && <InputDuration - name="payments.refund_deadline" - label={i18n.str`Refund time`} - help={<DeadlineHelp duration={value.payments?.refund_deadline} />} - withForever - withoutClear - tooltip={i18n.str`Time while the order can be refunded by the merchant. Time starts after the order is created.`} - side={ - <span> - <button class="button" onClick={() => { - valueHandler({ - ...value, - payments: { - ...(value.payments ?? {}), - refund_deadline: instance_default.payments?.refund_deadline - } - }) - }}> - <i18n.Translate>default</i18n.Translate> - </button> - </span> - } - />} - {(settings.advanceOrderMode || noDefault_wireDeadline) && <InputDuration - name="payments.wire_transfer_deadline" - label={i18n.str`Wire transfer time`} - help={<DeadlineHelp duration={value.payments?.wire_transfer_deadline} />} - withoutClear - withForever - tooltip={i18n.str`Time for the exchange to make the wire transfer. Time starts after the order is created.`} - side={ - <span> - <button class="button" onClick={() => { - valueHandler({ - ...value, - payments: { - ...(value.payments ?? {}), - wire_transfer_deadline: instance_default.payments?.wire_transfer_deadline - } - }) - }}> - <i18n.Translate>default</i18n.Translate> - </button> - </span> - } - />} - {settings.advanceOrderMode && <InputDuration - name="payments.auto_refund_deadline" - label={i18n.str`Auto-refund time`} - help={<DeadlineHelp duration={value.payments?.auto_refund_deadline} />} - tooltip={i18n.str`Time until which the wallet will automatically check for refunds without user interaction.`} - withForever - />} - - {settings.advanceOrderMode && <InputCurrency - name="payments.max_fee" - label={i18n.str`Maximum fee`} - tooltip={i18n.str`Maximum fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`} - />} - {settings.advanceOrderMode && <InputToggle - name="payments.createToken" - label={i18n.str`Create token`} - tooltip={i18n.str`If the order ID is easy to guess the token will prevent user to steal orders from others.`} - />} - {settings.advanceOrderMode && <InputNumber - name="payments.minimum_age" - label={i18n.str`Minimum age required`} - tooltip={i18n.str`Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products`} - help={ - minAgeByProducts > 0 - ? i18n.str`Min age defined by the producs is ${minAgeByProducts}` - : i18n.str`No product with age restriction in this order` - } - />} + {(settings.advanceOrderMode || noDefault_payDeadline) && ( + <InputDuration + name="payments.pay_deadline" + label={i18n.str`Payment time`} + help={ + <DeadlineHelp duration={value.payments?.pay_deadline} /> + } + withForever + withoutClear + tooltip={i18n.str`Time for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline. Time start to run after the order is created.`} + side={ + <span> + <button + class="button" + onClick={() => { + const c = { + ...value, + payments: { + ...(value.payments ?? {}), + pay_deadline: + instance_default.payments?.pay_deadline, + }, + }; + valueHandler(c); + }} + > + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } + /> + )} + {settings.advanceOrderMode && ( + <InputDuration + name="payments.refund_deadline" + label={i18n.str`Refund time`} + help={ + <DeadlineHelp + duration={value.payments?.refund_deadline} + /> + } + withForever + withoutClear + tooltip={i18n.str`Time while the order can be refunded by the merchant. Time starts after the order is created.`} + side={ + <span> + <button + class="button" + onClick={() => { + valueHandler({ + ...value, + payments: { + ...(value.payments ?? {}), + refund_deadline: + instance_default.payments?.refund_deadline, + }, + }); + }} + > + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } + /> + )} + {(settings.advanceOrderMode || noDefault_wireDeadline) && ( + <InputDuration + name="payments.wire_transfer_deadline" + label={i18n.str`Wire transfer time`} + help={ + <DeadlineHelp + duration={value.payments?.wire_transfer_deadline} + /> + } + withoutClear + withForever + tooltip={i18n.str`Time for the exchange to make the wire transfer. Time starts after the order is created.`} + side={ + <span> + <button + class="button" + onClick={() => { + valueHandler({ + ...value, + payments: { + ...(value.payments ?? {}), + wire_transfer_deadline: + instance_default.payments + ?.wire_transfer_deadline, + }, + }); + }} + > + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } + /> + )} + {settings.advanceOrderMode && ( + <InputDuration + name="payments.auto_refund_deadline" + label={i18n.str`Auto-refund time`} + help={ + <DeadlineHelp + duration={value.payments?.auto_refund_deadline} + /> + } + tooltip={i18n.str`Time until which the wallet will automatically check for refunds without user interaction.`} + withForever + /> + )} + + {settings.advanceOrderMode && ( + <InputCurrency + name="payments.max_fee" + label={i18n.str`Maximum fee`} + tooltip={i18n.str`Maximum fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`} + /> + )} + {settings.advanceOrderMode && ( + <InputToggle + name="payments.createToken" + label={i18n.str`Create token`} + tooltip={i18n.str`If the order ID is easy to guess the token will prevent user to steal orders from others.`} + /> + )} + {settings.advanceOrderMode && ( + <InputNumber + name="payments.minimum_age" + label={i18n.str`Minimum age required`} + tooltip={i18n.str`Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products`} + help={ + minAgeByProducts > 0 + ? i18n.str`Min age defined by the producs is ${minAgeByProducts}` + : i18n.str`No product with age restriction in this order` + } + /> + )} </InputGroup> - } + )} - {settings.advanceOrderMode && + {settings.advanceOrderMode && ( <InputGroup name="extra" label={i18n.str`Additional information`} tooltip={i18n.str`Custom information to be included in the contract for this order.`} > - {Object.keys(value.extra ?? {}).map((key) => { - - return <Input - name={`extra.${key}`} - inputType="multiline" - label={key} - tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`} - side={ - <button class="button" onClick={(e) => { - if (value.extra && value.extra[key] !== undefined) { - console.log(value.extra) - delete value.extra[key] - } - valueHandler({ - ...value, - }) - }}>remove</button> - } - /> + {Object.keys(value.extra ?? {}).map((key, idx) => { + return ( + <Input + name={`extra.${key}`} + key={String(idx)} + inputType="multiline" + label={key} + tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`} + side={ + <button + class="button" + onClick={(e) => { + if ( + value.extra && + value.extra[key] !== undefined + ) { + console.log(value.extra); + delete value.extra[key]; + } + valueHandler({ + ...value, + }); + e.preventDefault(); + }} + > + remove + </button> + } + /> + ); })} <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label"> <i18n.Translate>Custom field name</i18n.Translate> - <span class="icon has-tooltip-right" data-tooltip={"new extra field"}> + <span + class="icon has-tooltip-right" + data-tooltip={"new extra field"} + > <i class="mdi mdi-information" /> </span> </label> @@ -632,23 +715,33 @@ export function CreatePage({ <div class="field-body is-flex-grow-3"> <div class="field"> <p class="control"> - <input class="input " value={newField} onChange={(e) => setNewField(e.currentTarget.value)} /> + <input + class="input " + value={newField} + onChange={(e) => setNewField(e.currentTarget.value)} + /> </p> </div> </div> - <button class="button" onClick={(e) => { - setNewField("") - valueHandler({ - ...value, - extra: { - ...(value.extra ?? {}), - [newField]: "" - } - }) - }}>add</button> + <button + class="button" + onClick={(e) => { + setNewField(""); + valueHandler({ + ...value, + extra: { + ...(value.extra ?? {}), + [newField]: "", + }, + }); + e.preventDefault(); + }} + > + add + </button> </div> </InputGroup> - } + )} </FormProvider> <div class="buttons is-right mt-5"> @@ -686,20 +779,24 @@ function asProduct(p: ProductAndQuantity): MerchantBackend.Product { }; } - function DeadlineHelp({ duration }: { duration?: Duration }): VNode { const { i18n } = useTranslationContext(); - const [now, setNow] = useState(AbsoluteTime.now()) + const [now, setNow] = useState(AbsoluteTime.now()); useEffect(() => { const iid = setInterval(() => { - setNow(AbsoluteTime.now()) - }, 60 * 1000) + setNow(AbsoluteTime.now()); + }, 60 * 1000); return () => { - clearInterval(iid) - } - }) - if (!duration) return <i18n.Translate>Disabled</i18n.Translate> - const when = AbsoluteTime.addDuration(now, duration) - if (when.t_ms === "never") return <i18n.Translate>No deadline</i18n.Translate> - return <i18n.Translate>Deadline at {format(when.t_ms, "dd/MM/yy HH:mm")}</i18n.Translate> + clearInterval(iid); + }; + }); + if (!duration) return <i18n.Translate>Disabled</i18n.Translate>; + const when = AbsoluteTime.addDuration(now, duration); + if (when.t_ms === "never") + return <i18n.Translate>No deadline</i18n.Translate>; + return ( + <i18n.Translate> + Deadline at {format(when.t_ms, "dd/MM/yy HH:mm")} + </i18n.Translate> + ); } 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 1efaaf6e0..69e9df52e 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 @@ -20,7 +20,7 @@ */ import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util"; -import { useMerchantApiContext, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { format, formatDistance } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -33,6 +33,7 @@ import { InputGroup } from "../../../../components/form/InputGroup.js"; import { InputLocation } from "../../../../components/form/InputLocation.js"; import { TextField } from "../../../../components/form/TextField.js"; import { ProductList } from "../../../../components/product/ProductList.js"; +import { useSessionContext } from "../../../../context/session.js"; import { MerchantBackend } from "../../../../declaration.js"; import { datetimeFormatForSettings, usePreference } from "../../../../hooks/preference.js"; import { mergeRefunds } from "../../../../utils/amount.js"; @@ -415,10 +416,12 @@ function PaidPage({ }) const [value, valueHandler] = useState<Partial<Paid>>(order); - const { url: backendURL } = useMerchantApiContext(); + const { + state: { backendUrl }, + } = useSessionContext(); const refundurl = stringifyRefundUri({ - merchantBaseUrl: backendURL.href, + merchantBaseUrl: backendUrl, orderId: order.contract_terms.order_id }) const refundable = @@ -764,7 +767,3 @@ export function DetailPage({ id, selected, onRefund, onBack }: Props): VNode { </Fragment> ); } - -async function copyToClipboard(text: string) { - return navigator.clipboard.writeText(text); -} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx index 87e84945c..cebc4afe6 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx @@ -20,7 +20,10 @@ */ import { Amounts } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useMerchantApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { h, VNode } from "preact"; import { StateUpdater, useState } from "preact/hooks"; @@ -33,10 +36,12 @@ import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputGroup } from "../../../../components/form/InputGroup.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; import { ConfirmModal } from "../../../../components/modal/index.js"; -import { useConfigContext } from "../../../../context/config.js"; import { MerchantBackend, WithId } from "../../../../declaration.js"; import { mergeRefunds } from "../../../../utils/amount.js"; -import { datetimeFormatForSettings, usePreference } from "../../../../hooks/preference.js"; +import { + datetimeFormatForSettings, + usePreference, +} from "../../../../hooks/preference.js"; type Entity = MerchantBackend.Orders.OrderHistoryEntry & WithId; interface Props { @@ -141,10 +146,7 @@ function Table({ return ( <div class="table-container"> {hasMoreBefore && ( - <button - class="button is-fullwidth" - onClick={onLoadMoreBefore} - > + <button class="button is-fullwidth" onClick={onLoadMoreBefore}> <i18n.Translate>load newer orders</i18n.Translate> </button> )} @@ -174,9 +176,9 @@ function Table({ {i.timestamp.t_s === "never" ? "never" : format( - new Date(i.timestamp.t_s * 1000), - datetimeFormatForSettings(settings), - )} + new Date(i.timestamp.t_s * 1000), + datetimeFormatForSettings(settings), + )} </td> <td onClick={(): void => onSelect(i)} @@ -218,10 +220,7 @@ function Table({ </tbody> </table> {hasMoreAfter && ( - <button - class="button is-fullwidth" - onClick={onLoadMoreAfter} - > + <button class="button is-fullwidth" onClick={onLoadMoreAfter}> <i18n.Translate>load older orders</i18n.Translate> </button> )} @@ -268,7 +267,7 @@ export function RefundModal({ order.order_status === "paid" ? order.refund_details : [] ).reduce(mergeRefunds, []); - const config = useConfigContext(); + const { config } = useMerchantApiContext(); const totalRefunded = refunds .map((r) => r.amount) .reduce( @@ -362,9 +361,9 @@ export function RefundModal({ {r.timestamp.t_s === "never" ? "never" : format( - new Date(r.timestamp.t_s * 1000), - datetimeFormatForSettings(settings), - )} + new Date(r.timestamp.t_s * 1000), + datetimeFormatForSettings(settings), + )} </td> <td>{r.amount}</td> <td>{r.reason}</td> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx index 83345de3e..930a0d82c 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx @@ -34,7 +34,6 @@ import { import { Input } from "../../../../components/form/Input.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; -import { useBackendContext } from "../../../../context/backend.js"; import { MerchantBackend } from "../../../../declaration.js"; type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; @@ -49,7 +48,6 @@ const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"]; export function CreatePage({ onCreate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); - const backend = useBackendContext(); const [state, setState] = useState<Partial<Entity>>({}); @@ -145,6 +143,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { ...s, otp_key: randomRfc3548Base32Key(), })); + e.preventDefault(); }} > <i18n.Translate>random</i18n.Translate> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx index c6591cdbe..60abc3ca6 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx @@ -15,12 +15,11 @@ */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { VNode, h } from "preact"; import { QR } from "../../../../components/exception/QR.js"; import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; -import { useInstanceContext } from "../../../../context/instance.js"; +import { useSessionContext } from "../../../../context/session.js"; import { MerchantBackend } from "../../../../declaration.js"; -import { useBackendContext } from "../../../../context/backend.js"; type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; @@ -34,11 +33,13 @@ export function CreatedSuccessfully({ onConfirm, }: Props): VNode { const { i18n } = useTranslationContext(); - const { url: backendURL } = useBackendContext() - const { id: instanceId } = useInstanceContext(); - 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)}...`; + const { + state: { backendUrl }, + } = useSessionContext(); + const { state } = useSessionContext(); + const issuer = backendUrl; + const qrText = `otpauth://totp/${state.instance}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`; + const qrTextSafe = `otpauth://totp/${state.instance}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`; return ( <Template onConfirm={onConfirm} > 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 d27f6a022..b07582252 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 @@ -23,11 +23,12 @@ import { AmountString, Amounts, Duration, - MerchantTemplateContractDetails, assertUnreachable, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { + useTranslationContext +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; import { @@ -39,12 +40,11 @@ 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 { InputTab } from "../../../../components/form/InputTab.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; -import { useBackendContext } from "../../../../context/backend.js"; +import { useSessionContext } from "../../../../context/session.js"; import { MerchantBackend } from "../../../../declaration.js"; import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; -import { undefinedIfEmpty } from "../../../../utils/table.js"; -import { InputTab } from "../../../../components/form/InputTab.js"; enum Steps { BOTH_FIXED, @@ -55,14 +55,14 @@ enum Steps { // type Entity = MerchantBackend.Template.TemplateAddDetails & { type: Steps }; type Entity = { - id?: string, - description?: string, - otpId?: string, - summary?: string, - amount?: AmountString, - minimum_age?: number, - pay_duration?: Duration, - type: Steps, + id?: string; + description?: string; + otpId?: string; + summary?: string; + amount?: AmountString; + minimum_age?: number; + pay_duration?: Duration; + type: Steps; }; interface Props { @@ -72,8 +72,10 @@ interface Props { export function CreatePage({ onCreate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); - const { url: backendURL } = useBackendContext() - const devices = useInstanceOtpDevices() + const { + state: { backendUrl }, + } = useSessionContext(); + const devices = useInstanceOtpDevices(); const [state, setState] = useState<Partial<Entity>>({ minimum_age: 0, @@ -83,9 +85,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { type: Steps.NON_FIXED, }); - const parsedPrice = !state.amount - ? undefined - : Amounts.parse(state.amount); + const parsedPrice = !state.amount ? undefined : Amounts.parse(state.amount); const errors: FormErrors<Entity> = { id: !state.id @@ -93,10 +93,10 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { : !/[a-zA-Z0-9]*/.test(state.id) ? i18n.str`no valid. only characters and numbers` : undefined, - description: !state.description - ? i18n.str`should not be empty` - : undefined, - amount: !(state.type === Steps.FIXED_PRICE || state.type === Steps.BOTH_FIXED) + description: !state.description ? i18n.str`should not be empty` : undefined, + amount: !( + state.type === Steps.FIXED_PRICE || state.type === Steps.BOTH_FIXED + ) ? undefined : !state.amount ? i18n.str`required` @@ -105,7 +105,9 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { : Amounts.isZero(parsedPrice) ? i18n.str`must be greater than 0` : undefined, - summary: !(state.type === Steps.FIXED_SUMMARY || state.type === Steps.BOTH_FIXED) + summary: !( + state.type === Steps.FIXED_SUMMARY || state.type === Steps.BOTH_FIXED + ) ? undefined : !state.summary ? i18n.str`required` @@ -130,55 +132,60 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { const submitForm = () => { if (hasErrors || state.type === undefined) return Promise.reject(); switch (state.type) { - case Steps.FIXED_PRICE: return onCreate({ - template_id: state.id!, - template_description: state.description!, - template_contract: { - minimum_age: state.minimum_age!, - pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!), - amount: state.amount!, - // summary: state.summary, - }, - otp_id: state.otpId! - }) - case Steps.FIXED_SUMMARY: return onCreate({ - template_id: state.id!, - template_description: state.description!, - template_contract: { - minimum_age: state.minimum_age!, - pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!), - // amount: state.amount!, - summary: state.summary, - }, - otp_id: state.otpId!, - }) - case Steps.NON_FIXED: return onCreate({ - template_id: state.id!, - template_description: state.description!, - template_contract: { - minimum_age: state.minimum_age!, - pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!), - // amount: state.amount!, - // summary: state.summary, - }, - otp_id: state.otpId!, - }) - case Steps.BOTH_FIXED: return onCreate({ - template_id: state.id!, - template_description: state.description!, - template_contract: { - minimum_age: state.minimum_age!, - pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!), - amount: state.amount!, - summary: state.summary, - }, - otp_id: state.otpId!, - }) - default: assertUnreachable(state.type) + case Steps.FIXED_PRICE: + return onCreate({ + template_id: state.id!, + template_description: state.description!, + template_contract: { + minimum_age: state.minimum_age!, + pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!), + amount: state.amount!, + // summary: state.summary, + }, + otp_id: state.otpId!, + }); + case Steps.FIXED_SUMMARY: + return onCreate({ + template_id: state.id!, + template_description: state.description!, + template_contract: { + minimum_age: state.minimum_age!, + pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!), + // amount: state.amount!, + summary: state.summary, + }, + otp_id: state.otpId!, + }); + case Steps.NON_FIXED: + return onCreate({ + template_id: state.id!, + template_description: state.description!, + template_contract: { + minimum_age: state.minimum_age!, + pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!), + // amount: state.amount!, + // summary: state.summary, + }, + otp_id: state.otpId!, + }); + case Steps.BOTH_FIXED: + return onCreate({ + template_id: state.id!, + template_description: state.description!, + template_contract: { + minimum_age: state.minimum_age!, + pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!), + amount: state.amount!, + summary: state.summary, + }, + otp_id: state.otpId!, + }); + default: + assertUnreachable(state.type); // return onCreate(state); - }; - } - const deviceList = !devices.ok ? [] : devices.data.otp_devices + } + }; + const deviceList = !devices.ok ? [] : devices.data.otp_devices; return ( <div> @@ -193,7 +200,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { > <InputWithAddon<Entity> name="id" - help={`${backendURL}/templates/${state.id ?? ""}`} + help={new URL(`templates/${state.id ?? ""}`, backendUrl).href} label={i18n.str`Identifier`} tooltip={i18n.str`Name of the template in URLs.`} /> @@ -207,12 +214,16 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { name="type" label={i18n.str`Type`} help={(() => { - if (state.type === undefined) return "" + if (state.type === undefined) return ""; switch (state.type) { - case Steps.NON_FIXED: return i18n.str`User will be able to input price and summary before payment.` - case Steps.FIXED_PRICE: return i18n.str`User will be able to add a summary before payment.` - case Steps.FIXED_SUMMARY: return i18n.str`User will be able to set the price before payment.` - case Steps.BOTH_FIXED: return i18n.str`User will not be able to change the price or the summary.` + case Steps.NON_FIXED: + return i18n.str`User will be able to input price and summary before payment.`; + case Steps.FIXED_PRICE: + return i18n.str`User will be able to add a summary before payment.`; + case Steps.FIXED_SUMMARY: + return i18n.str`User will be able to set the price before payment.`; + case Steps.BOTH_FIXED: + return i18n.str`User will not be able to change the price or the summary.`; } })()} tooltip={i18n.str`Define what the user be allowed to modify`} @@ -224,28 +235,34 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { ]} toStr={(v: Steps): string => { switch (v) { - case Steps.NON_FIXED: return i18n.str`Simple` - case Steps.FIXED_PRICE: return i18n.str`With price` - case Steps.FIXED_SUMMARY: return i18n.str`With summary` - case Steps.BOTH_FIXED: return i18n.str`With price and summary` + case Steps.NON_FIXED: + return i18n.str`Simple`; + case Steps.FIXED_PRICE: + return i18n.str`With price`; + case Steps.FIXED_SUMMARY: + return i18n.str`With summary`; + case Steps.BOTH_FIXED: + return i18n.str`With price and summary`; } }} /> - {state.type === Steps.BOTH_FIXED || state.type === Steps.FIXED_SUMMARY ? + {state.type === Steps.BOTH_FIXED || + state.type === Steps.FIXED_SUMMARY ? ( <Input<Entity> name="summary" inputType="multiline" label={i18n.str`Fixed summary`} tooltip={i18n.str`If specified, this template will create order with the same summary`} /> - : undefined} - {state.type === Steps.BOTH_FIXED || state.type === Steps.FIXED_PRICE ? + ) : undefined} + {state.type === Steps.BOTH_FIXED || + state.type === Steps.FIXED_PRICE ? ( <InputCurrency<Entity> name="amount" label={i18n.str`Fixed price`} tooltip={i18n.str`If specified, this template will create order with the same price`} /> - : undefined} + ) : undefined} <InputNumber<Entity> name="minimum_age" label={i18n.str`Minimum age`} @@ -262,28 +279,29 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { name="otpId" label={i18n.str`OTP device`} readonly - side={<button - class="button is-danger" - data-tooltip={i18n.str`without otp device`} - onClick={(): void => { - setState((v) => ({ ...v, otpId: undefined })); - }} - > - <span> - <i18n.Translate>remove</i18n.Translate> - </span> - </button>} + side={ + <button + class="button is-danger" + data-tooltip={i18n.str`without otp device`} + onClick={(): void => { + setState((v) => ({ ...v, otpId: undefined })); + }} + > + <span> + <i18n.Translate>remove</i18n.Translate> + </span> + </button> + } tooltip={i18n.str`Use to verify transaction in offline mode.`} /> <InputSearchOnList label={i18n.str`Search device`} onChange={(p) => setState((v) => ({ ...v, otpId: p?.id }))} - list={deviceList.map(e => ({ + list={deviceList.map((e) => ({ description: e.device_description, - id: e.otp_device_id + id: e.otp_device_id, }))} /> - </FormProvider> <div class="buttons is-right mt-5"> 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 809151565..1aa5bc317 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 @@ -20,7 +20,10 @@ */ import { stringifyPayTemplateUri } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useMerchantApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import { QR } from "../../../../components/exception/QR.js"; @@ -30,9 +33,7 @@ import { } from "../../../../components/form/FormProvider.js"; import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { useBackendContext } from "../../../../context/backend.js"; -import { useConfigContext } from "../../../../context/config.js"; -import { useInstanceContext } from "../../../../context/instance.js"; +import { useSessionContext } from "../../../../context/session.js"; import { MerchantBackend } from "../../../../declaration.js"; type Entity = MerchantBackend.Template.UsingTemplateDetails; @@ -45,9 +46,10 @@ interface Props { export function QrPage({ contract, id: templateId, onBack }: Props): VNode { const { i18n } = useTranslationContext(); - const { url: backendURL } = useBackendContext() - const { id: instanceId } = useInstanceContext(); - const config = useConfigContext(); + const { + state: { backendUrl }, + } = useSessionContext(); + const { config } = useMerchantApiContext(); const [state, setState] = useState<Partial<Entity>>({ amount: contract.amount, @@ -59,30 +61,26 @@ export function QrPage({ contract, id: templateId, onBack }: Props): VNode { const fixedAmount = !!contract.amount; const fixedSummary = !!contract.summary; - const templateParams: Record<string, string> = {} + const templateParams: Record<string, string> = {}; if (!fixedAmount) { if (state.amount) { - templateParams.amount = state.amount + templateParams.amount = state.amount; } else { - templateParams.amount = config.currency + templateParams.amount = config.currency; } } if (!fixedSummary) { - templateParams.summary = state.summary ?? "" + templateParams.summary = state.summary ?? ""; } - const merchantBaseUrl = new URL(backendURL).href; + const merchantBaseUrl = backendUrl; const payTemplateUri = stringifyPayTemplateUri({ merchantBaseUrl, templateId, - templateParams - }) - - const issuer = encodeURIComponent( - `${new URL(backendURL).host}/${instanceId}`, - ); + templateParams, + }); return ( <div> 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 cdf2ebab4..ae11ad991 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 @@ -26,7 +26,7 @@ import { assertUnreachable } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; import { @@ -37,11 +37,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 { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js"; import { InputTab } from "../../../../components/form/InputTab.js"; -import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; -import { useBackendContext } from "../../../../context/backend.js"; +import { useSessionContext } from "../../../../context/session.js"; import { MerchantBackend } from "../../../../declaration.js"; -import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js"; import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; enum Steps { @@ -68,7 +67,10 @@ interface Props { export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); - const { url: backendURL } = useBackendContext() + const { + state: { backendUrl }, + } = useSessionContext(); + const intialStep = template.template_contract.amount === undefined && template.template_contract.summary === undefined @@ -187,7 +189,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { <div class="level-left"> <div class="level-item"> <span class="is-size-4"> - {backendURL}/templates/{template.otp_id} + {new URL(`templates/${template.otp_id}`,backendUrl).href} </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 1e9186624..f2b1db29b 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx @@ -25,19 +25,23 @@ import { useState } from "preact/hooks"; 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"; import { NotificationCard } from "../../../components/menu/index.js"; +import { useSessionContext } from "../../../context/session.js"; +import { AccessToken } from "@gnu-taler/taler-util"; interface Props { - instanceId: string; hasToken: boolean | undefined; onClearToken: (c: AccessToken | undefined) => void; onNewToken: (c: AccessToken | undefined, s: AccessToken) => void; onBack?: () => void; } -export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearToken }: Props): VNode { +export function DetailPage({ + hasToken, + onBack, + onNewToken, + onClearToken, +}: Props): VNode { type State = { old_token: string; new_token: string; repeat_token: string }; const [form, setValue] = useState<Partial<State>>({ old_token: "", @@ -47,9 +51,10 @@ export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearTo const { i18n } = useTranslationContext(); const errors = { - old_token: hasToken && !form.old_token - ? i18n.str`you need your access token to perform the operation` - : undefined, + old_token: + hasToken && !form.old_token + ? i18n.str`you need your access token to perform the operation` + : undefined, new_token: !form.new_token ? i18n.str`cannot be empty` : form.new_token === form.old_token @@ -65,15 +70,17 @@ export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearTo (k) => (errors as any)[k] !== undefined, ); - const instance = useInstanceContext(); + const { state } = useSessionContext(); - const text = i18n.str`You are updating the access token from instance with id "${instance.id}"`; + const text = i18n.str`You are updating the access token from instance with id "${state.instance}"`; async function submitForm() { if (hasErrors) return; - const oldToken = hasToken ? `secret-token:${form.old_token}` as AccessToken : undefined; + const oldToken = hasToken + ? (`secret-token:${form.old_token}` as AccessToken) + : undefined; const newToken = `secret-token:${form.new_token}` as AccessToken; - onNewToken(oldToken, newToken) + onNewToken(oldToken, newToken); } return ( @@ -84,9 +91,7 @@ export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearTo <div class="level"> <div class="level-left"> <div class="level-item"> - <span class="is-size-4"> - {text} - </span> + <span class="is-size-4">{text}</span> </div> </div> </div> @@ -94,7 +99,7 @@ export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearTo </section> <hr /> - {!hasToken && + {!hasToken && ( <NotificationCard notification={{ message: i18n.str`This instance doesn't have authentication token.`, @@ -102,7 +107,7 @@ export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearTo type: "WARN", }} /> - } + )} <div class="columns"> <div class="column" /> @@ -119,7 +124,8 @@ export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearTo /> <p> <i18n.Translate> - Clearing the access token will mean public access to the instance. + Clearing the access token will mean public access to the + instance. </i18n.Translate> </p> <div class="buttons is-right mt-5"> @@ -127,10 +133,11 @@ export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearTo class="button" onClick={() => { if (hasToken) { - const oldToken = `secret-token:${form.old_token}` as AccessToken; - onClearToken(oldToken) + const oldToken = + `secret-token:${form.old_token}` as AccessToken; + onClearToken(oldToken); } else { - onClearToken(undefined) + onClearToken(undefined); } }} > @@ -140,7 +147,6 @@ export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearTo </Fragment> )} - <Input<State> name="new_token" label={i18n.str`New access token`} @@ -176,7 +182,6 @@ export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearTo </div> <div class="column" /> </div> - </section> </div> ); 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 13642ec22..d7bf7a6d5 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx @@ -16,15 +16,13 @@ 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 { AccessToken, MerchantBackend } from "../../../declaration.js"; -import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js"; -import { DetailPage } from "./DetailPage.js"; -import { useInstanceContext } from "../../../context/instance.js"; import { useState } from "preact/hooks"; +import { Loading } from "../../../components/exception/loading.js"; import { NotificationCard } from "../../../components/menu/index.js"; +import { MerchantBackend } from "../../../declaration.js"; +import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js"; import { Notification } from "../../../utils/types.js"; -import { useBackendContext } from "../../../context/backend.js"; +import { DetailPage } from "./DetailPage.js"; interface Props { onUnauthorized: () => VNode; @@ -45,7 +43,6 @@ export default function Token({ const [notif, setNotif] = useState<Notification | undefined>(undefined); const { clearAccessToken, setNewAccessToken } = useInstanceAPI(); - const { id } = useInstanceContext(); const result = useInstanceDetails() if (result.loading) return <Loading />; @@ -69,7 +66,6 @@ export default function Token({ <Fragment> <NotificationCard notification={notif} /> <DetailPage - instanceId={id} onBack={onCancel} hasToken={hasToken} onClearToken={async (currentToken): Promise<void> => { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx index eb25045a0..576c21cd2 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx @@ -20,7 +20,7 @@ */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; +import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; import { @@ -30,7 +30,6 @@ import { import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; -import { useConfigContext } from "../../../../context/config.js"; import { MerchantBackend } from "../../../../declaration.js"; import { CROCKFORD_BASE32_REGEX, @@ -47,7 +46,6 @@ interface Props { export function CreatePage({ accounts, onCreate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); - const { currency } = useConfigContext(); const [state, setState] = useState<Partial<Entity>>({ wtid: "", diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx index ff0d55d2d..f0f0bfac9 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx @@ -19,6 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { Duration } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -28,10 +29,9 @@ import { FormProvider, } from "../../../components/form/FormProvider.js"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; -import { useInstanceContext } from "../../../context/instance.js"; import { MerchantBackend } from "../../../declaration.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; -import { Duration } from "@gnu-taler/taler-util"; +import { useSessionContext } from "../../../context/session.js"; export type Entity = Omit<Omit<MerchantBackend.Instances.InstanceReconfigurationMessage, "default_pay_delay">, "default_wire_transfer_delay"> & { default_pay_delay: Duration, @@ -64,7 +64,7 @@ export function UpdatePage({ selected, onBack, }: Props): VNode { - const { id } = useInstanceContext(); + const { state } = useSessionContext(); const [value, valueHandler] = useState<Partial<Entity>>(convert(selected)); @@ -125,7 +125,7 @@ export function UpdatePage({ <div class="level-left"> <div class="level-item"> <span class="is-size-4"> - <i18n.Translate>Instance id</i18n.Translate>: <b>{id}</b> + <i18n.Translate>Instance id</i18n.Translate>: <b>{state.instance}</b> </span> </div> </div> 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 be3793ac3..de1371974 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx @@ -24,8 +24,7 @@ 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 { AccessToken, MerchantBackend } from "../../../declaration.js"; +import { MerchantBackend } from "../../../declaration.js"; import { useInstanceAPI, useInstanceDetails, @@ -65,7 +64,6 @@ function CommonUpdate( onConfirm, onLoadError, onNotFound, - onUpdateError, onUnauthorized, }: Props, result: HttpResponse< diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx index b89e5e6bf..83604711e 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx @@ -28,12 +28,8 @@ import { FormProvider, } from "../../../../components/form/FormProvider.js"; 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 { useBackendContext } from "../../../../context/backend.js"; -import { MerchantBackend } from "../../../../declaration.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { MerchantBackend } from "../../../../declaration.js"; type Entity = MerchantBackend.Webhooks.WebhookAddDetails; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx index 304ac90f3..be21629d5 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx @@ -28,7 +28,6 @@ import { FormProvider, } from "../../../../components/form/FormProvider.js"; import { Input } from "../../../../components/form/Input.js"; -import { useBackendContext } from "../../../../context/backend.js"; import { MerchantBackend, WithId } from "../../../../declaration.js"; type Entity = MerchantBackend.Webhooks.WebhookPatchDetails & WithId; diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx index d94b7e506..1c0b915bd 100644 --- a/packages/merchant-backoffice-ui/src/paths/login/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -19,7 +19,11 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { + AccessToken, + HttpStatusCode, + TalerAuthentication, +} from "@gnu-taler/taler-util"; import { useMerchantApiContext, useTranslationContext, @@ -27,40 +31,68 @@ import { import { ComponentChildren, Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { NotificationCard } from "../../components/menu/index.js"; -import { AccessToken } from "../../declaration.js"; -import { DEFAULT_ADMIN_USERNAME, useSessionState } from "../../hooks/session.js"; +import { + DEFAULT_ADMIN_USERNAME, + useSessionContext, +} from "../../context/session.js"; import { Notification } from "../../utils/types.js"; -interface Props { -} +interface Props {} -function normalizeToken(r: string): AccessToken { - return `secret-token:${r}` as AccessToken; -} +const tokenRequest = { + scope: "write", + duration: { + d_us: "forever" as const, + }, + refreshable: true, +}; export function LoginPage(_p: Props): VNode { const [token, setToken] = useState(""); const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { state, logIn } = useSessionState(); + const { state, logIn } = useSessionContext(); const { lib } = useMerchantApiContext(); const { i18n } = useTranslationContext(); + async function doImpersonateImpl(instanceId: string) { + const result = await lib + .impersonate(instanceId) + .createAccessTokenMerchant(token, tokenRequest); + if (result.type === "ok") { + const { token } = result.body; + logIn({ token }); + return; + } else { + switch (result.case) { + case HttpStatusCode.Unauthorized: { + setNotif({ + message: "Your password is incorrect", + type: "ERROR", + }); + return; + } + case HttpStatusCode.NotFound: { + setNotif({ + message: "Your instance not found", + type: "ERROR", + }); + return; + } + } + } + } async function doLoginImpl() { - const secretToken = normalizeToken(token); - const result = await lib.authenticate.createAccessToken(secretToken, { - scope: "write", - duration: { - d_us: "forever" - }, - refreshable: true, - }); + const result = await lib.authenticate.createAccessTokenMerchant( + token, + tokenRequest, + ); if (result.type === "ok") { - const { access_token } = result.body; - logIn({ instance: state.instance, token: access_token }); + const { token } = result.body; + logIn({ token }); return; } else { - switch(result.case) { + switch (result.case) { case HttpStatusCode.Unauthorized: { setNotif({ message: "Your password is incorrect", @@ -79,8 +111,8 @@ export function LoginPage(_p: Props): VNode { } } - if (state.isAdmin && state.instance !== DEFAULT_ADMIN_USERNAME) { - //admin trying to access another instance + if (state.status === "loggedIn" && state.impersonate !== undefined) { + //the user is loggedin but trying to do an impersonation return ( <div class="columns is-centered" style={{ margin: "auto" }}> <div class="column is-two-thirds "> @@ -115,7 +147,9 @@ export function LoginPage(_p: Props): VNode { placeholder={"current access token"} name="token" onKeyPress={(e) => - e.keyCode === 13 ? doLoginImpl() : null + e.keyCode === 13 + ? doImpersonateImpl(state.instance) + : null } value={token} onInput={(e): void => setToken(e?.currentTarget.value)} @@ -133,7 +167,7 @@ export function LoginPage(_p: Props): VNode { borderTop: 0, }} > - <AsyncButton onClick={doLoginImpl}> + <AsyncButton onClick={() => doImpersonateImpl(state.instance)}> <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> </footer> diff --git a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx index 4efda43be..6290f48e6 100644 --- a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx @@ -1,10 +1,30 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + import { useLang, useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { FormErrors, FormProvider } from "../../components/form/FormProvider.js"; +import { + FormErrors, + FormProvider, +} from "../../components/form/FormProvider.js"; import { InputSelector } from "../../components/form/InputSelector.js"; import { InputToggle } from "../../components/form/InputToggle.js"; import { LangSelector } from "../../components/menu/LangSelector.js"; -import { Settings, usePreference } from "../../hooks/preference.js"; +import { Preferences, usePreference } from "../../hooks/preference.js"; +import { AbsoluteTime } from "@gnu-taler/taler-util"; function getBrowserLang(): string | undefined { if (typeof window === "undefined") return undefined; @@ -14,99 +34,107 @@ function getBrowserLang(): string | undefined { } export function Settings({ onClose }: { onClose?: () => void }): VNode { - const { i18n } = useTranslationContext() - const borwserLang = getBrowserLang() - const { update } = useLang(undefined, {}) + const { i18n } = useTranslationContext(); + const borwserLang = getBrowserLang(); + const { update } = useLang(undefined, {}); - const [value, updateValue] = usePreference() - const errors: FormErrors<Settings> = { - } + const [value, , updateValue] = usePreference(); + const errors: FormErrors<Preferences> = {}; - function valueHandler(s: (d: Partial<Settings>) => Partial<Settings>): void { - const next = s(value) - const v: Settings = { + function valueHandler(s: (d: Partial<Preferences>) => Partial<Preferences>): void { + const next = s(value); + const v: Preferences = { advanceOrderMode: next.advanceOrderMode ?? false, - dateFormat: next.dateFormat ?? "ymd" - } - updateValue(v) + hideKycUntil: next.hideKycUntil ?? AbsoluteTime.never(), + dateFormat: next.dateFormat ?? "ymd", + }; + updateValue(v); } - return <div> - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <div> - - <FormProvider<Settings> - name="settings" - errors={errors} - object={value} - valueHandler={valueHandler} - > - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label"> - <i18n.Translate>Language</i18n.Translate> - <span class="icon has-tooltip-right" data-tooltip={"Force language setting instance of taking the browser"}> - <i class="mdi mdi-information" /> - </span> - </label> - </div> - <div class="field field-body has-addons is-flex-grow-3"> - <LangSelector /> - - {borwserLang !== undefined && <button - data-tooltip={i18n.str`generate random secret key`} - class="button is-info mr-2" - onClick={(e) => { - update(borwserLang.substring(0, 2)) - }} - > - <i18n.Translate>Set default</i18n.Translate> - </button>} + return ( + <div> + <section class="section is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <div> + <FormProvider<Preferences> + name="settings" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Language</i18n.Translate> + <span + class="icon has-tooltip-right" + data-tooltip={ + "Force language setting instance of taking the browser" + } + > + <i class="mdi mdi-information" /> + </span> + </label> + </div> + <div class="field field-body has-addons is-flex-grow-3"> + <LangSelector /> + + {borwserLang !== undefined && ( + <button + data-tooltip={i18n.str`generate random secret key`} + class="button is-info mr-2" + onClick={(e) => { + update(borwserLang.substring(0, 2)); + e.preventDefault() + }} + > + <i18n.Translate>Set default</i18n.Translate> + </button> + )} + </div> </div> - </div> - <InputToggle<Settings> - label={i18n.str`Advance order creation`} - tooltip={i18n.str`Shows more options in the order creation form`} - name="advanceOrderMode" - /> - <InputSelector<Settings> - name="dateFormat" - label={i18n.str`Date format`} - expand={true} - help={ - value.dateFormat === "dmy" ? "31/12/2001" : value.dateFormat === "mdy" ? "12/31/2001" : value.dateFormat === "ymd" ? "2001/12/31" : "" - } - toStr={(e) => { - if (e === "ymd") return "year month day" - if (e === "mdy") return "month day year" - if (e === "dmy") return "day month year" - return "choose one" - }} - values={[ - "ymd", - "mdy", - "dmy", - ]} - tooltip={i18n.str`how the date is going to be displayed`} - /> - </FormProvider> + <InputToggle<Preferences> + label={i18n.str`Advance order creation`} + tooltip={i18n.str`Shows more options in the order creation form`} + name="advanceOrderMode" + /> + <InputSelector<Preferences> + name="dateFormat" + label={i18n.str`Date format`} + expand={true} + help={ + value.dateFormat === "dmy" + ? "31/12/2001" + : value.dateFormat === "mdy" + ? "12/31/2001" + : value.dateFormat === "ymd" + ? "2001/12/31" + : "" + } + toStr={(e) => { + if (e === "ymd") return "year month day"; + if (e === "mdy") return "month day year"; + if (e === "dmy") return "day month year"; + return "choose one"; + }} + values={["ymd", "mdy", "dmy"]} + tooltip={i18n.str`how the date is going to be displayed`} + /> + </FormProvider> + </div> </div> + <div class="column" /> </div> - <div class="column" /> - </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 + {onClose && ( + <section class="section is-main-section"> + <button class="button" onClick={onClose}> + <i18n.Translate>Close</i18n.Translate> + </button> + </section> + )} + </div> + ); +} diff --git a/packages/taler-util/src/http-client/authentication.ts b/packages/taler-util/src/http-client/authentication.ts index e8ef6a274..00ef21a06 100644 --- a/packages/taler-util/src/http-client/authentication.ts +++ b/packages/taler-util/src/http-client/authentication.ts @@ -34,6 +34,7 @@ import { AccessToken, TalerAuthentication, codecForTokenSuccessResponse, + codecForTokenSuccessResponseMerchant, } from "./types.js"; import { makeBearerTokenAuthHeader } from "./utils.js"; @@ -85,6 +86,35 @@ export class TalerAuthenticationHttpClient { } } + /** + * + * @returns + */ + async createAccessTokenMerchant( + password: string, + body: TalerAuthentication.TokenRequest, + ) { + const url = new URL(`token`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + headers: { + Authorization: makeBearerTokenAuthHeader(password as AccessToken), + }, + body, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForTokenSuccessResponseMerchant()); + //FIXME: missing in docs + case HttpStatusCode.Unauthorized: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownFailure(resp, await resp.text()); + } + } + async deleteAccessToken(token: AccessToken) { const url = new URL(`token`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts index 7407cce66..688e80c29 100644 --- a/packages/taler-util/src/http-client/merchant.ts +++ b/packages/taler-util/src/http-client/merchant.ts @@ -15,6 +15,7 @@ */ import { + AccessToken, HttpStatusCode, LibtoolVersion, PaginationParams, @@ -64,6 +65,7 @@ import { opSuccessFromHttp, opUnknownFailure } from "../operation.js"; import { CacheEvictor, addMerchantPaginationParams, + makeBearerTokenAuthHeader, nullEvictor, } from "./utils.js"; @@ -126,12 +128,15 @@ export class TalerMerchantInstanceHttpClient { /** * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-orders-$ORDER_ID-claim */ - async claimOrder(orderId: string, body: TalerMerchantApi.ClaimRequest) { + async claimOrder(token: AccessToken, orderId: string, body: TalerMerchantApi.ClaimRequest) { const url = new URL(`orders/${orderId}/claim`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", body, + headers: { + Authorization: makeBearerTokenAuthHeader(token), + } }); switch (resp.status) { @@ -516,11 +521,14 @@ export class TalerMerchantInstanceHttpClient { /** * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-accounts */ - async listAccounts() { + async listAccounts(token: AccessToken) { const url = new URL(`private/accounts`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", + headers: { + Authorization: makeBearerTokenAuthHeader(token), + } }); switch (resp.status) { @@ -1496,7 +1504,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp } getSubInstanceAPI(instanceId: string) { - return new URL(`instances/${instanceId}`, this.baseUrl); + return new URL(`instances/${instanceId}/`, this.baseUrl); } // diff --git a/packages/taler-util/src/http-client/types.ts b/packages/taler-util/src/http-client/types.ts index 05897614a..7f97f9ff1 100644 --- a/packages/taler-util/src/http-client/types.ts +++ b/packages/taler-util/src/http-client/types.ts @@ -221,6 +221,16 @@ export namespace TalerAuthentication { // Opque access token. access_token: AccessToken; } + export interface TokenSuccessResponseMerchant { + // Expiration determined by the server. + // Can be based on the token_duration + // from the request, but ultimately the + // server decides the expiration. + expiration: Timestamp; + + // Opque access token. + token: AccessToken; + } } // DD51 https://docs.taler.net/design-documents/051-fractional-digits.html @@ -254,6 +264,13 @@ export const codecForTokenSuccessResponse = .property("expiration", codecForTimestamp) .build("TalerAuthentication.TokenSuccessResponse"); +export const codecForTokenSuccessResponseMerchant = + (): Codec<TalerAuthentication.TokenSuccessResponseMerchant> => + buildCodecForObject<TalerAuthentication.TokenSuccessResponseMerchant>() + .property("token", codecForAccessToken()) + .property("expiration", codecForTimestamp) + .build("TalerAuthentication.TokenSuccessResponseMerchant"); + export const codecForCurrencySpecificiation = (): Codec<CurrencySpecification> => buildCodecForObject<CurrencySpecification>() |