Skip to content

Commit

Permalink
feat: auth provider proposal
Browse files Browse the repository at this point in the history
  • Loading branch information
Guillermo Machado authored and Guillermo Machado committed Jan 16, 2025
1 parent 564f6df commit 0a5ff91
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 14 deletions.
14 changes: 7 additions & 7 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 <Redirect href="/onboarding" />;
}
if (status === 'signOut') {
if (!isAuthenticated && ready) {
return <Redirect href="/sign-in" />;
}
return (
Expand All @@ -45,7 +47,6 @@ export default function TabLayout() {
name="style"
options={{
title: 'Style',
headerShown: false,
tabBarIcon: ({ color }) => <StyleIcon color={color} />,
tabBarTestID: 'style-tab',
}}
Expand All @@ -54,7 +55,6 @@ export default function TabLayout() {
name="settings"
options={{
title: 'Settings',
headerShown: false,
tabBarIcon: ({ color }) => <SettingsIcon color={color} />,
tabBarTestID: 'settings-tab',
}}
Expand Down
7 changes: 4 additions & 3 deletions src/app/(app)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -67,7 +68,7 @@ export default function Settings() {

<View className="my-8">
<ItemsContainer>
<Item text="settings.logout" onPress={signOut} />
<Item text="settings.logout" onPress={logout} />
</ItemsContainer>
</View>
</View>
Expand Down
11 changes: 7 additions & 4 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -61,10 +62,12 @@ function Providers({ children }: { children: React.ReactNode }) {
<KeyboardProvider>
<ThemeProvider value={theme}>
<APIProvider>
<BottomSheetModalProvider>
{children}
<FlashMessage position="top" />
</BottomSheetModalProvider>
<AuthProvider>
<BottomSheetModalProvider>
{children}
<FlashMessage position="top" />
</BottomSheetModalProvider>
</AuthProvider>
</APIProvider>
</ThemeProvider>
</KeyboardProvider>
Expand Down
171 changes: 171 additions & 0 deletions src/components/providers/auth.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthContextProps | undefined>(undefined);

export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [token, setToken] = useState<string | null>(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 (
<AuthContext.Provider
value={{
token,
isAuthenticated: !!token,
loading,
ready,
logout,
}}
>
{children}
</AuthContext.Provider>
);
};

export const useAuth = (): AuthContextProps => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

0 comments on commit 0a5ff91

Please sign in to comment.