Skip to content

Commit

Permalink
fix: store redirect to localStorage to avoid loss of redirect (#5929)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
andreas-unleash authored Jan 19, 2024
1 parent 4ee2acb commit a096b2a
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 9 deletions.
7 changes: 7 additions & 0 deletions frontend/src/component/user/Authentication/Authentication.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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', {
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/component/user/Login/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 <Navigate to={parseRedirectParam(redirect)} replace />;
}

Expand Down
120 changes: 120 additions & 0 deletions frontend/src/utils/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
setLocalStorageItem,
getLocalStorageItem,
setSessionStorageItem,
getSessionStorageItem,
} from './storage';
import { vi } from 'vitest';

// Mocking the global localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};

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<string, string> = {};

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<typeof testObject>(
'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<typeof testObject>(
'testObjectKeySession',
);
expect(retrievedObject).toBeUndefined();
});
});
78 changes: 70 additions & 8 deletions frontend/src/utils/storage.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,87 @@
type Expirable<T> = {
value: T | undefined;
expiry: number | null;
};

// Get an item from localStorage.
// Returns undefined if the browser denies access.
export function getLocalStorageItem<T>(key: string): T | undefined {
try {
return parseStoredItem<T>(window.localStorage.getItem(key));
const itemStr = window.localStorage.getItem(key);
if (!itemStr) {
return undefined;
}

const item: Expirable<T> | 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<T>(
key: string,
value: T | undefined = undefined,
timeToLive?: number,
) {
try {
const item: Expirable<T> = {
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<T>(
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<T> = {
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<T>(key: string): T | undefined {
try {
const itemStr = window.sessionStorage.getItem(key);
if (!itemStr) {
return undefined;
}

const item: Expirable<T> | 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;
}
}

Expand Down

0 comments on commit a096b2a

Please sign in to comment.