DEV Community

Kyle Rummens
Kyle Rummens

Posted on • Originally published at gist.github.com

Supabase Angular authentication with RxJS Observables

Overview

The goal of this example is to build a more powerful authentication system in our Supabase Angular applications by leveraging RxJS Observables.

Supabase has a great tutorial that explains how to set up your Angular app to work with Supabase, but while that tutorial works with Angular, I wouldn't say it's built for Angular.

When you create a new Angular app with the Angular CLI, baked-in is the powerful library, RxJS. Let's combine the ease of Supabase with the power of RxJS.

Another important addition that I will lay out is the ability to seamlessly combine a public.profiles table with your auth.users table from Supabase Auth. Many (if not most) applications need to store more data about their users than what sits in the auth.users table in your database, which is where Supabase Auth pulls from. With RxJS Observables and Supabase Realtime, any changes to our user's profile can immediately be reflected across our entire application.

This tutorial assumes you have an understanding of Angular, at least a minimal understanding of RxJS, and an existing Supabase project.

Quick definitions

  • Supabase user: the user object returned by Supabase Auth
  • Profile: the profile data for our user as found in our public.profiles table in our database

Full source code can be found at https://github.com/kylerummens/angular-supabase-auth

Project setup

Create an Angular application with the Angular CLI

ng new angular-supabase-auth
Enter fullscreen mode Exit fullscreen mode

Install the Supabase javascript client library

npm install @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

Create a supabase service and initialize the Supabase client

ng generate service services/supabase --skip-tests
Enter fullscreen mode Exit fullscreen mode
// supabase.service.ts
import { Injectable } from '@angular/core';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { environment } from 'src/environments/environment';

@Injectable({
  providedIn: 'root'
})
export class SupabaseService {

  public client: SupabaseClient;

  constructor() {
    this.client = createClient(environment.supabaseUrl, environment.supabaseKey);
  }
}

Enter fullscreen mode Exit fullscreen mode

Add your API URL and the anon key from your Supabase dashboard to your environment variables

// environment.ts
export const environment = {
  production: false,
  supabaseUrl: 'YOUR_SUPABASE_URL',
  supabaseKey: 'YOUR_SUPABASE_KEY'
};
Enter fullscreen mode Exit fullscreen mode

Auth service

Now that we're all set up, we can get to the fun stuff. Let's create our Auth service. This is where the bulk of our authentication logic will sit, and if we do it right, building the rest of our application will be a breeze.

ng generate service services/auth --skip-tests
Enter fullscreen mode Exit fullscreen mode
// auth.service.ts
import { Injectable } from '@angular/core';
import { RealtimeChannel, User } from '@supabase/supabase-js';
import { BehaviorSubject, first, Observable, skipWhile } from 'rxjs';
import { SupabaseService } from './supabase.service';

export interface Profile {
  user_id: string;
  photo_url: string;
  email: string;
  first_name: string;
  last_name: string;
}

@Injectable({
  providedIn: 'root'
})
export class AuthService {

  constructor(private supabase: SupabaseService) { }

}
Enter fullscreen mode Exit fullscreen mode

The above code should be pretty straightforward. We created a Profile interface to match our public.profiles table from the database, and we injected our SupabaseService into the AuthService.

Now let's define the state properties on our auth service.


// auth.service.ts

/* ... imports, etc. ... */

export class AuthService {

  // Supabase user state
  private _$user = new BehaviorSubject<User | null | undefined>(undefined);
  $user = this._$user.pipe(skipWhile(_ => typeof _ === 'undefined')) as Observable<User | null>;
  private user_id?: string;

  // Profile state
  private _$profile = new BehaviorSubject<Profile | null | undefined>(undefined);
  $profile = this._$profile.pipe(skipWhile(_ => typeof _ === 'undefined')) as Observable<Profile | null>;
  private profile_subscription?: RealtimeChannel;

  constructor(private supabase: SupabaseService) { }

}
Enter fullscreen mode Exit fullscreen mode

