From 327406cdbc5d17838e660b382e7b18e0d7983ce5 Mon Sep 17 00:00:00 2001 From: Tudor Morar Date: Wed, 17 Jul 2024 11:05:48 +0300 Subject: [PATCH] Add auto logout middleware (#9) Add logout --- src/core/ProviderFactory.ts | 6 +- src/core/methods/account/getIsLoggedIn.ts | 4 +- src/core/methods/account/getWebviewToken.ts | 9 + src/core/methods/login/webWalletLogin.ts | 2 +- src/core/methods/logout/logout.ts | 72 ++++++ src/core/providers/accountProvider.ts | 2 +- src/core/providers/helpers/utils.ts | 2 +- src/lib/sdkWebWalletCrossWindowProvider.ts | 1 + src/storage/index.ts | 4 + src/storage/local.ts | 80 ++++++ src/storage/session.ts | 57 +++++ .../actions/sharedActions/sharedActions.ts | 11 +- src/store/middleware/applyMiddleware.ts | 7 + src/store/middleware/index.ts | 1 + src/store/middleware/logoutMiddleware.ts | 46 ++++ src/store/selectors/accountSelectors.ts | 6 + src/store/store.ts | 3 + src/utils/window/addOriginToLocationPath.ts | 15 -- src/utils/window/getIsAuthRoute.ts | 32 +++ src/utils/window/index.ts | 2 +- src/utils/window/isWindowAvailable copy.ts | 2 + src/utils/window/matchPath.ts | 240 ++++++++++++++++++ .../tests/addOriginToLocationPath.test.ts | 67 ----- src/utils/window/tests/getIsAuthRoute.test.ts | 37 +++ 24 files changed, 607 insertions(+), 101 deletions(-) create mode 100644 src/core/methods/account/getWebviewToken.ts create mode 100644 src/core/methods/logout/logout.ts create mode 100644 src/lib/sdkWebWalletCrossWindowProvider.ts create mode 100644 src/storage/index.ts create mode 100644 src/storage/local.ts create mode 100644 src/storage/session.ts create mode 100644 src/store/middleware/applyMiddleware.ts create mode 100644 src/store/middleware/index.ts create mode 100644 src/store/middleware/logoutMiddleware.ts delete mode 100644 src/utils/window/addOriginToLocationPath.ts create mode 100644 src/utils/window/getIsAuthRoute.ts create mode 100644 src/utils/window/isWindowAvailable copy.ts create mode 100644 src/utils/window/matchPath.ts delete mode 100644 src/utils/window/tests/addOriginToLocationPath.test.ts create mode 100644 src/utils/window/tests/getIsAuthRoute.test.ts diff --git a/src/core/ProviderFactory.ts b/src/core/ProviderFactory.ts index e71dc41..66791ca 100644 --- a/src/core/ProviderFactory.ts +++ b/src/core/ProviderFactory.ts @@ -1,9 +1,5 @@ import { Transaction } from '@multiversx/sdk-core'; -import { - // IframeProvider, - CrossWindowProvider - // ICrossWindowWalletAccount -} from '@multiversx/sdk-web-wallet-cross-window-provider/out/CrossWindowProvider/CrossWindowProvider'; +import { CrossWindowProvider } from 'lib/sdkWebWalletCrossWindowProvider'; export interface IProvider { login: (options?: { token?: string }) => Promise; diff --git a/src/core/methods/account/getIsLoggedIn.ts b/src/core/methods/account/getIsLoggedIn.ts index 110d470..9316522 100644 --- a/src/core/methods/account/getIsLoggedIn.ts +++ b/src/core/methods/account/getIsLoggedIn.ts @@ -1,5 +1,7 @@ +import { isLoggedInSelector } from 'store/selectors/accountSelectors'; import { getAddress } from './getAddress'; +import { getState } from 'store/store'; export function getIsLoggedIn() { - return Boolean(getAddress()); + return isLoggedInSelector(getState()); } diff --git a/src/core/methods/account/getWebviewToken.ts b/src/core/methods/account/getWebviewToken.ts new file mode 100644 index 0000000..fcaa009 --- /dev/null +++ b/src/core/methods/account/getWebviewToken.ts @@ -0,0 +1,9 @@ +import { getWindowLocation } from 'utils/window/getWindowLocation'; + +export const getWebviewToken = () => { + const { search } = getWindowLocation(); + const urlSearchParams = new URLSearchParams(search) as any; + const searchParams = Object.fromEntries(urlSearchParams); + + return searchParams?.accessToken; +}; diff --git a/src/core/methods/login/webWalletLogin.ts b/src/core/methods/login/webWalletLogin.ts index d556e9f..8875f0e 100644 --- a/src/core/methods/login/webWalletLogin.ts +++ b/src/core/methods/login/webWalletLogin.ts @@ -1,6 +1,5 @@ import { LoginMethodsEnum } from 'types/enums.types'; import { OnProviderLoginType } from 'types/login.types'; -import { CrossWindowProvider } from '@multiversx/sdk-web-wallet-cross-window-provider/out/CrossWindowProvider/CrossWindowProvider'; import { getWindowLocation } from 'utils/window/getWindowLocation'; import { getLoginService } from './helpers/getLoginService'; import { networkSelector } from 'store/selectors'; @@ -14,6 +13,7 @@ import { loginAction } from 'store/actions/sharedActions'; import { setAccount } from 'store/actions/account/accountActions'; import { getLatestNonce } from 'utils/account/getLatestNonce'; import { AccountType } from 'types/account.types'; +import { CrossWindowProvider } from 'lib/sdkWebWalletCrossWindowProvider'; export const webWalletLogin = async ({ token: tokenToSign, diff --git a/src/core/methods/logout/logout.ts b/src/core/methods/logout/logout.ts new file mode 100644 index 0000000..acfd0db --- /dev/null +++ b/src/core/methods/logout/logout.ts @@ -0,0 +1,72 @@ +import { storage } from 'storage'; +import { localStorageKeys } from 'storage/local'; +import { LoginMethodsEnum } from 'types'; +import { getAddress } from '../account/getAddress'; +import { CrossWindowProvider } from 'lib/sdkWebWalletCrossWindowProvider'; +import { logoutAction } from 'store/actions/sharedActions/sharedActions'; +import { getWebviewToken } from '../account/getWebviewToken'; +import { getAccountProvider } from 'core/providers/accountProvider'; +import { getProviderType } from 'core/providers/helpers/utils'; + +const broadcastLogoutAcrossTabs = (address: string) => { + const storedData = storage.local?.getItem(localStorageKeys.logoutEvent); + const { data } = storedData ? JSON.parse(storedData) : { data: address }; + + if (address !== data) { + return; + } + + storage.local.setItem({ + key: localStorageKeys.logoutEvent, + data: address, + expires: 0 + }); + + storage.local.removeItem(localStorageKeys.logoutEvent); +}; + +export type LogoutPropsType = { + shouldAttemptReLogin?: boolean; + shouldBroadcastLogoutAcrossTabs?: boolean; + /* + * Only used for web-wallet crossWindow login + */ + hasConsentPopup?: boolean; +}; + +export async function logout( + shouldAttemptReLogin = Boolean(getWebviewToken()), + options = { + shouldBroadcastLogoutAcrossTabs: true, + hasConsentPopup: false + } +) { + let address = getAddress(); + const provider = getAccountProvider(); + const providerType = getProviderType(provider); + + if (shouldAttemptReLogin && provider?.relogin != null) { + return provider.relogin(); + } + + if (options.shouldBroadcastLogoutAcrossTabs) { + broadcastLogoutAcrossTabs(address); + } + + try { + logoutAction(); + + if ( + options.hasConsentPopup && + providerType === LoginMethodsEnum.crossWindow + ) { + (provider as unknown as CrossWindowProvider).setShouldShowConsentPopup( + true + ); + } + + await provider.logout(); + } catch (err) { + console.error('Logging out error:', err); + } +} diff --git a/src/core/providers/accountProvider.ts b/src/core/providers/accountProvider.ts index f73a536..44f1ba1 100644 --- a/src/core/providers/accountProvider.ts +++ b/src/core/providers/accountProvider.ts @@ -1,6 +1,6 @@ -import { CrossWindowProvider } from '@multiversx/sdk-web-wallet-cross-window-provider/out/CrossWindowProvider/CrossWindowProvider'; import { IDappProvider } from 'types/dappProvider.types'; import { emptyProvider } from './helpers/emptyProvider'; +import { CrossWindowProvider } from 'lib/sdkWebWalletCrossWindowProvider'; export type ProvidersType = IDappProvider | CrossWindowProvider; diff --git a/src/core/providers/helpers/utils.ts b/src/core/providers/helpers/utils.ts index 843c1d1..db82443 100644 --- a/src/core/providers/helpers/utils.ts +++ b/src/core/providers/helpers/utils.ts @@ -2,11 +2,11 @@ import { ExtensionProvider } from '@multiversx/sdk-extension-provider'; import { HWProvider } from '@multiversx/sdk-hw-provider'; import { MetamaskProvider } from '@multiversx/sdk-metamask-provider/out/metamaskProvider'; import { OperaProvider } from '@multiversx/sdk-opera-provider'; -import { CrossWindowProvider } from '@multiversx/sdk-web-wallet-cross-window-provider/out/CrossWindowProvider/CrossWindowProvider'; import { WalletProvider } from '@multiversx/sdk-web-wallet-provider'; import { LoginMethodsEnum } from 'types/enums.types'; import { WalletConnectV2Provider } from 'utils/walletconnect/__sdkWalletconnectProvider'; import { EmptyProvider } from './emptyProvider'; +import { CrossWindowProvider } from 'lib/sdkWebWalletCrossWindowProvider'; export const getProviderType = ( provider?: TProvider | null diff --git a/src/lib/sdkWebWalletCrossWindowProvider.ts b/src/lib/sdkWebWalletCrossWindowProvider.ts new file mode 100644 index 0000000..7bf0706 --- /dev/null +++ b/src/lib/sdkWebWalletCrossWindowProvider.ts @@ -0,0 +1 @@ +export { CrossWindowProvider } from '@multiversx/sdk-web-wallet-cross-window-provider/out/CrossWindowProvider/CrossWindowProvider'; diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 0000000..86b177a --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,4 @@ +import * as local from './local'; +import * as session from './session'; + +export const storage = { session, local }; diff --git a/src/storage/local.ts b/src/storage/local.ts new file mode 100644 index 0000000..ac027e5 --- /dev/null +++ b/src/storage/local.ts @@ -0,0 +1,80 @@ +import { getUnixTimestamp } from 'utils/dateTime'; + +export const localStorageKeys = { + loginExpiresAt: 'sdk-dapp-login-expires-at', + logoutEvent: 'sdk-dapp-logout-event' +} as const; + +type LocalValueType = keyof typeof localStorageKeys; +type LocalKeyType = typeof localStorageKeys[LocalValueType]; + +type ExpiresType = number | false; + +const hasLocalStorage = typeof localStorage !== 'undefined'; + +export const setItem = ({ + key, + data, + expires +}: { + key: LocalKeyType; + data: any; + expires: ExpiresType; +}) => { + if (!hasLocalStorage) { + return; + } + localStorage.setItem( + String(key), + JSON.stringify({ + expires, + data + }) + ); +}; + +export const getItem = (key: LocalKeyType): any => { + if (!hasLocalStorage) { + return; + } + const item = localStorage.getItem(String(key)); + if (!item) { + return null; + } + + const deserializedItem = JSON.parse(item); + if (!deserializedItem) { + return null; + } + + if ( + !deserializedItem.hasOwnProperty('expires') || + !deserializedItem.hasOwnProperty('data') + ) { + return null; + } + + const expired = getUnixTimestamp() >= deserializedItem.expires; + if (expired) { + localStorage.removeItem(String(key)); + return null; + } + + return deserializedItem.data; +}; + +export const removeItem = (key: LocalKeyType) => { + if (!hasLocalStorage) { + return; + } + + localStorage.removeItem(String(key)); +}; + +export const clear = () => { + if (!hasLocalStorage) { + return; + } + + localStorage.clear(); +}; diff --git a/src/storage/session.ts b/src/storage/session.ts new file mode 100644 index 0000000..a4d1c83 --- /dev/null +++ b/src/storage/session.ts @@ -0,0 +1,57 @@ +export type SessionKeyType = 'address' | 'shard' | 'toasts' | 'toastProgress'; +type ExpiresType = number | false; + +export interface SetItemType { + key: SessionKeyType; + data: any; + expires: ExpiresType; +} + +export const setItem = ({ key, data, expires }: SetItemType) => { + sessionStorage.setItem( + String(key), + JSON.stringify({ + expires, + data + }) + ); +}; + +export const getItem = (key: SessionKeyType): any => { + const item = sessionStorage.getItem(String(key)); + if (!item) { + return null; + } + + const deserializedItem = JSON.parse(item); + if (!deserializedItem) { + return null; + } + + if ( + !deserializedItem.hasOwnProperty('expires') || + !deserializedItem.hasOwnProperty('data') + ) { + return null; + } + + const expired = Date.now() >= deserializedItem.expires; + if (expired) { + sessionStorage.removeItem(String(key)); + return null; + } + + return deserializedItem.data; +}; + +export const removeItem = (key: SessionKeyType) => + sessionStorage.removeItem(String(key)); + +export const clear = () => sessionStorage.clear(); + +export const storage = { + setItem, + getItem, + removeItem, + clear +}; diff --git a/src/store/actions/sharedActions/sharedActions.ts b/src/store/actions/sharedActions/sharedActions.ts index 1fdd747..594c4e1 100644 --- a/src/store/actions/sharedActions/sharedActions.ts +++ b/src/store/actions/sharedActions/sharedActions.ts @@ -1,15 +1,9 @@ import { Address } from '@multiversx/sdk-core/out'; -import { initialState as initialAccountState } from 'store/slices/account/accountSlice'; -import { initialState as initialLoginInfoState } from 'store/slices/loginInfo/loginInfoSlice'; import { store } from '../../store'; import { LoginMethodsEnum } from 'types/enums.types'; +import { resetStore } from 'store/middleware/logoutMiddleware'; -export const logoutAction = () => - store.setState((store) => { - store.account = initialAccountState; - store.loginInfo = initialLoginInfoState; - }); - +export const logoutAction = () => store.setState(resetStore); export interface LoginActionPayloadType { address: string; loginMethod: LoginMethodsEnum; @@ -20,5 +14,4 @@ export const loginAction = ({ address, loginMethod }: LoginActionPayloadType) => account.address = address; account.publicKey = new Address(address).hex(); loginInfo.loginMethod = loginMethod; - // setLoginExpiresAt(getNewLoginExpiresTimestamp()); }); diff --git a/src/store/middleware/applyMiddleware.ts b/src/store/middleware/applyMiddleware.ts new file mode 100644 index 0000000..ab0d0dd --- /dev/null +++ b/src/store/middleware/applyMiddleware.ts @@ -0,0 +1,7 @@ +import { StoreType } from '../store.types'; +import { StoreApi } from 'zustand/vanilla'; +import { logoutMiddleware } from './logoutMiddleware'; + +export const applyMiddleware = (store: StoreApi) => { + store.subscribe(logoutMiddleware); +}; diff --git a/src/store/middleware/index.ts b/src/store/middleware/index.ts new file mode 100644 index 0000000..6f49a51 --- /dev/null +++ b/src/store/middleware/index.ts @@ -0,0 +1 @@ +export * from './applyMiddleware'; diff --git a/src/store/middleware/logoutMiddleware.ts b/src/store/middleware/logoutMiddleware.ts new file mode 100644 index 0000000..303703c --- /dev/null +++ b/src/store/middleware/logoutMiddleware.ts @@ -0,0 +1,46 @@ +import { storage } from 'storage'; +import { WritableDraft } from 'immer'; +import { initialState as initialAccountState } from 'store/slices/account/accountSlice'; +import { initialState as initialLoginInfoState } from 'store/slices/loginInfo/loginInfoSlice'; +import { localStorageKeys } from 'storage/local'; +import { isLoggedInSelector } from 'store/selectors'; +import { StoreType } from '../store.types'; + +export const resetStore = (store: WritableDraft) => { + store.account = initialAccountState; + store.loginInfo = initialLoginInfoState; +}; + +export function getNewLoginExpiresTimestamp() { + return new Date().setHours(new Date().getHours() + 24); +} + +export function setLoginExpiresAt(expiresAt: number) { + storage.local.setItem({ + key: localStorageKeys.loginExpiresAt, + data: expiresAt, + expires: expiresAt + }); +} + +export const logoutMiddleware = (newStore: StoreType) => { + const isLoggedIn = isLoggedInSelector(newStore); + const loginTimestamp = storage.local.getItem(localStorageKeys.loginExpiresAt); + + if (!isLoggedIn) { + return; + } + + if (loginTimestamp == null) { + setLoginExpiresAt(getNewLoginExpiresTimestamp()); + return; + } + + const now = Date.now(); + const isExpired = loginTimestamp - now < 0; + + if (isExpired) { + // logout + resetStore(newStore); + } +}; diff --git a/src/store/selectors/accountSelectors.ts b/src/store/selectors/accountSelectors.ts index 75d6fbf..a592554 100644 --- a/src/store/selectors/accountSelectors.ts +++ b/src/store/selectors/accountSelectors.ts @@ -8,3 +8,9 @@ export const addressSelector = ({ account: { address } }: StoreType) => address; export const accountNonceSelector = (store: StoreType) => accountSelector(store)?.nonce || 0; + +export const isLoggedInSelector = (store: StoreType) => { + const address = addressSelector(store); + const account = accountSelector(store); + return Boolean(address && account?.address === address); +}; diff --git a/src/store/store.ts b/src/store/store.ts index 5600c50..8f963f3 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -6,6 +6,7 @@ import { accountSlice } from './slices/account/accountSlice'; import { createBoundedUseStore } from './createBoundedStore'; import { loginInfoSlice } from './slices/loginInfo'; import { StoreType } from './store.types'; +import { applyMiddleware } from './middleware/applyMiddleware'; export type MutatorsIn = [ ['zustand/devtools', never], @@ -35,6 +36,8 @@ export const store = createStore( ) ); +applyMiddleware(store); + export const getState = () => store.getState(); export const useStore = createBoundedUseStore(store); diff --git a/src/utils/window/addOriginToLocationPath.ts b/src/utils/window/addOriginToLocationPath.ts deleted file mode 100644 index ffd408d..0000000 --- a/src/utils/window/addOriginToLocationPath.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getWindowLocation } from './getWindowLocation'; - -export const addOriginToLocationPath = (path = '') => { - const location = getWindowLocation(); - const isHrefUrl = path.startsWith('http') || path.startsWith('www.'); - - const shouldNotChangePath = - !location.origin || path.startsWith(location.origin) || isHrefUrl; - - if (shouldNotChangePath) { - return path; - } - - return `${location.origin}/${path.replace('/', '')}`; -}; diff --git a/src/utils/window/getIsAuthRoute.ts b/src/utils/window/getIsAuthRoute.ts new file mode 100644 index 0000000..e74cc4c --- /dev/null +++ b/src/utils/window/getIsAuthRoute.ts @@ -0,0 +1,32 @@ +import { matchPath } from './matchPath'; + +/** + * Allow detecting authenticated routes with pattern parameters + * @example + * routes = [ + * { + path: "/users/:id", + component: () => <>, + authenticatedRoute: true + } +] + */ +export const getIsAuthRoute = < + T extends { + authenticatedRoute: boolean; + path: string; + } +>( + routes: Array, + pathname: string +) => { + const authenticatedRoutes = routes.filter(({ authenticatedRoute }) => + Boolean(authenticatedRoute) + ); + + const isOnAuthenticatedRoute = authenticatedRoutes.some( + ({ path }) => matchPath(path, pathname) !== null + ); + + return isOnAuthenticatedRoute; +}; diff --git a/src/utils/window/index.ts b/src/utils/window/index.ts index c4aeeff..698dd2e 100644 --- a/src/utils/window/index.ts +++ b/src/utils/window/index.ts @@ -1,6 +1,6 @@ -export * from './addOriginToLocationPath'; export * from './getDefaultCallbackUrl'; export * from './getWindowLocation'; export * from './isWindowAvailable'; export * from './sanitizeCallbackUrl'; export * from './buildUrlParams'; +export * from './getIsAuthRoute'; diff --git a/src/utils/window/isWindowAvailable copy.ts b/src/utils/window/isWindowAvailable copy.ts new file mode 100644 index 0000000..46354e5 --- /dev/null +++ b/src/utils/window/isWindowAvailable copy.ts @@ -0,0 +1,2 @@ +export const isWindowAvailable = () => + typeof window != 'undefined' && typeof window?.location != 'undefined'; diff --git a/src/utils/window/matchPath.ts b/src/utils/window/matchPath.ts new file mode 100644 index 0000000..33641c0 --- /dev/null +++ b/src/utils/window/matchPath.ts @@ -0,0 +1,240 @@ +// credits go to: https://remix.run +// sourcecode: https://raw.githubusercontent.com/remix-run/react-router/6b44e99f0b659428ce2ec8d5098e90c7fddda2c5/packages/react-router/lib/router.ts + +function warning(cond: any, message: string): void { + if (!cond) { + // eslint-disable-next-line no-console + if (typeof console !== 'undefined') console.warn(message); + + try { + // Welcome to debugging React Router! + // + // This error is thrown as a convenience so you can more easily + // find the source for a warning that appears in the console by + // enabling "pause on exceptions" in your JavaScript debugger. + throw new Error(message); + // eslint-disable-next-line no-empty + } catch (e) {} + } +} + +type ParamParseFailed = { failed: true }; + +type ParamParseSegment = + // Check here if there exists a forward slash in the string. + // eslint-disable-next-line prettier/prettier + Segment extends `${infer LeftSegment}/${infer RightSegment}` + ? // If there is a forward slash, then attempt to parse each side of the + // forward slash. + ParamParseSegment extends infer LeftResult + ? ParamParseSegment extends infer RightResult + ? LeftResult extends string + ? // If the left side is successfully parsed as a param, then check if + // the right side can be successfully parsed as well. If both sides + // can be parsed, then the result is a union of the two sides + // (read: "foo" | "bar"). + RightResult extends string + ? LeftResult | RightResult + : LeftResult + : // If the left side is not successfully parsed as a param, then check + // if only the right side can be successfully parse as a param. If it + // can, then the result is just right, else it's a failure. + RightResult extends string + ? RightResult + : ParamParseFailed + : ParamParseFailed + : // If the left side didn't parse into a param, then just check the right + // side. + ParamParseSegment extends infer RightResult + ? RightResult extends string + ? RightResult + : ParamParseFailed + : ParamParseFailed + : // If there's no forward slash, then check if this segment starts with a + // colon. If it does, then this is a dynamic segment, so the result is + // just the remainder of the string. Otherwise, it's a failure. + Segment extends `:${infer Remaining}` + ? Remaining + : ParamParseFailed; + +// Attempt to parse the given string segment. If it fails, then just return the +// plain string type as a default fallback. Otherwise return the union of the +// parsed string literals that were referenced as dynamic segments in the route. +type ParamParseKey = + ParamParseSegment extends string + ? ParamParseSegment + : string; + +/** + * The parameters that were parsed from the URL path. + */ +type Params = { + readonly [key in Key]: string | undefined; +}; + +/** + * A PathPattern is used to match on some portion of a URL pathname. + */ +interface PathPattern { + /** + * A string to match against a URL pathname. May contain `:id`-style segments + * to indicate placeholders for dynamic parameters. May also end with `/*` to + * indicate matching the rest of the URL pathname. + */ + path: Path; + /** + * Should be `true` if the static portions of the `path` should be matched in + * the same case. + */ + caseSensitive?: boolean; + /** + * Should be `true` if this pattern should match the entire URL pathname. + */ + end?: boolean; +} + +/** + * A PathMatch contains info about how a PathPattern matched on a URL pathname. + */ +interface PathMatch { + /** + * The names and values of dynamic parameters in the URL. + */ + params: Params; + /** + * The portion of the URL pathname that was matched. + */ + pathname: string; + /** + * The portion of the URL pathname that was matched before child routes. + */ + pathnameBase: string; + /** + * The pattern that was used to match. + */ + pattern: PathPattern; +} + +type Mutable = { + -readonly [P in keyof T]: T[P]; +}; + +/** + * Performs pattern matching on a URL pathname and returns information about + * the match. + * + * @see https://reactrouter.com/docs/en/v6/utils/match-path + */ +export function matchPath< + ParamKey extends ParamParseKey, + Path extends string +>( + pattern: PathPattern | Path, + pathname: string +): PathMatch | null { + if (typeof pattern === 'string') { + pattern = { path: pattern, caseSensitive: false, end: true }; + } + + const [matcher, paramNames] = compilePath( + pattern.path, + pattern.caseSensitive, + pattern.end + ); + + const match = pathname.match(matcher); + if (!match) return null; + + const matchedPathname = match[0]; + let pathnameBase = matchedPathname.replace(/(.)\/+$/, '$1'); + const captureGroups = match.slice(1); + const params: Params = paramNames.reduce>( + (memo, paramName, index) => { + // We need to compute the pathnameBase here using the raw splat value + // instead of using params["*"] later because it will be decoded then + if (paramName === '*') { + const splatValue = captureGroups[index] || ''; + pathnameBase = matchedPathname + .slice(0, matchedPathname.length - splatValue.length) + .replace(/(.)\/+$/, '$1'); + } + + memo[paramName] = safelyDecodeURIComponent( + captureGroups[index] || '', + paramName + ); + return memo; + }, + {} + ); + + return { + params, + pathname: matchedPathname, + pathnameBase, + pattern + }; +} + +function compilePath( + path: string, + caseSensitive = false, + end = true +): [RegExp, string[]] { + warning( + path === '*' || !path.endsWith('*') || path.endsWith('/*'), + `Route path "${path}" will be treated as if it were ` + + `"${path.replace(/\*$/, '/*')}" because the \`*\` character must ` + + 'always follow a `/` in the pattern. To get rid of this warning, ' + + `please change the route path to "${path.replace(/\*$/, '/*')}".` + ); + + const paramNames: string[] = []; + let regexpSource = + '^' + + path + .replace(/\/*\*?$/, '') // Ignore trailing / and /*, we'll handle it below + .replace(/^\/*/, '/') // Make sure it has a leading / + .replace(/[\\.*+^$?{}|()[\]]/g, '\\$&') // Escape special regex chars + .replace(/:(\w+)/g, (_: string, paramName: string) => { + paramNames.push(paramName); + return '([^\\/]+)'; + }); + + if (path.endsWith('*')) { + paramNames.push('*'); + regexpSource += + path === '*' || path === '/*' + ? '(.*)$' // Already matched the initial /, just match the rest + : '(?:\\/(.+)|\\/*)$'; // Don't include the / in params["*"] + } else { + regexpSource += end + ? '\\/*$' // When matching to the end, ignore trailing slashes + : // Otherwise, match a word boundary or a proceeding /. The word boundary restricts + // parent routes to matching only their own words and nothing more, e.g. parent + // route "/home" should not match "/home2". + // Additionally, allow paths starting with `.`, `-`, `~`, and url-encoded entities, + // but do not consume the character in the matched path so they can match against + // nested paths. + '(?:(?=[.~-]|%[0-9A-F]{2})|\\b|\\/|$)'; + } + + const matcher = new RegExp(regexpSource, caseSensitive ? undefined : 'i'); + + return [matcher, paramNames]; +} + +function safelyDecodeURIComponent(value: string, paramName: string) { + try { + return decodeURIComponent(value); + } catch (error) { + warning( + false, + `The value for the URL param "${paramName}" will not be decoded because` + + ` the string "${value}" is a malformed URL segment. This is probably` + + ` due to a bad percent encoding (${error}).` + ); + + return value; + } +} diff --git a/src/utils/window/tests/addOriginToLocationPath.test.ts b/src/utils/window/tests/addOriginToLocationPath.test.ts deleted file mode 100644 index e61f23e..0000000 --- a/src/utils/window/tests/addOriginToLocationPath.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { addOriginToLocationPath } from '../addOriginToLocationPath'; - -let windowSpy: jest.SpyInstance; - -beforeEach(() => { - windowSpy = jest.spyOn(window, 'window', 'get'); -}); -afterEach(() => { - windowSpy.mockRestore(); -}); - -describe('Add window origin to pathname', () => { - it('should leave the path unchanged if origin is not available', () => { - windowSpy.mockImplementation(() => ({ - location: { - origin: '' - } - })); - - const path = addOriginToLocationPath('http://somesite/unlock'); - expect(path).toStrictEqual('http://somesite/unlock'); - }); - - it('should leave the path unchanged if it contains origin', () => { - windowSpy.mockImplementation(() => ({ - location: { - origin: 'https://multiversx.com' - } - })); - - const path = addOriginToLocationPath('http://somesite/unlock'); - expect(path).toStrictEqual('http://somesite/unlock'); - }); - - it('should leave the path unchanged if it contains the same origin as current one', () => { - windowSpy.mockImplementation(() => ({ - location: { - origin: 'http://somesite' - } - })); - - const path = addOriginToLocationPath('http://somesite/unlock'); - expect(path).toStrictEqual('http://somesite/unlock'); - }); - - it('should add the current origin to the path', () => { - windowSpy.mockImplementation(() => ({ - location: { - origin: 'https://multiversx.com' - } - })); - - const path = addOriginToLocationPath('/unlock'); - expect(path).toStrictEqual('https://multiversx.com/unlock'); - }); - - it('should return current origin if no parameter is specified', () => { - windowSpy.mockImplementation(() => ({ - location: { - origin: 'https://multiversx.com' - } - })); - - const path = addOriginToLocationPath(); - expect(path).toStrictEqual('https://multiversx.com/'); - }); -}); diff --git a/src/utils/window/tests/getIsAuthRoute.test.ts b/src/utils/window/tests/getIsAuthRoute.test.ts new file mode 100644 index 0000000..e2678b1 --- /dev/null +++ b/src/utils/window/tests/getIsAuthRoute.test.ts @@ -0,0 +1,37 @@ +import { getIsAuthRoute } from '../getIsAuthRoute'; + +const createRoutes = (path: string, authenticatedRoute = true) => [ + { + path, + component: () => null, + authenticatedRoute + } +]; + +describe('matchRoute', () => { + it('should return true for simple routes', () => { + const result = getIsAuthRoute(createRoutes('/home'), '/home'); + expect(result).toBe(true); + }); + it('should return true for pattern routes', () => { + const result = getIsAuthRoute( + createRoutes('/user/:id'), + '/user/first-name' + ); + expect(result).toBe(true); + }); + it('should return false for non-matching pattern routes', () => { + const result = getIsAuthRoute( + createRoutes('/user/:id'), + '/user/first-name/detail' + ); + expect(result).toBe(false); + }); + it('should return true for non-athenticated non-matching pattern routes', () => { + const result = getIsAuthRoute( + createRoutes('/user/:id', false), + '/user/first-name' + ); + expect(result).toBe(false); + }); +});