Expo Starter: Passwordless Auth
📱

Expo Starter: Passwordless Auth

Note

This is a little complicated to do with Cognito because Cognito doesn't support passwordless login but we can "fake it" by generating a password behind the scenes, using that, and then verifying the user via SMS/Email. You can replace Cognito with a different auth service (e.g. Auth0) and just use this UI but I prefer Cognito because of the pricing and that we can configure it with the CDK. We are going to implement the passwordless auth using SMS but it should be easy to modify the code to handle passwordless auth by email although then you should probably implement deep links to allow the user to simply click the link in the email and have it open the app and verify. Perhaps I will tackle this in a future post!

Demo

This is what we will be building in this post. Sorry for the blurred parts but I don't feel like publicly sharing my phone number and home address 😋.
notion image

Create UI

I think a good starting point for this project is the UI. We are going to need to make two new screens.
  • PasswordlessAuthMobileInputScreen
  • PasswordlessAuthVerificationScreen

Create Custom Alert

Since we want our project to work on iOS, Android, and the web, we have to create a little Alert wrapper that, when on the web, replaces the react-native alert with a JavaScript alert.
// src/utilities/alerts.ts import { Alert as NativeAlert, Platform } from 'react-native'; export default function Alert(title: string, message: string, handleClose: () => void = () => {}): void { if (Platform.OS === 'web') { alert(`${title}: ${message}`); } else { NativeAlert.alert(title, message, [{ text: 'OK', onPress: () => handleClose() }], { cancelable: false }); } }

Mobile Input Screen

For handling the mobile input we will use react-native-phone-number-input. This library provides a nice UI that allows the user to select their country code and also checks if a phone number is valid.

Install Dependencies

yarn add react-native-phone-number-input

Create Screen

// src/screens/PasswordlessAuthMobileInputScreen.tsx import React, { useState, useRef } from 'react'; import { View, StyleSheet, Pressable, Text } from 'react-native'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import PhoneInput from 'react-native-phone-number-input'; import Alert from '@/utilities/alerts'; type RootStackParamList = { PasswordlessAuthMobileInput: undefined; PasswordlessAuthVerification: { mobile: string }; }; type Props = NativeStackScreenProps<RootStackParamList, 'PasswordlessAuthMobileInput'> & typeof defaultProps; const defaultProps = Object.freeze({}); const initialState = Object.freeze({ value: '', formattedValue: '', }); export default function PasswordlessAuthMobileInputScreen({ navigation }: Props): JSX.Element { const phoneInput = useRef<PhoneInput>(null); const [formattedValue, setFormattedValue] = useState(initialState.formattedValue); const showErrorAlert = (error: string) => Alert('Error', error); const handleLogin = async () => { if (phoneInput.current?.isValidNumber(formattedValue)) { try { // TODO: add login call // navigation.navigate('PasswordlessAuthVerification', { mobile: formattedValue }); } catch (err: any) { showErrorAlert(err.message); } } else { showErrorAlert('Invalid phone number'); } }; return ( <View style={styles.container}> <PhoneInputref={phoneInput}defaultCode="US"layout="first"onChangeFormattedText={text => { setFormattedValue(text); }}withShadow autoFocus /> <View style={styles.spacer} /> <Pressable style={styles.button} onPress={() => handleLogin()}> <Text style={styles.buttonTitle}>Login</Text> </Pressable> </View>); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', }, spacer: { height: 24, }, button: { alignItems: 'center', justifyContent: 'center', width: '80%', paddingVertical: 12, paddingHorizontal: 32, borderRadius: 4, elevation: 3, backgroundColor: 'black', }, buttonTitle: { fontSize: 16, lineHeight: 21, fontWeight: 'bold', letterSpacing: 0.25, color: 'white', }, }); PasswordlessAuthMobileInputScreen.defaultProps = defaultProps;

Add to Screen Index

... export { default as PasswordlessAuthMobileInputScreen } from '@/screens/PasswordlessAuthMobileInputScreen';

Update navigation

