From a096b2a48577d431e014f954c743440b1aff6c9c Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Fri, 19 Jan 2024 11:32:26 +0200 Subject: [PATCH] fix: store redirect to localStorage to avoid loss of redirect (#5929) Stores `redirect` param in localStorage in the Authentication component. Retrieves the `redirect` param from localStorage at the Login screen if it is not there in the url. This will solve losing the redirect information all provider logins Closes # [1-1890](https://linear.app/unleash/issue/1-1890/capture-path-before-logging-in-and-redirect-to-it-if-there-and-custom) --------- Signed-off-by: andreas-unleash --- .../user/Authentication/Authentication.tsx | 7 + frontend/src/component/user/Login/Login.tsx | 5 +- frontend/src/utils/storage.test.ts | 120 ++++++++++++++++++ frontend/src/utils/storage.ts | 78 ++++++++++-- 4 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 frontend/src/utils/storage.test.ts diff --git a/frontend/src/component/user/Authentication/Authentication.tsx b/frontend/src/component/user/Authentication/Authentication.tsx index 10642d1a2178..84fcb2cf078e 100644 --- a/frontend/src/component/user/Authentication/Authentication.tsx +++ b/frontend/src/component/user/Authentication/Authentication.tsx @@ -17,6 +17,7 @@ import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails'; import { AUTH_PAGE_ID } from 'utils/testIds'; import { ReactElement, useEffect } from 'react'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { setSessionStorageItem } from 'utils/storage'; interface IAuthenticationProps { redirect: string; @@ -32,6 +33,12 @@ const Authentication = ({ const error = params.get('errorMsg'); const { trackEvent } = usePlausibleTracker(); + useEffect(() => { + if (redirect) { + setSessionStorageItem('login-redirect', redirect, 1000 * 60 * 10); + } + }, [redirect]); + useEffect(() => { if (invited) { trackEvent('invite', { diff --git a/frontend/src/component/user/Login/Login.tsx b/frontend/src/component/user/Login/Login.tsx index e09ea3fc7423..a5e86f34354c 100644 --- a/frontend/src/component/user/Login/Login.tsx +++ b/frontend/src/component/user/Login/Login.tsx @@ -8,6 +8,7 @@ import Authentication from '../Authentication/Authentication'; import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails'; import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; import { parseRedirectParam } from 'component/user/Login/parseRedirectParam'; +import { getSessionStorageItem, setSessionStorageItem } from 'utils/storage'; const StyledDiv = styled('div')(({ theme }) => ({ display: 'flex', @@ -26,9 +27,11 @@ const Login = () => { const query = useQueryParams(); const resetPassword = query.get('reset') === 'true'; const invited = query.get('invited') === 'true'; - const redirect = query.get('redirect') || '/'; + const redirect = + query.get('redirect') || getSessionStorageItem('login-redirect') || '/'; if (user) { + setSessionStorageItem('login-redirect'); return ; } diff --git a/frontend/src/utils/storage.test.ts b/frontend/src/utils/storage.test.ts new file mode 100644 index 000000000000..5d98caccf940 --- /dev/null +++ b/frontend/src/utils/storage.test.ts @@ -0,0 +1,120 @@ +import { + setLocalStorageItem, + getLocalStorageItem, + setSessionStorageItem, + getSessionStorageItem, +} from './storage'; +import { vi } from 'vitest'; + +// Mocking the global localStorage +const localStorageMock = (() => { + let store: Record = {}; + + return { + getItem(key: string) { + return store[key] || null; + }, + setItem(key: string, value: string) { + store[key] = value.toString(); + }, + removeItem(key: string) { + delete store[key]; + }, + clear() { + store = {}; + }, + }; +})(); + +const sessionStorageMock = (() => { + let store: Record = {}; + + return { + getItem(key: string) { + return store[key] || null; + }, + setItem(key: string, value: string) { + store[key] = value.toString(); + }, + removeItem(key: string) { + delete store[key]; + }, + clear() { + store = {}; + }, + }; +})(); + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}); + +Object.defineProperty(window, 'sessionStorage', { + value: sessionStorageMock, +}); + +// Test suite +describe('localStorage with TTL', () => { + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + }); + + test('item should be retrievable before TTL expires', () => { + setLocalStorageItem('testKey', 'testValue', 600000); + expect(getLocalStorageItem('testKey')).toBe('testValue'); + }); + + test('item should not be retrievable after TTL expires', () => { + setLocalStorageItem('testKey', 'testValue', 500000); + + vi.advanceTimersByTime(600000); + + expect(getLocalStorageItem('testKey')).toBeUndefined(); + }); + test('object should be retrievable before TTL expires', () => { + const testObject = { name: 'Test', number: 123 }; + setLocalStorageItem('testObjectKey', testObject, 600000); + + const retrievedObject = getLocalStorageItem<{ + name: string; + number: number; + }>('testObjectKey'); + expect(retrievedObject).toEqual(testObject); + }); + + test('object should not be retrievable after TTL expires', () => { + const testObject = { name: 'Test', number: 123 }; + setLocalStorageItem('testObjectKey', testObject, 500000); + + vi.advanceTimersByTime(600000); + + const retrievedObject = getLocalStorageItem<{ + name: string; + number: number; + }>('testObjectKey'); + expect(retrievedObject).toBeUndefined(); + }); + + test('object should be retrievable before TTL expires in sessionStorage', () => { + const testObject = { name: 'TestSession', number: 456 }; + setSessionStorageItem('testObjectKeySession', testObject, 500000); + + const retrievedObject = getSessionStorageItem( + 'testObjectKeySession', + ); + expect(retrievedObject).toEqual(testObject); + }); + + test('object should not be retrievable after TTL expires in sessionStorage', () => { + const testObject = { name: 'TestSession', number: 456 }; + setSessionStorageItem('testObjectKeySession', testObject, 500000); // 10 minutes TTL + + vi.advanceTimersByTime(600000); + + const retrievedObject = getSessionStorageItem( + 'testObjectKeySession', + ); + expect(retrievedObject).toBeUndefined(); + }); +}); diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts index 5cad745927a7..113f3a65f544 100644 --- a/frontend/src/utils/storage.ts +++ b/frontend/src/utils/storage.ts @@ -1,25 +1,87 @@ +type Expirable = { + value: T | undefined; + expiry: number | null; +}; + // Get an item from localStorage. // Returns undefined if the browser denies access. export function getLocalStorageItem(key: string): T | undefined { try { - return parseStoredItem(window.localStorage.getItem(key)); + const itemStr = window.localStorage.getItem(key); + if (!itemStr) { + return undefined; + } + + const item: Expirable | undefined = parseStoredItem(itemStr); + if (item?.expiry && new Date().getTime() > item.expiry) { + window.localStorage.removeItem(key); + return undefined; + } + return item?.value; } catch (err: unknown) { console.warn(err); + return undefined; } } // Store an item in localStorage. // Does nothing if the browser denies access. -export function setLocalStorageItem(key: string, value: unknown) { +export function setLocalStorageItem( + key: string, + value: T | undefined = undefined, + timeToLive?: number, +) { + try { + const item: Expirable = { + value, + expiry: + timeToLive !== undefined + ? new Date().getTime() + timeToLive + : null, + }; + window.localStorage.setItem(key, JSON.stringify(item)); + } catch (err: unknown) { + console.warn(err); + } +} + +// Store an item in sessionStorage with optional TTL +export function setSessionStorageItem( + key: string, + value: T | undefined = undefined, + timeToLive?: number, +) { try { - window.localStorage.setItem( - key, - JSON.stringify(value, (_key, value) => - value instanceof Set ? [...value] : value, - ), - ); + const item: Expirable = { + value, + expiry: + timeToLive !== undefined + ? new Date().getTime() + timeToLive + : null, + }; + window.sessionStorage.setItem(key, JSON.stringify(item)); + } catch (err: unknown) { + console.warn(err); + } +} + +// Get an item from sessionStorage, checking for TTL +export function getSessionStorageItem(key: string): T | undefined { + try { + const itemStr = window.sessionStorage.getItem(key); + if (!itemStr) { + return undefined; + } + + const item: Expirable | undefined = parseStoredItem(itemStr); + if (item?.expiry && new Date().getTime() > item.expiry) { + window.sessionStorage.removeItem(key); + return undefined; + } + return item?.value; } catch (err: unknown) { console.warn(err); + return undefined; } }