Fakultas Ilmu Komputer UI

Commit 30ba7cf3 authored by Wulan Mantiri's avatar Wulan Mantiri
Browse files

Add user and page access management, fetch user on first load, and link user data

parent 934501f8
......@@ -4,6 +4,7 @@ import { NativeModules } from 'react-native';
jest.mock('react-native/Libraries/Animated/src/NativeAnimatedHelper');
jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage);
jest.mock('axios');
NativeModules.RNGoogleSignin = {
BUTTON_SIZE_ICON: 0,
......
import { LoginResponse } from 'services/auth/models';
import { LoginResponse, UserRole } from 'services/auth/models';
export const validRegistrationValues: { [_: string]: any } = {
name: 'Doan Didinding',
......@@ -31,5 +31,6 @@ export const authResponse: LoginResponse = {
id: 1,
email: validRegistrationValues.email,
name: validRegistrationValues.name,
role: UserRole.CLIENT,
},
};
import React from 'react';
import { render } from '@testing-library/react-native';
import { HeaderLeft, ErrorToast } from './styles';
import { ErrorToast } from './styles';
import App from '.';
describe('Application', () => {
......@@ -9,10 +9,6 @@ describe('Application', () => {
render(<App />);
});
test('header left button renders correctly', () => {
render(<HeaderLeft />);
});
test('error toast renders correctly', () => {
render(<ErrorToast text1NumberOfLines={2} text2NumberOfLines={2} />);
});
......
import React from 'react';
import { StyleSheet } from 'react-native';
import {
HeaderBackButton,
StackHeaderLeftButtonProps,
StackNavigationOptions,
TransitionSpecs,
} from '@react-navigation/stack';
import { BaseToast, BaseToastProps } from 'react-native-toast-message';
import { colors, typographyStyles, typography } from 'styles';
export const HeaderLeft = (props: StackHeaderLeftButtonProps) => (
<HeaderBackButton {...props} />
);
export const screenOptions: StackNavigationOptions = {
cardStyle: {
backgroundColor: '#fff',
......@@ -24,7 +19,16 @@ export const screenOptions: StackNavigationOptions = {
headerTintColor: colors.primary,
headerTitleStyle: typographyStyles.headingMedium,
headerTitleAlign: 'center',
headerLeft: HeaderLeft,
transitionSpec: {
open: {
animation: 'timing',
config: {
duration: 1,
delay: 0,
},
},
close: TransitionSpecs.TransitionIOSSpec,
},
};
const styles = StyleSheet.create({
......
import React from 'react';
import { render } from '@testing-library/react-native';
import Loader from '.';
import Loader, { DietelaCoverLoader } from '.';
describe('Loader component', () => {
it('renders correctly', () => {
render(<Loader />);
});
});
describe('DietelaCoverLoader component', () => {
it('renders correctly', () => {
render(<DietelaCoverLoader />);
});
});
import React from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { colors } from 'styles';
import { dietelaLogo } from 'assets/images';
import { Image } from 'react-native-elements';
const Loader = () => (
<View style={styles.container}>
......@@ -8,11 +10,35 @@ const Loader = () => (
</View>
);
const DietelaCoverLoader = () => (
<View style={styles.dietelaCover}>
<Image
source={dietelaLogo}
style={styles.logo}
placeholderStyle={styles.img}
/>
</View>
);
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
},
dietelaCover: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primaryYellow,
},
logo: {
width: 200,
height: 100,
},
img: {
backgroundColor: colors.primaryYellow,
},
});
export default Loader;
export { DietelaCoverLoader };
......@@ -3,7 +3,7 @@ export { default as BigButton } from './BigButton';
export { default as CarouselPagination } from './CarouselPagination';
export { default as InfoCard } from './InfoCard';
export { default as Link } from './Link';
export { default as Loader } from './Loader';
export { default as Loader, DietelaCoverLoader } from './Loader';
export { default as ResultCard } from './ResultCard';
export { default as Statistic } from './Statistic';
export { default as Toast } from 'react-native-toast-message';
......
import { useContext, useEffect } from 'react';
import { useContext, useEffect, useCallback, useState } from 'react';
import { useNavigation } from '@react-navigation/native';
import { UserContext } from 'provider';
import * as ROUTES from 'constants/routes';
import CACHE_KEYS from 'constants/cacheKeys';
import { getCache } from 'utils/cache';
const useAuthEffect = () => {
const { isAuthenticated } = useContext(UserContext);
const useAuthEffect = (isLogin?: boolean) => {
const { isAuthenticated, isUnpaidClient, isFirstLoading } = useContext(
UserContext,
);
const navigation = useNavigation();
const [isLoading, setIsLoading] = useState(false);
const checkCart = useCallback(async () => {
setIsLoading(true);
const dietProfileId = await getCache(CACHE_KEYS.dietProfileId);
const cartId = await getCache(CACHE_KEYS.cartId);
if (!dietProfileId) {
navigation.reset({
index: 0,
routes: [
{ name: ROUTES.initial },
{ name: ROUTES.allAccessQuestionnaire },
],
});
} else if (!cartId) {
navigation.reset({
index: 0,
routes: [{ name: ROUTES.initial }, { name: ROUTES.choosePlan }],
});
}
setIsLoading(false);
}, [navigation]);
useEffect(() => {
if (isAuthenticated) {
if (navigation.canGoBack()) {
navigation.goBack();
if (isUnpaidClient) {
navigation.reset({
index: 0,
routes: [{ name: ROUTES.checkout }],
});
} else {
navigation.navigate(ROUTES.profile);
navigation.reset({
index: 0,
routes: [{ name: ROUTES.profile }],
});
}
} else if (isLogin) {
checkCart();
}
}, [isAuthenticated, navigation]);
return () => {
setIsLoading(false);
};
}, [checkCart, isLogin, isAuthenticated, isUnpaidClient, navigation]);
return isFirstLoading || isLoading;
};
export default useAuthEffect;
......@@ -3,15 +3,15 @@ import { useNavigation } from '@react-navigation/native';
import { UserContext } from 'provider';
import * as ROUTES from 'constants/routes';
const useAuthGuardEffect = () => {
const useAuthGuardEffect = (signupFallback?: boolean) => {
const { isAuthenticated } = useContext(UserContext);
const navigation = useNavigation();
useEffect(() => {
if (!isAuthenticated) {
navigation.navigate(ROUTES.login);
navigation.navigate(signupFallback ? ROUTES.registration : ROUTES.login);
}
}, [isAuthenticated, navigation]);
}, [isAuthenticated, signupFallback, navigation]);
};
export default useAuthGuardEffect;
......@@ -4,12 +4,20 @@ import { GoogleSignin } from '@react-native-google-signin/google-signin';
import { Toast } from 'components/core';
import CACHE_KEYS from 'constants/cacheKeys';
import { removeCache, getCache, setCache } from 'utils/cache';
import { googleLoginApi, loginApi, signupApi } from 'services/auth';
import {
googleLoginApi,
loginApi,
signupApi,
retrieveUserApi,
linkUserDataApi,
} from 'services/auth';
import {
User,
RegistrationRequest,
LoginRequest,
LoginResponse,
UserRole,
} from 'services/auth/models';
import { set401Callback, setAuthHeader, resetAuthHeader } from 'services/api';
......@@ -19,25 +27,18 @@ const initialUser = {
id: null,
email: '',
name: '',
};
const setUserFromResponse = async (
success: boolean,
setUser: React.Dispatch<React.SetStateAction<User>>,
data?: LoginResponse,
) => {
if (success && data) {
await setCache(CACHE_KEYS.authToken, data.access_token);
await setCache(CACHE_KEYS.refreshToken, data.refresh_token);
setUser(data.user);
}
role: null,
};
export const UserContext = createContext<iUserContext>({
user: initialUser,
isAuthenticated: false,
isUnpaidClient: false,
isPaidClient: false,
isNutritionist: false,
isAdmin: false,
isLoading: false,
isFirstLoading: false,
signup: () => Promise.reject(),
login: () => Promise.reject(),
loginWithGoogle: () => Promise.reject(),
......@@ -47,36 +48,79 @@ export const UserContext = createContext<iUserContext>({
export const useUserContext = (): iUserContext => {
const [user, setUser] = useState<User>(initialUser);
const [isLoading, setIsLoading] = useState(false);
const [isFirstLoading, setIsFirstLoading] = useState(false);
const [clientHasPaid] = useState(false);
const logout = useCallback(async () => {
await GoogleSignin.signOut();
await removeCache(CACHE_KEYS.authToken);
await removeCache(CACHE_KEYS.refreshToken);
setUser(initialUser);
resetAuthHeader();
}, []);
const getUser = useCallback(async () => {
setIsFirstLoading(true);
const token = await getCache(CACHE_KEYS.authToken);
if (token) {
setAuthHeader(token);
// TODO: fetch user data
const response = await retrieveUserApi();
if (response.success && response.data) {
setUser(response.data);
} else {
await logout();
Toast.show({
type: 'error',
text1: 'Sesi Anda sudah berakhir.',
text2: 'Silakan coba masuk lagi.',
});
}
}
}, []);
setIsFirstLoading(false);
}, [logout]);
const authSuccess = async (data: LoginResponse) => {
const accessToken = data.access_token;
await setCache(CACHE_KEYS.authToken, accessToken);
await setCache(CACHE_KEYS.refreshToken, data.refresh_token);
setUser(data.user);
setAuthHeader(accessToken);
};
const linkUserData = async (email: string) => {
const dietProfileId = await getCache(CACHE_KEYS.dietProfileId);
const cartId = await getCache(CACHE_KEYS.cartId);
if (dietProfileId && cartId) {
const response = await linkUserDataApi({
email,
diet_profile_id: parseInt(dietProfileId, 10),
cart_id: parseInt(cartId, 10),
});
return response;
}
return {
success: false,
};
};
const signup = async (registerData: RegistrationRequest) => {
const response = await signupApi(registerData);
await setUserFromResponse(response.success, setUser, response.data);
if (response.success && response.data) {
await authSuccess(response.data);
return await linkUserData(response.data.user.email);
}
return response;
};
const login = async (loginData: LoginRequest) => {
const response = await loginApi(loginData);
await setUserFromResponse(response.success, setUser, response.data);
if (response.success && response.data) {
await authSuccess(response.data);
}
return response;
};
const logout = useCallback(async () => {
await GoogleSignin.signOut();
await removeCache(CACHE_KEYS.authToken);
setUser(initialUser);
resetAuthHeader();
}, []);
const loginWithGoogle = async () => {
setIsLoading(true);
try {
await GoogleSignin.hasPlayServices();
await GoogleSignin.signIn();
......@@ -85,16 +129,18 @@ export const useUserContext = (): iUserContext => {
access_token: tokens.accessToken,
});
if (response.success && response.data) {
await setCache(CACHE_KEYS.authToken, response.data.access_token);
await setCache(CACHE_KEYS.authToken, response.data.access_token);
setUser(response.data.user);
await authSuccess(response.data);
const linkResponse = await linkUserData(response.data.user.email);
if (!linkResponse.success) {
await logout();
Toast.show({
type: 'error',
text1: 'Gagal masuk dengan Google',
text2: 'Terjadi kesalahan di sisi kami. Silakan coba lagi',
});
}
} else {
await logout();
Toast.show({
type: 'error',
text1: 'Sesi Anda sudah berakhir.',
text2: 'Silakan coba masuk lagi.',
});
}
} catch (error) {
console.log(error);
......@@ -116,7 +162,12 @@ export const useUserContext = (): iUserContext => {
return {
user,
isAuthenticated: user.id !== null,
isUnpaidClient: user.role === UserRole.CLIENT,
isPaidClient: user.role === UserRole.CLIENT && clientHasPaid,
isNutritionist: user.role === UserRole.NUTRITIONIST,
isAdmin: user.role === UserRole.ADMIN,
isLoading,
isFirstLoading,
signup,
login,
loginWithGoogle,
......
......@@ -4,13 +4,21 @@ import {
LoginResponse,
RegistrationRequest,
User,
LinkUserDataResponse,
} from 'services/auth/models';
export interface iUserContext {
user: User;
isAuthenticated: boolean;
isUnpaidClient: boolean;
isPaidClient: boolean;
isNutritionist: boolean;
isAdmin: boolean;
isLoading: boolean;
signup: (data: RegistrationRequest) => ApiResponse<LoginResponse>;
isFirstLoading: boolean;
signup: (
data: RegistrationRequest,
) => ApiResponse<LoginResponse | LinkUserDataResponse>;
login: (data: LoginRequest) => ApiResponse<LoginResponse>;
loginWithGoogle: () => Promise<void>;
logout: () => Promise<void>;
......
......@@ -4,7 +4,7 @@ import { render } from '@testing-library/react-native';
import Provider from '.';
describe('Provider', () => {
it('renders correctly', () => {
render(<Provider />);
it('renders correctly', async () => {
render(<Provider>children</Provider>);
});
});
......@@ -2,6 +2,8 @@ import React from 'react';
import { render, fireEvent, waitFor } from 'utils/testing';
import * as ROUTES from 'constants/routes';
import axios from 'axios';
import CACHE_KEYS from 'constants/cacheKeys';
import { setCache } from 'utils/cache';
import Login from '.';
import {
......@@ -16,8 +18,23 @@ jest.mock('axios');
const mockAxios = axios as jest.Mocked<typeof axios>;
describe('Login page', () => {
it('renders correctly', () => {
render(<Login />, ROUTES.login);
it('shows dietela cover loader when is loading', async () => {
const { queryByText } = render(<Login />, ROUTES.login);
await waitFor(() =>
expect(queryByText(/Lanjut dengan Google/i)).toBeFalsy(),
);
});
it('renders correctly if client has filled questionnaire and cart', async () => {
await setCache(CACHE_KEYS.cartId, 1);
await setCache(CACHE_KEYS.dietProfileId, 1);
const { queryByText } = render(<Login />, ROUTES.login);
await waitFor(() =>
expect(queryByText(/Lanjut dengan Google/i)).toBeTruthy(),
);
});
it('success when field is valid and submit success', async () => {
......@@ -33,6 +50,10 @@ describe('Login page', () => {
ROUTES.login,
);
await waitFor(() =>
expect(queryByText(/Lanjut dengan Google/i)).toBeTruthy(),
);
textField.map(({ name, placeholder }) => {
const formField = getByPlaceholderText(placeholder as string);
fireEvent.changeText(formField, validLoginValues[name]);
......@@ -40,9 +61,6 @@ describe('Login page', () => {
const loginButton = getByTestId('loginButton');
await waitFor(() => fireEvent.press(loginButton));
const toastWarning = queryByText(/Profile/i);
expect(toastWarning).toBeTruthy();
});
it('fails when field is invalid and submit success', async () => {
......@@ -60,6 +78,10 @@ describe('Login page', () => {
ROUTES.login,
);
await waitFor(() =>
expect(queryByText(/Lanjut dengan Google/i)).toBeTruthy(),
);
textField.map(({ name, placeholder }) => {
const formField = getByPlaceholderText(placeholder as string);
fireEvent.changeText(formField, invalidLoginValues[name]);
......
......@@ -4,7 +4,7 @@ import { UserContext } from 'provider';
import { useAuthEffect, useForm } from 'hooks';
import { GoogleLoginButton } from '../components';
import { BigButton, Toast } from 'components/core';
import { BigButton, Toast, Loader } from 'components/core';
import { fieldValidation, initialValues, textField } from './schema';
import { generateValidationSchema } from 'utils/form';
......@@ -45,8 +45,11 @@ const Login: FC = () => {
},
});
useAuthEffect();
const isProcessing = useAuthEffect(true);
if (isProcessing) {
return <Loader />;
}
return (
<View style={layoutStyles}>
{textField.map(({ name, label, required, placeholder }, i) => (
......
......@@ -28,7 +28,7 @@ describe('ManualRegistrationPage', () => {
});
mockAxios.request.mockImplementationOnce(signupApi);
const { getByPlaceholderText, queryByText, getByTestId } = render(
const { getByPlaceholderText, getByTestId } = render(
<ManualRegistrationPage />,
ROUTES.registration,
);
......@@ -40,9 +40,6 @@ describe('ManualRegistrationPage', () => {
const submitButton = getByTestId('submitButton');
await waitFor(() => fireEvent.press(submitButton));
const toastWarning = queryByText(/Profile/i);
expect(toastWarning).toBeTruthy();
});
it('fails when field is valid and submit fails', async () => {
......@@ -50,7 +47,7 @@ describe('ManualRegistrationPage', () => {
Promise.reject({
status: 400,
response: {
error: 'error',
data: 'error',
},
});
mockAxios.request.mockImplementationOnce(signupApi);
......@@ -68,7 +65,7 @@ describe('ManualRegistrationPage', () => {
const submitButton = getByTestId('submitButton');
await waitFor(() => fireEvent.press(submitButton));
const nextPageText = queryByText(/Profile/i);
const nextPageText = queryByText(/Checkout/i);
expect(nextPageText).toBeFalsy();
});
......@@ -79,7 +76,7 @@ describe('ManualRegistrationPage', () => {
Promise.reject({
status: 400,
response: {
error: {
data: {
name: 'Wrong name',
email: alreadyRegistered,
password1: 'Wrong password',
......@@ -102,7 +99,7 @@ describe('ManualRegistrationPage', () => {
const submitButton = getByTestId('submitButton');
await waitFor(() => fireEvent.press(submitButton));
const nextPageText = queryByText(/Profile/i);