It is very important that we understand the above code before moving on. We are creating properties for the Supabase user as well as the user's profile from our public.profiles table. For both the Supabase user and the profile, we are creating the following:

  • a private BehaviorSubject
  • a public Observable, created from piping the BehaviorSubject

The BehaviorSubject will be used to store the value of the current user/profile. When we receive changes from Supabase Auth or Realtime, we can update the value by calling the .next() method. As you can see, we are providing a type parameter of User | null | undefined on the BehaviorSubject, and then setting the initial value as undefined. Our BehaviorSubject will contain those types under the following circumstances:

  • undefined is used to represent the state where we don't yet know if there is a signed-in user or not. That is why our BehaviorSubject is initialized in the undefined state.
  • null is used when we know that there is no user signed in
  • When there is a user, the value will be an object of type User

The values that we want to expose to the rest of our application ($user and $profile) use the RxJS skipWhile pipe to only emit events when the value is not undefined. In other words, the rest of our application will only be notified once we're confident of the state. Either there is a user, or not.

Now let's hook up our _$user BehaviorSubject to Supabase Auth by adding the following code inside of our auth service's constructor:

constructor(private supabase: SupabaseService) {

  // Initialize Supabase user
  // Get initial user from the current session, if exists
  this.supabase.client.auth.getUser().then(({ data, error }) => {
    this._$user.next(data && data.user && !error ? data.user : null);

    // After the initial value is set, listen for auth state changes
    this.supabase.client.auth.onAuthStateChange((event, session) => {
      this._$user.next(session?.user ?? null);
    });
  });

}
Enter fullscreen mode Exit fullscreen mode

We are using two Supabase Auth methods here:

  • getUser returns a Promise with the current value of the user from the session, if it exists
  • onAuthStateChange listens to changes to the user state, such as sign-in and sign-out

When getUser gives us its value, we are populating the _$user with either the user data or null. After we get the initial value, if there are any changes to the session we will update the _$user again.

The next step is the trickiest. For the user's profile, we need to:

  • Subscribe to the Supabase user
  • Make a one-time API call to Supabase to get the user's profile from the profiles table
  • Listen to changes to the profiles table, and update the _$profile value

Add the following code to the auth service constructor after the above code:

