diff options
Diffstat (limited to 'packages/merchant-backoffice-ui/src/hooks')
6 files changed, 173 insertions, 106 deletions
diff --git a/packages/merchant-backoffice-ui/src/hooks/backend.ts b/packages/merchant-backoffice-ui/src/hooks/backend.ts index ecd34df6d..fe4155788 100644 --- a/packages/merchant-backoffice-ui/src/hooks/backend.ts +++ b/packages/merchant-backoffice-ui/src/hooks/backend.ts @@ -19,19 +19,21 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { useSWRConfig } from "swr"; -import { MerchantBackend } from "../declaration.js"; -import { useBackendContext } from "../context/backend.js"; -import { useCallback, useEffect, useState } from "preact/hooks"; -import { useInstanceContext } from "../context/instance.js"; +import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util"; import { ErrorType, + HttpError, HttpResponse, HttpResponseOk, RequestError, RequestOptions, + useApiContext, } from "@gnu-taler/web-util/browser"; -import { useApiContext } from "@gnu-taler/web-util/browser"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import { useSWRConfig } from "swr"; +import { useBackendContext } from "../context/backend.js"; +import { useInstanceContext } from "../context/instance.js"; +import { AccessToken, LoginToken, MerchantBackend, Timestamp } from "../declaration.js"; export function useMatchMutate(): ( @@ -85,6 +87,9 @@ export function useBackendInstancesTestForAdmin(): HttpResponse< return result; } +const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000; +const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000; + export function useBackendConfig(): HttpResponse< MerchantBackend.VersionResponse, RequestError<MerchantBackend.ErrorDetail> @@ -92,18 +97,33 @@ export function useBackendConfig(): HttpResponse< const { request } = useBackendBaseRequest(); type Type = MerchantBackend.VersionResponse; - - const [result, setResult] = useState< - HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>> - >({ loading: true }); + type State = { data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>, timer: number } + const [result, setResult] = useState<State>({ data: { loading: true }, timer: 0 }); useEffect(() => { - request<Type>(`/config`) - .then((data) => setResult(data)) - .catch((error) => setResult(error)); + if (result.timer) { + clearTimeout(result.timer) + } + function tryConfig(): void { + request<Type>(`/config`) + .then((data) => { + const timer: any = setTimeout(() => { + tryConfig() + }, CHECK_CONFIG_INTERVAL_OK) + setResult({ data, timer }) + }) + .catch((error) => { + const timer: any = setTimeout(() => { + tryConfig() + }, CHECK_CONFIG_INTERVAL_FAIL) + const data = error.cause + setResult({ data, timer }) + }); + } + tryConfig() }, [request]); - return result; + return result.data; } interface useBackendInstanceRequestType { @@ -149,32 +169,86 @@ interface useBackendBaseRequestType { } type YesOrNo = "yes" | "no"; +type LoginResult = { + valid: true; + token: string; + expiration: Timestamp; +} | { + valid: false; + cause: HttpError<{}>; +} export function useCredentialsChecker() { const { request } = useApiContext(); //check against instance details endpoint //while merchant backend doesn't have a login endpoint - async function testLogin( - instance: string, - token: string, - ): Promise<{ - valid: boolean; - cause?: ErrorType; - }> { + async function requestNewLoginToken( + baseUrl: string, + token: AccessToken, + ): Promise<LoginResult> { + const data: MerchantBackend.Instances.LoginTokenRequest = { + scope: "write", + duration: { + d_us: "forever" + }, + refreshable: true, + } try { - const response = await request(instance, `/private/`, { + const response = await request<MerchantBackend.Instances.LoginTokenSuccessResponse>(baseUrl, `/private/token`, { + method: "POST", token, + data }); - return { valid: true }; + return { valid: true, token: response.data.token, expiration: response.data.expiration }; } catch (error) { if (error instanceof RequestError) { - return { valid: false, cause: error.cause.type }; + return { valid: false, cause: error.cause }; } - return { valid: false, cause: ErrorType.UNEXPECTED }; + return { + valid: false, cause: { + type: ErrorType.UNEXPECTED, + loading: false, + info: { + hasToken: true, + status: 0, + options: {}, + url: `/private/token`, + payload: {} + }, + exception: error, + message: (error instanceof Error ? error.message : "unpexepected error") + } + }; } }; - return testLogin + + async function refreshLoginToken( + baseUrl: string, + token: LoginToken + ): Promise<LoginResult> { + + if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) { + return { + valid: false, cause: { + type: ErrorType.CLIENT, + status: HttpStatusCode.Unauthorized, + message: "login token expired, login again.", + info: { + hasToken: true, + status: 401, + options: {}, + url: `/private/token`, + payload: {} + }, + payload: {} + }, + } + } + + return requestNewLoginToken(baseUrl, token.token as AccessToken) + } + return { requestNewLoginToken, refreshLoginToken } } /** @@ -183,15 +257,20 @@ export function useCredentialsChecker() { * @returns request handler to */ export function useBackendBaseRequest(): useBackendBaseRequestType { - const { url: backend, token } = useBackendContext(); + const { url: backend, token: loginToken } = useBackendContext(); const { request: requestHandler } = useApiContext(); + const token = loginToken?.token; const request = useCallback( function requestImpl<T>( endpoint: string, options: RequestOptions = {}, ): Promise<HttpResponseOk<T>> { - return requestHandler<T>(backend, endpoint, { token, ...options }); + return requestHandler<T>(backend, endpoint, { token, ...options }).then(res => { + return res + }).catch(err => { + throw err + }); }, [backend, token], ); @@ -204,10 +283,12 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType { const { token: instanceToken, id, admin } = useInstanceContext(); const { request: requestHandler } = useApiContext(); - const { baseUrl, token } = !admin + const { baseUrl, token: loginToken } = !admin ? { baseUrl: rootBackendUrl, token: rootToken } : { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken }; + const token = loginToken?.token; + const request = useCallback( function requestImpl<T>( endpoint: string, diff --git a/packages/merchant-backoffice-ui/src/hooks/index.ts b/packages/merchant-backoffice-ui/src/hooks/index.ts index 79b22304a..ee696779f 100644 --- a/packages/merchant-backoffice-ui/src/hooks/index.ts +++ b/packages/merchant-backoffice-ui/src/hooks/index.ts @@ -19,9 +19,11 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { StateUpdater, useCallback, useEffect, useState } from "preact/hooks"; +import { buildCodecForObject, codecForMap, codecForString, codecForTimestamp } from "@gnu-taler/taler-util"; +import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; +import { StateUpdater, useEffect, useState } from "preact/hooks"; +import { LoginToken } from "../declaration.js"; import { ValueOrFunction } from "../utils/types.js"; -import { useMemoryStorage } from "@gnu-taler/web-util/browser"; import { useMatchMutate } from "./backend.js"; const calculateRootPath = () => { @@ -32,53 +34,55 @@ const calculateRootPath = () => { return rootPath; }; +const loginTokenCodec = buildCodecForObject<LoginToken>() + .property("token", codecForString()) + .property("expiration", codecForTimestamp) + .build("loginToken") +const TOKENS_KEY = buildStorageKey("backend-token", codecForMap(loginTokenCodec)); + + export function useBackendURL( url?: string, -): [string, boolean, StateUpdater<string>, () => void] { - const [value, setter] = useNotNullLocalStorage( +): [string, StateUpdater<string>] { + const [value, setter] = useSimpleLocalStorage( "backend-url", url || calculateRootPath(), ); - const [triedToLog, setTriedToLog] = useLocalStorage("tried-login"); const checkedSetter = (v: ValueOrFunction<string>) => { - setTriedToLog("yes"); - return setter((p) => (v instanceof Function ? v(p) : v).replace(/\/$/, "")); + return setter((p) => (v instanceof Function ? v(p ?? "") : v).replace(/\/$/, "")); }; - const resetBackend = () => { - setTriedToLog(undefined); - }; - return [value, !!triedToLog, checkedSetter, resetBackend]; + return [value!, checkedSetter]; } export function useBackendDefaultToken( - initialValue?: string, -): [string | undefined, ((d: string | undefined) => void)] { - // uncomment for testing - initialValue = "secret-token:secret" as string | undefined - const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token`, initialValue) +): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] { + const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {}) + + const tokenOfDefaultInstance = tokenMap["default"] const clearCache = useMatchMutate() useEffect(() => { clearCache() - }, [token]) + }, [tokenOfDefaultInstance]) function updateToken( - value: (string | undefined) + value: (LoginToken | undefined) ): void { if (value === undefined) { reset() } else { - setToken(value) + const res = { ...tokenMap, "default": value } + setToken(res) } } - return [token, updateToken]; + return [tokenMap["default"], updateToken]; } export function useBackendInstanceToken( id: string, -): [string | undefined, ((d: string | undefined) => void)] { - const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token-${id}`) +): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] { + const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {}) const [defaultToken, defaultSetToken] = useBackendDefaultToken(); // instance named 'default' use the default token @@ -86,16 +90,17 @@ export function useBackendInstanceToken( return [defaultToken, defaultSetToken]; } function updateToken( - value: (string | undefined) + value: (LoginToken | undefined) ): void { if (value === undefined) { reset() } else { - setToken(value) + const res = { ...tokenMap, [id]: value } + setToken(res) } } - return [token, updateToken]; + return [tokenMap[id], updateToken]; } export function useLang(initial?: string): [string, StateUpdater<string>] { @@ -104,10 +109,10 @@ export function useLang(initial?: string): [string, StateUpdater<string>] { ? navigator.language || (navigator as any).userLanguage : undefined; const defaultLang = (browserLang || initial || "en").substring(0, 2); - return useNotNullLocalStorage("lang-preference", defaultLang); + return useSimpleLocalStorage("lang-preference", defaultLang) as [string, StateUpdater<string>]; } -export function useLocalStorage( +export function useSimpleLocalStorage( key: string, initialValue?: string, ): [string | undefined, StateUpdater<string | undefined>] { @@ -137,28 +142,3 @@ export function useLocalStorage( return [storedValue, setValue]; } - -export function useNotNullLocalStorage( - key: string, - initialValue: string, -): [string, StateUpdater<string>] { - const [storedValue, setStoredValue] = useState<string>((): string => { - return typeof window !== "undefined" - ? window.localStorage.getItem(key) || initialValue - : initialValue; - }); - - const setValue = (value: string | ((val: string) => string)) => { - const valueToStore = value instanceof Function ? value(storedValue) : value; - setStoredValue(valueToStore); - if (typeof window !== "undefined") { - if (!valueToStore) { - window.localStorage.removeItem(key); - } else { - window.localStorage.setItem(key, valueToStore); - } - } - }; - - return [storedValue, setValue]; -} diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.test.ts b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts index d15b3f6d7..a7b8d047c 100644 --- a/packages/merchant-backoffice-ui/src/hooks/instance.test.ts +++ b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts @@ -21,7 +21,7 @@ import * as tests from "@gnu-taler/web-util/testing"; import { expect } from "chai"; -import { MerchantBackend } from "../declaration.js"; +import { AccessToken, MerchantBackend } from "../declaration.js"; import { useAdminAPI, useBackendInstances, @@ -158,7 +158,7 @@ describe("instance api interaction with details", () => { }, } as MerchantBackend.Instances.QueryInstancesResponse, }); - api.setNewToken("secret"); + api.setNewToken("secret" as AccessToken); }, ({ query, api }) => { expect(env.assertJustExpectedRequestWereMade()).deep.eq({ diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts index 32ed30c6f..50f9487a3 100644 --- a/packages/merchant-backoffice-ui/src/hooks/instance.ts +++ b/packages/merchant-backoffice-ui/src/hooks/instance.ts @@ -19,10 +19,11 @@ import { RequestError, } from "@gnu-taler/web-util/browser"; import { useBackendContext } from "../context/backend.js"; -import { MerchantBackend } from "../declaration.js"; +import { AccessToken, MerchantBackend } from "../declaration.js"; import { useBackendBaseRequest, useBackendInstanceRequest, + useCredentialsChecker, useMatchMutate, } from "./backend.js"; @@ -36,7 +37,7 @@ interface InstanceAPI { ) => Promise<void>; deleteInstance: () => Promise<void>; clearToken: () => Promise<void>; - setNewToken: (token: string) => Promise<void>; + setNewToken: (token: AccessToken) => Promise<void>; } export function useAdminAPI(): AdminAPI { @@ -86,8 +87,10 @@ export interface AdminAPI { export function useManagementAPI(instanceId: string): InstanceAPI { const mutateAll = useMatchMutate(); + const { url: backendURL } = useBackendContext() const { updateToken } = useBackendContext(); const { request } = useBackendBaseRequest(); + const { requestNewLoginToken } = useCredentialsChecker() const updateInstance = async ( instance: MerchantBackend.Instances.InstanceReconfigurationMessage, @@ -117,13 +120,20 @@ export function useManagementAPI(instanceId: string): InstanceAPI { mutateAll(/\/management\/instances/); }; - const setNewToken = async (newToken: string): Promise<void> => { + const setNewToken = async (newToken: AccessToken): Promise<void> => { await request(`/management/instances/${instanceId}/auth`, { method: "POST", data: { method: "token", token: newToken }, }); - updateToken(newToken); + const resp = await requestNewLoginToken(backendURL, newToken) + if (resp.valid) { + const { token, expiration } = resp + updateToken({ token, expiration }); + } else { + updateToken(undefined) + } + mutateAll(/\/management\/instances/); }; @@ -132,12 +142,13 @@ export function useManagementAPI(instanceId: string): InstanceAPI { export function useInstanceAPI(): InstanceAPI { const { mutate } = useSWRConfig(); + const { url: backendURL, updateToken } = useBackendContext() + const { - url: baseUrl, token: adminToken, - updateLoginStatus, } = useBackendContext(); const { request } = useBackendInstanceRequest(); + const { requestNewLoginToken } = useCredentialsChecker() const updateInstance = async ( instance: MerchantBackend.Instances.InstanceReconfigurationMessage, @@ -147,7 +158,7 @@ export function useInstanceAPI(): InstanceAPI { data: instance, }); - if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null); + if (adminToken) mutate(["/private/instances", adminToken, backendURL], null); mutate([`/private/`], null); }; @@ -157,7 +168,7 @@ export function useInstanceAPI(): InstanceAPI { // token: adminToken, }); - if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null); + if (adminToken) mutate(["/private/instances", adminToken, backendURL], null); mutate([`/private/`], null); }; @@ -170,13 +181,20 @@ export function useInstanceAPI(): InstanceAPI { mutate([`/private/`], null); }; - const setNewToken = async (newToken: string): Promise<void> => { + const setNewToken = async (newToken: AccessToken): Promise<void> => { await request(`/private/auth`, { method: "POST", data: { method: "token", token: newToken }, }); - updateLoginStatus(baseUrl, newToken); + const resp = await requestNewLoginToken(backendURL, newToken) + if (resp.valid) { + const { token, expiration } = resp + updateToken({ token, expiration }); + } else { + updateToken(undefined) + } + mutate([`/private/`], null); }; diff --git a/packages/merchant-backoffice-ui/src/hooks/testing.tsx b/packages/merchant-backoffice-ui/src/hooks/testing.tsx index ebbc6f64a..847d512b0 100644 --- a/packages/merchant-backoffice-ui/src/hooks/testing.tsx +++ b/packages/merchant-backoffice-ui/src/hooks/testing.tsx @@ -90,10 +90,7 @@ export class ApiMockEnvironment extends MockEnvironment { const SC: any = SWRConfig; return ( - <BackendContextProvider - defaultUrl="http://backend" - initialToken={undefined} - > + <BackendContextProvider defaultUrl="http://backend"> <InstanceContextProvider value={{ token: undefined, diff --git a/packages/merchant-backoffice-ui/src/hooks/useSettings.ts b/packages/merchant-backoffice-ui/src/hooks/useSettings.ts index 7dee9f896..8c1ebd9f6 100644 --- a/packages/merchant-backoffice-ui/src/hooks/useSettings.ts +++ b/packages/merchant-backoffice-ui/src/hooks/useSettings.ts @@ -24,15 +24,6 @@ import { codecForString, } from "@gnu-taler/taler-util"; -function parse_json_or_undefined<T>(str: string | undefined): T | undefined { - if (str === undefined) return undefined; - try { - return JSON.parse(str); - } catch { - return undefined; - } -} - export interface Settings { advanceOrderMode: boolean; dateFormat: "ymd" | "dmy" | "mdy"; |