From 3aeb285c7b58eaf4d7e2c7efaf76e41df7f7452a Mon Sep 17 00:00:00 2001 From: Bassel Kanso Date: Mon, 24 Mar 2025 16:10:25 +0200 Subject: [PATCH] enhancement: admin session presists across tabs (#23188) * enhancement: admin session presists across tabs * test: clear cookies in login tests * test: remove tests from EE --- .../core/admin/admin/src/features/Auth.tsx | 4 +- packages/core/admin/admin/src/reducer.ts | 15 +++---- .../core/admin/admin/src/utils/cookies.ts | 43 +++++++++++++++++++ .../admin/admin/src/utils/getFetchClient.ts | 4 +- .../admin/src/utils/tests/cookies.test.ts | 42 ++++++++++++++++++ .../admin/ee/admin/src/pages/AuthResponse.tsx | 2 +- .../core/admin/ee/admin/src/utils/cookies.ts | 16 ------- .../core/admin/ee/admin/tests/cookies.test.ts | 35 --------------- tests/e2e/tests/admin/login.spec.ts | 7 +-- 9 files changed, 102 insertions(+), 66 deletions(-) create mode 100644 packages/core/admin/admin/src/utils/cookies.ts create mode 100644 packages/core/admin/admin/src/utils/tests/cookies.test.ts delete mode 100644 packages/core/admin/ee/admin/src/utils/cookies.ts delete mode 100644 packages/core/admin/ee/admin/tests/cookies.test.ts diff --git a/packages/core/admin/admin/src/features/Auth.tsx b/packages/core/admin/admin/src/features/Auth.tsx index 8b56270e13..d93639d34c 100644 --- a/packages/core/admin/admin/src/features/Auth.tsx +++ b/packages/core/admin/admin/src/features/Auth.tsx @@ -71,7 +71,7 @@ interface AuthProviderProps { const STORAGE_KEYS = { TOKEN: 'jwtToken', - USER: 'userInfo', + STATUS: 'isLoggedIn', }; const AuthProvider = ({ @@ -149,7 +149,7 @@ const AuthProvider = ({ * This will log a user out of all tabs if they log out in one tab. */ const handleUserStorageChange = (event: StorageEvent) => { - if (event.key === STORAGE_KEYS.USER && event.newValue === null) { + if (event.key === STORAGE_KEYS.STATUS && event.newValue === null) { clearStateAndLogout(); } }; diff --git a/packages/core/admin/admin/src/reducer.ts b/packages/core/admin/admin/src/reducer.ts index 8be2a9f3d6..dde74b21c6 100644 --- a/packages/core/admin/admin/src/reducer.ts +++ b/packages/core/admin/admin/src/reducer.ts @@ -1,6 +1,7 @@ import { createSlice } from '@reduxjs/toolkit'; import { PermissionMap } from './types/permissions'; +import { getCookieValue, setCookie, deleteCookie } from './utils/cookies'; import type { PayloadAction } from '@reduxjs/toolkit'; @@ -21,15 +22,14 @@ interface AppState { const STORAGE_KEYS = { TOKEN: 'jwtToken', - USER: 'userInfo', + STATUS: 'isLoggedIn', }; const THEME_LOCAL_STORAGE_KEY = 'STRAPI_THEME'; const LANGUAGE_LOCAL_STORAGE_KEY = 'strapi-admin-language'; export const getStoredToken = (): string | null => { - const token = - localStorage.getItem(STORAGE_KEYS.TOKEN) ?? sessionStorage.getItem(STORAGE_KEYS.TOKEN); + const token = localStorage.getItem(STORAGE_KEYS.TOKEN) ?? getCookieValue(STORAGE_KEYS.TOKEN); if (typeof token === 'string') { return JSON.parse(token); @@ -75,19 +75,18 @@ const adminSlice = createSlice({ const { token, persist } = action.payload; if (!persist) { - window.sessionStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token)); + setCookie(STORAGE_KEYS.TOKEN, JSON.stringify(token)); } else { window.localStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token)); } - + window.localStorage.setItem(STORAGE_KEYS.STATUS, 'true'); state.token = token; }, logout(state) { state.token = null; + deleteCookie(STORAGE_KEYS.TOKEN); window.localStorage.removeItem(STORAGE_KEYS.TOKEN); - window.localStorage.removeItem(STORAGE_KEYS.USER); - window.sessionStorage.removeItem(STORAGE_KEYS.TOKEN); - window.sessionStorage.removeItem(STORAGE_KEYS.USER); + window.localStorage.removeItem(STORAGE_KEYS.STATUS); }, }, }); diff --git a/packages/core/admin/admin/src/utils/cookies.ts b/packages/core/admin/admin/src/utils/cookies.ts new file mode 100644 index 0000000000..c056f2ad51 --- /dev/null +++ b/packages/core/admin/admin/src/utils/cookies.ts @@ -0,0 +1,43 @@ +/** + * Retrieves the value of a specified cookie. + * + * @param name - The name of the cookie to retrieve. + * @returns The decoded cookie value if found, otherwise null. + */ +export const getCookieValue = (name: string): string | null => { + let result = null; + const cookieArray = document.cookie.split(';'); + cookieArray.forEach((cookie) => { + const [key, value] = cookie.split('=').map((item) => item.trim()); + if (key === name) { + result = decodeURIComponent(value); + } + }); + return result; +}; + +/** + * Sets a cookie with the given name, value, and optional expiration time. + * + * @param name - The name of the cookie. + * @param value - The value of the cookie. + * @param days - (Optional) Number of days until the cookie expires. If omitted, the cookie is a session cookie. + */ +export const setCookie = (name: string, value: string, days?: number): void => { + let expires = ''; + if (days) { + const date = new Date(); + date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); + expires = `; Expires=${date.toUTCString()}`; + } + document.cookie = `${name}=${encodeURIComponent(value)}; Path=/${expires}`; +}; + +/** + * Deletes a cookie by setting its expiration date to a past date. + * + * @param name - The name of the cookie to delete. + */ +export const deleteCookie = (name: string): void => { + document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`; +}; diff --git a/packages/core/admin/admin/src/utils/getFetchClient.ts b/packages/core/admin/admin/src/utils/getFetchClient.ts index f5c5f33141..3fd9e89997 100644 --- a/packages/core/admin/admin/src/utils/getFetchClient.ts +++ b/packages/core/admin/admin/src/utils/getFetchClient.ts @@ -1,6 +1,8 @@ import pipe from 'lodash/fp/pipe'; import qs from 'qs'; +import { getCookieValue } from './cookies'; + import type { errors } from '@strapi/utils'; export type ApiError = @@ -72,7 +74,7 @@ const isFetchError = (error: unknown): error is FetchError => { const getToken = () => JSON.parse( - localStorage.getItem(STORAGE_KEYS.TOKEN) ?? sessionStorage.getItem(STORAGE_KEYS.TOKEN) ?? '""' + localStorage.getItem(STORAGE_KEYS.TOKEN) ?? getCookieValue(STORAGE_KEYS.TOKEN) ?? '""' ); type FetchClient = { diff --git a/packages/core/admin/admin/src/utils/tests/cookies.test.ts b/packages/core/admin/admin/src/utils/tests/cookies.test.ts new file mode 100644 index 0000000000..a3000ee6ad --- /dev/null +++ b/packages/core/admin/admin/src/utils/tests/cookies.test.ts @@ -0,0 +1,42 @@ +import { getCookieValue, setCookie, deleteCookie } from '../cookies'; + +describe('Cookie Utilities', () => { + beforeEach(() => { + // Clear all cookies before each test + document.cookie.split(';').forEach((cookie) => { + const [name] = cookie.split('='); + document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`; + }); + }); + + describe('setCookie', () => { + it('should set a cookie with the correct value', () => { + setCookie('testCookie', 'testValue', 1); + expect(document.cookie).toContain('testCookie=testValue'); + }); + + it('should set a session cookie when no expiration is given', () => { + setCookie('sessionCookie', 'sessionValue'); + expect(document.cookie).toContain('sessionCookie=sessionValue'); + }); + }); + + describe('getCookieValue', () => { + it('should return the correct cookie value', () => { + document.cookie = 'user=JohnDoe; Path=/;'; + expect(getCookieValue('user')).toBe('JohnDoe'); + }); + + it('should return null for a non-existing cookie', () => { + expect(getCookieValue('nonExistent')).toBeNull(); + }); + }); + + describe('deleteCookie', () => { + it('should delete a cookie', () => { + document.cookie = 'deleteMe=value; Path=/;'; + deleteCookie('deleteMe'); + expect(getCookieValue('deleteMe')).toBeNull(); + }); + }); +}); diff --git a/packages/core/admin/ee/admin/src/pages/AuthResponse.tsx b/packages/core/admin/ee/admin/src/pages/AuthResponse.tsx index 215a52b44e..b712364f80 100644 --- a/packages/core/admin/ee/admin/src/pages/AuthResponse.tsx +++ b/packages/core/admin/ee/admin/src/pages/AuthResponse.tsx @@ -6,7 +6,7 @@ import { useNavigate, useMatch } from 'react-router-dom'; import { Page } from '../../../../admin/src/components/PageHelpers'; import { useTypedDispatch } from '../../../../admin/src/core/store/hooks'; import { login } from '../../../../admin/src/reducer'; -import { getCookieValue, deleteCookie } from '../utils/cookies'; +import { getCookieValue, deleteCookie } from '../../../../admin/src/utils/cookies'; const AuthResponse = () => { const match = useMatch('/auth/login/:authResponse'); diff --git a/packages/core/admin/ee/admin/src/utils/cookies.ts b/packages/core/admin/ee/admin/src/utils/cookies.ts deleted file mode 100644 index 744fe6beb9..0000000000 --- a/packages/core/admin/ee/admin/src/utils/cookies.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const getCookieValue = (name: string) => { - let result = null; - const cookieArray = document.cookie.split(';'); - cookieArray.forEach((cookie) => { - const [key, value] = cookie.split('=').map((item) => item.trim()); - if (key.trim() === name) { - result = decodeURIComponent(value); - } - }); - return result; -}; - -export const deleteCookie = (name: string) => { - // Set the cookie to expire in the past - document.cookie = name + '=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; -}; diff --git a/packages/core/admin/ee/admin/tests/cookies.test.ts b/packages/core/admin/ee/admin/tests/cookies.test.ts deleted file mode 100644 index 7151e8f8b2..0000000000 --- a/packages/core/admin/ee/admin/tests/cookies.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getCookieValue, deleteCookie } from '../src/utils/cookies'; -describe('Cookie utils', () => { - beforeEach(() => { - document.cookie = 'cookie1=value1;'; - document.cookie = 'cookie2=value2;'; - document.cookie = 'cookie3=value3;'; - }); - - describe('getCookieValue', () => { - it('should return the value of the specified cookie', () => { - expect(getCookieValue('cookie1')).toBe('value1'); - expect(getCookieValue('cookie2')).toBe('value2'); - expect(getCookieValue('cookie3')).toBe('value3'); - }); - - it('should return null if the specified cookie does not exist', () => { - expect(getCookieValue('cookie4')).toBeNull(); - expect(getCookieValue('cookie5')).toBeNull(); - }); - }); - - describe('deleteCookie', () => { - it('should delete the specified cookie', () => { - deleteCookie('cookie2'); - expect(document.cookie).toBe('cookie1=value1; cookie3=value3'); - }); - - it('should not delete any cookies if the specified cookie does not exist', () => { - deleteCookie('cookie4'); - expect(document.cookie.split(';')).toEqual( - expect.arrayContaining('cookie1=value1; cookie2=value2; cookie3=value3'.split(';')) - ); - }); - }); -}); diff --git a/tests/e2e/tests/admin/login.spec.ts b/tests/e2e/tests/admin/login.spec.ts index 4b74f541c1..f46044f607 100644 --- a/tests/e2e/tests/admin/login.spec.ts +++ b/tests/e2e/tests/admin/login.spec.ts @@ -5,8 +5,9 @@ import { ADMIN_EMAIL_ADDRESS, ADMIN_PASSWORD, TITLE_HOME, TITLE_LOGIN } from '.. import { login } from '../../utils/login'; test.describe('Login', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page, context }) => { await resetDatabaseAndImportDataFromPath('with-admin.tar'); + await context.clearCookies(); await page.goto('/admin'); }); @@ -20,7 +21,7 @@ test.describe('Login', () => { await expect(page).toHaveTitle(TITLE_HOME); await page.close(); - + await context.clearCookies(); page = await context.newPage(); await page.goto('/admin'); await expect(page).toHaveTitle(TITLE_LOGIN); @@ -30,7 +31,7 @@ test.describe('Login', () => { await expect(page).toHaveTitle(TITLE_HOME); await page.close(); - + await context.clearCookies(); page = await context.newPage(); await page.goto('/admin'); await expect(page).toHaveTitle(TITLE_HOME);