mirror of
https://github.com/strapi/strapi.git
synced 2025-06-27 00:41:25 +00:00
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:
parent
66fd5e4469
commit
3aeb285c7b
@ -71,7 +71,7 @@ interface AuthProviderProps {
|
|||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
TOKEN: 'jwtToken',
|
TOKEN: 'jwtToken',
|
||||||
USER: 'userInfo',
|
STATUS: 'isLoggedIn',
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthProvider = ({
|
const AuthProvider = ({
|
||||||
@ -149,7 +149,7 @@ const AuthProvider = ({
|
|||||||
* This will log a user out of all tabs if they log out in one tab.
|
* This will log a user out of all tabs if they log out in one tab.
|
||||||
*/
|
*/
|
||||||
const handleUserStorageChange = (event: StorageEvent) => {
|
const handleUserStorageChange = (event: StorageEvent) => {
|
||||||
if (event.key === STORAGE_KEYS.USER && event.newValue === null) {
|
if (event.key === STORAGE_KEYS.STATUS && event.newValue === null) {
|
||||||
clearStateAndLogout();
|
clearStateAndLogout();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { PermissionMap } from './types/permissions';
|
import { PermissionMap } from './types/permissions';
|
||||||
|
import { getCookieValue, setCookie, deleteCookie } from './utils/cookies';
|
||||||
|
|
||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
@ -21,15 +22,14 @@ interface AppState {
|
|||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
TOKEN: 'jwtToken',
|
TOKEN: 'jwtToken',
|
||||||
USER: 'userInfo',
|
STATUS: 'isLoggedIn',
|
||||||
};
|
};
|
||||||
|
|
||||||
const THEME_LOCAL_STORAGE_KEY = 'STRAPI_THEME';
|
const THEME_LOCAL_STORAGE_KEY = 'STRAPI_THEME';
|
||||||
const LANGUAGE_LOCAL_STORAGE_KEY = 'strapi-admin-language';
|
const LANGUAGE_LOCAL_STORAGE_KEY = 'strapi-admin-language';
|
||||||
|
|
||||||
export const getStoredToken = (): string | null => {
|
export const getStoredToken = (): string | null => {
|
||||||
const token =
|
const token = localStorage.getItem(STORAGE_KEYS.TOKEN) ?? getCookieValue(STORAGE_KEYS.TOKEN);
|
||||||
localStorage.getItem(STORAGE_KEYS.TOKEN) ?? sessionStorage.getItem(STORAGE_KEYS.TOKEN);
|
|
||||||
|
|
||||||
if (typeof token === 'string') {
|
if (typeof token === 'string') {
|
||||||
return JSON.parse(token);
|
return JSON.parse(token);
|
||||||
@ -75,19 +75,18 @@ const adminSlice = createSlice({
|
|||||||
const { token, persist } = action.payload;
|
const { token, persist } = action.payload;
|
||||||
|
|
||||||
if (!persist) {
|
if (!persist) {
|
||||||
window.sessionStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token));
|
setCookie(STORAGE_KEYS.TOKEN, JSON.stringify(token));
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token));
|
window.localStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token));
|
||||||
}
|
}
|
||||||
|
window.localStorage.setItem(STORAGE_KEYS.STATUS, 'true');
|
||||||
state.token = token;
|
state.token = token;
|
||||||
},
|
},
|
||||||
logout(state) {
|
logout(state) {
|
||||||
state.token = null;
|
state.token = null;
|
||||||
|
deleteCookie(STORAGE_KEYS.TOKEN);
|
||||||
window.localStorage.removeItem(STORAGE_KEYS.TOKEN);
|
window.localStorage.removeItem(STORAGE_KEYS.TOKEN);
|
||||||
window.localStorage.removeItem(STORAGE_KEYS.USER);
|
window.localStorage.removeItem(STORAGE_KEYS.STATUS);
|
||||||
window.sessionStorage.removeItem(STORAGE_KEYS.TOKEN);
|
|
||||||
window.sessionStorage.removeItem(STORAGE_KEYS.USER);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
43
packages/core/admin/admin/src/utils/cookies.ts
Normal file
43
packages/core/admin/admin/src/utils/cookies.ts
Normal 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;`;
|
||||||
|
};
|
@ -1,6 +1,8 @@
|
|||||||
import pipe from 'lodash/fp/pipe';
|
import pipe from 'lodash/fp/pipe';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
|
|
||||||
|
import { getCookieValue } from './cookies';
|
||||||
|
|
||||||
import type { errors } from '@strapi/utils';
|
import type { errors } from '@strapi/utils';
|
||||||
|
|
||||||
export type ApiError =
|
export type ApiError =
|
||||||
@ -72,7 +74,7 @@ const isFetchError = (error: unknown): error is FetchError => {
|
|||||||
|
|
||||||
const getToken = () =>
|
const getToken = () =>
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
localStorage.getItem(STORAGE_KEYS.TOKEN) ?? sessionStorage.getItem(STORAGE_KEYS.TOKEN) ?? '""'
|
localStorage.getItem(STORAGE_KEYS.TOKEN) ?? getCookieValue(STORAGE_KEYS.TOKEN) ?? '""'
|
||||||
);
|
);
|
||||||
|
|
||||||
type FetchClient = {
|
type FetchClient = {
|
||||||
|
42
packages/core/admin/admin/src/utils/tests/cookies.test.ts
Normal file
42
packages/core/admin/admin/src/utils/tests/cookies.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -6,7 +6,7 @@ import { useNavigate, useMatch } from 'react-router-dom';
|
|||||||
import { Page } from '../../../../admin/src/components/PageHelpers';
|
import { Page } from '../../../../admin/src/components/PageHelpers';
|
||||||
import { useTypedDispatch } from '../../../../admin/src/core/store/hooks';
|
import { useTypedDispatch } from '../../../../admin/src/core/store/hooks';
|
||||||
import { login } from '../../../../admin/src/reducer';
|
import { login } from '../../../../admin/src/reducer';
|
||||||
import { getCookieValue, deleteCookie } from '../utils/cookies';
|
import { getCookieValue, deleteCookie } from '../../../../admin/src/utils/cookies';
|
||||||
|
|
||||||
const AuthResponse = () => {
|
const AuthResponse = () => {
|
||||||
const match = useMatch('/auth/login/:authResponse');
|
const match = useMatch('/auth/login/:authResponse');
|
||||||
|
@ -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;';
|
|
||||||
};
|
|
@ -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(';'))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -5,8 +5,9 @@ import { ADMIN_EMAIL_ADDRESS, ADMIN_PASSWORD, TITLE_HOME, TITLE_LOGIN } from '..
|
|||||||
import { login } from '../../utils/login';
|
import { login } from '../../utils/login';
|
||||||
|
|
||||||
test.describe('Login', () => {
|
test.describe('Login', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page, context }) => {
|
||||||
await resetDatabaseAndImportDataFromPath('with-admin.tar');
|
await resetDatabaseAndImportDataFromPath('with-admin.tar');
|
||||||
|
await context.clearCookies();
|
||||||
await page.goto('/admin');
|
await page.goto('/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -20,7 +21,7 @@ test.describe('Login', () => {
|
|||||||
await expect(page).toHaveTitle(TITLE_HOME);
|
await expect(page).toHaveTitle(TITLE_HOME);
|
||||||
|
|
||||||
await page.close();
|
await page.close();
|
||||||
|
await context.clearCookies();
|
||||||
page = await context.newPage();
|
page = await context.newPage();
|
||||||
await page.goto('/admin');
|
await page.goto('/admin');
|
||||||
await expect(page).toHaveTitle(TITLE_LOGIN);
|
await expect(page).toHaveTitle(TITLE_LOGIN);
|
||||||
@ -30,7 +31,7 @@ test.describe('Login', () => {
|
|||||||
await expect(page).toHaveTitle(TITLE_HOME);
|
await expect(page).toHaveTitle(TITLE_HOME);
|
||||||
|
|
||||||
await page.close();
|
await page.close();
|
||||||
|
await context.clearCookies();
|
||||||
page = await context.newPage();
|
page = await context.newPage();
|
||||||
await page.goto('/admin');
|
await page.goto('/admin');
|
||||||
await expect(page).toHaveTitle(TITLE_HOME);
|
await expect(page).toHaveTitle(TITLE_HOME);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user