Now that we created our screen, we need to update our navigation so that we can actually see it. We will create a new stack navigator and set it in our App.tsx.
// src/navigators/PasswordlessAuthStackNavigator/PasswordlessAuthStackNavigator.tsx import React from 'react'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { PasswordlessAuthMobileInputScreen } from '@/screens/index'; type Props = {} & typeof defaultProps; const defaultProps = Object.freeze({}); const initialState = Object.freeze({}); const PasswordlessAuthStack = createNativeStackNavigator(); export default function PasswordlessAuthStackNavigator(props: Props): JSX.Element { return ( <PasswordlessAuthStack.Navigator> <PasswordlessAuthStack.Screen name='PasswordlessAuthMobileInput' component={PasswordlessAuthMobileInputScreen} options={{ headerShown: false }} /> </PasswordlessAuthStack.Navigator> ); } PasswordlessAuthStackNavigator.defaultProps = defaultProps;
// App.tsx import React from 'react'; import { NavigationContainer } from '@react-navigation/native'; import PasswordlessAuthStackNavigator from '@/navigators/PasswordlessAuthStackNavigator'; export default function App() { return ( <NavigationContainer> <PasswordlessAuthStackNavigator /> </NavigationContainer> ); }
notion image

Verification Screen

Create Screen

Let's be lazy and modify the verification screen from this thoughtbot post (Thanks thoughtbot!).
// src/screens/PasswordlessAuthVerificationScreen import React, { useState, useRef, useEffect } from 'react'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { StyleSheet, Text, View, TextInput, Pressable, ActivityIndicator, Platform } from 'react-native'; import Alert from '@/utilities/alerts'; const CODE_LENGTH = 6; type RootStackParamList = { PasswordlessAuthVerification: { mobile: string }; }; type Props = NativeStackScreenProps<RootStackParamList, 'PasswordlessAuthVerification'>; const PasswordlessAuthVerificationScreen = ({ route, navigation }: Props): JSX.Element => { const { mobile } = route.params; const [code, setCode] = useState(''); const [containerIsFocused, setContainerIsFocused] = useState(false); const [resendTimeout, setResendTimeout] = useState(60); const [isVerifying, setIsVerifying] = useState(false); const showAlert = (err: string) => Alert('Verification Error', err); const handleResend = async () => { if (resendTimeout !== 0) return; setResendTimeout(60); try { // TODO: Add resend call here } catch (e: any) { showAlert(e.message); } }; const handleChangeNumber = () => navigation.goBack(); const handleChangeText = (value: string) => { setCode(value); if (value.length === CODE_LENGTH) verifyCode(); }; /* Set resend timeout and handle inital focus */ useEffect(() => { const timer = setTimeout(() => { setResendTimeout(resendTimeout === 0 ? resendTimeout : resendTimeout - 1); }, 1000); focusInputField(); return () => clearTimeout(timer); }); const reset = () => { setCode(''); setIsVerifying(false); focusInputField(); }; const codeDigitsArray = new Array(CODE_LENGTH).fill(0); const ref = useRef<TextInput>(null); const focusInputField = () => { setContainerIsFocused(true); ref?.current?.focus(); }; const handleOnBlur = () => { setContainerIsFocused(false); }; const verifyCode = () => { setIsVerifying(true); // TODO: Add verification call here setTimeout(() => reset(), 2000); }; const toDigitInput = (_value: number, idx: number) => { const emptyInputChar = ' '; const digit = code[idx] || emptyInputChar; const isCurrentDigit = idx === code.length; const isLastDigit = idx === CODE_LENGTH - 1; const isCodeFull = code.length === CODE_LENGTH; const isFocused = isCurrentDigit || (isLastDigit && isCodeFull); return ( <View key={idx} style={containerIsFocused && isFocused ? style.inputContainerFocused : style.inputContainer}> <Text style={style.inputText}>{digit}</Text> </View>); }; return isVerifying ? ( <View style={style.container}> <ActivityIndicator size="large" color="#363836" /> </View>) : ( <View style={style.container}> <Pressable style={style.inputsContainer} onPress={focusInputField}> {codeDigitsArray.map(toDigitInput)} </Pressable> <TextInputref={ref}value={code}onChangeText={value => handleChangeText(value)}onSubmitEditing={handleOnBlur}keyboardType="number-pad"returnKeyType="done"textContentType="oneTimeCode"maxLength={CODE_LENGTH}style={style.hiddenCodeInput}/> <View style={style.buttonsContainer}> <Text style={style.resendText}> Need a new code?{' '} <Text style={style.clickableText} onPress={() => handleResend()}> Press to resend{`${resendTimeout === 0 ? '' : ` in ${resendTimeout}`}`} </Text> </Text> <Text style={style.clickableText} onPress={() => handleChangeNumber()}> Press to change number </Text> </View> </View>); }; const style = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', }, inputsContainer: { width: '80%', maxWidth: 600, flexDirection: 'row', justifyContent: 'space-between', }, inputContainer: { borderColor: '#cccccc', borderWidth: 2, borderRadius: 4, padding: 12, }, inputContainerFocused: { borderColor: 'black', borderWidth: 2, borderRadius: 4, padding: 12, }, inputText: { fontSize: 24, fontFamily: Platform.OS === 'ios' ? 'Menlo-Regular' : 'monospace', }, hiddenCodeInput: { position: 'absolute', height: 0, width: 0, opacity: 0, }, buttonsContainer: { width: '80%', maxWidth: 600, }, resendText: { paddingTop: 8, paddingBottom: 4, alignSelf: 'flex-end', fontSize: 15, color: 'black', textAlign: 'right', }, clickableText: { alignSelf: 'flex-end', textAlign: 'right', color: '#0f5181', fontSize: 15, }, }); export default PasswordlessAuthVerificationScreen;
In order to keep the input box sizes the same, you have to use a monospace font.

