From 0a5ff91616e1ab5ee33b96af3939db3f8926911d Mon Sep 17 00:00:00 2001 From: Guillermo Machado Date: Wed, 15 Jan 2025 12:56:59 -0300 Subject: [PATCH] feat: auth provider proposal --- src/app/(app)/_layout.tsx | 14 +-- src/app/(app)/settings.tsx | 7 +- src/app/_layout.tsx | 11 +- src/components/providers/auth.tsx | 171 ++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 src/components/providers/auth.tsx diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 6be6b5cd..2c9ef1fe 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,25 @@ 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') { + if (!ready) { setTimeout(() => { hideSplash(); }, TIMEOUT); } - }, [hideSplash, status]); + }, [hideSplash, ready]); if (isFirstTime) { return ; } - if (status === 'signOut') { + if (!isAuthenticated && ready) { return ; } return ( @@ -45,7 +47,6 @@ export default function TabLayout() { name="style" options={{ title: 'Style', - headerShown: false, tabBarIcon: ({ color }) => , tabBarTestID: 'style-tab', }} @@ -54,7 +55,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..343a80b1 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -3,16 +3,17 @@ import { Link } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React from 'react'; +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 { colors, FocusAwareStatusBar, ScrollView, Text, View } from '@/ui'; import { Website } from '@/ui/icons'; export default function Settings() { - const signOut = useAuth.use.signOut(); + const { logout } = useAuth(); const { colorScheme } = useColorScheme(); const iconColor = colorScheme === 'dark' ? colors.neutral[400] : colors.neutral[500]; @@ -67,7 +68,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.tsx b/src/components/providers/auth.tsx new file mode 100644 index 00000000..c08d10d6 --- /dev/null +++ b/src/components/providers/auth.tsx @@ -0,0 +1,171 @@ +import dayjs from 'dayjs'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { MMKV } from 'react-native-mmkv'; + +import { client } from '@/api'; +import { storage } from '@/core/storage'; + +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) ?? '', +}); + +// 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 + } + + 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])).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 = () => { + storage.delete(HEADER_KEYS.ACCESS_TOKEN); + storage.delete(HEADER_KEYS.REFRESH_TOKEN); + storage.delete(HEADER_KEYS.USER_ID); + storage.delete(HEADER_KEYS.EXPIRY); + setToken(null); + }; + + useEffect(() => { + checkToken(); + const requestInterceptor = client.interceptors.response.use( + (config) => { + checkToken(); + return config; + }, + (error) => Promise.reject(error), + ); + + return () => { + // Clean up the interceptor when the component unmounts + client.interceptors.request.eject(requestInterceptor); + }; + }, [checkToken]); + return ( + + {children} + + ); +}; + +export const useAuth = (): AuthContextProps => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +};