enhancement: admin session presists across tabs (#23188)

* enhancement: admin session presists across tabs

* test: clear cookies in login tests

* test: remove tests from EE
This commit is contained in:
Bassel Kanso 2025-03-24 16:10:25 +02:00 committed by GitHub
parent 66fd5e4469
commit 3aeb285c7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 102 additions and 66 deletions

View File

@ -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();
}
};

View File

@ -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);
},
},
});

View File

@ -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;`;
};

View File

@ -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 = {

View File

@ -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();
});
});
});

View File

@ -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');

View File

@ -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;';
};

View File

@ -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(';'))
);
});
});
});

View File

@ -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);