Fakultas Ilmu Komputer UI

Commit 4fb5fa03 authored by Wulan Mantiri's avatar Wulan Mantiri
Browse files

Merge branch 'PBI-5-access_management' into 'staging'

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

See merge request !34
parents 934501f8 30ba7cf3
Pipeline #73404 passed with stages
in 79 minutes and 25 seconds
......@@ -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(