diff --git a/packages/twenty-front/src/index.tsx b/packages/twenty-front/src/index.tsx index 5bbdd64bc1f..ca1d2610d2c 100644 --- a/packages/twenty-front/src/index.tsx +++ b/packages/twenty-front/src/index.tsx @@ -1,6 +1,7 @@ import ReactDOM from 'react-dom/client'; import { App } from '@/app/components/App'; +import { migrateTokenPairCookieToLocalStorage } from '@/auth/utils/migrateTokenPairCookieToLocalStorage'; import 'react-loading-skeleton/dist/skeleton.css'; import 'twenty-ui-deprecated/style.css'; import 'twenty-ui-deprecated/theme-light.css'; @@ -10,6 +11,10 @@ import 'twenty-ui-deprecated/theme-dark.css'; import 'twenty-ui/style.css'; import './index.css'; +// TODO: REMOVE this after 2026-12-12 — temporary migration of tokenPair from the +// legacy cookie to localStorage (legacy cookie has a 180-day expiry). +migrateTokenPairCookieToLocalStorage(); + const root = ReactDOM.createRoot( document.getElementById('root') ?? document.body, ); diff --git a/packages/twenty-front/src/modules/apollo/utils/__tests__/getTokenPair.test.ts b/packages/twenty-front/src/modules/apollo/utils/__tests__/getTokenPair.test.ts index b7d687d2d4d..d3a1756039b 100644 --- a/packages/twenty-front/src/modules/apollo/utils/__tests__/getTokenPair.test.ts +++ b/packages/twenty-front/src/modules/apollo/utils/__tests__/getTokenPair.test.ts @@ -1,126 +1,110 @@ -import { cookieStorage } from '~/utils/cookie-storage'; import { getTokenPair } from '@/apollo/utils/getTokenPair'; - -jest.mock('~/utils/cookie-storage', () => ({ - cookieStorage: { - getItem: jest.fn(), - removeItem: jest.fn(), - }, -})); - -const mockCookieStorage = cookieStorage as jest.Mocked; +import { TOKEN_PAIR_LOCAL_STORAGE_KEY } from '@/auth/states/tokenPairState'; describe('getTokenPair', () => { + let getItemSpy: jest.SpyInstance; + let removeItemSpy: jest.SpyInstance; + beforeEach(() => { - jest.clearAllMocks(); + getItemSpy = jest.spyOn(Storage.prototype, 'getItem'); + removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); }); - describe('when tokenPair cookie does not exist', () => { - it('should return undefined when cookie is not set', () => { - mockCookieStorage.getItem.mockReturnValue(undefined); + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('when tokenPair is not stored', () => { + it('should return undefined when nothing is stored', () => { + getItemSpy.mockReturnValue(null); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.getItem).toHaveBeenCalledWith('tokenPair'); - }); - - it('should return undefined when cookie is undefined', () => { - mockCookieStorage.getItem.mockReturnValue(undefined); - - const result = getTokenPair(); - - expect(result).toBeUndefined(); - expect(mockCookieStorage.getItem).toHaveBeenCalledWith('tokenPair'); + expect(getItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); }); - describe('when tokenPair cookie has invalid JSON', () => { - it('should remove cookie and return undefined for malformed JSON', () => { - mockCookieStorage.getItem.mockReturnValue('invalid-json'); + describe('when stored tokenPair has invalid JSON', () => { + it('should remove the item and return undefined for malformed JSON', () => { + getItemSpy.mockReturnValue('invalid-json'); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined for partial JSON', () => { - mockCookieStorage.getItem.mockReturnValue('{"incomplete":'); + it('should remove the item and return undefined for partial JSON', () => { + getItemSpy.mockReturnValue('{"incomplete":'); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); }); - describe('when tokenPair cookie has invalid structure', () => { - it('should remove cookie and return undefined when tokenPair is null', () => { - mockCookieStorage.getItem.mockReturnValue('null'); + describe('when stored tokenPair has invalid structure', () => { + it('should remove the item and return undefined when tokenPair is null', () => { + getItemSpy.mockReturnValue('null'); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined when tokenPair is not an object', () => { - mockCookieStorage.getItem.mockReturnValue('"string-value"'); + it('should remove the item and return undefined when tokenPair is not an object', () => { + getItemSpy.mockReturnValue('"string-value"'); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined when accessOrWorkspaceAgnosticToken is missing', () => { + it('should remove the item and return undefined when accessOrWorkspaceAgnosticToken is missing', () => { const invalidTokenPair = { refreshToken: { token: 'refresh-token' }, }; - mockCookieStorage.getItem.mockReturnValue( - JSON.stringify(invalidTokenPair), - ); + getItemSpy.mockReturnValue(JSON.stringify(invalidTokenPair)); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined when accessOrWorkspaceAgnosticToken is not an object', () => { + it('should remove the item and return undefined when accessOrWorkspaceAgnosticToken is not an object', () => { const invalidTokenPair = { accessOrWorkspaceAgnosticToken: 'not-an-object', refreshToken: { token: 'refresh-token' }, }; - mockCookieStorage.getItem.mockReturnValue( - JSON.stringify(invalidTokenPair), - ); + getItemSpy.mockReturnValue(JSON.stringify(invalidTokenPair)); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined when token is missing', () => { + it('should remove the item and return undefined when token is missing', () => { const invalidTokenPair = { accessOrWorkspaceAgnosticToken: { expiresAt: '2024-01-01T00:00:00Z', }, refreshToken: { token: 'refresh-token' }, }; - mockCookieStorage.getItem.mockReturnValue( - JSON.stringify(invalidTokenPair), - ); + getItemSpy.mockReturnValue(JSON.stringify(invalidTokenPair)); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); - it('should remove cookie and return undefined when token is not a string', () => { + it('should remove the item and return undefined when token is not a string', () => { const invalidTokenPair = { accessOrWorkspaceAgnosticToken: { token: 123, @@ -128,14 +112,12 @@ describe('getTokenPair', () => { }, refreshToken: { token: 'refresh-token' }, }; - mockCookieStorage.getItem.mockReturnValue( - JSON.stringify(invalidTokenPair), - ); + getItemSpy.mockReturnValue(JSON.stringify(invalidTokenPair)); const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); }); it('should accept empty string token as valid', () => { @@ -146,16 +128,16 @@ describe('getTokenPair', () => { }, refreshToken: { token: 'refresh-token' }, }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); expect(result).toEqual(validTokenPair); - expect(mockCookieStorage.removeItem).not.toHaveBeenCalled(); + expect(removeItemSpy).not.toHaveBeenCalled(); }); }); - describe('when tokenPair cookie has valid structure', () => { + describe('when stored tokenPair has valid structure', () => { it('should return valid tokenPair with all required fields', () => { const validTokenPair = { accessOrWorkspaceAgnosticToken: { @@ -167,12 +149,12 @@ describe('getTokenPair', () => { expiresAt: '2024-01-02T00:00:00Z', }, }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); expect(result).toEqual(validTokenPair); - expect(mockCookieStorage.removeItem).not.toHaveBeenCalled(); + expect(removeItemSpy).not.toHaveBeenCalled(); }); it('should return valid tokenPair with minimal required fields', () => { @@ -181,12 +163,12 @@ describe('getTokenPair', () => { token: 'minimal-access-token', }, }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); expect(result).toEqual(validTokenPair); - expect(mockCookieStorage.removeItem).not.toHaveBeenCalled(); + expect(removeItemSpy).not.toHaveBeenCalled(); }); it('should return valid tokenPair with extra fields', () => { @@ -201,19 +183,18 @@ describe('getTokenPair', () => { }, additionalField: 'additional-value', }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); expect(result).toEqual(validTokenPair); - expect(mockCookieStorage.removeItem).not.toHaveBeenCalled(); + expect(removeItemSpy).not.toHaveBeenCalled(); }); }); describe('edge cases', () => { it('should handle JSON parsing error gracefully', () => { - mockCookieStorage.getItem.mockReturnValue('{"valid": "json"'); - // Simulate JSON.parse throwing an error + getItemSpy.mockReturnValue('{"valid": "json"'); const originalParse = JSON.parse; JSON.parse = jest.fn(() => { throw new SyntaxError('Unexpected end of JSON input'); @@ -222,9 +203,8 @@ describe('getTokenPair', () => { const result = getTokenPair(); expect(result).toBeUndefined(); - expect(mockCookieStorage.removeItem).toHaveBeenCalledWith('tokenPair'); + expect(removeItemSpy).toHaveBeenCalledWith(TOKEN_PAIR_LOCAL_STORAGE_KEY); - // Restore original JSON.parse JSON.parse = originalParse; }); @@ -235,7 +215,7 @@ describe('getTokenPair', () => { token: longToken, }, }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); @@ -250,7 +230,7 @@ describe('getTokenPair', () => { token: unicodeToken, }, }; - mockCookieStorage.getItem.mockReturnValue(JSON.stringify(validTokenPair)); + getItemSpy.mockReturnValue(JSON.stringify(validTokenPair)); const result = getTokenPair(); diff --git a/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts b/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts index 656bb909c53..e25b1cc1b0d 100644 --- a/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts +++ b/packages/twenty-front/src/modules/apollo/utils/getTokenPair.ts @@ -1,15 +1,12 @@ import { isDefined } from 'twenty-shared/utils'; +import { TOKEN_PAIR_LOCAL_STORAGE_KEY } from '@/auth/states/tokenPairState'; import { type AuthTokenPair } from '~/generated-metadata/graphql'; -import { cookieStorage } from '~/utils/cookie-storage'; import { isValidAuthTokenPair } from './isValidAuthTokenPair'; export const getTokenPair = (): AuthTokenPair | undefined => { - const stringTokenPair = cookieStorage.getItem('tokenPair'); + const stringTokenPair = localStorage.getItem(TOKEN_PAIR_LOCAL_STORAGE_KEY); if (!isDefined(stringTokenPair)) { - // oxlint-disable-next-line no-console - console.log('tokenPair is undefined'); - return undefined; } @@ -17,13 +14,13 @@ export const getTokenPair = (): AuthTokenPair | undefined => { const parsedTokenPair = JSON.parse(stringTokenPair); if (!isValidAuthTokenPair(parsedTokenPair)) { - cookieStorage.removeItem('tokenPair'); + localStorage.removeItem(TOKEN_PAIR_LOCAL_STORAGE_KEY); return undefined; } return parsedTokenPair; } catch { - cookieStorage.removeItem('tokenPair'); + localStorage.removeItem(TOKEN_PAIR_LOCAL_STORAGE_KEY); return undefined; } }; diff --git a/packages/twenty-front/src/modules/auth/states/tokenPairState.ts b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts index 5213f5be3a4..8e17915b864 100644 --- a/packages/twenty-front/src/modules/auth/states/tokenPairState.ts +++ b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts @@ -1,12 +1,13 @@ +import { isValidAuthTokenPair } from '@/apollo/utils/isValidAuthTokenPair'; import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; import { type AuthTokenPair } from '~/generated-metadata/graphql'; +export const TOKEN_PAIR_LOCAL_STORAGE_KEY = 'tokenPairState'; + export const tokenPairState = createAtomState({ - key: 'tokenPairState', + key: TOKEN_PAIR_LOCAL_STORAGE_KEY, defaultValue: null, - useCookieStorage: { - cookieKey: 'tokenPair', - validateInitFn: (payload: AuthTokenPair) => - Boolean(payload['accessOrWorkspaceAgnosticToken']), - }, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, + validateInitFn: (payload) => isValidAuthTokenPair(payload), }); diff --git a/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts b/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts index bb234cdb673..465bc9621b7 100644 --- a/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts +++ b/packages/twenty-front/src/modules/auth/utils/ensureTokenRenewed.ts @@ -19,7 +19,7 @@ export const ensureTokenRenewed = ( const tokenPair = store.get(tokenPairState.atom); - if (!isDefined(tokenPair)) { + if (!isDefined(tokenPair?.refreshToken?.token)) { return Promise.resolve(false); } diff --git a/packages/twenty-front/src/modules/auth/utils/migrateTokenPairCookieToLocalStorage.ts b/packages/twenty-front/src/modules/auth/utils/migrateTokenPairCookieToLocalStorage.ts new file mode 100644 index 00000000000..e7a81805f94 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/utils/migrateTokenPairCookieToLocalStorage.ts @@ -0,0 +1,29 @@ +import { isDefined } from 'twenty-shared/utils'; +import { TOKEN_PAIR_LOCAL_STORAGE_KEY } from '@/auth/states/tokenPairState'; +import { cookieStorage } from '~/utils/cookie-storage'; + +const LEGACY_TOKEN_PAIR_COOKIE_KEY = 'tokenPair'; + +export const migrateTokenPairCookieToLocalStorage = () => { + try { + const legacyCookieValue = cookieStorage.getItem( + LEGACY_TOKEN_PAIR_COOKIE_KEY, + ); + + if (!isDefined(legacyCookieValue)) { + return; + } + + const existingLocalStorageValue = localStorage.getItem( + TOKEN_PAIR_LOCAL_STORAGE_KEY, + ); + + if (!isDefined(existingLocalStorageValue)) { + localStorage.setItem(TOKEN_PAIR_LOCAL_STORAGE_KEY, legacyCookieValue); + } + + cookieStorage.removeItem(LEGACY_TOKEN_PAIR_COOKIE_KEY); + } catch { + // noop + } +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createAtomState.test.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createAtomState.test.ts new file mode 100644 index 00000000000..303d80609d4 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/__tests__/createAtomState.test.ts @@ -0,0 +1,41 @@ +import { createStore } from 'jotai'; +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +type Stored = { token: string } | null; + +const validateInitFn = (payload: NonNullable) => + typeof payload.token === 'string'; + +describe('createAtomState validated localStorage', () => { + afterEach(() => { + localStorage.clear(); + }); + + it('hydrates a persisted value that passes validateInitFn', () => { + localStorage.setItem('validAtom', JSON.stringify({ token: 'abc' })); + + const state = createAtomState({ + key: 'validAtom', + defaultValue: null, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, + validateInitFn, + }); + + expect(createStore().get(state.atom)).toEqual({ token: 'abc' }); + }); + + it('falls back to the default when the persisted value fails validateInitFn', () => { + localStorage.setItem('invalidAtom', JSON.stringify({ nope: true })); + + const state = createAtomState({ + key: 'invalidAtom', + defaultValue: null, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, + validateInitFn, + }); + + expect(createStore().get(state.atom)).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts index 5f538352629..c72b0aa1ce9 100644 --- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts +++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts @@ -24,12 +24,38 @@ type StateAtom = WritableAtom< type LocalStorageOptions = { getOnInit?: boolean }; +// Wraps the default JSON localStorage so a persisted value that fails +// validateInitFn falls back to the initial value instead of hydrating the atom +// with an invalid payload. +const createValidatedLocalStorage = ( + validateInitFn: (payload: NonNullable) => boolean, +) => { + const storage = createJSONStorage(() => localStorage); + + return { + ...storage, + getItem: (key: string, initialValue: ValueType): ValueType => { + const value = storage.getItem(key, initialValue) as ValueType; + + if ( + isDefined(value) && + !validateInitFn(value as NonNullable) + ) { + return initialValue; + } + + return value; + }, + }; +}; + export const createAtomState = ({ key, defaultValue, useLocalStorage = false, useSessionStorage = false, localStorageOptions, + validateInitFn, useCookieStorage, }: { key: string; @@ -37,6 +63,7 @@ export const createAtomState = ({ useLocalStorage?: boolean; useSessionStorage?: boolean; localStorageOptions?: LocalStorageOptions; + validateInitFn?: (payload: NonNullable) => boolean; useCookieStorage?: CookieStorageConfig; }): State => { let baseAtom: StateAtom; @@ -59,10 +86,13 @@ export const createAtomState = ({ getOnInit: true, }) as StateAtom; } else if (useLocalStorage) { + const storage = isDefined(validateInitFn) + ? createValidatedLocalStorage(validateInitFn) + : undefined; baseAtom = atomWithStorage( key, defaultValue, - undefined, + storage, localStorageOptions ?? undefined, ) as StateAtom; } else {