Skip to content

Instantly share code, notes, and snippets.

@kylerummens
Created December 18, 2022 05:43

Revisions

  1. kylerummens created this gist Dec 18, 2022.
    436 changes: 436 additions & 0 deletions supabase-angular-auth-with-rxjs.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,436 @@
    # 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](https://supabase.com/docs/guides/getting-started/tutorials/with-angular) 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](https://rxjs.dev/). 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
    ```
    <br>

    Install the Supabase javascript client library
    ```
    npm install @supabase/supabase-js
    ```
    <br>

    Create a supabase service and initialize the Supabase client
    ```
    ng generate service services/supabase --skip-tests
    ```
    ```typescript
    // 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);
    }
    }

    ```
    <br>

    Add your API URL and the anon key from your Supabase dashboard to your environment variables
    ```typescript
    // environment.ts
    export const environment = {
    production: false,
    supabaseUrl: 'YOUR_SUPABASE_URL',
    supabaseKey: 'YOUR_SUPABASE_KEY'
    };
    ```


    # 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
    ```

    ```typescript
    // 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) { }

    }
    ```

    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.
    <br>

    ```typescript
    // 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) { }

    }
    ```

    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:

    ```typescript
    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);
    });
    });

    }
    ```

    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:

    ```typescript
    // 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);
    });
    }
    }
    })
    ```
    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:
    ```typescript
    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()
    }
    ```

    # 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
    ```
    ```
    ng generate component pages/login --skip-tests
    ```
    ```
    ng generate component pages/dashboard --skip-tests
    ```

    Update our app's routing module:
    ```typescript
    // 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 { }
    ```

    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:

    ```typescript
    // 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;
    }
    })
    )

    }

    }

    ```


    Login component:

    ```typescript
    // 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;
    })

    }
    }

    }
    ```


    Dashboard component:
    ```typescript
    // 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']);
    })
    }

    }
    ```