// Initialize the user's profile
// The state of the user's profile is dependent on their being a user. If no user is set, there shouldn't be a profile.
this.$user.subscribe(user => {
  if (user) {
    // We only make changes if the user is different
    if (user.id !== this.user_id) {
      const user_id = user.id;
      this.user_id = user_id;

      // One-time API call to Supabase to get the user's profile
      this.supabase
        .client
        .from('profiles')
        .select('*')
        .match({ user_id })
        .single()
        .then(res => {

          // Update our profile BehaviorSubject with the current value
          this._$profile.next(res.data ?? null);

          // Listen to any changes to our user's profile using Supabase Realtime
          this.profile_subscription = this.supabase
            .client
            .channel('public:profiles')
            .on('postgres_changes', {
              event: '*',
              schema: 'public',
              table: 'profiles',
              filter: 'user_id=eq.' + user.id
            }, (payload: any) => {

              // Update our profile BehaviorSubject with the newest value
              this._$profile.next(payload.new);

            })
            .subscribe()

        })
    }
  }
  else {
    // If there is no user, update the profile BehaviorSubject, delete the user_id, and unsubscribe from Supabase Realtime
    this._$profile.next(null);
    delete this.user_id;
    if (this.profile_subscription) {
      this.supabase.client.removeChannel(this.profile_subscription).then(res => {
        console.log('Removed profile channel subscription with status: ', res);
      });
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Since our _$profile BehaviorSubject is dependent on the $user Observable, when we sign out our user, the _$profile is automatically updated. We can now use the $profile Observable everywhere in our app.

Lastly, for the sake of this example, add the following methods to allow us to login with email/password, and logout:

signIn(email: string, password: string) {
  return new Promise<void>((resolve, reject) => {

    // Set _$profile back to undefined. This will mean that $profile will wait to emit a value
    this._$profile.next(undefined);
    this.supabase.client.auth.signInWithPassword({ email, password })
      .then(({ data, error }) => {
        if (error || !data) reject('Invalid email/password combination');

        // Wait for $profile to be set again.
        // We don't want to proceed until our API request for the user's profile has completed
        this.$profile.pipe(first()).subscribe(() => {
          resolve();
        });
      })
  })
}

logout() {
  return this.supabase.client.auth.signOut()
}
Enter fullscreen mode Exit fullscreen mode

Protecting routes using guards

Let's create some components for our app, and a guard to protect our routes.

ng generate guard guards/profile --implements CanActivate --skip-tests
Enter fullscreen mode Exit fullscreen mode
ng generate component pages/login --skip-tests
Enter fullscreen mode Exit fullscreen mode
ng generate component pages/dashboard --skip-tests
Enter fullscreen mode Exit fullscreen mode

Update our app's routing module:

// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ProfileGuard } from './guards/profile.guard';
import { DashboardComponent } from './pages/dashboard/dashboard.component';
import { LoginComponent } from './pages/login/login.component';

const routes: Routes = [
  { path: '', component: DashboardComponent, canActivate: [ProfileGuard] },
  { path: 'login', component: LoginComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }
Enter fullscreen mode Exit fullscreen mode

Now we add logic to our ProfileGuard so that it only allows users to continue if they are signed-in and their profile has loaded:

// profile.guard.ts
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { first, map, Observable } from 'rxjs';
import { AuthService } from '../services/auth.service';

@Injectable({
  providedIn: 'root'
})
export class ProfileGuard implements CanActivate {

  constructor(
    private router: Router,
    private authService: AuthService) { }

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {

    return this.authService.$profile.pipe(
      // We only want to get the first emitted value from the $profile
      first(),
      map(profile => {

        // Allow access if the user's profile is set
        if (profile) return true;

        // If the user is not signed in and does not have a profile, do not allow access
        else {
          // Redirect to the /login route, while capturing the current url so we can redirect after login
          this.router.navigate(['/login'], {
            queryParams: { redirect_url: state.url }
          });
          return false;
        }
      })
    )

  }

}

Enter fullscreen mode Exit fullscreen mode

Login component:

// login.component.ts
import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from 'src/app/services/auth.service';

@Component({
  selector: 'app-login',
  template: `
    <form [formGroup]="login_form" (ngSubmit)="onSubmit()">

      <div>
          <label for="email">Email</label>
          <input id="email" type="email" formControlName="email">
      </div>

      <div>
          <label for="password">Password</label>
          <input id="password" type="password" formControlName="password">
      </div>

      <div style="color:red" *ngIf="error">{{ error }}</div>

      <button type="submit" [disabled]="login_form.invalid">Submit</button>

  </form>
  `
})
export class LoginComponent {

  login_form = new FormGroup({
    email: new FormControl('', [Validators.required, Validators.email]),
    password: new FormControl('', [Validators.required])
  });

  error?: string;

  constructor(
    private router: Router,
    private authService: AuthService) { }

  onSubmit() {
    if (this.login_form.valid) {

      delete this.error;

      const { email, password } = this.login_form.value;
      this.authService.signIn(email!, password!)
        .then(() => {
          this.router.navigate(['/']);
        })
        .catch(err => {
          this.error = err;
        })

    }
  }

}
Enter fullscreen mode Exit fullscreen mode

Dashboard component:

// dashboard.component.ts
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from 'src/app/services/auth.service';

@Component({
  selector: 'app-dashboard',
  template: `
    <h1>Dashboard</h1>

    <ng-container *ngIf="authService.$profile | async as profile">
        <div>{{ profile | json }}</div>
    </ng-container>

    <button (click)="logout()">Logout</button>
  `
})
export class DashboardComponent {

  constructor(
    public authService: AuthService,
    private router: Router) { }

  logout() {
    this.authService.logout().then(() => {
      this.router.navigate(['/login']);
    })
  }

}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)