Add to Screen Index

... export { default as PasswordlessAuthVerificationScreen } from '@/screens/PasswordlessAuthVerificationScreen';

Add Screen to Stack Navigator

In order to be able to navigate to our new screen we have to add it to PasswordlessAuthStack.
// src/navigators/PasswordlessAuthStack/PasswordlessAuthStack.tsx ... import { PasswordlessAuthMobileInputScreen, PasswordlessAuthVerificationScreen } from '@/screens/index'; ... export default function PasswordlessAuthStackNavigator(props: Props): JSX.Element { return ( <PasswordlessAuthStack.Navigator> ... <PasswordlessAuthStack.Screenname="PasswordlessAuthVerification"component={PasswordlessAuthVerificationScreen}options={{ headerShown: false }}/> </PasswordlessAuthStack.Navigator>); } ...

Navigate From Mobile Input Screen

Now, let's enable the navigation call from PasswordlessAuthMobileInputScreen to our new screen.
// src/screens/ ... export default function PasswordlessAuthMobileInputScreen({ navigation }: Props): JSX.Element { ... return ( <div style={styles.container}> <... <Button ... onPress={async () => { ... if (countryCode && isValid) { try { //const mobile = await passwordlessAuthLogin(countryCode, value); const mobile = '+1 5555555'; navigation.navigate('PasswordlessAuthVerification', { mobile }); } catch (error) { ... } } else { ... } }} /> </div>); } ...
notion image

Add Authentication Functionality

We need to modify our CDK code to add the auth capabilities and then update our environment variables with the new values. But first, for Cognito to be able to send SMSs, we have to setup a phone number in SNS and verify a test number.

Setup SMS

Unfortunately, this can not be done in the CDK, so we will have to deal with the AWS console.
In order to setup the SMS phone number, navigate to Pinpoint > SMS and voice > Phone numbers. Here, click Request phone number, choose your country, Toll-free, uncheck Voice, set Transactional, and finish the request.
notion image
Usually apps use shortcodes for SMSs but we will just use the toll free number for now since a short code is $1000 a month!
While our account is in a SMS sandbox, we can only send SMSs to verified number so we have to verify our phone.
Still in Pinpoint, go to SMS and voice > Overview and under Destination phone numbers, click Add phone number and verify your number.
notion image
Okay, our originating SMS number and our test device number are all setup, now let's setup our authentication backend with the CDK.

Update CDK

