diff --git a/package.json b/package.json index ddf2fe74..be1a3af2 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,10 @@ "@lukemorales/query-key-factory": "^1.3.4", "@shopify/flash-list": "1.6.4", "@tanstack/react-query": "^5.52.1", + "@testing-library/react-hooks": "^8.0.1", "app-icon-badge": "^0.0.15", "axios": "^1.7.5", + "dayjs": "^1.11.13", "expo": "~51.0.39", "expo-constants": "~16.0.2", "expo-dev-client": "~4.0.29", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41bca8f4..5130d74a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,12 +26,18 @@ importers: '@tanstack/react-query': specifier: ^5.52.1 version: 5.52.2(react@18.2.0) + '@testing-library/react-hooks': + specifier: ^8.0.1 + version: 8.0.1(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react-test-renderer@18.3.1(react@18.2.0))(react@18.2.0) app-icon-badge: specifier: ^0.0.15 version: 0.0.15 axios: specifier: ^1.7.5 version: 1.7.5 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 expo: specifier: ~51.0.39 version: 51.0.39(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2)) @@ -1915,6 +1921,22 @@ packages: resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react-hooks@8.0.1': + resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==} + engines: {node: '>=12'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 + react: ^16.9.0 || ^17.0.0 + react-dom: ^16.9.0 || ^17.0.0 + react-test-renderer: ^16.9.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-dom: + optional: true + react-test-renderer: + optional: true + '@testing-library/react-native@12.9.0': resolution: {integrity: sha512-wIn/lB1FjV2N4Q7i9PWVRck3Ehwq5pkhAef5X5/bmQ78J/NoOsGbVY2/DG5Y9Lxw+RfE+GvSEh/fe5Tz6sKSvw==} peerDependencies: @@ -6302,6 +6324,12 @@ packages: peerDependencies: react: ^18.2.0 + react-error-boundary@3.1.4: + resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1' + react-error-boundary@4.0.13: resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} peerDependencies: @@ -10524,6 +10552,16 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 + '@testing-library/react-hooks@8.0.1(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react-test-renderer@18.3.1(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.25.4 + react: 18.2.0 + react-error-boundary: 3.1.4(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.79 + react-dom: 18.2.0(react@18.2.0) + react-test-renderer: 18.3.1(react@18.2.0) + '@testing-library/react-native@12.9.0(jest@29.7.0(@types/node@22.5.0)(ts-node@10.9.2(@types/node@22.5.0)(typescript@5.3.3)))(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react-test-renderer@18.3.1(react@18.2.0))(react@18.2.0)': dependencies: jest-matcher-utils: 29.7.0 @@ -15778,6 +15816,11 @@ snapshots: react: 18.2.0 scheduler: 0.23.2 + react-error-boundary@3.1.4(react@18.2.0): + dependencies: + '@babel/runtime': 7.25.4 + react: 18.2.0 + react-error-boundary@4.0.13(react@18.2.0): dependencies: '@babel/runtime': 7.25.4 diff --git a/src/api/auth/use-user.tsx b/src/api/auth/use-user.tsx new file mode 100644 index 00000000..fdca349f --- /dev/null +++ b/src/api/auth/use-user.tsx @@ -0,0 +1,24 @@ +import { createQuery } from 'react-query-kit'; + +import { client } from '../common'; + +export type User = { + id: number; + name: string; + email: string; + picture: string | null; + birthday: Date | null; +}; + +const getUser = async () => { + const { data } = await client({ + url: '/v1/users', + method: 'GET', + }); + return data; +}; + +export const useUser = createQuery({ + queryKey: ['getUser'], + fetcher: getUser, +}); diff --git a/src/api/types.ts b/src/api/types.ts index 410359c8..2ac18043 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -4,3 +4,9 @@ export type PaginateQuery = { next: string | null; previous: string | null; }; + +export type ApiResponse = + | { + errors: string[]; + } + | T; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 6be6b5cd..ba1d0717 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -1,7 +1,8 @@ import { Link, Redirect, SplashScreen, Tabs } from 'expo-router'; import { useCallback, useEffect } from 'react'; -import { useAuth, useIsFirstTime } from '@/core'; +import { useAuth } from '@/components/providers/auth'; +import { useIsFirstTime } from '@/core'; import { Pressable, Text } from '@/ui'; import { Feed as FeedIcon, @@ -10,24 +11,22 @@ import { } from '@/ui/icons'; export default function TabLayout() { - const status = useAuth.use.status(); + const { isAuthenticated, ready } = useAuth(); const [isFirstTime] = useIsFirstTime(); const hideSplash = useCallback(async () => { await SplashScreen.hideAsync(); }, []); + useEffect(() => { - const TIMEOUT = 1000; - if (status !== 'idle') { - setTimeout(() => { - hideSplash(); - }, TIMEOUT); + if (!ready) { + hideSplash(); } - }, [hideSplash, status]); + }, [hideSplash, ready]); if (isFirstTime) { return ; } - if (status === 'signOut') { + if (!isAuthenticated && ready) { return ; } return ( @@ -45,7 +44,6 @@ export default function TabLayout() { name="style" options={{ title: 'Style', - headerShown: false, tabBarIcon: ({ color }) => , tabBarTestID: 'style-tab', }} @@ -54,7 +52,6 @@ export default function TabLayout() { name="settings" options={{ title: 'Settings', - headerShown: false, tabBarIcon: ({ color }) => , tabBarTestID: 'settings-tab', }} diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 6ad56e82..ae32809d 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -1,18 +1,22 @@ -import { Env } from '@env'; +/* eslint-disable max-lines-per-function */ import { Link } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React from 'react'; +import { useUser } from '@/api/auth/use-user'; +import { useAuth } from '@/components/providers/auth'; import { Item } from '@/components/settings/item'; import { ItemsContainer } from '@/components/settings/items-container'; import { LanguageItem } from '@/components/settings/language-item'; import { ThemeItem } from '@/components/settings/theme-item'; -import { translate, useAuth } from '@/core'; +import { translate } from '@/core'; +import { Env } from '@/core/env'; import { colors, FocusAwareStatusBar, ScrollView, Text, View } from '@/ui'; import { Website } from '@/ui/icons'; export default function Settings() { - const signOut = useAuth.use.signOut(); + const { data: userData } = useUser(); + const { logout } = useAuth(); const { colorScheme } = useColorScheme(); const iconColor = colorScheme === 'dark' ? colors.neutral[400] : colors.neutral[500]; @@ -20,12 +24,18 @@ export default function Settings() { return ( <> - - + {translate('settings.title')} + + + + @@ -67,7 +77,7 @@ export default function Settings() { - + diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 19915def..2ba89602 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -12,6 +12,7 @@ import { KeyboardProvider } from 'react-native-keyboard-controller'; import { APIProvider } from '@/api'; import interceptors from '@/api/common/interceptors'; +import { AuthProvider } from '@/components/providers/auth'; import { hydrateAuth, loadSelectedTheme } from '@/core'; import { useThemeConfig } from '@/core/use-theme-config'; @@ -61,10 +62,12 @@ function Providers({ children }: { children: React.ReactNode }) { - - {children} - - + + + {children} + + + diff --git a/src/components/providers/auth.test.tsx b/src/components/providers/auth.test.tsx new file mode 100644 index 00000000..7ed68a46 --- /dev/null +++ b/src/components/providers/auth.test.tsx @@ -0,0 +1,190 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react-native'; +import dayjs from 'dayjs'; + +import { fireEvent, render, screen } from '@/core/test-utils'; +import { Text, TouchableOpacity, View } from '@/ui'; + +import { + AuthProvider, + clearTokens, + getTokenDetails, + storeTokens, + useAuth, +} from './auth'; + +// Mock MMKV Storage +jest.mock('react-native-mmkv', () => { + const mockStorage = new Map(); + return { + MMKV: jest.fn().mockImplementation(() => ({ + set: (key: string, value: string) => mockStorage.set(key, value), + getString: (key: string) => mockStorage.get(key) || null, + delete: (key: string) => mockStorage.delete(key), + })), + }; +}); + +// Mock API client interceptors +jest.mock('@/api', () => ({ + client: { + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }, +})); + +const TestComponent = () => { + const { token, isAuthenticated, loading, ready, logout } = useAuth(); + return ( + + {token} + {isAuthenticated ? 'true' : 'false'} + {loading ? 'true' : 'false'} + {ready ? 'true' : 'false'} + + Logout + + + ); +}; + +const mockedAccessToken = 'access-token'; +const mockedValidToken = 'valid-token'; +const mockedRefreshToken = 'refresh-token'; +const mockedExpiryDate = '2025-01-17T00:00:00Z'; +const mockedUserId = 'user-id'; + +describe('Auth Utilities', () => { + afterEach(() => { + clearTokens(); + }); + + it('stores tokens correctly', () => { + storeTokens({ + accessToken: mockedAccessToken, + refreshToken: mockedRefreshToken, + userId: mockedUserId, + expiration: mockedExpiryDate, + }); + + const tokens = getTokenDetails(); + expect(tokens).toEqual({ + accessToken: mockedAccessToken, + refreshToken: mockedRefreshToken, + userId: mockedUserId, + expiration: mockedExpiryDate, + }); + }); + + it('clears tokens correctly', () => { + storeTokens({ + accessToken: mockedAccessToken, + refreshToken: mockedRefreshToken, + userId: mockedUserId, + expiration: mockedExpiryDate, + }); + clearTokens(); + + const tokens = getTokenDetails(); + expect(tokens).toEqual({ + accessToken: '', + refreshToken: '', + userId: '', + expiration: '', + }); + }); +}); + +describe('AuthProvider', () => { + it('provides initial state correctly', () => { + const { result } = renderHook(() => useAuth(), { + wrapper: AuthProvider, + }); + + expect(result.current).toEqual({ + token: null, + isAuthenticated: false, + loading: false, + ready: true, + logout: expect.any(Function), + }); + }); + + it('handles token state correctly', async () => { + storeTokens({ + accessToken: mockedValidToken, + refreshToken: mockedRefreshToken, + userId: mockedUserId, + expiration: dayjs().add(1, 'hour').toISOString(), + }); + + const { result } = renderHook(() => useAuth(), { + wrapper: AuthProvider, + }); + + await waitFor(() => { + expect(result.current.isAuthenticated).toBe(true); + }); + + expect(result.current.token).toBe('valid-token'); + expect(result.current.loading).toBe(false); + expect(result.current.ready).toBe(true); + }); + + it('logs out correctly', () => { + const { result } = renderHook(() => useAuth(), { + wrapper: AuthProvider, + }); + + act(() => { + result.current.logout(); + }); + + expect(getTokenDetails()).toEqual({ + accessToken: '', + refreshToken: '', + userId: '', + expiration: '', + }); + expect(result.current.isAuthenticated).toBe(false); + }); +}); +describe('TestComponent', () => { + afterEach(() => { + clearTokens(); + }); + + it('renders correctly and handles logout', async () => { + // Set initial tokens + storeTokens({ + accessToken: mockedValidToken, + refreshToken: mockedRefreshToken, + userId: mockedUserId, + expiration: dayjs().add(1, 'hour').toISOString(), + }); + + // Render the component with AuthProvider + render( + + + , + ); + + // Verify initial state + expect(screen.getByTestId('token')).toHaveTextContent('valid-token'); + expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('true'); + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + expect(screen.getByTestId('ready')).toHaveTextContent('true'); + + // Simulate logout action + fireEvent.press(screen.getByTestId('logout')); + + // Verify state after logout + expect(screen.getByTestId('token')).toHaveTextContent(''); + expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('false'); + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + expect(screen.getByTestId('ready')).toHaveTextContent('true'); + }); +}); diff --git a/src/components/providers/auth.tsx b/src/components/providers/auth.tsx new file mode 100644 index 00000000..604f4601 --- /dev/null +++ b/src/components/providers/auth.tsx @@ -0,0 +1,185 @@ +import dayjs from 'dayjs'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { MMKV } from 'react-native-mmkv'; + +import { client } from '@/api'; + +const unauthorizedHttpStatusCode = 401; + +const storageKey = 'auth-storage'; + +export const authStorage = new MMKV({ + id: storageKey, +}); + +export const HEADER_KEYS = { + ACCESS_TOKEN: 'access-token', + REFRESH_TOKEN: 'client', + USER_ID: 'uid', + EXPIRY: 'expiry', + AUTHORIZATION: 'Authorization', +}; + +export const storeTokens = (args: { + accessToken: string; + refreshToken: string; + userId: string; + expiration: string; +}) => { + authStorage.set(HEADER_KEYS.ACCESS_TOKEN, args.accessToken); + authStorage.set(HEADER_KEYS.REFRESH_TOKEN, args.refreshToken); + authStorage.set(HEADER_KEYS.USER_ID, args.userId); + authStorage.set(HEADER_KEYS.EXPIRY, args.expiration); +}; + +export const getTokenDetails = () => ({ + accessToken: authStorage.getString(HEADER_KEYS.ACCESS_TOKEN) ?? '', + refreshToken: authStorage.getString(HEADER_KEYS.REFRESH_TOKEN) ?? '', + userId: authStorage.getString(HEADER_KEYS.USER_ID) ?? '', + expiration: authStorage.getString(HEADER_KEYS.EXPIRY) ?? '', +}); + +export const clearTokens = () => { + authStorage.delete(HEADER_KEYS.ACCESS_TOKEN); + authStorage.delete(HEADER_KEYS.REFRESH_TOKEN); + authStorage.delete(HEADER_KEYS.USER_ID); + authStorage.delete(HEADER_KEYS.EXPIRY); +}; + +// Request interceptor to add Authorization header +client.interceptors.request.use( + (config) => { + const { accessToken, expiration } = getTokenDetails(); + + // Check if token is expired + if (dayjs().isAfter(dayjs(expiration))) { + // TODO + // Handle token refresh logic + clearTokens(); + } + + if (accessToken) { + config.headers[HEADER_KEYS.AUTHORIZATION] = `Bearer ${accessToken}`; + } + + return config; + }, + (error) => Promise.reject(error), +); + +// Response interceptor to handle tokens +client.interceptors.response.use( + (response) => { + const accessToken = response.headers[HEADER_KEYS.ACCESS_TOKEN] || ''; + const refreshToken = response.headers[HEADER_KEYS.REFRESH_TOKEN] || ''; + const userId = response.headers[HEADER_KEYS.USER_ID] || ''; + + const expiration = response.headers[HEADER_KEYS.EXPIRY] + ? dayjs + .unix(parseInt(response.headers[HEADER_KEYS.EXPIRY], 10)) + .toISOString() + : dayjs().add(1, 'hour').toISOString(); + + if (accessToken && refreshToken && userId && expiration) { + storeTokens({ accessToken, refreshToken, userId, expiration }); + } + + return response; + }, + (error) => Promise.reject(error), +); + +interface AuthContextProps { + token: string | null; + isAuthenticated: boolean; + loading: boolean; + ready: boolean; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(true); + const [ready, setReady] = useState(false); + + const checkToken = useCallback(() => { + const storedToken = authStorage.getString(HEADER_KEYS.ACCESS_TOKEN); + const expiration = authStorage.getString(HEADER_KEYS.EXPIRY); + + if (!storedToken || !expiration) { + setToken(null); + setLoading(false); + setReady(true); + return; + } + + const isExpired = dayjs().isAfter(dayjs(expiration)); + + if (isExpired) { + setToken(null); // Token expired, clear it + } else { + setToken(storedToken); // Token is valid, set it + } + + setLoading(false); + setReady(true); + }, []); + + const logout = () => { + clearTokens(); + setToken(null); + }; + + useEffect(() => { + try { + checkToken(); + } catch { + setReady(true); + } + const requestInterceptor = client.interceptors.response.use( + (config) => { + if (config.status === unauthorizedHttpStatusCode) { + logout(); + } + checkToken(); + return config; + }, + (error) => Promise.reject(error), + ); + + return () => { + client.interceptors.request.eject(requestInterceptor); + }; + }, [checkToken]); + + const values = useMemo( + () => ({ + token, + isAuthenticated: !!token, + loading, + ready, + logout, + }), + [loading, ready, token], + ); + return {children}; +}; + +export const useAuth = (): AuthContextProps => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/src/components/settings/items-container.tsx b/src/components/settings/items-container.tsx index 4ece303a..b107d0dc 100644 --- a/src/components/settings/items-container.tsx +++ b/src/components/settings/items-container.tsx @@ -9,12 +9,12 @@ type Props = { }; export const ItemsContainer = ({ children, title }: Props) => ( - <> - {title && } - { - - {children} - - } - - ); + + {title && } + { + + {children} + + } + +); diff --git a/src/core/test-utils.tsx b/src/core/test-utils.tsx index c3a7e13d..3a7ec405 100644 --- a/src/core/test-utils.tsx +++ b/src/core/test-utils.tsx @@ -6,7 +6,14 @@ import type { RenderOptions } from '@testing-library/react-native'; import { render, userEvent } from '@testing-library/react-native'; import type { ReactElement } from 'react'; import React from 'react'; -const createAppWrapper = () => ({ children }: { children: React.ReactNode }) => ( + +jest.mock('@dev-plugins/react-query', () => ({ + useReactQueryDevTools: jest.fn(), +})); + +const createAppWrapper = + () => + ({ children }: { children: React.ReactNode }) => ( {children} diff --git a/src/translations/en.json b/src/translations/en.json index 42ac3695..6c406279 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -65,6 +65,11 @@ }, "settings": { "about": "About", + "account": { + "email": "Email", + "name": "Name", + "title": "Account" + }, "app_name": "App Name", "arabic": "Arabic", "english": "English",