Skip to content
This repository was archived by the owner on Aug 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { createContext, useMemo } from 'react';

import { useAuth } from '@app/hooks/useAuth';
import { AuthStateMode } from '@app/hooks/useAuthState';
import { LoginLoadingScreen, LoginScreen } from '@app/screens/LoginScreen';

export interface AuthContextInterface {
Expand All @@ -14,7 +15,7 @@ export interface AuthContextInterface {
* the case when the auth state is not yet set (auth state === undefined)
* to show the user a loading screen (see LoginLoadingScreen in AuthProvider).
*/
isAuthenticated: boolean | null;
isAuthenticated: boolean | AuthStateMode.Loading;
}

/**
Expand Down Expand Up @@ -50,7 +51,7 @@ export const AuthProvider: AuthProviderType = ({
[isAuthenticated, logout],
);

if (authContextValue.isAuthenticated === null) {
if (authContextValue.isAuthenticated === AuthStateMode.Loading) {
return <LoginLoadingScreen />;
}

Expand Down
4 changes: 2 additions & 2 deletions src/contexts/__tests__/ApolloProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ApolloLink, execute, gql } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { render } from '@testing-library/react-native';

import { useAuthState } from '@app/hooks/useAuthState';
import { AuthStateMode, useAuthState } from '@app/hooks/useAuthState';

import {
ApolloProvider,
Expand All @@ -26,7 +26,7 @@ describe('ApolloProvider', () => {
beforeEach(() => {
mockedUseAuthState.mockReturnValue({
getIdToken: mockGetIdToken,
authState: null,
authState: AuthStateMode.Loading,
storeAuthState: jest.fn(),
getAuthState: jest.fn(),
removeAuthState: jest.fn(),
Expand Down
11 changes: 7 additions & 4 deletions src/contexts/__tests__/AuthContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,26 @@ import { View } from 'react-native-ui-lib';

import { AuthProvider } from '@app/contexts/AuthContext';
import { AuthLoadingState, useAuth } from '@app/hooks/useAuth';
import { AuthStateMode } from '@app/hooks/useAuthState';

jest.mock('@app/hooks/useAuth');
const mockedUseAuth = jest.mocked(useAuth);

describe('AuthProvider', () => {
const setIsAuthenticated = (isAuthenticated: boolean | null) =>
const setIsAuthenticated = (
isAuthenticated: boolean | AuthStateMode.Loading,
) =>
mockedUseAuth.mockReturnValue({
login: jest.fn(),
logout: jest.fn(),
authState: undefined,
authState: AuthStateMode.Loading,
authLoading: AuthLoadingState.NotLoading,
isAuthenticated,
});

describe('when isAuthenticated is null', () => {
describe('when isAuthenticated is AuthStateMode.Loading', () => {
beforeEach(() => {
setIsAuthenticated(null);
setIsAuthenticated(AuthStateMode.Loading);
});

it('renders the LoginLoadingScreen if', () => {
Expand Down
35 changes: 23 additions & 12 deletions src/hooks/__tests__/useAuth.helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
shouldRefreshComplex,
updateRefreshToken,
} from '@app/hooks/useAuth.helpers';
import { AuthState } from '@app/hooks/useAuthState';
import { AuthState, AuthStateMode } from '@app/hooks/useAuthState';
import { authorize, refresh, revoke } from '@app/utils/oauth';

jest.mock('../../utils/oauth');
Expand All @@ -18,11 +18,13 @@ const mockedRevoke = jest.mocked(revoke);

describe('getIsAuthenticated', () => {
it('should return null for an undefined authState', () => {
expect(getIsAuthenticated(undefined)).toBeNull();
expect(getIsAuthenticated(AuthStateMode.Loading)).toBe(
AuthStateMode.Loading,
);
});

it('should return false for null authState', () => {
expect(getIsAuthenticated(null)).toBe(false);
expect(getIsAuthenticated(AuthStateMode.NotAuthenticated)).toBe(false);
});

it('should return true for authState with a defined accessToken', () => {
Expand Down Expand Up @@ -59,13 +61,16 @@ describe('shouldRefresh', () => {
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
};

it('should return false if authState is undefined', () => {
const result = shouldRefresh(undefined, AuthLoadingState.Init);
it('should return false if authState is loading', () => {
const result = shouldRefresh(AuthStateMode.Loading, AuthLoadingState.Init);
expect(result).toBe(false);
});

it('should return false if authState is null', () => {
const result = shouldRefresh(null, AuthLoadingState.Init);
it('should return false if authState is not authenticated', () => {
const result = shouldRefresh(
AuthStateMode.NotAuthenticated,
AuthLoadingState.Init,
);
expect(result).toBe(false);
});

Expand Down Expand Up @@ -117,13 +122,19 @@ describe('shouldRefreshComplex', () => {
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
};

it('should return false if authState is undefined', () => {
const result = shouldRefreshComplex(undefined, AuthLoadingState.Init);
it('should return false if authState is loading', () => {
const result = shouldRefreshComplex(
AuthStateMode.Loading,
AuthLoadingState.Init,
);
expect(result).toBe(false);
});

it('should return false if authState is null', () => {
const result = shouldRefreshComplex(null, AuthLoadingState.Init);
it('should return false if authState is not authenticated', () => {
const result = shouldRefreshComplex(
AuthStateMode.NotAuthenticated,
AuthLoadingState.Init,
);
expect(result).toBe(false);
});

Expand Down Expand Up @@ -181,7 +192,7 @@ describe('updateRefreshToken', () => {

it('should remove auth state if authState is not valid', async () => {
await updateRefreshToken(
null,
AuthStateMode.NotAuthenticated,
setAuthLoading,
storeAuthState,
removeAuthState,
Expand Down
16 changes: 10 additions & 6 deletions src/hooks/__tests__/useAuthState.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { act, renderHook } from '@testing-library/react-hooks';

import { AuthState, useAuthState } from '@app/hooks/useAuthState';
import {
AuthState,
AuthStateMode,
useAuthState,
} from '@app/hooks/useAuthState';
import { useEncryptedStorage } from '@app/hooks/useEncryptedStorage';

jest.mock('../useEncryptedStorage');
Expand Down Expand Up @@ -70,7 +74,7 @@ describe('useAuthState', () => {
});

expect(mockedGetItem).toHaveBeenCalledWith('authState');
expect(result.current.authState).toBeNull();
expect(result.current.authState).toBe(AuthStateMode.NotAuthenticated);
});

it('should return null if auth state does not exist', async () => {
Expand All @@ -83,7 +87,7 @@ describe('useAuthState', () => {
});

expect(mockedGetItem).toHaveBeenCalledWith('authState');
expect(result.current.authState).toBeNull();
expect(result.current.authState).toBe(AuthStateMode.NotAuthenticated);
});

it('should handle an empty string in storage', async () => {
Expand All @@ -96,7 +100,7 @@ describe('useAuthState', () => {
});

expect(mockedGetItem).toHaveBeenCalledWith('authState');
expect(result.current.authState).toBeNull();
expect(result.current.authState).toBe(AuthStateMode.NotAuthenticated);
});

it('should handle bad data in the storage', async () => {
Expand All @@ -113,7 +117,7 @@ describe('useAuthState', () => {
});

expect(mockedGetItem).toHaveBeenCalledWith('authState');
expect(result.current.authState).toBeNull();
expect(result.current.authState).toBe(AuthStateMode.NotAuthenticated);
expect(mockedSetItem).toHaveBeenCalledWith('authState', '');

expect(consoleErrorMock).toHaveBeenCalledWith(
Expand All @@ -132,7 +136,7 @@ describe('useAuthState', () => {
await result.current.removeAuthState();
});
expect(mockedSetItem).toHaveBeenCalledWith('authState', '');
expect(result.current.authState).toBeNull();
expect(result.current.authState).toBe(AuthStateMode.NotAuthenticated);
});
});
});
15 changes: 7 additions & 8 deletions src/hooks/useAuth.helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AuthLoadingState } from '@app/hooks/useAuth';
import {
AuthState,
AuthStateMode,
isAuthState,
MaybeAuthState,
} from '@app/hooks/useAuthState';
Expand All @@ -14,20 +15,18 @@ import {
/**
* This function is used to determine if the user is authenticated.
*
* It returns null if the auth state is not present yet. This is useful
* to prevent the app from rendering the LoginScreen shortly before the
* It returns AuthStateMode.Loading if the auth state is not present yet. This is useful
* to prevent the app from rendering the LoginScreen shortly before the possibly
* authenticated auth state is retrieved.
*/
export const getIsAuthenticated = (
authState: MaybeAuthState,
): boolean | null => {
// Undefined means that the auth state is not present yet.
if (authState === undefined) {
return null;
): boolean | AuthStateMode.Loading => {
if (authState === AuthStateMode.Loading) {
return AuthStateMode.Loading;
}

// Null means that the user is not authenticated.
return authState !== null;
return authState !== AuthStateMode.NotAuthenticated;
};

/**
Expand Down
7 changes: 3 additions & 4 deletions src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ export enum AuthLoadingState {
* On startup, the auth state is unknown, hence we don't know yet if we need to
* refresh it.
*/
Init,
Init = 'init',
/**
* An auth state refresh is in progress.
*/
Loading,
Loading = 'loading',
/**
* No auth state refresh is in progress.
*/
NotLoading,
NotLoading = 'notLoading',
}

/**
Expand Down Expand Up @@ -60,7 +60,6 @@ export const useAuth = (): UseAuthReturnType => {
const { authState, getAuthState, storeAuthState, removeAuthState } =
useAuthState();

// Undefined means it's the first run
const [authLoading, setAuthLoading] = useState<AuthLoadingState>(
AuthLoadingState.Init,
);
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useAuth.useEffect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const REFRESH_CHECK_INTERVAL = 2 * 1000; // eslint-disable-line no-magic-numbers
* for the auth state.
*/
export const effectUpdateRefreshToken = (
authState: MaybeAuthState | null,
authState: MaybeAuthState,
authLoading: AuthLoadingState,
setAuthLoading: (authLoading: AuthLoadingState) => void,
storeAuthState: (authState: AuthState) => Promise<void>,
Expand Down
43 changes: 29 additions & 14 deletions src/hooks/useAuthState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,22 @@ export interface AuthState {
refreshToken: string | null;
expiresAt: string;
}

export enum AuthStateMode {
/**
* This mode means that on startup, the auth state is not loaded yet.
*/
Loading = 'loading',
/**
* This mode means that no auth state is available and the user is not authenticated.
*/
NotAuthenticated = 'notAuthenticated',
}

/**
* This state is used to store the auth state in memory.
*
* Undefined means the initial state when nothing is loaded yet.
* Null means no auth state is available and the user is not authenticated.
* This state represents the state of the auth information.
*/
export type MaybeAuthState = AuthState | null | undefined;
export type MaybeAuthState = AuthState | AuthStateMode;

/**
* Type guard to check if the auth state is an AuthState.
Expand All @@ -42,17 +51,19 @@ export const isAuthState = (authState: unknown): authState is AuthState =>
*/
const parseAuthStateFromStorage = (
authStateFromStorageString: string | null,
): AuthState | null => {
): AuthState | AuthStateMode.NotAuthenticated => {
if (
authStateFromStorageString === null ||
authStateFromStorageString === ''
) {
return null;
return AuthStateMode.NotAuthenticated;
}

const parsedAuthState = JSON.parse(authStateFromStorageString) as unknown;

return isAuthState(parsedAuthState) ? parsedAuthState : null;
return isAuthState(parsedAuthState)
? parsedAuthState
: AuthStateMode.NotAuthenticated;
};

interface UseAuthStateReturnType {
Expand All @@ -68,7 +79,7 @@ interface UseAuthStateReturnType {
*
* @returns The auth state or undefined if it doesn't exist.
*/
getAuthState: () => Promise<AuthState | null>;
getAuthState: () => Promise<AuthState | AuthStateMode.NotAuthenticated>;
/**
* This method is used to get the id token from the auth state without triggering
* re-renders in case the ID token changes. This is needed in the ApolloProvider so
Expand All @@ -89,7 +100,9 @@ interface UseAuthStateReturnType {
export const useAuthState = (): UseAuthStateReturnType => {
const { setItem, getItem } = useEncryptedStorage();

const [authState, setAuthState] = useState<MaybeAuthState>(undefined);
const [authState, setAuthState] = useState<MaybeAuthState>(
AuthStateMode.Loading,
);

const storeAuthState = useCallback(
async (newAuthState: AuthState) => {
Expand All @@ -99,7 +112,9 @@ export const useAuthState = (): UseAuthStateReturnType => {
[setItem],
);

const getAuthState = useCallback(async (): Promise<AuthState | null> => {
const getAuthState = useCallback(async (): Promise<
AuthState | AuthStateMode.NotAuthenticated
> => {
try {
const authStateFromStorageString = await getItem('authState');
const authStateFromStorage = parseAuthStateFromStorage(
Expand All @@ -115,8 +130,8 @@ export const useAuthState = (): UseAuthStateReturnType => {
// we remove it from the encrypted storage.
await setItem('authState', '');

setAuthState(null);
return null;
setAuthState(AuthStateMode.NotAuthenticated);
return AuthStateMode.NotAuthenticated;
}
}, [getItem, setItem]);

Expand All @@ -137,7 +152,7 @@ export const useAuthState = (): UseAuthStateReturnType => {
}, [getItem]);

const removeAuthState = useCallback(async () => {
setAuthState(null);
setAuthState(AuthStateMode.NotAuthenticated);
await setItem('authState', '');
}, [setItem]);

Expand Down