Next, let's update our CDK stack to setup our SMS authentication.
// backend/lib/backend-stack.ts import * as cdk from '@aws-cdk/core'; import * as cognito from '@aws-cdk/aws-cognito'; import * as pinpoint from '@aws-cdk/aws-pinpoint'; import * as location from '@aws-cdk/aws-location'; import { createCognitoIamRoles } from './cognito-auth-roles'; require('dotenv').config({ path: `../envs/.env.${process.env.NODE_ENV}` }); const { CfnOutput } = cdk; export class BackendStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); /* COGNITO */ const userPool = new cognito.UserPool(this, 'users', { userPoolName: `userpool-${process.env.APP_NAME}-${process.env.NODE_ENV}`, standardAttributes: { phoneNumber: { required: true, mutable: true } }, selfSignUpEnabled: true, userVerification: { smsMessage: `Your ${process.env.APP_NAME} verification code is {####}`, }, passwordPolicy: { requireDigits: false, requireUppercase: false, requireSymbols: false, }, accountRecovery: cognito.AccountRecovery.PHONE_ONLY_WITHOUT_MFA, signInAliases: { phone: true }, autoVerify: { phone: true }, removalPolicy: cdk.RemovalPolicy.DESTROY, }); const userPoolClient = userPool.addClient('app-client', { authFlows: { userPassword: true, }, refreshTokenValidity: cdk.Duration.days(365), }); const identityPool = new cognito.CfnIdentityPool(this, 'IdentityPool', { allowUnauthenticatedIdentities: true, cognitoIdentityProviders: [ { clientId: userPoolClient.userPoolClientId, providerName: userPool.userPoolProviderName, }, ], }); createCognitoIamRoles(this, identityPool.ref); /* RESOURCES */ const pinpointApp = new pinpoint.CfnApp(this, 'PinpointApp', { name: `pinpoint-${process.env.APP_NAME}-${process.env.NODE_ENV}`, }); const placeIndex = new location.CfnPlaceIndex(this, 'PlaceIndex', { indexName: `place-${process.env.APP_NAME}-${process.env.NODE_ENV}`, dataSource: 'Esri', pricingPlan: 'RequestBasedUsage', }); /* OUTPUTS */ new CfnOutput(this, 'PINPOINT_APP_ID', { value: pinpointApp.ref, }); new CfnOutput(this, 'PLACE_INDEX_ID', { value: placeIndex.ref, }); new CfnOutput(this, 'USER_POOL_ID', { value: userPool.userPoolId, }); new CfnOutput(this, 'USER_POOL_CLIENT_ID', { value: userPoolClient.userPoolClientId, }); new CfnOutput(this, 'IDENTITY_POOL_ID', { value: identityPool.ref, }); } }

Update Environment Variables

Update your environment variables with the new outputs.
// envs/.env.development ... AWS_REGION= AWS_IDENTITY_POOL_ID= AWS_PINPOINT_APP_ID= AWS_USER_POOL_CLIENT_ID= AWS_USER_POOL_ID= AWS_LOCATION_PLACE_INDEX_ID=
I changed the name of some of the variables since they no longer refer to just the analytics setup, so you will have to update them throughout the project.

Create Authentication Service

Update Constants

We are going to have to temporarily store the generated password so let's define the storage key in the constant's file.
// src/utilities/constants.ts export const StorageKeys = { UID: 'uid', REVERSE_GEOCODE: 'reverse-geocode', PASSWORD_KEY: 'password-key', };

Create Authentication Service

