/* 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 */ import { AccessToken, Codec, TalerMerchantApi, buildCodecForObject, codecForString, codecForURL, codecOptional, } from "@gnu-taler/taler-util"; import { buildStorageKey, useLocalStorage, useMerchantApiContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, VNode, createContext, h } from "preact"; import { useContext, useEffect, useState } from "preact/hooks"; import { mutate } from "swr"; import { MerchantLib } from "../../../web-util/lib/context/activity.js"; /** * Has the information to reach and * authenticate at the bank's backend. */ export type SessionState = LoggedIn | LoggedOut; interface LoggedIn { status: "loggedIn"; // is this instance admin? usually "default" name isAdmin: boolean; // url where all the request will be made // usually this is from where the SPA was loaded // unless it's using impersonate feature backendUrl: URL; // instance name instance: string; // session is not the same from where it was loaded impersonated: boolean; //instance access token token: AccessToken | undefined; } interface LoggedOut { status: "loggedOut"; backendUrl: URL; instance: string; isAdmin: boolean; token: AccessToken | undefined; } interface SavedSession { backendUrl: URL; token: AccessToken | undefined; prevToken: AccessToken | undefined; } export const codecForSessionState = (): Codec => buildCodecForObject() .property("backendUrl", codecForURL()) .property("token", codecOptional(codecForString() as Codec)) .property( "prevToken", codecOptional(codecForString() as Codec), ) .build("SavedSession"); 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): SavedSession => { return { backendUrl: url, token: undefined, prevToken: undefined, }; }; export interface SessionStateHandler { lib: MerchantLib; config: TalerMerchantApi.VersionResponse; state: SessionState; /** * from every state to logout state */ logOut(): void; /** * from impersonate to loggedIn */ deImpersonate(): void; /** * from any to loggedIn * @param info */ logIn(token: AccessToken | undefined): void; /** * from loggedIn to impersonate * @param info */ impersonate(baseUrl: URL): void; } const SESSION_STATE_KEY = buildStorageKey( "merchant-session", codecForSessionState(), ); export const DEFAULT_ADMIN_USERNAME = "default"; export const INSTANCE_ID_LOOKUP = /\/instances\/([^/]*)\/?$/; export function cleanAllCache(): void { mutate(() => true, undefined, { revalidate: false }); } const Context = createContext(undefined!); export const useSessionContext = (): SessionStateHandler => useContext(Context); export const SessionContextProvider = ({ children, // value, }: { // value: MerchantUiSettings; children: ComponentChildren; }): VNode => { const { lib: rootLib, config: rootConfig, url: merchantUrl, } = useMerchantApiContext(); const [status, setStatus] = useState<"loggedIn" | "loggedOut">("loggedIn"); const [currentConfig, setCurrentConfig] = useState(); const { value: state, update } = useLocalStorage( SESSION_STATE_KEY, defaultState(merchantUrl), ); const currentInstance = inferInstanceName(state.backendUrl); let lib: MerchantLib; let config: TalerMerchantApi.VersionResponse; const doingImpersonation = state.backendUrl.href !== merchantUrl.href; if (doingImpersonation) { /** * FIXME: can't impersonate other than local instances */ lib = rootLib.subInstanceApi(inferInstanceName(state.backendUrl)); config = currentConfig ?? rootConfig; } else { lib = rootLib; config = rootConfig; } useEffect(() => { // FIXME: handle what happen if the subinstance /config // fails if (!doingImpersonation) return; lib.instance.getConfig().then((resp) => { if (resp.type === "ok") { setCurrentConfig(resp.body); } }); }, [state.backendUrl.href]); const value: SessionStateHandler = { state: { backendUrl: state.backendUrl, token: state.token, impersonated: doingImpersonation, instance: currentInstance, isAdmin: currentInstance === DEFAULT_ADMIN_USERNAME, status: status, }, lib, config, logOut() { setStatus("loggedOut"); update({ backendUrl: merchantUrl, token: undefined, prevToken: undefined, }); cleanAllCache(); }, deImpersonate() { cleanAllCache(); update({ backendUrl: merchantUrl, token: state.prevToken, prevToken: undefined, }); setStatus("loggedIn"); }, impersonate(baseUrl) { /** * FIXME: can't impersonate other than local instances */ update({ backendUrl: baseUrl, token: undefined, prevToken: state.token, }); setStatus("loggedIn"); cleanAllCache(); }, logIn(token) { cleanAllCache(); setStatus("loggedIn"); update({ backendUrl: state.backendUrl, token: token, prevToken: state.prevToken, }); }, }; return h(Context.Provider, { value, children, }); };