The grass is rarely greener, but it's always different

Show Google Calendar events in Fullcalendar with React

For convenience, Psychologists in TheGoodPsy are able to connect to their Google Calendar to see their events along with the appointments local to the platform in order to prevent clashes between the two. It is quite easy to add an appointment with a patient in one calendar and propose an appointment in the other without noticing.

Google Identity Services became the new authentication flow enforced by Google, forcing newly created credentials to use it by default without allowing to fallback to Google Sign-In Platform.

While there are some tutorials about how to connect a React application to Google Calendar using the old method, I couldn't find almost any tutorial about the specific flow we needed to implement, so I decided to write my own.

In order to keep the article brief, I won't be explaining how to create credentials & configure the OAuth screen here, there is plenty of documentation about how to do it.

A couple details about our user flow:

  • We are using the User Code Model because we need to handle ourselves refreshing the authorization tokens from the backend.
  • We are using the Popup mode for the OAuth permission screen, so the user stays in the app instead of being redirected.

Sign-In to Google

We make use of the @react-oauth/google' library to configure the auth flow, ux_mode, and scopes for getting the authorization token that we will then use to ask Google for the authentication token to make API calls.

It all starts with a simple button:

<GoogleButton
    id='google-calendar-connection'
    label='Connect Google Calendar'
    onClick={googleLogin}
/>

The googleLogin function opens the OAuth screen and calls the backend passing the authorization token to get the access token:

const getGoogleAccessToken = async (authorizationCode) => {
    const response = await axios.post(
        `/api/google/accesstoken`, 
        { authorizationCode }
    );

    if (response.data.access_token) {
        localStorage.setItem("google_access_token", JSON.stringify(response.data.access_token));
    }

    if (response.data.expiry_date) {
        localStorage.setItem("google_access_token_expiry_date", JSON.stringify(response.data.expiry_date));
    }

    return response.data;  
}


const fetchGoogleAccessToken = async (tokenResponse) => {
    const accessToken = await getGoogleAccessToken(tokenResponse.code);
    if (localStorage.getItem("google_access_token")) {
        setGoogleCalendarSync(true);
    }
    return accessToken;
}


const googleLogin = useGoogleLogin({
    onSuccess: fetchGoogleAccessToken,
    onError: error => console.log(error),
    flow: 'auth-code',
    ux_mode: 'popup',
    scope: GOOGLE_CALENDAR_SCOPES
});

The API endpoint that handles the getGoogleAccessToken() function call:

const getAccessToken = async (req, res) => {
    const { authorizationCode } = req.body;
    const user = req.user;

    // Get access and refresh tokens (if access_type is offline)
    let { tokens } = await oauth2Client.getToken(authorizationCode);
    oauth2Client.setCredentials(tokens);

    let userGoogleAuthentication;
    userGoogleAuthentication = await user.getGoogleAuthentication();

    //If the user has already a google authentication, update the refresh token,
    //otherwise create a google authentication object and associate it to the user.
    if (userGoogleAuthentication) {
        await userGoogleAuthentication.update({ refresh_token: tokens.refresh_token });
    }
    else {
        userGoogleAuthentication =
            await GoogleAuthentication.create({
                refresh_token: tokens.refresh_token,
                userId: user.id
            });
    }

    return res.status(200).json({ ...tokens });
}

Now we have the access & refresh tokens in the browser's localStorage as google_access_token and google_access_token_expiry_date respectively. What's left is to fetch the Google Calendar events as part of the function that fetches events for the events property of FullCalendar. Baked into this bit is the functionality to refresh the token in the backend if the current one has expired.

This is the frontend part:

const refreshGoogleAccessToken = async () => {
    const response = await axios.post(
        `/api/google/refreshtoken`,
        {}
    );

    if (response.data.access_token) {
        localStorage.setItem("google_access_token", JSON.stringify(response.data.access_token));
    }

    if (response.data.expiry_date) {
        localStorage.setItem("google_access_token_expiry_date", JSON.stringify(response.data.expiry_date));
    }

    return response.data;
}

//API call to the Google Calendar endpoint.
const googleEventsFetch = async ({ token, from, until }) => {
    const response = await fetch(
        `${GOOGLE_CALENDAR_EVENTS_API_URL}/?key=${GOOGLE_CALENDAR_API_KEY}&orderBy=startTime&singleEvents=true&timeMin=${from.toISOString()}&timeMax=${until.toISOString()}`,
        {
            headers: {
                Authorization: `Bearer ${token}`,
            },
        }
    );
    return response;
}

//Small wrapper around functionality
const getGoogleEvents = async ({ token, from, until }) => {
    if (from && until) {
        const response = await googleEventsFetch({ token, from, until });

        if (response.status === OK) {
            const data = await response.json();
            return {
                status: response.status,
                items: data.items
            }
        }
        else {
            return {
                status: response.status,
                items: []
            }
        }
    }
    else return [];
}


// Load events from Google Calendar between 2 dates.
const loadGoogleCalendarEvents = useCallback(async (from, until) => {
    const googleAccessToken = localStorage.getItem("google_access_token");
    const googleAccessTokenExpirationDate = localStorage.getItem("google_access_token_expiry_date");

    //If the's an expiration date in the offline storage.
    if (googleAccessTokenExpirationDate && googleAccessToken) {
        const googleAccesTokenExpirationDateParsed = parseInt(googleAccessTokenExpirationDate);
        const gAccesTokenExpDateMoment = moment(googleAccesTokenExpirationDateParsed);
        const currentDateMoment = moment();

        //If the token has expired.
        if (currentDateMoment.isAfter(gAccesTokenExpDateMoment)) {
            localStorage.removeItem("google_access_token_expiry_date");
            localStorage.removeItem("google_access_token");

            //Get a new access token & expiry_date with the refresh token.
            const { access_token: newAccessToken} = await refreshGoogleAccessToken();

            if (newAccessToken) {
                const newResponse = await getGoogleEvents({
                    token: newAccessToken,
                    from,
                    until
                });

                if (newResponse.status === OK) {
                    setGoogleCalendarSync(true);
                    return newResponse.items;
                }
                else {
                    setGoogleCalendarSync(false);
                    return [];
                }           
            }
        }
        // If the token hasn't expired yet.
        else {
            const response = await getGoogleEvents({
                token: googleAccessToken,
                from,
                until
            });

            if (response.status === OK) {
                return response.items;
            }
            else { //Token expired
                setGoogleCalendarSync(false);
            }
        }
    }
    else {
        return [];
    }
}, []);


const fetchEvents = async (fetchInfo, successCallback) => {    
    const googleEvents = await loadGoogleCalendarEvents(fetchInfo.start, fetchInfo.end);
    //...Merging googleEvents with local events..
}

<FullCalendar
    ...attributes...
    events={fetchEvents} // alternatively, use the `events` setting to fetch from a feed
/>

And finally, the API endpoint asks for a refresh token. Refresh tokens are stored for each user in the DB.

const refreshToken = async (req, res) => {
    const user = req.user;
    const userGoogleAuthentication = await user.getGoogleAuthentication();

    if (userGoogleAuthentication) {
        const tokenResponse = await oauth2Client.refreshToken(userGoogleAuthentication.refresh_token);
        return res.status(200).json({ ...tokenResponse.tokens });
    }
    else {
        return res.sendStatus(500);
    }
}

That's it, hopefully, it is helpful for someone else. Have fun!

#programming #web #javascript #learning

- 1 toast