Skip to content

Commit 0a5ff91

Browse files
Guillermo MachadoGuillermo Machado
authored andcommitted
feat: auth provider proposal
1 parent 564f6df commit 0a5ff91

File tree

4 files changed

+189
-14
lines changed

4 files changed

+189
-14
lines changed

src/app/(app)/_layout.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Link, Redirect, SplashScreen, Tabs } from 'expo-router';
22
import { useCallback, useEffect } from 'react';
33

4-
import { useAuth, useIsFirstTime } from '@/core';
4+
import { useAuth } from '@/components/providers/auth';
5+
import { useIsFirstTime } from '@/core';
56
import { Pressable, Text } from '@/ui';
67
import {
78
Feed as FeedIcon,
@@ -10,24 +11,25 @@ import {
1011
} from '@/ui/icons';
1112

1213
export default function TabLayout() {
13-
const status = useAuth.use.status();
14+
const { isAuthenticated, ready } = useAuth();
1415
const [isFirstTime] = useIsFirstTime();
1516
const hideSplash = useCallback(async () => {
1617
await SplashScreen.hideAsync();
1718
}, []);
19+
1820
useEffect(() => {
1921
const TIMEOUT = 1000;
20-
if (status !== 'idle') {
22+
if (!ready) {
2123
setTimeout(() => {
2224
hideSplash();
2325
}, TIMEOUT);
2426
}
25-
}, [hideSplash, status]);
27+
}, [hideSplash, ready]);
2628

2729
if (isFirstTime) {
2830
return <Redirect href="/onboarding" />;
2931
}
30-
if (status === 'signOut') {
32+
if (!isAuthenticated && ready) {
3133
return <Redirect href="/sign-in" />;
3234
}
3335
return (
@@ -45,7 +47,6 @@ export default function TabLayout() {
4547
name="style"
4648
options={{
4749
title: 'Style',
48-
headerShown: false,
4950
tabBarIcon: ({ color }) => <StyleIcon color={color} />,
5051
tabBarTestID: 'style-tab',
5152
}}
@@ -54,7 +55,6 @@ export default function TabLayout() {
5455
name="settings"
5556
options={{
5657
title: 'Settings',
57-
headerShown: false,
5858
tabBarIcon: ({ color }) => <SettingsIcon color={color} />,
5959
tabBarTestID: 'settings-tab',
6060
}}

src/app/(app)/settings.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@ import { Link } from 'expo-router';
33
import { useColorScheme } from 'nativewind';
44
import React from 'react';
55

6+
import { useAuth } from '@/components/providers/auth';
67
import { Item } from '@/components/settings/item';
78
import { ItemsContainer } from '@/components/settings/items-container';
89
import { LanguageItem } from '@/components/settings/language-item';
910
import { ThemeItem } from '@/components/settings/theme-item';
10-
import { translate, useAuth } from '@/core';
11+
import { translate } from '@/core';
1112
import { colors, FocusAwareStatusBar, ScrollView, Text, View } from '@/ui';
1213
import { Website } from '@/ui/icons';
1314

1415
export default function Settings() {
15-
const signOut = useAuth.use.signOut();
16+
const { logout } = useAuth();
1617
const { colorScheme } = useColorScheme();
1718
const iconColor =
1819
colorScheme === 'dark' ? colors.neutral[400] : colors.neutral[500];
@@ -67,7 +68,7 @@ export default function Settings() {
6768

6869
<View className="my-8">
6970
<ItemsContainer>
70-
<Item text="settings.logout" onPress={signOut} />
71+
<Item text="settings.logout" onPress={logout} />
7172
</ItemsContainer>
7273
</View>
7374
</View>

src/app/_layout.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { KeyboardProvider } from 'react-native-keyboard-controller';
1212

1313
import { APIProvider } from '@/api';
1414
import interceptors from '@/api/common/interceptors';
15+
import { AuthProvider } from '@/components/providers/auth';
1516
import { hydrateAuth, loadSelectedTheme } from '@/core';
1617
import { useThemeConfig } from '@/core/use-theme-config';
1718

@@ -61,10 +62,12 @@ function Providers({ children }: { children: React.ReactNode }) {
6162
<KeyboardProvider>
6263
<ThemeProvider value={theme}>
6364
<APIProvider>
64-
<BottomSheetModalProvider>
65-
{children}
66-
<FlashMessage position="top" />
67-
</BottomSheetModalProvider>
65+
<AuthProvider>
66+
<BottomSheetModalProvider>
67+
{children}
68+
<FlashMessage position="top" />
69+
</BottomSheetModalProvider>
70+
</AuthProvider>
6871
</APIProvider>
6972
</ThemeProvider>
7073
</KeyboardProvider>

src/components/providers/auth.tsx

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import dayjs from 'dayjs';
2+
import React, {
3+
createContext,
4+
useCallback,
5+
useContext,
6+
useEffect,
7+
useState,
8+
} from 'react';
9+
import { MMKV } from 'react-native-mmkv';
10+
11+
import { client } from '@/api';
12+
import { storage } from '@/core/storage';
13+
14+
const storageKey = 'auth-storage';
15+
16+
export const authStorage = new MMKV({
17+
id: storageKey,
18+
});
19+
20+
export const HEADER_KEYS = {
21+
ACCESS_TOKEN: 'access-token',
22+
REFRESH_TOKEN: 'client',
23+
USER_ID: 'uid',
24+
EXPIRY: 'expiry',
25+
AUTHORIZATION: 'Authorization',
26+
};
27+
28+
export const storeTokens = (args: {
29+
accessToken: string;
30+
refreshToken: string;
31+
userId: string;
32+
expiration: string;
33+
}) => {
34+
authStorage.set(HEADER_KEYS.ACCESS_TOKEN, args.accessToken);
35+
authStorage.set(HEADER_KEYS.REFRESH_TOKEN, args.refreshToken);
36+
authStorage.set(HEADER_KEYS.USER_ID, args.userId);
37+
authStorage.set(HEADER_KEYS.EXPIRY, args.expiration);
38+
};
39+
40+
export const getTokenDetails = () => ({
41+
accessToken: authStorage.getString(HEADER_KEYS.ACCESS_TOKEN) ?? '',
42+
refreshToken: authStorage.getString(HEADER_KEYS.REFRESH_TOKEN) ?? '',
43+
userId: authStorage.getString(HEADER_KEYS.USER_ID) ?? '',
44+
expiration: authStorage.getString(HEADER_KEYS.EXPIRY) ?? '',
45+
});
46+
47+
// Request interceptor to add Authorization header
48+
client.interceptors.request.use(
49+
(config) => {
50+
const { accessToken, expiration } = getTokenDetails();
51+
52+
// Check if token is expired
53+
if (dayjs().isAfter(dayjs(expiration))) {
54+
// TODO
55+
// Handle token refresh logic
56+
}
57+
58+
if (accessToken) {
59+
config.headers[HEADER_KEYS.AUTHORIZATION] = `Bearer ${accessToken}`;
60+
}
61+
62+
return config;
63+
},
64+
(error) => Promise.reject(error),
65+
);
66+
67+
// Response interceptor to handle tokens
68+
client.interceptors.response.use(
69+
(response) => {
70+
const accessToken = response.headers[HEADER_KEYS.ACCESS_TOKEN] || '';
71+
const refreshToken = response.headers[HEADER_KEYS.REFRESH_TOKEN] || '';
72+
const userId = response.headers[HEADER_KEYS.USER_ID] || '';
73+
74+
const expiration = response.headers[HEADER_KEYS.EXPIRY]
75+
? dayjs.unix(parseInt(response.headers[HEADER_KEYS.EXPIRY])).toISOString()
76+
: dayjs().add(1, 'hour').toISOString();
77+
78+
if (accessToken && refreshToken && userId && expiration) {
79+
storeTokens({ accessToken, refreshToken, userId, expiration });
80+
}
81+
82+
return response;
83+
},
84+
(error) => Promise.reject(error),
85+
);
86+
87+
interface AuthContextProps {
88+
token: string | null;
89+
isAuthenticated: boolean;
90+
loading: boolean;
91+
ready: boolean;
92+
logout: () => void;
93+
}
94+
95+
const AuthContext = createContext<AuthContextProps | undefined>(undefined);
96+
97+
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
98+
children,
99+
}) => {
100+
const [token, setToken] = useState<string | null>(null);
101+
const [loading, setLoading] = useState(true);
102+
const [ready, setReady] = useState(false);
103+
104+
const checkToken = useCallback(() => {
105+
const storedToken = authStorage.getString(HEADER_KEYS.ACCESS_TOKEN);
106+
const expiration = authStorage.getString(HEADER_KEYS.EXPIRY);
107+
108+
if (!storedToken || !expiration) {
109+
setToken(null);
110+
setLoading(false);
111+
setReady(true);
112+
return;
113+
}
114+
115+
const isExpired = dayjs().isAfter(dayjs(expiration));
116+
117+
if (isExpired) {
118+
setToken(null); // Token expired, clear it
119+
} else {
120+
setToken(storedToken); // Token is valid, set it
121+
}
122+
123+
setLoading(false);
124+
setReady(true);
125+
}, []);
126+
127+
const logout = () => {
128+
storage.delete(HEADER_KEYS.ACCESS_TOKEN);
129+
storage.delete(HEADER_KEYS.REFRESH_TOKEN);
130+
storage.delete(HEADER_KEYS.USER_ID);
131+
storage.delete(HEADER_KEYS.EXPIRY);
132+
setToken(null);
133+
};
134+
135+
useEffect(() => {
136+
checkToken();
137+
const requestInterceptor = client.interceptors.response.use(
138+
(config) => {
139+
checkToken();
140+
return config;
141+
},
142+
(error) => Promise.reject(error),
143+
);
144+
145+
return () => {
146+
// Clean up the interceptor when the component unmounts
147+
client.interceptors.request.eject(requestInterceptor);
148+
};
149+
}, [checkToken]);
150+
return (
151+
<AuthContext.Provider
152+
value={{
153+
token,
154+
isAuthenticated: !!token,
155+
loading,
156+
ready,
157+
logout,
158+
}}
159+
>
160+
{children}
161+
</AuthContext.Provider>
162+
);
163+
};
164+
165+
export const useAuth = (): AuthContextProps => {
166+
const context = useContext(AuthContext);
167+
if (!context) {
168+
throw new Error('useAuth must be used within an AuthProvider');
169+
}
170+
return context;
171+
};

0 commit comments

Comments
 (0)