Now we will create the authentication service. The authentication flow is kind of confusing so I heavily commented the code to make it clearer.
// src/services/authentication.ts import { Auth } from 'aws-amplify'; import * as Random from 'expo-random'; import AsyncStorage from '@react-native-async-storage/async-storage'; import LOGGER from '@/services/logger'; import { StorageKeys } from '@/utilities/constants'; LOGGER.enable('AUTH'); const log = LOGGER.extend('AUTH'); export const getConfig = () => { return { Auth: { region: process.env.AWS_REGION, identityPoolId: process.env.AWS_IDENTITY_POOL_ID, userPoolId: process.env.AWS_USER_POOL_ID, userPoolWebClientId: process.env.AWS_USER_POOL_CLIENT_ID, mandatorySignIn: false, authenticationFlowType: 'USER_PASSWORD_AUTH', }, }; }; async function isLoggedIn() { try { await Auth.currentAuthenticatedUser(); return true; } catch { return false; } } export async function logout(): Promise<void> { try { await Auth.signOut(); } catch (error) { log.error('error signing out: ', error); } } const generatePassword = async () => { const bytes = await Random.getRandomBytesAsync(48); /* @ts-ignore */ return Array.prototype.map .call(new Uint8Array(bytes), x => `00${x.toString(16)}`.slice(-2)) .join('') .match(/[a-fA-F0-9]{2}/g) .reverse() .join(''); }; // In order to simulate passwordless mobile login we // have to do the following steps, // 1. Generate a random password // 2. If new user sign-up is successful, store password // to be used later in verification // 3. If user exists, send password reset verification // 4. If password reset fails, user never completed // initial verification, resend sign-up SMS // Continued in passwordlessMobileLoginVerify export const passwordlessMobileLogin = async (mobile: string): Promise<void> => { // 1. Generate password const password = await generatePassword(); log.debug(mobile, password); // 3. Regular Sign Up try { log.debug('Attempting user sign-up'); await Auth.signUp(mobile, password); await AsyncStorage.setItem(StorageKeys.PASSWORD_KEY, password); } catch (error: any) { // 4. Forgot Password if (error.code === 'UsernameExistsException') { log.debug('User exists. attempting to send password reset SMS'); try { await Auth.forgotPassword(mobile); } catch (error2: any) { log.debug('User never finished verification, resending sign-up SMS'); // 5. Resend Sign Up Verification if (error2.code === 'InvalidParameterException') { try { await Auth.resendSignUp(mobile); } catch (error3) { log.debug('Resend Sign Up Failed'); throw error3; } } else { throw error2; } } } else { throw error; } } }; // 6. Try to retrieve stored password // 7. If password exists, this is the normal sign-up flow, // verify with code and delete password from storage // 8. If the password doesn't exist, we are in the forgot // password flow, generate and set new password // 9. Sign-in export const passwordlessMobileLoginVerify = async (mobile: string, code: string): Promise<void> => { try { // 6: Attempt to retrieve password let password = await AsyncStorage.getItem(StorageKeys.PASSWORD_KEY); log.debug(`Passwordless login: ${mobile}, ${password}, ${code}`); if (password) { // 7: Normal sign-up verification await Auth.confirmSignUp(mobile, code); log.debug(`Confirmed ${mobile} with code: ${code}`); await AsyncStorage.removeItem(StorageKeys.PASSWORD_KEY); } else { // 8: Forgot password, generate and set password password = await generatePassword(); await Auth.forgotPasswordSubmit(mobile, code, password); log.debug(`Set new password for ${mobile} with code: ${code}`); } // 9: Sign-in const user = await Auth.signIn(mobile, password); log.debug('Sign In Successful'); log.debug(user); } catch (error: any) { switch (error) { case 'CodeMismatchException': throw Error('Incorrect code. Please try again.'); default: throw Error('Failed to verify code. Please try again.'); } } }; // Try to send sign up comfirmation and if user is already // verified, we are in the forgot password flow so we need // to resend forgot password verification instead. export const resendConfirmationCode = async (mobile: string): Promise<void> => { try { await Auth.resendSignUp(mobile); } catch (error: any) { log.debug(error); if (error.message === 'User is already confirmed.') { try { await Auth.forgotPassword(mobile); } catch (error2: any) { log.debug(error2); throw Error('Failed to resend verification code.'); } } else { throw Error('Failed to resend verification code.'); } } }; export default passwordlessMobileLogin;

Update Amplify Configuration

We need to update our Amplify authentication configuration now that we are utilizing a user pool. Our authentication service that we just created exports the required configuration so we will just have to import and use that.
// src/api/amplify.ts import Amplify from 'aws-amplify'; import LOGGER from '@/services/logger'; import { getConfig as getAuthenticationConfig } from '@/services/authentication'; import { getConfig as getAnalyticsConfig } from '@/services/analytics'; LOGGER.enable('AMPLIFY'); const log = LOGGER.extend('AMPLIFY'); export const initAmplify = async (): Promise<void> => { const authenticationConfig = getAuthenticationConfig(); const analyticsConfig = await getAnalyticsConfig(); Amplify.configure({ ...authenticationConfig, ...analyticsConfig, }); log.info('Amplify initialized'); };

Update Mobile Input Screen

Now let’s update our mobile input screen to call our login function.
// src/screens/PasswordlessAuthMobileInputScreen/PasswordlessAuthMobileInputScreen.tsx ... import passwordlessMobileLogin from '@/services/authentication'; ... export default function PasswordlessAuthMobileInputScreen({ navigation }: Props): JSX.Element { ... const handleLogin = async () => { if (phoneInput.current?.isValidNumber(formattedValue)) { try { await passwordlessMobileLogin(formattedValue); navigation.navigate('PasswordlessAuthVerification', { mobile: formattedValue }); } catch (err: any) { showErrorAlert(err.message); } } else { showErrorAlert('Invalid phone number'); } }; ... } ...

