diff --git a/packages/merchant-backoffice-ui/src/Application.tsx b/packages/merchant-backoffice-ui/src/Application.tsx index 5e82821ae..1a7617643 100644 --- a/packages/merchant-backoffice-ui/src/Application.tsx +++ b/packages/merchant-backoffice-ui/src/Application.tsx @@ -41,7 +41,8 @@ import { import { ConfigContextProvider } from "./context/config.js"; import { useBackendConfig } from "./hooks/backend.js"; import { strings } from "./i18n/strings.js"; -import LoginPage from "./paths/login/index.js"; +import { ConnectionPage, LoginPage } from "./paths/login/index.js"; +import { LoginToken } from "./declaration.js"; export function Application(): VNode { return ( @@ -59,25 +60,20 @@ export function Application(): VNode { * @returns */ function ApplicationStatusRoutes(): VNode { - const { url, updateLoginStatus, triedToLog } = useBackendContext(); + const { url: backendURL, updateToken, changeBackend } = useBackendContext(); const result = useBackendConfig(); const { i18n } = useTranslationContext(); - const updateLoginInfoAndGoToRoot = (url: string, token?: string) => { - updateLoginStatus(url, token); - route("/"); - }; - const { currency, version } = result.ok ? result.data : { currency: "unknown", version: "unknown" }; const ctx = useMemo(() => ({ currency, version }), [currency, version]); - if (!triedToLog) { + if (!backendURL) { return ( - + ); } @@ -91,7 +87,7 @@ function ApplicationStatusRoutes(): VNode { return ( - + ); } @@ -109,7 +105,7 @@ function ApplicationStatusRoutes(): VNode { description: `Check your url`, }} /> - + ); } @@ -120,10 +116,10 @@ function ApplicationStatusRoutes(): VNode { notification={{ message: i18n.str`Server response with an error code`, type: "ERROR", - description: i18n.str`Got message ${result.message} from ${result.info?.url}`, + description: i18n.str`Got message "${result.message}" from ${result.info?.url}`, }} /> - + ; } if (result.type === ErrorType.UNREADABLE) { @@ -133,10 +129,10 @@ function ApplicationStatusRoutes(): VNode { notification={{ message: i18n.str`Response from server is unreadable, http status: ${result.status}`, type: "ERROR", - description: i18n.str`Got message ${result.message} from ${result.info?.url}`, + description: i18n.str`Got message "${result.message}" from ${result.info?.url}`, }} /> - + ; } return ( @@ -146,10 +142,10 @@ function ApplicationStatusRoutes(): VNode { notification={{ message: i18n.str`Unexpected Error`, type: "ERROR", - description: i18n.str`Got message ${result.message} from ${result.info?.url}`, + description: i18n.str`Got message "${result.message}" from ${result.info?.url}`, }} /> - + ); } @@ -168,7 +164,7 @@ function ApplicationStatusRoutes(): VNode { description: i18n.str`Merchant backend server version ${result.data.version} is not compatible with the supported version ${SUPPORTED_VERSION}`, }} /> - + } diff --git a/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx b/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx index 46dea98e3..8bfbdb076 100644 --- a/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx +++ b/packages/merchant-backoffice-ui/src/ApplicationReadyRoutes.tsx @@ -18,22 +18,23 @@ * * @author Sebastian Javier Marchano (sebasjm) */ +import { HttpStatusCode } from "@gnu-taler/taler-util"; import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser"; import { createHashHistory } from "history"; -import { Fragment, h, VNode } from "preact"; -import { Router, Route, route } from "preact-router"; -import { useEffect, useState } from "preact/hooks"; +import { Fragment, VNode, h } from "preact"; +import { Route, Router, route } from "preact-router"; +import { useState } from "preact/hooks"; +import { InstanceRoutes } from "./InstanceRoutes.js"; import { - NotificationCard, NotYetReadyAppMenu, + NotificationCard, } from "./components/menu/index.js"; import { useBackendContext } from "./context/backend.js"; +import { LoginToken } from "./declaration.js"; import { useBackendInstancesTestForAdmin } from "./hooks/backend.js"; -import { InstanceRoutes } from "./InstanceRoutes.js"; -import LoginPage from "./paths/login/index.js"; -import { INSTANCE_ID_LOOKUP } from "./utils/constants.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { ConnectionPage, LoginPage } from "./paths/login/index.js"; import { Settings } from "./paths/settings/index.js"; +import { INSTANCE_ID_LOOKUP } from "./utils/constants.js"; /** * Check if admin against /management/instances @@ -41,15 +42,14 @@ import { Settings } from "./paths/settings/index.js"; */ export function ApplicationReadyRoutes(): VNode { const { i18n } = useTranslationContext(); + const { url: backendURL, changeBackend } = useBackendContext() const [unauthorized, setUnauthorized] = useState(false) const { - url: backendURL, - updateLoginStatus: updateLoginStatus2, + updateToken, } = useBackendContext(); - function updateLoginStatus(url: string, token: string | undefined) { - console.log("updateing", url, token) - updateLoginStatus2(url, token) + function updateLoginStatus(token: LoginToken | undefined) { + updateToken(token) setUnauthorized(false) } @@ -59,15 +59,15 @@ export function ApplicationReadyRoutes(): VNode { route("/"); }; const [showSettings, setShowSettings] = useState(false) - // useEffect(() => { - // setUnauthorized(FF) - // }, [FF]) - const unauthorizedAdmin = !result.loading && !result.ok && result.type === ErrorType.CLIENT && result.status === HttpStatusCode.Unauthorized + const unauthorizedAdmin = !result.loading + && !result.ok + && result.type === ErrorType.CLIENT + && result.status === HttpStatusCode.Unauthorized; if (showSettings) { return setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> - + setShowSettings(false)} /> } @@ -100,7 +100,7 @@ export function ApplicationReadyRoutes(): VNode { type: "ERROR", }} /> - + ); } @@ -108,14 +108,13 @@ export function ApplicationReadyRoutes(): VNode { instanceNameByBackendURL = match[1]; } - console.log(unauthorized, unauthorizedAdmin) if (unauthorized || unauthorizedAdmin) { return setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> @@ -132,7 +131,6 @@ export function ApplicationReadyRoutes(): VNode { admin={admin} onUnauthorized={() => setUnauthorized(true)} onLoginPass={() => { - console.log("ahora si") setUnauthorized(false) }} instanceNameByBackendURL={instanceNameByBackendURL} diff --git a/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx b/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx index ee8db9a9f..c2a9d3b18 100644 --- a/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx +++ b/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx @@ -35,7 +35,7 @@ import { InstanceContextProvider } from "./context/instance.js"; import { useBackendDefaultToken, useBackendInstanceToken, - useLocalStorage, + useSimpleLocalStorage, } from "./hooks/index.js"; import { useInstanceKYCDetails } from "./hooks/instance.js"; import InstanceCreatePage from "./paths/admin/create/index.js"; @@ -71,10 +71,10 @@ import InstanceUpdatePage, { AdminUpdate as InstanceAdminUpdatePage, Props as InstanceUpdatePageProps, } from "./paths/instance/update/index.js"; -import LoginPage from "./paths/login/index.js"; +import { LoginPage } from "./paths/login/index.js"; import NotFoundPage from "./paths/notfound/index.js"; import { Notification } from "./utils/types.js"; -import { MerchantBackend } from "./declaration.js"; +import { LoginToken, MerchantBackend } from "./declaration.js"; import { Settings } from "./paths/settings/index.js"; import { dateFormatForSettings, useSettings } from "./hooks/useSettings.js"; @@ -143,7 +143,7 @@ export function InstanceRoutes({ id, admin, path, - onUnauthorized, + // onUnauthorized, onLoginPass, setInstanceName, }: Props): VNode { @@ -155,7 +155,7 @@ export function InstanceRoutes({ const [globalNotification, setGlobalNotification] = useState(undefined); - const changeToken = (token?: string) => { + const changeToken = (token?: LoginToken) => { if (admin) { updateToken(token); } else { @@ -201,14 +201,17 @@ export function InstanceRoutes({ // const LoginPageAccessDeniend = onUnauthorized const LoginPageAccessDenied = () => { - onUnauthorized() - return + return + + + + } function IfAdminCreateDefaultOr(Next: FunctionComponent) { @@ -687,9 +690,7 @@ function AdminInstanceUpdatePage({ ...rest }: { id: string } & InstanceUpdatePageProps): VNode { const [token, changeToken] = useBackendInstanceToken(id); - const { updateLoginStatus: changeBackend } = useBackendContext(); - const updateLoginStatus = (url: string, token?: string): void => { - changeBackend(url); + const updateLoginStatus = (token?: LoginToken): void => { changeToken(token); }; const value = useMemo( @@ -752,7 +753,7 @@ function KycBanner(): VNode { const { i18n } = useTranslationContext(); const [settings] = useSettings(); const today = format(new Date(), dateFormatForSettings(settings)); - const [lastHide, setLastHide] = useLocalStorage("kyc-last-hide"); + const [lastHide, setLastHide] = useSimpleLocalStorage("kyc-last-hide"); const hasBeenHidden = today === lastHide; const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect"; if (hasBeenHidden || !needsToBeShown) return ; diff --git a/packages/merchant-backoffice-ui/src/components/exception/login.tsx b/packages/merchant-backoffice-ui/src/components/exception/login.tsx deleted file mode 100644 index 4fa440fc7..000000000 --- a/packages/merchant-backoffice-ui/src/components/exception/login.tsx +++ /dev/null @@ -1,244 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useBackendContext } from "../../context/backend.js"; -import { useInstanceContext } from "../../context/instance.js"; -import { useCredentialsChecker } from "../../hooks/backend.js"; -import { Notification } from "../../utils/types.js"; - -interface Props { - withMessage?: Notification; - onConfirm: (backend: string, token?: string) => void; -} - -function getTokenValuePart(t: string): string { - if (!t) return t; - const match = /secret-token:(.*)/.exec(t); - if (!match || !match[1]) return ""; - return match[1]; -} - -function normalizeToken(r: string): string { - return `secret-token:${r}`; -} - -function cleanUp(s: string): string { - let result = s; - if (result.indexOf("webui/") !== -1) { - result = result.substring(0, result.indexOf("webui/")); - } - return result; -} - -export function LoginModal({ onConfirm, withMessage }: Props): VNode { - const { url: backendUrl, token: baseToken } = useBackendContext(); - const { admin, token: instanceToken, id } = useInstanceContext(); - const testLogin = useCredentialsChecker(); - const currentToken = getTokenValuePart( - (!admin ? baseToken : instanceToken) ?? "", - ); - const [token, setToken] = useState(currentToken); - - const [url, setURL] = useState(cleanUp(backendUrl)); - const { i18n } = useTranslationContext(); - - if (admin && id !== "default") { - //admin trying to access another instance - return ( - - - - {i18n.str`Login required`} - - - - Need the access token for the instance. - - - - - Access Token - - - - - - - e.keyCode === 13 - ? onConfirm(url, normalizeToken(token)) - : null - } - value={token} - onInput={(e): void => setToken(e?.currentTarget.value)} - /> - - - - - - - - - ) - } - - return ( - - - - - {i18n.str`Login required`} - - - Please enter your access token. - - - URL - - - - - - e.keyCode === 13 - ? onConfirm(url, normalizeToken(token)) - : null - } - onInput={(e): void => setURL(e?.currentTarget.value)} - /> - - - - - - - - Access Token - - - - - - - e.keyCode === 13 - ? onConfirm(url, normalizeToken(token)) - : null - } - value={token} - onInput={(e): void => setToken(e?.currentTarget.value)} - /> - - - - - - - - - - ); -} - -function AsyncButton({ onClick, children }: { onClick: () => Promise, children: ComponentChildren }): VNode { - const [running, setRunning] = useState(false) - return { - setRunning(true) - onClick().then(() => { - setRunning(false) - }).catch(() => { - setRunning(false) - }) - }}> - {children} - -} diff --git a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx index b75dc83b3..6f5881fc0 100644 --- a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx +++ b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx @@ -40,13 +40,13 @@ export function DefaultInstanceFormFields({ showId: boolean; }): VNode { const { i18n } = useTranslationContext(); - const backend = useBackendContext(); + const { url: backendURL } = useBackendContext() return ( {showId && ( name="id" - addonBefore={`${backend.url}/instances/`} + addonBefore={`${backendURL}/instances/`} readonly={readonlyId} label={i18n.str`Identifier`} tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`} diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx index be2f8dde5..3d5f20c85 100644 --- a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx +++ b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -25,7 +25,6 @@ import { useBackendContext } from "../../context/backend.js"; import { useConfigContext } from "../../context/config.js"; import { useInstanceKYCDetails } from "../../hooks/instance.js"; import { LangSelector } from "./LangSelector.js"; -import { useCredentialsChecker } from "../../hooks/backend.js"; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; @@ -50,7 +49,7 @@ export function Sidebar({ isPasswordOk }: Props): VNode { const config = useConfigContext(); - const backend = useBackendContext(); + const { url: backendURL } = useBackendContext() const { i18n } = useTranslationContext(); const kycStatus = useInstanceKYCDetails(); const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; @@ -230,7 +229,7 @@ export function Sidebar({ - {new URL(backend.url).hostname} + {new URL(backendURL).hostname} diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx index 726a94f5e..8bebbd298 100644 --- a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx +++ b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx @@ -114,7 +114,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { onSubscribe(hasErrors ? undefined : submit); }, [submit, hasErrors]); - const backend = useBackendContext(); + const { url: backendURL } = useBackendContext() const { i18n } = useTranslationContext(); return ( @@ -128,7 +128,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { {alreadyExist ? undefined : ( name="product_id" - addonBefore={`${backend.url}/product/`} + addonBefore={`${backendURL}/product/`} label={i18n.str`ID`} tooltip={i18n.str`product identification to use in URLs (for internal use only)`} /> diff --git a/packages/merchant-backoffice-ui/src/context/backend.test.ts b/packages/merchant-backoffice-ui/src/context/backend.test.ts index cb0010c4b..b042d5a25 100644 --- a/packages/merchant-backoffice-ui/src/context/backend.test.ts +++ b/packages/merchant-backoffice-ui/src/context/backend.test.ts @@ -21,7 +21,7 @@ import * as tests from "@gnu-taler/web-util/testing"; import { ComponentChildren, h, VNode } from "preact"; -import { MerchantBackend } from "../declaration.js"; +import { AccessToken, MerchantBackend } from "../declaration.js"; import { useAdminAPI, useInstanceAPI, @@ -64,7 +64,7 @@ describe("backend context api ", () => { } as MerchantBackend.Instances.QueryInstancesResponse, }); - management.setNewToken("another_token"); + management.setNewToken("another_token" as AccessToken); }, ({ instance, management, admin }) => { expect(env.assertJustExpectedRequestWereMade()).deep.eq({ @@ -113,7 +113,7 @@ describe("backend context api ", () => { name: "instance_name", } as MerchantBackend.Instances.QueryInstancesResponse, }); - instance.setNewToken("another_token"); + instance.setNewToken("another_token" as AccessToken); }, ({ instance, management, admin }) => { expect(env.assertJustExpectedRequestWereMade()).deep.eq({ diff --git a/packages/merchant-backoffice-ui/src/context/backend.ts b/packages/merchant-backoffice-ui/src/context/backend.ts index 43e9e4d27..056f9a192 100644 --- a/packages/merchant-backoffice-ui/src/context/backend.ts +++ b/packages/merchant-backoffice-ui/src/context/backend.ts @@ -20,90 +20,46 @@ */ import { createContext, h, VNode } from "preact"; -import { useCallback, useContext, useState } from "preact/hooks"; +import { useContext } from "preact/hooks"; +import { LoginToken } from "../declaration.js"; import { useBackendDefaultToken, useBackendURL } from "../hooks/index.js"; interface BackendContextType { - url: string; - token?: string; - triedToLog: boolean; - resetBackend: () => void; - // clearAllTokens: () => void; - // addTokenCleaner: (c: () => void) => void; - updateLoginStatus: (url: string, token?: string) => void; - updateToken: (token?: string) => void; + url: string, + token?: LoginToken; + updateToken: (token: LoginToken | undefined) => void; + changeBackend: (url: string) => void; } const BackendContext = createContext({ url: "", token: undefined, - triedToLog: false, - resetBackend: () => null, - // clearAllTokens: () => null, - // addTokenCleaner: () => null, - updateLoginStatus: () => null, updateToken: () => null, + changeBackend: () => null, }); function useBackendContextState( defaultUrl?: string, - initialToken?: string, ): BackendContextType { - const [url, triedToLog, changeBackend, resetBackend] = - useBackendURL(defaultUrl); - const [token, _updateToken] = useBackendDefaultToken(initialToken); - const updateToken = (t?: string) => { - _updateToken(t); - }; - - // const tokenCleaner = useCallback(() => { - // updateToken(undefined); - // }, []); - // const [cleaners, setCleaners] = useState([tokenCleaner]); - // const addTokenCleaner = (c: () => void) => setCleaners((cs) => [...cs, c]); - // const addTokenCleanerMemo = useCallback( - // (c: () => void) => { - // addTokenCleaner(c); - // }, - // [tokenCleaner], - // ); - - // const clearAllTokens = () => { - // cleaners.forEach((c) => c()); - // for (let i = 0; i < localStorage.length; i++) { - // const k = localStorage.key(i); - // if (k && /^backend-token/.test(k)) localStorage.removeItem(k); - // } - // resetBackend(); - // }; - - const updateLoginStatus = (url: string, token?: string) => { - changeBackend(url); - updateToken(token); - }; + const [url, changeBackend] = useBackendURL(defaultUrl); + const [token, updateToken] = useBackendDefaultToken(); return { url, token, - triedToLog, - updateLoginStatus, - resetBackend, - // clearAllTokens, updateToken, - // addTokenCleaner: addTokenCleanerMemo, + changeBackend }; } export const BackendContextProvider = ({ children, defaultUrl, - initialToken, }: { children: any; defaultUrl?: string; - initialToken?: string; }): VNode => { - const value = useBackendContextState(defaultUrl, initialToken); + const value = useBackendContextState(defaultUrl); return h(BackendContext.Provider, { value, children }); }; diff --git a/packages/merchant-backoffice-ui/src/context/instance.ts b/packages/merchant-backoffice-ui/src/context/instance.ts index 9a25fe80c..3c6cc2b63 100644 --- a/packages/merchant-backoffice-ui/src/context/instance.ts +++ b/packages/merchant-backoffice-ui/src/context/instance.ts @@ -21,12 +21,13 @@ import { createContext } from "preact"; import { useContext } from "preact/hooks"; +import { LoginToken } from "../declaration.js"; interface Type { id: string; - token?: string; + token?: LoginToken; admin?: boolean; - changeToken: (t?: string) => void; + changeToken: (t?: LoginToken) => void; } const Context = createContext({} as any); diff --git a/packages/merchant-backoffice-ui/src/declaration.d.ts b/packages/merchant-backoffice-ui/src/declaration.d.ts index 5ca9c1e09..c3e6ea3da 100644 --- a/packages/merchant-backoffice-ui/src/declaration.d.ts +++ b/packages/merchant-backoffice-ui/src/declaration.d.ts @@ -107,6 +107,16 @@ interface RegexAccountRestriction { // human hints. human_hint_i18n?: { [lang_tag: string]: string }; } +interface LoginToken { + token: string, + expiration: Timestamp, +} +// token used to get loginToken +// must forget after used +declare const __ac_token: unique symbol; +type AccessToken = string & { + [__ac_token]: true; +}; export namespace ExchangeBackend { interface WireResponse { @@ -491,6 +501,35 @@ export namespace MerchantBackend { }; } // DELETE /private/instances/$INSTANCE + interface LoginTokenRequest { + // Scope of the token (which kinds of operations it will allow) + scope: "readonly" | "write"; + + // Server may impose its own upper bound + // on the token validity duration + duration?: RelativeTime; + + // Can this token be refreshed? + // Defaults to false. + refreshable?: boolean; + } + interface LoginTokenSuccessResponse { + // The login token that can be used to access resources + // that are in scope for some time. Must be prefixed + // with "Bearer " when used in the "Authorization" HTTP header. + // Will already begin with the RFC 8959 prefix. + token: string; + + // Scope of the token (which kinds of operations it will allow) + scope: "readonly" | "write"; + + // Server may impose its own upper bound + // on the token validity duration + expiration: Timestamp; + + // Can this token be refreshed? + refreshable: boolean; + } } namespace KYC { diff --git a/packages/merchant-backoffice-ui/src/hooks/backend.ts b/packages/merchant-backoffice-ui/src/hooks/backend.ts index ecd34df6d..fe4155788 100644 --- a/packages/merchant-backoffice-ui/src/hooks/backend.ts +++ b/packages/merchant-backoffice-ui/src/hooks/backend.ts @@ -19,19 +19,21 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { useSWRConfig } from "swr"; -import { MerchantBackend } from "../declaration.js"; -import { useBackendContext } from "../context/backend.js"; -import { useCallback, useEffect, useState } from "preact/hooks"; -import { useInstanceContext } from "../context/instance.js"; +import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util"; import { ErrorType, + HttpError, HttpResponse, HttpResponseOk, RequestError, RequestOptions, + useApiContext, } from "@gnu-taler/web-util/browser"; -import { useApiContext } from "@gnu-taler/web-util/browser"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import { useSWRConfig } from "swr"; +import { useBackendContext } from "../context/backend.js"; +import { useInstanceContext } from "../context/instance.js"; +import { AccessToken, LoginToken, MerchantBackend, Timestamp } from "../declaration.js"; export function useMatchMutate(): ( @@ -85,6 +87,9 @@ export function useBackendInstancesTestForAdmin(): HttpResponse< return result; } +const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000; +const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000; + export function useBackendConfig(): HttpResponse< MerchantBackend.VersionResponse, RequestError @@ -92,18 +97,33 @@ export function useBackendConfig(): HttpResponse< const { request } = useBackendBaseRequest(); type Type = MerchantBackend.VersionResponse; - - const [result, setResult] = useState< - HttpResponse> - >({ loading: true }); + type State = { data: HttpResponse>, timer: number } + const [result, setResult] = useState({ data: { loading: true }, timer: 0 }); useEffect(() => { - request(`/config`) - .then((data) => setResult(data)) - .catch((error) => setResult(error)); + if (result.timer) { + clearTimeout(result.timer) + } + function tryConfig(): void { + request(`/config`) + .then((data) => { + const timer: any = setTimeout(() => { + tryConfig() + }, CHECK_CONFIG_INTERVAL_OK) + setResult({ data, timer }) + }) + .catch((error) => { + const timer: any = setTimeout(() => { + tryConfig() + }, CHECK_CONFIG_INTERVAL_FAIL) + const data = error.cause + setResult({ data, timer }) + }); + } + tryConfig() }, [request]); - return result; + return result.data; } interface useBackendInstanceRequestType { @@ -149,32 +169,86 @@ interface useBackendBaseRequestType { } type YesOrNo = "yes" | "no"; +type LoginResult = { + valid: true; + token: string; + expiration: Timestamp; +} | { + valid: false; + cause: HttpError<{}>; +} export function useCredentialsChecker() { const { request } = useApiContext(); //check against instance details endpoint //while merchant backend doesn't have a login endpoint - async function testLogin( - instance: string, - token: string, - ): Promise<{ - valid: boolean; - cause?: ErrorType; - }> { + async function requestNewLoginToken( + baseUrl: string, + token: AccessToken, + ): Promise { + const data: MerchantBackend.Instances.LoginTokenRequest = { + scope: "write", + duration: { + d_us: "forever" + }, + refreshable: true, + } try { - const response = await request(instance, `/private/`, { + const response = await request(baseUrl, `/private/token`, { + method: "POST", token, + data }); - return { valid: true }; + return { valid: true, token: response.data.token, expiration: response.data.expiration }; } catch (error) { if (error instanceof RequestError) { - return { valid: false, cause: error.cause.type }; + return { valid: false, cause: error.cause }; } - return { valid: false, cause: ErrorType.UNEXPECTED }; + return { + valid: false, cause: { + type: ErrorType.UNEXPECTED, + loading: false, + info: { + hasToken: true, + status: 0, + options: {}, + url: `/private/token`, + payload: {} + }, + exception: error, + message: (error instanceof Error ? error.message : "unpexepected error") + } + }; } }; - return testLogin + + async function refreshLoginToken( + baseUrl: string, + token: LoginToken + ): Promise { + + if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) { + return { + valid: false, cause: { + type: ErrorType.CLIENT, + status: HttpStatusCode.Unauthorized, + message: "login token expired, login again.", + info: { + hasToken: true, + status: 401, + options: {}, + url: `/private/token`, + payload: {} + }, + payload: {} + }, + } + } + + return requestNewLoginToken(baseUrl, token.token as AccessToken) + } + return { requestNewLoginToken, refreshLoginToken } } /** @@ -183,15 +257,20 @@ export function useCredentialsChecker() { * @returns request handler to */ export function useBackendBaseRequest(): useBackendBaseRequestType { - const { url: backend, token } = useBackendContext(); + const { url: backend, token: loginToken } = useBackendContext(); const { request: requestHandler } = useApiContext(); + const token = loginToken?.token; const request = useCallback( function requestImpl( endpoint: string, options: RequestOptions = {}, ): Promise> { - return requestHandler(backend, endpoint, { token, ...options }); + return requestHandler(backend, endpoint, { token, ...options }).then(res => { + return res + }).catch(err => { + throw err + }); }, [backend, token], ); @@ -204,10 +283,12 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const { token: instanceToken, id, admin } = useInstanceContext(); const { request: requestHandler } = useApiContext(); - const { baseUrl, token } = !admin + const { baseUrl, token: loginToken } = !admin ? { baseUrl: rootBackendUrl, token: rootToken } : { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken }; + const token = loginToken?.token; + const request = useCallback( function requestImpl( endpoint: string, diff --git a/packages/merchant-backoffice-ui/src/hooks/index.ts b/packages/merchant-backoffice-ui/src/hooks/index.ts index 79b22304a..ee696779f 100644 --- a/packages/merchant-backoffice-ui/src/hooks/index.ts +++ b/packages/merchant-backoffice-ui/src/hooks/index.ts @@ -19,9 +19,11 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { StateUpdater, useCallback, useEffect, useState } from "preact/hooks"; +import { buildCodecForObject, codecForMap, codecForString, codecForTimestamp } from "@gnu-taler/taler-util"; +import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; +import { StateUpdater, useEffect, useState } from "preact/hooks"; +import { LoginToken } from "../declaration.js"; import { ValueOrFunction } from "../utils/types.js"; -import { useMemoryStorage } from "@gnu-taler/web-util/browser"; import { useMatchMutate } from "./backend.js"; const calculateRootPath = () => { @@ -32,53 +34,55 @@ const calculateRootPath = () => { return rootPath; }; +const loginTokenCodec = buildCodecForObject() + .property("token", codecForString()) + .property("expiration", codecForTimestamp) + .build("loginToken") +const TOKENS_KEY = buildStorageKey("backend-token", codecForMap(loginTokenCodec)); + + export function useBackendURL( url?: string, -): [string, boolean, StateUpdater, () => void] { - const [value, setter] = useNotNullLocalStorage( +): [string, StateUpdater] { + const [value, setter] = useSimpleLocalStorage( "backend-url", url || calculateRootPath(), ); - const [triedToLog, setTriedToLog] = useLocalStorage("tried-login"); const checkedSetter = (v: ValueOrFunction) => { - setTriedToLog("yes"); - return setter((p) => (v instanceof Function ? v(p) : v).replace(/\/$/, "")); + return setter((p) => (v instanceof Function ? v(p ?? "") : v).replace(/\/$/, "")); }; - const resetBackend = () => { - setTriedToLog(undefined); - }; - return [value, !!triedToLog, checkedSetter, resetBackend]; + return [value!, checkedSetter]; } export function useBackendDefaultToken( - initialValue?: string, -): [string | undefined, ((d: string | undefined) => void)] { - // uncomment for testing - initialValue = "secret-token:secret" as string | undefined - const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token`, initialValue) +): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] { + const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {}) + + const tokenOfDefaultInstance = tokenMap["default"] const clearCache = useMatchMutate() useEffect(() => { clearCache() - }, [token]) + }, [tokenOfDefaultInstance]) function updateToken( - value: (string | undefined) + value: (LoginToken | undefined) ): void { if (value === undefined) { reset() } else { - setToken(value) + const res = { ...tokenMap, "default": value } + setToken(res) } } - return [token, updateToken]; + return [tokenMap["default"], updateToken]; } export function useBackendInstanceToken( id: string, -): [string | undefined, ((d: string | undefined) => void)] { - const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token-${id}`) +): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] { + const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {}) const [defaultToken, defaultSetToken] = useBackendDefaultToken(); // instance named 'default' use the default token @@ -86,16 +90,17 @@ export function useBackendInstanceToken( return [defaultToken, defaultSetToken]; } function updateToken( - value: (string | undefined) + value: (LoginToken | undefined) ): void { if (value === undefined) { reset() } else { - setToken(value) + const res = { ...tokenMap, [id]: value } + setToken(res) } } - return [token, updateToken]; + return [tokenMap[id], updateToken]; } export function useLang(initial?: string): [string, StateUpdater] { @@ -104,10 +109,10 @@ export function useLang(initial?: string): [string, StateUpdater] { ? navigator.language || (navigator as any).userLanguage : undefined; const defaultLang = (browserLang || initial || "en").substring(0, 2); - return useNotNullLocalStorage("lang-preference", defaultLang); + return useSimpleLocalStorage("lang-preference", defaultLang) as [string, StateUpdater]; } -export function useLocalStorage( +export function useSimpleLocalStorage( key: string, initialValue?: string, ): [string | undefined, StateUpdater] { @@ -137,28 +142,3 @@ export function useLocalStorage( return [storedValue, setValue]; } - -export function useNotNullLocalStorage( - key: string, - initialValue: string, -): [string, StateUpdater] { - const [storedValue, setStoredValue] = useState((): string => { - return typeof window !== "undefined" - ? window.localStorage.getItem(key) || initialValue - : initialValue; - }); - - const setValue = (value: string | ((val: string) => string)) => { - const valueToStore = value instanceof Function ? value(storedValue) : value; - setStoredValue(valueToStore); - if (typeof window !== "undefined") { - if (!valueToStore) { - window.localStorage.removeItem(key); - } else { - window.localStorage.setItem(key, valueToStore); - } - } - }; - - return [storedValue, setValue]; -} diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.test.ts b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts index d15b3f6d7..a7b8d047c 100644 --- a/packages/merchant-backoffice-ui/src/hooks/instance.test.ts +++ b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts @@ -21,7 +21,7 @@ import * as tests from "@gnu-taler/web-util/testing"; import { expect } from "chai"; -import { MerchantBackend } from "../declaration.js"; +import { AccessToken, MerchantBackend } from "../declaration.js"; import { useAdminAPI, useBackendInstances, @@ -158,7 +158,7 @@ describe("instance api interaction with details", () => { }, } as MerchantBackend.Instances.QueryInstancesResponse, }); - api.setNewToken("secret"); + api.setNewToken("secret" as AccessToken); }, ({ query, api }) => { expect(env.assertJustExpectedRequestWereMade()).deep.eq({ diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts index 32ed30c6f..50f9487a3 100644 --- a/packages/merchant-backoffice-ui/src/hooks/instance.ts +++ b/packages/merchant-backoffice-ui/src/hooks/instance.ts @@ -19,10 +19,11 @@ import { RequestError, } from "@gnu-taler/web-util/browser"; import { useBackendContext } from "../context/backend.js"; -import { MerchantBackend } from "../declaration.js"; +import { AccessToken, MerchantBackend } from "../declaration.js"; import { useBackendBaseRequest, useBackendInstanceRequest, + useCredentialsChecker, useMatchMutate, } from "./backend.js"; @@ -36,7 +37,7 @@ interface InstanceAPI { ) => Promise; deleteInstance: () => Promise; clearToken: () => Promise; - setNewToken: (token: string) => Promise; + setNewToken: (token: AccessToken) => Promise; } export function useAdminAPI(): AdminAPI { @@ -86,8 +87,10 @@ export interface AdminAPI { export function useManagementAPI(instanceId: string): InstanceAPI { const mutateAll = useMatchMutate(); + const { url: backendURL } = useBackendContext() const { updateToken } = useBackendContext(); const { request } = useBackendBaseRequest(); + const { requestNewLoginToken } = useCredentialsChecker() const updateInstance = async ( instance: MerchantBackend.Instances.InstanceReconfigurationMessage, @@ -117,13 +120,20 @@ export function useManagementAPI(instanceId: string): InstanceAPI { mutateAll(/\/management\/instances/); }; - const setNewToken = async (newToken: string): Promise => { + const setNewToken = async (newToken: AccessToken): Promise => { await request(`/management/instances/${instanceId}/auth`, { method: "POST", data: { method: "token", token: newToken }, }); - updateToken(newToken); + const resp = await requestNewLoginToken(backendURL, newToken) + if (resp.valid) { + const { token, expiration } = resp + updateToken({ token, expiration }); + } else { + updateToken(undefined) + } + mutateAll(/\/management\/instances/); }; @@ -132,12 +142,13 @@ export function useManagementAPI(instanceId: string): InstanceAPI { export function useInstanceAPI(): InstanceAPI { const { mutate } = useSWRConfig(); + const { url: backendURL, updateToken } = useBackendContext() + const { - url: baseUrl, token: adminToken, - updateLoginStatus, } = useBackendContext(); const { request } = useBackendInstanceRequest(); + const { requestNewLoginToken } = useCredentialsChecker() const updateInstance = async ( instance: MerchantBackend.Instances.InstanceReconfigurationMessage, @@ -147,7 +158,7 @@ export function useInstanceAPI(): InstanceAPI { data: instance, }); - if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null); + if (adminToken) mutate(["/private/instances", adminToken, backendURL], null); mutate([`/private/`], null); }; @@ -157,7 +168,7 @@ export function useInstanceAPI(): InstanceAPI { // token: adminToken, }); - if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null); + if (adminToken) mutate(["/private/instances", adminToken, backendURL], null); mutate([`/private/`], null); }; @@ -170,13 +181,20 @@ export function useInstanceAPI(): InstanceAPI { mutate([`/private/`], null); }; - const setNewToken = async (newToken: string): Promise => { + const setNewToken = async (newToken: AccessToken): Promise => { await request(`/private/auth`, { method: "POST", data: { method: "token", token: newToken }, }); - updateLoginStatus(baseUrl, newToken); + const resp = await requestNewLoginToken(backendURL, newToken) + if (resp.valid) { + const { token, expiration } = resp + updateToken({ token, expiration }); + } else { + updateToken(undefined) + } + mutate([`/private/`], null); }; diff --git a/packages/merchant-backoffice-ui/src/hooks/testing.tsx b/packages/merchant-backoffice-ui/src/hooks/testing.tsx index ebbc6f64a..847d512b0 100644 --- a/packages/merchant-backoffice-ui/src/hooks/testing.tsx +++ b/packages/merchant-backoffice-ui/src/hooks/testing.tsx @@ -90,10 +90,7 @@ export class ApiMockEnvironment extends MockEnvironment { const SC: any = SWRConfig; return ( - + (str: string | undefined): T | undefined { - if (str === undefined) return undefined; - try { - return JSON.parse(str); - } catch { - return undefined; - } -} - export interface Settings { advanceOrderMode: boolean; dateFormat: "ymd" | "dmy" | "mdy"; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx index e42adc2ff..1cfbec29b 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx @@ -22,7 +22,7 @@ import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { format, formatDistance } from "date-fns"; -import { Fragment, h, VNode } from "preact"; +import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { FormProvider } from "../../../../components/form/FormProvider.js"; import { Input } from "../../../../components/form/Input.js"; @@ -35,10 +35,10 @@ import { TextField } from "../../../../components/form/TextField.js"; import { ProductList } from "../../../../components/product/ProductList.js"; import { useBackendContext } from "../../../../context/backend.js"; import { MerchantBackend } from "../../../../declaration.js"; +import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; import { mergeRefunds } from "../../../../utils/amount.js"; import { RefundModal } from "../list/Table.js"; import { Event, Timeline } from "./Timeline.js"; -import { dateFormatForSettings, datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse; type CT = MerchantBackend.ContractTerms; @@ -416,9 +416,9 @@ function PaidPage({ }) const [value, valueHandler] = useState>(order); - const { url } = useBackendContext(); + const { url: backendURL } = useBackendContext() const refundurl = stringifyRefundUri({ - merchantBaseUrl: url, + merchantBaseUrl: backendURL, orderId: order.contract_terms.order_id }) const refundable = diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx index 57a051ed7..780068a91 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx @@ -13,12 +13,12 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ +import { stringifyRewardUri } from "@gnu-taler/taler-util"; import { format } from "date-fns"; import { Fragment, h, VNode } from "preact"; import { useBackendContext } from "../../../../context/backend.js"; import { MerchantBackend } from "../../../../declaration.js"; import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; -import { stringifyRewardUri } from "@gnu-taler/taler-util"; type Entity = MerchantBackend.Rewards.RewardDetails; @@ -29,9 +29,9 @@ interface Props { } export function RewardInfo({ id: merchantRewardId, amount, entity }: Props): VNode { - const { url: merchantBaseUrl } = useBackendContext(); + const { url: backendURL } = useBackendContext() const [settings] = useSettings(); - const rewardURL = stringifyRewardUri({ merchantBaseUrl, merchantRewardId }) + const rewardURL = stringifyRewardUri({ merchantBaseUrl: backendURL, merchantRewardId }) return ( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx index 8629d8dee..78ea07477 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx @@ -35,16 +35,12 @@ import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputNumber } from "../../../../components/form/InputNumber.js"; +import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { useBackendContext } from "../../../../context/backend.js"; -import { useInstanceContext } from "../../../../context/instance.js"; import { MerchantBackend } from "../../../../declaration.js"; -import { - isBase32RFC3548Charset -} from "../../../../utils/crypto.js"; -import { undefinedIfEmpty } from "../../../../utils/table.js"; -import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js"; import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; +import { undefinedIfEmpty } from "../../../../utils/table.js"; type Entity = MerchantBackend.Template.TemplateAddDetails; @@ -55,7 +51,7 @@ interface Props { export function CreatePage({ onCreate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); - const backend = useBackendContext(); + const { url: backendURL } = useBackendContext() const devices = useInstanceOtpDevices() const [state, setState] = useState>({ @@ -128,7 +124,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { > name="template_id" - help={`${backend.url}/templates/${state.template_id ?? ""}`} + help={`${backendURL}/templates/${state.template_id ?? ""}`} label={i18n.str`Identifier`} tooltip={i18n.str`Name of the template in URLs.`} /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx index c65cf6a19..5140aae3a 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx @@ -19,8 +19,9 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; +import { stringifyPayTemplateUri } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import { QR } from "../../../../components/exception/QR.js"; import { @@ -29,14 +30,10 @@ import { } from "../../../../components/form/FormProvider.js"; import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; -import { ConfirmModal } from "../../../../components/modal/index.js"; import { useBackendContext } from "../../../../context/backend.js"; import { useConfigContext } from "../../../../context/config.js"; import { useInstanceContext } from "../../../../context/instance.js"; import { MerchantBackend } from "../../../../declaration.js"; -import { stringifyPayTemplateUri } from "@gnu-taler/taler-util"; -import { useOtpDeviceDetails } from "../../../../hooks/otp.js"; -import { Loading } from "../../../../components/exception/loading.js"; type Entity = MerchantBackend.Template.UsingTemplateDetails; @@ -48,7 +45,7 @@ interface Props { export function QrPage({ contract, id: templateId, onBack }: Props): VNode { const { i18n } = useTranslationContext(); - const { url: backendUrl } = useBackendContext(); + const { url: backendURL } = useBackendContext() const { id: instanceId } = useInstanceContext(); const config = useConfigContext(); @@ -75,7 +72,7 @@ export function QrPage({ contract, id: templateId, onBack }: Props): VNode { templateParams.summary = state.summary ?? "" } - const merchantBaseUrl = new URL(backendUrl).href; + const merchantBaseUrl = new URL(backendURL).href; const payTemplateUri = stringifyPayTemplateUri({ merchantBaseUrl, @@ -84,7 +81,7 @@ export function QrPage({ contract, id: templateId, onBack }: Props): VNode { }) const issuer = encodeURIComponent( - `${new URL(backendUrl).host}/${instanceId}`, + `${new URL(backendURL).host}/${instanceId}`, ); return ( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx index 30d47385c..82b74e1fa 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx @@ -24,7 +24,7 @@ import { MerchantTemplateContractDetails, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; import { @@ -35,17 +35,10 @@ import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputNumber } from "../../../../components/form/InputNumber.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { useBackendContext } from "../../../../context/backend.js"; import { MerchantBackend, WithId } from "../../../../declaration.js"; -import { - isBase32RFC3548Charset, - randomBase32Key, -} from "../../../../utils/crypto.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; -import { QR } from "../../../../components/exception/QR.js"; -import { useInstanceContext } from "../../../../context/instance.js"; type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId; @@ -55,12 +48,9 @@ interface Props { template: Entity; } -const algorithms = [0, 1, 2]; -const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"]; - export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); - const backend = useBackendContext(); + const { url: backendURL } = useBackendContext() const [state, setState] = useState>(template); @@ -115,7 +105,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { - {backend.url}/templates/{template.id} + {backendURL}/templates/{template.id} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx index 984880752..4b0db200a 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx @@ -26,12 +26,13 @@ import { AsyncButton } from "../../../components/exception/AsyncButton.js"; import { FormProvider } from "../../../components/form/FormProvider.js"; import { Input } from "../../../components/form/Input.js"; import { useInstanceContext } from "../../../context/instance.js"; +import { AccessToken } from "../../../declaration.js"; interface Props { instanceId: string; currentToken: string | undefined; onClearToken: () => void; - onNewToken: (s: string) => void; + onNewToken: (s: AccessToken) => void; onBack?: () => void; } @@ -71,7 +72,8 @@ export function DetailPage({ instanceId, currentToken: oldToken, onBack, onNewTo async function submitForm() { if (hasErrors) return; - onNewToken(form.new_token as any) + const nt = `secret-token:${form.new_token}` as AccessToken; + onNewToken(nt) } return ( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx index d5910361b..0a49448f8 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx @@ -17,7 +17,7 @@ import { HttpStatusCode } from "@gnu-taler/taler-util"; import { ErrorType, HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { Loading } from "../../../components/exception/loading.js"; -import { MerchantBackend } from "../../../declaration.js"; +import { AccessToken, MerchantBackend } from "../../../declaration.js"; import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js"; import { DetailPage } from "./DetailPage.js"; import { useInstanceContext } from "../../../context/instance.js"; @@ -49,13 +49,13 @@ export default function Token({ const { token: instanceToken, id, admin } = useInstanceContext(); const currentToken = !admin ? rootToken : instanceToken - const hasPrefix = currentToken !== undefined && currentToken.startsWith(PREFIX) + const hasPrefix = currentToken !== undefined && currentToken.token.startsWith(PREFIX) return ( => { try { await clearToken(); @@ -72,7 +72,7 @@ export default function Token({ }} onNewToken={async (newToken): Promise => { try { - await setNewToken(`secret-token:${newToken}`); + await setNewToken(newToken); onChange(); } catch (error) { if (error instanceof Error) { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx index 4a8162611..6c5e7a514 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx @@ -13,18 +13,19 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ +import { HttpStatusCode } from "@gnu-taler/taler-util"; import { ErrorType, HttpError, HttpResponse, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Loading } from "../../../components/exception/loading.js"; import { NotificationCard } from "../../../components/menu/index.js"; import { useInstanceContext } from "../../../context/instance.js"; -import { MerchantBackend } from "../../../declaration.js"; +import { AccessToken, MerchantBackend } from "../../../declaration.js"; import { useInstanceAPI, useInstanceDetails, @@ -33,7 +34,6 @@ import { } from "../../../hooks/instance.js"; import { Notification } from "../../../utils/types.js"; import { UpdatePage } from "./UpdatePage.js"; -import { HttpStatusCode } from "@gnu-taler/taler-util"; export interface Props { onBack: () => void; @@ -73,10 +73,9 @@ function CommonUpdate( MerchantBackend.ErrorDetail >, updateInstance: any, - clearToken: any, - setNewToken: any, + clearToken: () => Promise, + setNewToken: (t: AccessToken) => Promise, ): VNode { - const { changeToken } = useInstanceContext(); const [notif, setNotif] = useState(undefined); const { i18n } = useTranslationContext(); @@ -119,11 +118,8 @@ function CommonUpdate( d: MerchantBackend.Instances.InstanceAuthConfigurationMessage, ): Promise => { const apiCall = - d.method === "external" ? clearToken() : setNewToken(d.token!); - return apiCall - .then(() => changeToken(d.token)) - .then(onConfirm) - .catch(onUpdateError); + d.method === "external" ? clearToken() : setNewToken(d.token! as AccessToken); + return apiCall.then(onConfirm).catch(onUpdateError); }} /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx index 3ad3cb3a3..22ae55677 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx @@ -18,9 +18,9 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { QR } from "../../../../components/exception/QR.js"; import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; -import { useBackendContext } from "../../../../context/backend.js"; import { useInstanceContext } from "../../../../context/instance.js"; import { MerchantBackend } from "../../../../declaration.js"; +import { useBackendContext } from "../../../../context/backend.js"; type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; @@ -38,9 +38,9 @@ export function CreatedSuccessfully({ onConfirm, }: Props): VNode { const { i18n } = useTranslationContext(); - const backend = useBackendContext(); + const { url: backendURL } = useBackendContext() const { id: instanceId } = useInstanceContext(); - const issuer = new URL(backend.url).hostname; + const issuer = new URL(backendURL).hostname; const qrText = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`; const qrTextSafe = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`; diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx index caa63c714..9948307e4 100644 --- a/packages/merchant-backoffice-ui/src/paths/login/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -18,12 +18,301 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { h, VNode } from "preact"; -import { LoginModal } from "../../components/exception/login.js"; + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { ComponentChildren, h, VNode } from "preact"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import { useBackendContext } from "../../context/backend.js"; +import { useInstanceContext } from "../../context/instance.js"; +import { AccessToken, LoginToken } from "../../declaration.js"; +import { useCredentialsChecker } from "../../hooks/backend.js"; +import { useBackendURL } from "../../hooks/index.js"; interface Props { - onConfirm: (url: string, token?: string) => void; + onConfirm: (token: LoginToken | undefined) => void; } -export default function LoginPage({ onConfirm }: Props): VNode { - return ; + +function getTokenValuePart(t: string): string { + if (!t) return t; + const match = /secret-token:(.*)/.exec(t); + if (!match || !match[1]) return ""; + return match[1]; } + +function normalizeToken(r: string): AccessToken { + return `secret-token:${r}` as AccessToken; +} + +function cleanUp(s: string): string { + let result = s; + if (result.indexOf("webui/") !== -1) { + result = result.substring(0, result.indexOf("webui/")); + } + return result; +} + +export function LoginPage({ onConfirm }: Props): VNode { + const { url: backendURL, changeBackend } = useBackendContext(); + const { admin, id } = useInstanceContext(); + const { requestNewLoginToken } = useCredentialsChecker(); + const [token, setToken] = useState(""); + + const { i18n } = useTranslationContext(); + + + const doLogin = useCallback(async function doLoginImpl() { + const secretToken = normalizeToken(token); + const baseUrl = id === undefined ? backendURL : `${backendURL}/instances/${id}` + const result = await requestNewLoginToken(baseUrl, secretToken); + if (result.valid) { + const { token, expiration } = result + onConfirm({ token, expiration }); + } else { + onConfirm(undefined); + } + }, [backendURL, id, token]) + + async function changeServer() { + changeBackend("") + } + + console.log(admin, id) + if (admin && id !== "default") { + //admin trying to access another instance + return ( + + + + {i18n.str`Login required`} + + + + Need the access token for the instance. + + + + + Access Token + + + + + + + e.keyCode === 13 + ? doLogin() + : null + } + value={token} + onInput={(e): void => setToken(e?.currentTarget.value)} + /> + + + + + + + + + ) + } + + return ( + + + + + {i18n.str`Login required`} + + + Please enter your access token. + + + URL + + + + + + + + + + + + + Access Token + + + + + + + e.keyCode === 13 + ? doLogin() + : null + } + value={token} + onInput={(e): void => setToken(e?.currentTarget.value)} + /> + + + + + + + + + + ); +} + +function AsyncButton({ onClick, disabled, type = "", children }: { type?: string, disabled?: boolean, onClick: () => Promise, children: ComponentChildren }): VNode { + const [running, setRunning] = useState(false) + return { + setRunning(true) + onClick().then(() => { + setRunning(false) + }).catch(() => { + setRunning(false) + }) + }}> + {children} + +} + + +export function ConnectionPage({ onConfirm }: { onConfirm: (s: string) => void }): VNode { + const { url: backendURL } = useBackendContext() + + const [url, setURL] = useState(cleanUp(backendURL)); + const { i18n } = useTranslationContext(); + + async function doConnect() { + onConfirm(url) + } + + return ( + + + + + {i18n.str`Connect to backend`} + + + Location of the backend server + + + URL + + + + + + e.keyCode === 13 + ? doConnect() + : null + } + onInput={(e): void => setURL(e?.currentTarget.value)} + /> + + + + + + + + + + ); +} \ No newline at end of file diff --git a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx index 0d514f2df..87bd2fa39 100644 --- a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx @@ -13,7 +13,7 @@ function getBrowserLang(): string | undefined { return undefined; } -export function Settings(): VNode { +export function Settings({ onClose }: { onClose?: () => void }): VNode { const { i18n } = useTranslationContext() const borwserLang = getBrowserLang() const { update } = useLang() @@ -94,11 +94,19 @@ export function Settings(): VNode { /> - - - - + + {onClose && + + + Close + + + } + } \ No newline at end of file diff --git a/packages/web-util/src/utils/request.ts b/packages/web-util/src/utils/request.ts index 1464eca98..8ce21b0e1 100644 --- a/packages/web-util/src/utils/request.ts +++ b/packages/web-util/src/utils/request.ts @@ -25,6 +25,8 @@ export enum ErrorType { UNEXPECTED, } + + /** * * @param baseUrl URL where the service is located @@ -60,10 +62,27 @@ export async function defaultRequestHandler( const requestPreventCache = options.preventCache ?? false; const requestPreventCors = options.preventCors ?? false; - const _url = new URL(`${baseUrl}${endpoint}`); + const validURL = validateURL(baseUrl, endpoint); + + if (!validURL) { + const error: HttpResponseUnexpectedError = { + info: { + url: `${baseUrl}${endpoint}`, + payload: {}, + hasToken: !!options.token, + status: 0, + options, + }, + type: ErrorType.UNEXPECTED, + exception: undefined, + loading: false, + message: `invalid URL: "${validURL}"`, + }; + throw new RequestError(error) + } Object.entries(requestParams).forEach(([key, value]) => { - _url.searchParams.set(key, String(value)); + validURL.searchParams.set(key, String(value)); }); let payload: BodyInit | undefined = undefined; @@ -77,7 +96,20 @@ export async function defaultRequestHandler( } else if (typeof requestBody === "object") { payload = JSON.stringify(requestBody); } else { - throw Error("unsupported request body type"); + const error: HttpResponseUnexpectedError = { + info: { + url: validURL.href, + payload: {}, + hasToken: !!options.token, + status: 0, + options, + }, + type: ErrorType.UNEXPECTED, + exception: undefined, + loading: false, + message: `unsupported request body type: "${typeof requestBody}"`, + }; + throw new RequestError(error) } } @@ -88,7 +120,7 @@ export async function defaultRequestHandler( let response; try { - response = await fetch(_url.href, { + response = await fetch(validURL.href, { headers: requestHeaders, method: requestMethod, credentials: "omit", @@ -100,15 +132,29 @@ export async function defaultRequestHandler( } catch (ex) { const info: RequestInfo = { payload, - url: _url.href, + url: validURL.href, hasToken: !!options.token, status: 0, options, }; - const error: HttpRequestTimeoutError = { + + if (ex instanceof Error) { + if (ex.message === "HTTP_REQUEST_TIMEOUT") { + const error: HttpRequestTimeoutError = { + info, + type: ErrorType.TIMEOUT, + message: "request timeout", + }; + throw new RequestError(error); + } + } + + const error: HttpResponseUnexpectedError = { info, - type: ErrorType.TIMEOUT, - message: "Request timeout", + type: ErrorType.UNEXPECTED, + exception: ex, + loading: false, + message: (ex instanceof Error ? ex.message : ""), }; throw new RequestError(error); } @@ -124,7 +170,7 @@ export async function defaultRequestHandler( if (response.ok) { const result = await buildRequestOk( response, - _url.href, + validURL.href, payload, !!options.token, options, @@ -133,7 +179,7 @@ export async function defaultRequestHandler( } else { const dataTxt = await response.text(); const error = buildRequestFailed( - _url.href, + validURL.href, dataTxt, response.status, payload, @@ -377,3 +423,12 @@ export function buildRequestFailed( return error; } } + +function validateURL(baseUrl: string, endpoint: string): URL | undefined { + try { + return new URL(`${baseUrl}${endpoint}`) + } catch (ex) { + return undefined + } + +} \ No newline at end of file
{i18n.str`Login required`}
- Need the access token for the instance. -
- - e.keyCode === 13 - ? onConfirm(url, normalizeToken(token)) - : null - } - value={token} - onInput={(e): void => setToken(e?.currentTarget.value)} - /> -
- - e.keyCode === 13 - ? onConfirm(url, normalizeToken(token)) - : null - } - onInput={(e): void => setURL(e?.currentTarget.value)} - /> -
+ Need the access token for the instance. +
+ + e.keyCode === 13 + ? doLogin() + : null + } + value={token} + onInput={(e): void => setToken(e?.currentTarget.value)} + /> +
+ +
{i18n.str`Connect to backend`}
+ + e.keyCode === 13 + ? doConnect() + : null + } + onInput={(e): void => setURL(e?.currentTarget.value)} + /> +