Looking for work! Check my resume here.

· 6 min read

Offline-First PWA

A webapp built for offline users which gracefully handles expired authorizations.

I needed an app that would allow users to submit forms while offline and/or with expired authorization credentials. But the app is not available for anyone to access so the user must be manually registered and the user must first login while online once before using the app. Similar to a guest mode on e-commerce or other websites but the app is not open for the public and the app is expected to work without network or authorization to the backend; hence it works offline first.

The users for this app are delivery drivers who operate in remote areas for extended trips so a worst case scenario is losing authorization, going offline then stuck at the login screen unable to use the app to record deliveries.

Solution

The solution revolves around my authStore and the decision I made to treat both the user’s info and their authorization as independent entities. In a typical app with user login the two would be all or nothing and when the authorization token is lost the user is usually logged out and forced to login again to verify their identity. I decided if the user had logged in once already then I can allow them to continue in the app and they can fill an outbox with items but can not submit the items until they verify their identity again by logging in. Plus, this outbox pulls double-duty for authenticated users who just happen to be offline.

Handling Authorization

First step, I had to keep info about the user and the user’s authorization token in the browser’s local storage.

authStore.ts
export const storedUser: Ref<string | null> = useStorage('user', null);
export const authToken: Ref<string | null> = useStorage('auth_token', null);

Then I set up an auth store with Pinia and defined basic actions. I’m using Directus for the backend of this project. Of note, user is a computed value using the storedUser so that even after a page refresh we can seamlessly load the user from the browser’s storage.

authStore.ts
export const useAuthStore = defineStore('auth', {
  state: (): AuthStoreState => ({
    loading: false,
    error: null,
  }),
  getters: {
    user(): UserType | null {
      if (!storedUser.value) return null;
      try {
        return storedUser.value ? JSON.parse(storedUser.value) as UserType : null;
      } catch {
        console.log('[Auth] failed to parse user')
        storedUser.value = null;
        return null;
      }
    },
    isLoggedIn(): boolean {
      return authToken.value !== null && this.user !== null;
    },
  },
  actions: {
    async getMe() {
      try {
        const me = await directus.users.me.read();
        const user: UserType = {
          id: me.id,
          first_name: me.first_name,
          last_name: me.last_name,
          email: me.email,
          avatar: me.avatar,
        };
        storedUser.value = JSON.stringify(user);
      } catch (err) {
        console.log('[Auth] error fetching me', err);
        throw err;
      }
    },
    async login(credentials: CredentialsType) {
      try {
        this.loading = true;
        await directus.auth.login({ ...credentials });
        await this.getMe()
        return;
      } catch (err: any) {
        const error = err.response?.data?.errors[0]?.extensions?.code || err;
        if (error == 'INVALID_CREDENTIALS' || error == 'Error: Invalid user credentials.') {
          this.error = 'INVALID_CREDENTIALS';
        } else {
          console.error('[Auth] Unhandled login error', err);
          this.error = err;
        }
        throw err;
      } finally {
        this.loading = false;
      }
    },
    async logout() {
      try {
        await directus.auth.logout();
      } finally {
        authToken.value = null;
        storedUser.value = null;
      }
    },
  },
});

Login Page

The app’s router can now use the authStore and only if there is no user do we direct the user to the login page. This allows a user that has previously logged in to continue using the app without an auth token. This would now be an anonymous mode or something but not a guest mode like you would see on some e-commerce websites.

router.ts
router.beforeEach((to, from, next) => {
  if (to.name === 'login') {
    const authStore = useAuthStore();
    if (authStore.isLoggedIn) {
      next({ name: 'home' });
    }
  }
  if (to.matched.some((record) => record.meta.requiresAuth)) {
    const authStore = useAuthStore();
    if (!authStore.user) {
      next({ name: 'login' });
    }
  }
  next();
});

Later, have to handle some problems we just introduced if an unauthenticated user attempts to GET something from a protected endpoint.

A user may try to login but then become offline, so to prevent being stuck on the login screen we add a link to allow the user to return to the main app. Alternatively, we could have offered a modal for users to re-login which would not lock the user in a route they cannot navigate out of otherwise. Or someone could not design their login page like mine which hid all navigation by default .

LoginPage.vue
<template>
<!-- page layout and form omitted -->
<router-link v-if="user" :to="{ name: 'home' }">
  Skip
</router-link>
</template>
 
<script setup lang="ts">
const authStore = useAuthStore();
const { user } = storeToRefs(authStore);
</script>

Elsewhere we can write logic to distinguish between a user with valid authorization or not using isLoggedIn.

Foo.vue
<template>
  <button v-if="isLoggedIn" @click="syncPendingRequests">Send Outbox Items</button>
  <router-link v-else :to="{ name: 'login' }">Login</router-link>
</template>
 
<script setup lang="ts">
const authStore = useAuthStore()
const { isLoggedIn } = storeToRefs(authStore);
</script>

Handling Authorization Errors While Offline

This may be enough boilerplate for some offline apps; however, for this particular app I needed more as all API endpoints including GET endpoints were secured. Therefore, without authorization the app would not be able to fetch what it needs to function. Here the service worker again transparently does double service to provide a cache for us when the user is offline or when the user is currently unauthorized.

I was leaning on Workbox for this app’s service worker and to make it respond with cached results due to authorization errors I had to add a small plugin to the caching strategy.

service-worker.ts
registerRoute(
  matchDirectusApiCb,
  new StaleWhileRevalidate({
    cacheName: 'api',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      {
        cacheWillUpdate: async ({ request, response }) => {
          if (response && response.status === 403) {
            const cache = await caches.open('api');
            const cachedResponse = await cache.match(request);
            if (cachedResponse) {
              return cachedResponse;
            }
          }
          return response;
        },
      },
    ],
  })
);

Entry Point to Offline-First

Finally, we modify App.vue to attempt to update the current storedUser on initial load of the app. If that fails due to an expired or missing auth token then the app will continue forward using the last known user from the browser’s storage and if that fails then we hit the login page per the router’s setup. And that’s it, the user can now use the app in an offline-first manner.

App.vue
onBeforeMount(async () => {
  console.info('[App] Checking auth onBeforeMount');
  if (storedUser.value) {
    // previous login exists
    try {
      // update user
      await authStore.getMe();
    } catch (err) {
      console.log('[App] failed to get user', err);
    }
  }
});

Next Steps

Now, this particular setup works for my usecase but it is not a panacea for all offline-first apps.

Firstly, I have the benefit of an app with closed registration which reduces the number of edge cases. Otherwise, I may have to consider handling deleting the current outbox when the user voluntarily logs out of the app as a new user could inherit an existing outbox when they login to a shared device.

Secondly, this design relies on a service worker but an alternative solution could implement a local cache.

For a different app you may have to adapt this tutorial for your particular requirements.

Conclusion

I hope this tutorial helps others to develop more apps that do not depend on an ever-present internet access. Also, I am grateful for being afforded the time to develop this app which actually provides real-world benefit.

  • pwa
Share:
Back to Blog
FYI: This post is a part of the following project.