Update Verification Screen

Next, we have to add our verification and resend verification function calls to our verification screen.
// src/screens/PasswordlessAuthVerificationScreen/PasswordlessAuthVerificationScreen.tsx ... import { passwordlessMobileLoginVerify, resendConfirmationCode } from '@/services/authentication'; ... const PasswordlessAuthVerificationScreen = ({ route, navigation }: Props): JSX.Element => { ... const handleResend = async () => { if (resendTimeout !== 0) return; setResendTimeout(60); try { await resendConfirmationCode(mobile); } catch (e: any) { showAlert(e.message); } }; ... const handleChangeText = (value: string) => { setCode(value); if (value.length === CODE_LENGTH) verifyCode(value); }; ... const verifyCode = async (value: string) => { setIsVerifying(true); try { await passwordlessMobileLoginVerify(mobile, value); } catch (error: any) { showAlert(error); reset(); } }; ... }; ...

Update App Component

Now we can update out App component to show the home screen when a user is logged in. Also, we should move our Sentry and Amplify init methods to there.
// src/App.tsx import React, { useState, useEffect } from 'react'; import Amplify from 'aws-amplify'; import { Hub } from '@aws-amplify/core'; import { NavigationContainer } from '@react-navigation/native'; import LOGGER from '@/services/logger'; import Sentry, { initSentry } from '@/services/sentry'; import { initAmplify } from '@/api/amplify'; import PasswordlessAuthStackNavigator from '@/navigators/PasswordlessAuthStackNavigator'; import StackNavigator from '@/navigators/StackNavigator'; LOGGER.enable('APP'); const log = LOGGER.extend('APP'); initSentry(false); export default function App(): JSX.Element { const [user, setUser] = useState(null); // Monitor auth state useEffect(() => { const updateUser = async () => { try { const usr = await Amplify.Auth.currentAuthenticatedUser(); setUser(usr); } catch { setUser(null); } }; (async () => { await initAmplify(); // Listen for login/signup events Hub.listen('auth', updateUser); // Check manually the first time because we won't get a Hub event updateUser(); })(); return () => Hub.remove('auth', updateUser); }, []); return <NavigationContainer>{!user ? <PasswordlessAuthStackNavigator /> : <StackNavigator />}</NavigationContainer>; }

Add Logout to Home Screen

Let's add the ability to logout to the Home Screen as well as remove the Sentry and Amplify init methods (since we moved them to App.tsx).
// src/screens/HomeScreen/HomeScreen ... export default function HomeScreen({ navigation }: Props): JSX.Element { ... useEffect(() => { (async () => { const locEnabled = await initLocation(); const loc = await getLocation(); setLocation(loc); // TODO: If cannot access device location, use IP to Location API instead if (locEnabled) { const reverseGeo = await getReverseGeocode(loc.coords); setReverseGeocode(reverseGeo); await updateEndpointLocation(); } })(); }, []); ... return ( <View style={styles.container}> ... <Button title="Logout" onPress={() => logout()} /> ... </View>); } ...

Update Analytics User ID

Now that out user actually has an ID, let's use it for our analytics.
Add the following function to update the endpoint with the signed in user's information.
// src/services/analytics.ts ... export const updateEndpointUserInfo = async (user: any) => { const config = { userId: user.attributes.sub, userAttributes: { username: user.username, ...user.attributes, }, }; await Analytics.updateEndpoint({ config }); log.info('Updated endpoint with user info'); log.debug('\n', JSON.stringify(config, null, 2)); }; ...
Now we can call it after we retrieve the current user.
// src/App.tsx ... import { updateEndpointUserInfo } from '@/services/analytics'; ... export default function App(): JSX.Element { ... useEffect(() => { const updateUser = async () => { try { const usr = await Amplify.Auth.currentAuthenticatedUser(); setUser(usr); await updateEndpointUserInfo(usr); } catch { setUser(null); } }; ... }, []); ... }
 
Click to rocket boost to the top of the page!