Blog post

Authentication in Ionic Angular with Supabase

2022-11-08

55 minute read

Authentication in Ionic Angular with Supabase

Authentication of your apps is one of the most important topics, and by combining Angular logic with Supabase authentication functionality we can build powerful and secure applications in no time.

In this tutorial, we will create an Ionic Angular application with Supabase backend to build a simple realtime messaging app.

Ionic Angular Authentication with Supabase

Along the way we will dive into:

  • User registration and login with email/password
  • Adding Row Level Security to protect our database
  • Angular guards and token handling logic
  • Magic link authentication for both web and native mobile apps

After going through this tutorial you will be able to create your own user authentication and secure your Ionic Angular app.

If you are not yet familiar with Ionic you can check out the Ionic Quickstart guide of the Ionic Academy or check out the Ionic Supabase integration video if you prefer video.

However, most of the logic is Angular based and therefore applies to any Angular web project as well.

You can find the full code of this tutorial on Github where you just need to insert your own Supabase instance and then create the tables with the included SQL file.

But before we dive into the app, let's set up our Supabase project!

Creating the Supabase Project

First of all, we need a new Supabase project. If you don't have a Supabase account yet, you can get started for free!

In your dashboard, click "New Project" and leave it to the default settings, but make sure you keep a copy of your database password!

By default Supabase has the standard email/password authentication enabled, which you can find under the menu element Authentication and by scrolling down to the Auth Provider section.

Supabase Auth Provider

Need to add another provider in the future? No problem!

Supabase offers a ton of providers that you can easily integrate so your users can sign up with their favorite provider.

On top of that, you can customize the auth settings and also the email templates that users see when they need to confirm their account, get a magic link or want to reset their password.

Supabase Email Templates

Feel free to play around with the settings, and once you're done let's continue with our database.

Defining your Tables with SQL

Supabase uses Postgres for the database, so we need to write some SQL to define our tables upfront (although you can easily change them later through the Supabase Web UI as well!)

We want to build a simple messaging app, so what we need is:

  • users: A table to keep track of all registered users
  • groups: Keep track of user-created chat groups
  • messages: All messages of our app

To create the tables, simply navigate to the SQL Editor menu item and click on + New query, paste in the SQL and hit RUN which hopefully executes without issues:

create table users (
  id uuid not null primary key,
  email text
);

create table groups (
  id bigint generated by default as identity primary key,
  creator uuid references public.users not null default auth.uid(),
  title text not null,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null
);

create table messages (
  id bigint generated by default as identity primary key,
  user_id uuid references public.users not null default auth.uid(),
  text text check (char_length(text) > 0),
  group_id bigint references groups on delete cascade not null,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null
);

After creating the tables we need to define policies to prevent unauthorized access to some of our data.

In this scenario we allow unauthenticated users to read group data - everything else like creating a group, or anything related to messages is only allowed for authenticated users.

Go ahead and also run the following query in the editor:

-- Secure tables
alter table users enable row level security;
alter table groups enable row level security;
alter table messages enable row level security;

-- User Policies
create policy "Users can read the user email." on users
  for select using (true);

-- Group Policies
create policy "Groups are viewable by everyone." on groups
  for select using (true);

create policy "Authenticated users can create groups." on groups for
  insert to authenticated with check (true);

create policy "The owner can delete a group." on groups for
    delete using (auth.uid() = creator);

-- Message Policies
create policy "Authenticated users can read messages." on messages
  for select to authenticated using (true);

create policy "Authenticated users can create messages." on messages
  for insert to authenticated with check (true);

Now we also add a cool function that will automatically add user data after registration into our table. This is necessary if you want to keep track of some user information, because the Supabase auth table is an internal table that we can't access that easily.

Go ahead and run another SQL query in the editor now to create our trigger:

-- Function for handling new users
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.users (id, email)
  values (new.id, new.email);
  return new;
end;
$$ language plpgsql security definer;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

To wrap this up we want to enable realtime functionality of our database so we can get new messages instantly without another query.

For this we can activate the publication for our messages table by running one last query:

begin;
  -- remove the supabase_realtime publication
  drop publication if exists supabase_realtime;

  -- re-create the supabase_realtime publication with no tables and only for insert
  create publication supabase_realtime with (publish = 'insert');
commit;

-- add a table to the publication
alter publication supabase_realtime add table messages;

If you now open the Table Editor menu item you should see your three tables, and you can also check their RLS policies right from the web!

But enough SQL for today, let's write some Angular code.

Creating the Ionic Angular App

To get started with our Ionic app we can create a blank app without any additional pages and then install the Supabase Javascript client.

Besides that, we need some pages in our app for the different views, and services to keep our logic separated from the views. Finally we can also already generate a guard which we will use to protect internal pages later.

Go ahead now and run the following on your command line:

ionic start supaAuth blank --type=angular
npm install @supabase/supabase-js

# Add some pages
ionic g page pages/login
ionic g page pages/register
ionic g page pages/groups
ionic g page pages/messages

# Generate services
ionic g service services/auth
ionic g service services/data

# Add a guard to protect routes
ionic g guard guards/auth --implements=CanActivate

Ionic (or the Angular CLI under the hood) will now create the routing entries for us, but we gonna fine tune them a bit.

First of all we want to pass a groupid to our messages page, and we also want to make sure that page is protected by the guard we created before.

Therefore bring up the src/app/app-routing.module.ts and change it to:

import { AuthGuard } from './guards/auth.guard'
import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./pages/login/login.module').then((m) => m.LoginPageModule),
  },
  {
    path: 'register',
    loadChildren: () =>
      import('./pages/register/register.module').then((m) => m.RegisterPageModule),
  },
  {
    path: 'groups',
    loadChildren: () => import('./pages/groups/groups.module').then((m) => m.GroupsPageModule),
  },
  {
    path: 'groups/:groupid',
    loadChildren: () =>
      import('./pages/messages/messages.module').then((m) => m.MessagesPageModule),
    canActivate: [AuthGuard],
  },
  {
    path: '',
    redirectTo: 'home',
    pathMatch: 'full',
  },
]

@NgModule({
  imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })],
  exports: [RouterModule],
})
export class AppRoutingModule {}

Now all paths are correct and the app starts on the login page, and the messages page can only be activated if that guard returns true - which it does by default, so we will take care of its implementation later.

To connect our app properly to Supabase we now need to grab the project URL and the public anon key from the settings page of your Supabase project.

You can find those values in your Supabase project by clicking on the Settings icon and then navigating to API where it shows your Project API keys.

This information now goes straight into the src/environments/environment.ts of our Ionic project:

export const environment = {
  production: false,
  supabaseUrl: 'https://YOUR-APP.supabase.co',
  supabaseKey: 'YOUR-ANON-KEY',
}

By the way: The anon key is safe to use in a frontend project since we have enabled RLS on our database tables!

Building the Public Pages of our App

The big first step is to create the "outside" pages which allow a user to perform different operations:

Ionic Login Page

Before we dive into the UI of these pages we should define all the required logic in a service to easily inject it into our pages.

Preparing our Supabase authentication service

Our service should call expose all the functions for registration and login but also handle the state of the current user with a BehaviorSubject so we can easily emit new values later when the user session changes.

We are also loading the session once "by hand" using getUser() since the onAuthStateChange event is usually not broadcasted when the app loads, and we want to load a stored session right when the app starts.

The relevant functions for our user authentication are all part of the supabase.auth object, which makes it easy to find all relevant (and even some unknown) features.

Additionally, we expose our current user as an Observable to the outside and add some helper functions to get the current user ID synchronously.

Now move on by changing the src/app/services/auth.service.ts to this:

/* eslint-disable @typescript-eslint/naming-convention */
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { isPlatform } from '@ionic/angular'
import { createClient, SupabaseClient, User } from '@supabase/supabase-js'
import { BehaviorSubject, Observable } from 'rxjs'
import { environment } from '../../environments/environment'

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  private supabase: SupabaseClient
  private currentUser: BehaviorSubject<User | boolean> = new BehaviorSubject(null)

  constructor(private router: Router) {
    this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey)

    this.supabase.auth.onAuthStateChange((event, sess) => {
      if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
        console.log('SET USER')

        this.currentUser.next(sess.user)
      } else {
        this.currentUser.next(false)
      }
    })

    // Trigger initial session load
    this.loadUser()
  }

  async loadUser() {
    if (this.currentUser.value) {
      // User is already set, no need to do anything else
      return
    }
    const user = await this.supabase.auth.getUser()

    if (user.data.user) {
      this.currentUser.next(user.data.user)
    } else {
      this.currentUser.next(false)
    }
  }

  signUp(credentials: { email; password }) {
    return this.supabase.auth.signUp(credentials)
  }

  signIn(credentials: { email; password }) {
    return this.supabase.auth.signInWithPassword(credentials)
  }

  sendPwReset(email) {
    return this.supabase.auth.resetPasswordForEmail(email)
  }

  async signOut() {
    await this.supabase.auth.signOut()
    this.router.navigateByUrl('/', { replaceUrl: true })
  }

  getCurrentUser(): Observable<User | boolean> {
    return this.currentUser.asObservable()
  }

  getCurrentUserId(): string {
    if (this.currentUser.value) {
      return (this.currentUser.value as User).id
    } else {
      return null
    }
  }

  signInWithEmail(email: string) {
    return this.supabase.auth.signInWithOtp({ email })
  }
}

That's enough logic for our pages, so let's put that code to use.

Creating the Login Page

Although we first need to register a user, we begin with the login page. We can even "register" a user from here since we will offer the easiest sign-in option with magic link authentication that only requires an email, and a user entry will be added to our users table thanks to our trigger function.

To create a decent UX we will add a reactive form with Angular, for which we first need to import the ReactiveFormsModule into the src/app/pages/login/login.module.ts:

import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'

import { IonicModule } from '@ionic/angular'

import { LoginPageRoutingModule } from './login-routing.module'

import { LoginPage } from './login.page'

@NgModule({
  imports: [CommonModule, FormsModule, IonicModule, LoginPageRoutingModule, ReactiveFormsModule],
  declarations: [LoginPage],
})
export class LoginPageModule {}

Now we can define the form from code and add all required functions to our page, which becomes super easy thanks to our previous implementation of the service.

Right inside the constructor of the login page, we will also subscribe to the getCurrentUser() Observable and if we do have a valid user token, we can directly route the user forward to the groups overview page.

On login, we now only need to show some loading spinner and call the according function of our service, and since we already listen to the user in our constructor we don't even need to add any more logic for routing at this point and only show an alert in case something goes wrong.

Go ahead by changing the src/app/pages/login/login.page.ts to this now:

import { AuthService } from './../../services/auth.service'
import { Component } from '@angular/core'
import { FormBuilder, Validators } from '@angular/forms'
import { Router } from '@angular/router'
import { LoadingController, AlertController } from '@ionic/angular'

@Component({
  selector: 'app-login',
  templateUrl: './login.page.html',
  styleUrls: ['./login.page.scss'],
})
export class LoginPage {
  credentials = this.fb.nonNullable.group({
    email: ['', Validators.required],
    password: ['', Validators.required],
  })

  constructor(
    private fb: FormBuilder,
    private authService: AuthService,
    private loadingController: LoadingController,
    private alertController: AlertController,
    private router: Router
  ) {
    this.authService.getCurrentUser().subscribe((user) => {
      if (user) {
        this.router.navigateByUrl('/groups', { replaceUrl: true })
      }
    })
  }

  get email() {
    return this.credentials.controls.email
  }

  get password() {
    return this.credentials.controls.password
  }

  async login() {
    const loading = await this.loadingController.create()
    await loading.present()

    this.authService.signIn(this.credentials.getRawValue()).then(async (data) => {
      await loading.dismiss()

      if (data.error) {
        this.showAlert('Login failed', data.error.message)
      }
    })
  }

  async showAlert(title, msg) {
    const alert = await this.alertController.create({
      header: title,
      message: msg,
      buttons: ['OK'],
    })
    await alert.present()
  }
}

Additionally, we need a function to reset the password and trigger the magic link authentication.

In both cases, we can use an Ionic alert with one input field. This field can be accessed inside the handler of a button, and so we pass the value to the according function of our service and show another message after successfully submitting the request.

Go ahead with our login page and now also add these two functions:

  async forgotPw() {
    const alert = await this.alertController.create({
      header: "Receive a new password",
      message: "Please insert your email",
      inputs: [
        {
          type: "email",
          name: "email",
        },
      ],
      buttons: [
        {
          text: "Cancel",
          role: "cancel",
        },
        {
          text: "Reset password",
          handler: async (result) => {
            const loading = await this.loadingController.create();
            await loading.present();
            const { data, error } = await this.authService.sendPwReset(
              result.email
            );
            await loading.dismiss();

            if (error) {
              this.showAlert("Failed", error.message);
            } else {
              this.showAlert(
                "Success",
                "Please check your emails for further instructions!"
              );
            }
          },
        },
      ],
    });
    await alert.present();
  }

  async getMagicLink() {
    const alert = await this.alertController.create({
      header: "Get a Magic Link",
      message: "We will send you a link to magically log in!",
      inputs: [
        {
          type: "email",
          name: "email",
        },
      ],
      buttons: [
        {
          text: "Cancel",
          role: "cancel",
        },
        {
          text: "Get Magic Link",
          handler: async (result) => {
            const loading = await this.loadingController.create();
            await loading.present();
            const { data, error } = await this.authService.signInWithEmail(
              result.email
            );
            await loading.dismiss();

            if (error) {
              this.showAlert("Failed", error.message);
            } else {
              this.showAlert(
                "Success",
                "Please check your emails for further instructions!"
              );
            }
          },
        },
      ],
    });
    await alert.present();
  }

That's enough to handle everything, so now we just need a simple UI for our form and buttons.

Since recent Ionic versions, we can use the new error slot of the Ionic item, which we can use to present specific error messages in case one field of our reactive form is invalid.

We can easily access the email and password control since we exposed them with their own get function in our class before!

Below the form, we simply stack all of our buttons to trigger the actions and give them different colors.

Bring up the src/app/pages/login/login.page.html now and change it to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Supa Chat</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content scrollY="false">
  <ion-card>
    <ion-card-content>
      <form (ngSubmit)="login()" [formGroup]="credentials">
        <ion-item>
          <ion-label position="stacked">Your Email</ion-label>
          <ion-input
            type="email"
            inputmode="email"
            placeholder="Email"
            formControlName="email"
          ></ion-input>
          <ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors"
            >Please insert your email</ion-note
          >
        </ion-item>
        <ion-item>
          <ion-label position="stacked">Password</ion-label>
          <ion-input type="password" placeholder="Password" formControlName="password"></ion-input>
          <ion-note slot="error" *ngIf="(password.dirty || password.touched) && password.errors"
            >Please insert your password</ion-note
          >
        </ion-item>
        <ion-button type="submit" expand="block" strong="true" [disabled]="!credentials.valid"
          >Sign in</ion-button
        >

        <div class="ion-margin-top">
          <ion-button
            type="button"
            expand="block"
            color="primary"
            fill="outline"
            routerLink="register"
          >
            <ion-icon name="person-outline" slot="start"></ion-icon>
            Create Account
          </ion-button>

          <ion-button type="button" expand="block" color="secondary" (click)="forgotPw()">
            <ion-icon name="key-outline" slot="start"></ion-icon>
            Forgot password?
          </ion-button>

          <ion-button type="button" expand="block" color="tertiary" (click)="getMagicLink()">
            <ion-icon name="mail-outline" slot="start"></ion-icon>
            Get a Magic Link
          </ion-button>
          <ion-button type="button" expand="block" color="warning" routerLink="groups">
            <ion-icon name="arrow-forward" slot="start"></ion-icon>
            Start without account
          </ion-button>
        </div>
      </form>
    </ion-card-content>
  </ion-card>
</ion-content>

To give our login a bit nicer touch, let's also add a background image and some additional padding by adding the following to the src/app/pages/login/login.page.scss:

ion-content {
  --padding-top: 20%;
  --padding-start: 5%;
  --padding-end: 5%;
  --background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.7)),
    url('https://images.unsplash.com/photo-1508964942454-1a56651d54ac?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1035&q=80')
      no-repeat;
}

At this point you can already try our authentication by using the magic link button, so run the Ionic app with ionic serve for the web preview and then enter your email.

Magic Link Alert

Make sure you use a valid email since you do need to click on the link in the email. If everything works correctly you should receive an email like this quickly:

Magic Link Email

Keep in mind that you can easily change those email templates inside the settings of your Supabase project.

If you now inspect the link and copy the URL, you should see an URL that points to your Supabase project and after some tokens there is a query param &redirect_to=http://localhost:8100 which directly brings a user back into our local running app!

This will be even more important later when we implement magic link authentication for native apps so stick around until the end.

Creating the Registration Page

Some users will still prefer the good old registration, so let's provide them with a decent page for that.

The setup is almost the same as for the login, so we start again by adding the ReactiveFormsModule to the src/app/pages/register/register.module.ts:

import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'

import { IonicModule } from '@ionic/angular'

import { RegisterPageRoutingModule } from './register-routing.module'

import { RegisterPage } from './register.page'

@NgModule({
  imports: [CommonModule, FormsModule, IonicModule, RegisterPageRoutingModule, ReactiveFormsModule],
  declarations: [RegisterPage],
})
export class RegisterPageModule {}

Now we define our form just like before, and in the createAccount() we use the functionality of our initially created service to sign up a user.

Bring up the src/app/pages/register/register.page.ts and change it to:

import { Component } from '@angular/core'
import { Validators, FormBuilder } from '@angular/forms'
import { LoadingController, AlertController, NavController } from '@ionic/angular'
import { AuthService } from 'src/app/services/auth.service'

@Component({
  selector: 'app-register',
  templateUrl: './register.page.html',
  styleUrls: ['./register.page.scss'],
})
export class RegisterPage {
  credentials = this.fb.nonNullable.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(6)]],
  })

  constructor(
    private fb: FormBuilder,
    private authService: AuthService,
    private loadingController: LoadingController,
    private alertController: AlertController,
    private navCtrl: NavController
  ) {}

  get email() {
    return this.credentials.controls.email
  }

  get password() {
    return this.credentials.controls.password
  }

  async createAccount() {
    const loading = await this.loadingController.create()
    await loading.present()

    this.authService.signUp(this.credentials.getRawValue()).then(async (data) => {
      await loading.dismiss()

      if (data.error) {
        this.showAlert('Registration failed', data.error.message)
      } else {
        this.showAlert('Signup success', 'Please confirm your email now!')
        this.navCtrl.navigateBack('')
      }
    })
  }

  async showAlert(title, msg) {
    const alert = await this.alertController.create({
      header: title,
      message: msg,
      buttons: ['OK'],
    })
    await alert.present()
  }
}

The view for that page follows the same structure as the login, so let's continue with the src/app/pages/register/register.page.html now:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/"></ion-back-button>
    </ion-buttons>
    <ion-title>Supa Chat</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content scrollY="false">
  <ion-card>
    <ion-card-content>
      <form (ngSubmit)="createAccount()" [formGroup]="credentials">
        <ion-item>
          <ion-label position="stacked">Your Email</ion-label>
          <ion-input
            type="email"
            inputmode="email"
            placeholder="Email"
            formControlName="email"
          ></ion-input>
          <ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors"
            >Please insert a valid email</ion-note
          >
        </ion-item>
        <ion-item>
          <ion-label position="stacked">Password</ion-label>
          <ion-input type="password" placeholder="Password" formControlName="password"></ion-input>
          <ion-note
            slot="error"
            *ngIf="(password.dirty || password.touched) && password.errors?.required"
            >Please insert a password</ion-note
          >
          <ion-note
            slot="error"
            *ngIf="(password.dirty || password.touched) && password.errors?.minlength"
            >Minlength 6 characters</ion-note
          >
        </ion-item>
        <ion-button type="submit" expand="block" strong="true" [disabled]="!credentials.valid"
          >Create my account</ion-button
        >
      </form>
    </ion-card-content>
  </ion-card>
</ion-content>

And just like before we want to have the background image so also bring in the same snippet for styling the page into the src/app/pages/register/register.page.scss:

ion-content {
  --padding-top: 20%;
  --padding-start: 5%;
  --padding-end: 5%;
  --background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.7)),
    url('https://images.unsplash.com/photo-1508964942454-1a56651d54ac?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1035&q=80')
      no-repeat;
}

As a result, we have a clean registration page with decent error messages!

Ionic Registration Page

Give the default registration process a try with another email, and you should see another user inside the Authentication area of your Supabase project as well as inside the users table inside the Table Editor.

Before we make the magic link authentication work on mobile devices, let's focus on the internal pages and functionality of our app.

Implementing the Groups Page

The groups screen is the first "inside" screen, but it's not protected by default: On the login page we have the option to start without an account, so unauthorized users can enter this page - but they should only be allowed to see the chat groups, nothing more.

App Groups Page

Authenticated users should have controls to create a new group and to sign out, but before we get to the UI we need a way to interact with our Supabase tables.

Adding a Data Service

We already generated a service in the beginning, and here we can add the logic to create a connection to Supabase and a first function to create a new row in our groups table and to load all groups.

Creating a group requires just a title, and we can gather the user ID from our authentication service to then call the insert() function from the Supabase client to create a new record that we then return to the caller.

When we want to get a list of groups, we can use select() but since we have a foreign key that references the users table, we need to join that information so instead of just having the creator field we end up getting the actual email for that ID instead!

Go ahead now and start the src/app/services/data.service.ts like this:

/* eslint-disable @typescript-eslint/naming-convention */
import { Injectable } from '@angular/core'
import { SupabaseClient, createClient } from '@supabase/supabase-js'
import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment'

const GROUPS_DB = 'groups'
const MESSAGES_DB = 'messages'

export interface Message {
  created_at: string
  group_id: number
  id: number
  text: string
  user_id: string
}

@Injectable({
  providedIn: 'root',
})
export class DataService {
  private supabase: SupabaseClient

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

  getGroups() {
    return this.supabase
      .from(GROUPS_DB)
      .select(`title,id, users:creator ( email )`)
      .then((result) => result.data)
  }

  async createGroup(title) {
    const newgroup = {
      creator: (await this.supabase.auth.getUser()).data.user.id,
      title,
    }

    return this.supabase.from(GROUPS_DB).insert(newgroup).select().single()
  }
}

Nothing fancy, so let's move on to the UI of the groups page.

Creating the Groups Page

When we enter our page, we first load all groups through our service using the ionViewWillEnter Ionic lifecycle event.

The function to create a group follows the same logic as our alerts before where we have one input field that can be accessed in the handler of a button.

At that point, we will create a new group with the information, but also reload our list of groups and then navigate a user directly into the new group by using the ID of that created record.

This will then bring a user to the messages page since we defined the route "/groups/:groupid" initially in our routing!

Now go ahead and bring up the src/app/pages/groups/groups.page.ts and change it to:

import { Router } from '@angular/router'
import { AuthService } from './../../services/auth.service'
import { AlertController, NavController, LoadingController } from '@ionic/angular'
import { DataService } from './../../services/data.service'
import { Component, OnInit } from '@angular/core'

@Component({
  selector: 'app-groups',
  templateUrl: './groups.page.html',
  styleUrls: ['./groups.page.scss'],
})
export class GroupsPage implements OnInit {
  user = this.authService.getCurrentUser()
  groups = []

  constructor(
    private authService: AuthService,
    private data: DataService,
    private alertController: AlertController,
    private loadingController: LoadingController,
    private navController: NavController,
    private router: Router
  ) {}

  ngOnInit() {}

  async ionViewWillEnter() {
    this.groups = await this.data.getGroups()
  }

  async createGroup() {
    const alert = await this.alertController.create({
      header: 'Start Chat Group',
      message: 'Enter a name for your group. Note that all groups are public in this app!',
      inputs: [
        {
          type: 'text',
          name: 'title',
          placeholder: 'My cool group',
        },
      ],
      buttons: [
        {
          text: 'Cancel',
          role: 'cancel',
        },
        {
          text: 'Create group',
          handler: async (data) => {
            const loading = await this.loadingController.create()
            await loading.present()

            const newGroup = await this.data.createGroup(data.title)
            if (newGroup) {
              this.groups = await this.data.getGroups()
              await loading.dismiss()

              this.router.navigateByUrl(`/groups/${newGroup.data.id}`)
            }
          },
        },
      ],
    })

    await alert.present()
  }

  signOut() {
    this.authService.signOut()
  }

  openLogin() {
    this.navController.navigateBack('/')
  }
}

The UI of that page is rather simple since we can iterate those groups in a list and create an item with their title and the creator email easily.

Because unauthenticated users can enter this page as well we add checks to the user Observable to the buttons so only authenticated users see the FAB at the bottom and have the option to sign out!

Remember that protecting the UI of our page is just one piece of the puzzle, real security is implemented at the server level!

In our case, we did this through the RLS we defined in the beginning.

Continue with the src/app/pages/groups/groups.page.html now and change it to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Supa Chat Groups</ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="signOut()" *ngIf="user | async">
        <ion-icon name="log-out-outline" slot="icon-only"></ion-icon>
      </ion-button>

      <ion-button (click)="openLogin()" *ngIf="(user | async) === false"> Sign in </ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-list>
    <ion-item *ngFor="let group of groups" [routerLink]="[group.id]" button>
      <ion-label
        >{{group.title }}
        <p>By {{group.users.email}}</p>
      </ion-label>
    </ion-item>
  </ion-list>
  <ion-fab vertical="bottom" horizontal="end" slot="fixed" *ngIf="user | async">
    <ion-fab-button (click)="createGroup()">
      <ion-icon name="add"></ion-icon>
    </ion-fab-button>
  </ion-fab>
</ion-content>

At this point, you should be able to create your own chat groups, and you should be brought to the page automatically or also enter it from the list manually afterward.

Inside the ULR you should see the ID of that group - and that's all we need to retrieve information about it and build a powerful chat view now!

Building the Chat Page with Realtime Feature

We left out realtime features on the groups page so we manually need to load the lists again, but only to keep the tutorial a bit shorter.

Because now on our messages page we want to have that functionality, and because we enabled the publication for the messages table through SQL in the beginning we are already prepared.

To get started we need some more functions in our service, and we add another realtimeChannel variable.

Additionally, we now want to retrieve group information by ID (from the URL!), add messages to the messages table and retrieve the last 25 messages.

All of that is pretty straightforward, and the only fancy function is listenToGroup() which returns an Observable of changes.

We can create this on our own by handling postgres_changes events on the messages table. Inside the callback function, we can handle all CRUD events, but in our case, we will (for simplicity) only handle the case of an added record.

That means when a message is added to the table, we want to return that new record to whoever is subscribed to this channel - but because a message has the user as a foreign key we first need to make another call to the messages table to retrieve the right information and then emit the new value on our Subject.

For all of that bring up the src/app/services/data.service.ts and change it to:

/* eslint-disable @typescript-eslint/naming-convention */
import { Injectable } from '@angular/core'
import { SupabaseClient, createClient, RealtimeChannel } from '@supabase/supabase-js'
import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment'

const GROUPS_DB = 'groups'
const MESSAGES_DB = 'messages'

export interface Message {
  created_at: string
  group_id: number
  id: number
  text: string
  user_id: string
}

@Injectable({
  providedIn: 'root',
})
export class DataService {
  private supabase: SupabaseClient
  // ADD
  private realtimeChannel: RealtimeChannel

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

  getGroups() {
    return this.supabase
      .from(GROUPS_DB)
      .select(`title,id, users:creator ( email )`)
      .then((result) => result.data)
  }

  async createGroup(title) {
    const newgroup = {
      creator: (await this.supabase.auth.getUser()).data.user.id,
      title,
    }

    return this.supabase.from(GROUPS_DB).insert(newgroup).select().single()
  }

  // ADD NEW FUNCTIONS
  getGroupById(id) {
    return this.supabase
      .from(GROUPS_DB)
      .select(`created_at, title, id, users:creator ( email, id )`)
      .match({ id })
      .single()
      .then((result) => result.data)
  }

  async addGroupMessage(groupId, message) {
    const newMessage = {
      text: message,
      user_id: (await this.supabase.auth.getUser()).data.user.id,
      group_id: groupId,
    }

    return this.supabase.from(MESSAGES_DB).insert(newMessage)
  }

  getGroupMessages(groupId) {
    return this.supabase
      .from(MESSAGES_DB)
      .select(`created_at, text, id, users:user_id ( email, id )`)
      .match({ group_id: groupId })
      .limit(25) // Limit to 25 messages for our app
      .then((result) => result.data)
  }

  listenToGroup(groupId) {
    const changes = new Subject()

    this.realtimeChannel = this.supabase
      .channel('public:messages')
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'messages' },
        async (payload) => {
          console.log('DB CHANGE: ', payload)

          if (payload.new && (payload.new as Message).group_id === +groupId) {
            const msgId = (payload.new as any).id

            const msg = await this.supabase
              .from(MESSAGES_DB)
              .select(`created_at, text, id, users:user_id ( email, id )`)
              .match({ id: msgId })
              .single()
              .then((result) => result.data)
            changes.next(msg)
          }
        }
      )
      .subscribe()

    return changes.asObservable()
  }

  unsubscribeGroupChanges() {
    if (this.realtimeChannel) {
      this.supabase.removeChannel(this.realtimeChannel)
    }
  }
}

By handling the realtime logic here and only returning an Observable we make it super easy for our view.

The next step is to load the group information by accessing the groupid from the URL, then getting the last 25 messages and finally subscribing to listenToGroup() and pushing every new message into our local messages array.

After the view is initialized we can also scroll to the bottom of our ion-content to show the latest message.

Finally, we need to make sure we end our realtime listening when we leave the page or the page is destroyed.

Bring up the src/app/pages/messages/messages.page.ts and change it to:

import { AuthService } from './../../services/auth.service'
import { DataService } from './../../services/data.service'
import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { IonContent } from '@ionic/angular'

@Component({
  selector: 'app-messages',
  templateUrl: './messages.page.html',
  styleUrls: ['./messages.page.scss'],
})
export class MessagesPage implements OnInit, AfterViewInit, OnDestroy {
  @ViewChild(IonContent) content: IonContent
  group = null
  messages = []
  currentUserId = null
  messageText = ''

  constructor(
    private route: ActivatedRoute,
    private data: DataService,
    private authService: AuthService
  ) {}

  async ngOnInit() {
    const groupid = this.route.snapshot.paramMap.get('groupid')
    this.group = await this.data.getGroupById(groupid)
    this.currentUserId = this.authService.getCurrentUserId()
    this.messages = await this.data.getGroupMessages(groupid)
    this.data.listenToGroup(groupid).subscribe((msg) => {
      this.messages.push(msg)
      setTimeout(() => {
        this.content.scrollToBottom(200)
      }, 100)
    })
  }

  ngAfterViewInit(): void {
    setTimeout(() => {
      this.content.scrollToBottom(200)
    }, 300)
  }

  loadMessages() {}

  async sendMessage() {
    await this.data.addGroupMessage(this.group.id, this.messageText)
    this.messageText = ''
  }

  ngOnDestroy(): void {
    this.data.unsubscribeGroupChanges()
  }
}

That's all we need to handle realtime logic and add new messages to our Supabase table!

Sometimes life can be that easy.

For the view of that page, we need to distinguish between messages we sent, and messages sent from other users.

We can achieve this by comparing the user ID of a message with our currently authenticated user id, and we position our messages with an offset of 2 so they appear on the right hand side of the screen with a slightly different styling.

For this, open up the src/app/pages/messages/messages.page.html and change it to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/groups"></ion-back-button>
    </ion-buttons>
    <ion-title>{{ group?.title}}</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <ion-row *ngFor="let message of messages">
    <ion-col size="10" *ngIf="message.users.id !== currentUserId" class="message other-message">
      <span>{{ message.text }} </span>

      <div class="time ion-text-right"><br />{{ message.created_at | date:'shortTime' }}</div>
    </ion-col>

    <ion-col
      offset="2"
      size="10"
      *ngIf="message.users.id === currentUserId"
      class="message my-message"
    >
      <span>{{ message.text }} </span>
      <div class="time ion-text-right"><br />{{ message.created_at | date:'shortTime' }}</div>
    </ion-col>
  </ion-row>
</ion-content>

<ion-footer>
  <ion-toolbar color="light">
    <ion-row class="ion-align-items-center">
      <ion-col size="10">
        <ion-textarea
          class="message-input"
          autoGrow="true"
          rows="1"
          [(ngModel)]="messageText"
        ></ion-textarea>
      </ion-col>
      <ion-col size="2" class="ion-text-center">
        <ion-button fill="clear" (click)="sendMessage()">
          <ion-icon slot="icon-only" name="send-outline" color="primary" size="large"></ion-icon>
        </ion-button>
      </ion-col>
    </ion-row>
  </ion-toolbar>
</ion-footer>

For an even more advanced chat UI chat out the examples of Built with Ionic!

Now we can add the finishing touches to that screen with some CSS to give the page a background pattern image and styling for the messages inside the src/app/pages/messages/messages.page.scss:

ion-content {
  --background: url('../../../assets/pattern.png') no-repeat;
}

.message-input {
  border: 1px solid #c3c3c3;
  border-radius: 20px;
  background: #fff;
  box-shadow: 2px 2px 5px 0px rgb(0 0 0 / 5%);
}

ion-textarea {
  --padding-start: 20px;
  --padding-top: 4px;
  --padding-bottom: 4px;

  min-height: 30px;
}

.message {
  padding: 10px !important;
  border-radius: 10px !important;
  margin-bottom: 8px !important;

  img {
    width: 100%;
  }
}

.my-message {
  background: #dbf7c5;
  color: #000;
}

.other-message {
  background: #fff;
  color: #000;
}

.time {
  color: #cacaca;
  float: right;
  font-size: small;
}

You can find the full code of this tutorial on Github including the file for that pattern so your page looks almost like WhatsApp!

Ionic Supabase Chat Page

Now we have a well-working Ionic app with Supabase authentication and database integration, but there are two small but important additions we still need to make.

Protecting internal Pages

Right now everyone could access the messages page, but we wanted to make this page only available for authenticated users.

To protect the page (and all other pages you might want to protect) we now implement the guard that we generated in the beginning.

That guard will check the Observable of our service, filter out the initial state and then see if a user is allowed to access a page or not.

Bring up our src/app/guards/auth.guard.ts and change it to this:

import { AuthService } from './../services/auth.service'
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, CanActivate, Router, UrlTree } from '@angular/router'
import { Observable } from 'rxjs'
import { filter, map, take } from 'rxjs/operators'
import { ToastController } from '@ionic/angular'

@Injectable({
  providedIn: 'root',
})
export class AuthGuard implements CanActivate {
  constructor(
    private auth: AuthService,
    private router: Router,
    private toastController: ToastController
  ) {}

  canActivate(route: ActivatedRouteSnapshot): Observable<boolean | UrlTree> {
    return this.auth.getCurrentUser().pipe(
      filter((val) => val !== null), // Filter out initial Behavior subject value
      take(1), // Otherwise the Observable doesn't complete!
      map((isAuthenticated) => {
        if (isAuthenticated) {
          return true
        } else {
          this.toastController
            .create({
              message: 'You are not allowed to access this!',
              duration: 2000,
            })
            .then((toast) => toast.present())

          return this.router.createUrlTree(['/groups'])
        }
      })
    )
  }
}

In case the user is not allowed to activate a page, we display a toast and at the same time route to the groups page since that page is visible to everyone. Normally you might even bring users simply back to the login screen if you wanted to protect all internal pages of your Ionic app.

Ionic unauthorized message

We already applied this guard to our routing in the beginning, but now it finally serves the real purpose!

At last, we come to a challenging topic, which is handling the magic link on a mobile phone.

The problem is, that the link that a user receives has a callback to a URL, but if you open that link in your email client on a phone it's not opening your native app!

But we can change this by defining a custom URL scheme for our app like "supachat://", and then use that URL as the callback URL for magic link authentication.

First, make sure you add the native platforms with Capacitor to your project:

ionic build
ionic cap add ios
ionic cap add android

Inside the new native projects we need to define the URL scheme, so for iOS bring up the ios/App/App/Info.plist and insert another block:

	<key>CFBundleURLTypes</key>
		<array>
			<dict>
				<key>CFBundleURLSchemes</key>
				<array>
					<string>supachat</string>
				</array>
			</dict>
		</array>

For Android, we first define a new string inside the android/app/src/main/res/values/strings.xml:

<string name="custom_url_scheme">supachat</string>

Now we can update the android/app/src/main/AndroidManifest.xml and add an intent-filter inside which uses the custom_url_scheme value:

<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="@string/custom_url_scheme" />
</intent-filter>

By default, Supabase will use the host for the redirect URL, which works great if the request comes from a website.

That means we only want to change the behavior for native apps, so we can use the isPlatform() check in our app and use "supachat://login"as the redirect URL instead.

For this, bring up the src/app/services/auth.service.ts and update our signInWithEmail and add another new function:

  signInWithEmail(email: string) {
    const redirectTo = isPlatform("capacitor")
      ? "supachat://login"
      : `${window.location.origin}/groups`;

    return this.supabase.auth.signInWithOtp({
      email,
      options: { emailRedirectTo: redirectTo },
    });
  }

  async setSession(access_token, refresh_token) {
    return this.supabase.auth.setSession({ access_token, refresh_token });
  }

This second function is required since we need to manually set our session based on the other tokens of the magic link URL.

If we now click on the link in an email, it will open the browser the first time but then asks if we want to open our native app.

This is cool, but it's not loading the user information correctly, but we can easily do this manually!

Eventually, our app is opened with an URL that looks like this:

supachat://login#access_token=A-TOKEN&expires_in=3600&refresh_token=REF-TOKEN&token_type=bearer&type=magiclink

To set our session we now simply need to extract the access_token and refresh_token from that URL, and we can do this by adding a listener to the appUrlOpen event of the Capacitor app plugin.

Once we got that information we can call the setSession function that we just added to our service, and then route the user forward to the groups page!

To achieve this, bring up the src/app/app.component.ts and change it to:

import { AuthService } from 'src/app/services/auth.service'
import { Router } from '@angular/router'
import { Component, NgZone } from '@angular/core'
import { App, URLOpenListenerEvent } from '@capacitor/app'

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent {
  constructor(private zone: NgZone, private router: Router, private authService: AuthService) {
    this.setupListener()
  }

  setupListener() {
    App.addListener('appUrlOpen', async (data: URLOpenListenerEvent) => {
      console.log('app opened with URL: ', data)

      const openUrl = data.url
      const access = openUrl.split('#access_token=').pop().split('&')[0]
      const refresh = openUrl.split('&refresh_token=').pop().split('&')[0]

      await this.authService.setSession(access, refresh)

      this.zone.run(() => {
        this.router.navigateByUrl('/groups', { replaceUrl: true })
      })
    })
  }
}

If you would run the app now it still wouldn't work, because we haven't added our custom URL scheme as an allowed URL for redirecting to!

To finish this open your Supabase project again and go to the Settings of the Authentication menu entry where you can add a domain under Redirect URLs:

Supabase Auth Redirect URL

This was the last missing piece, and now you even got seamless Supabase authentication with magic links working inside your iOS and Android app!

Conclusion

We've come a long way and covered everything from setting up tables, to defining policies to protect data, and handling authentication in Ionic Angular applications.

You can find the full code of this tutorial on Github where you just need to insert your own Supabase instance and then create the tables with the included SQL file, plus updating the authentication settings as we did in the tutorial

Although we can now use magic link auth, something probably even better fitting for native apps would be phone auth with Twilio that's also easily possible with Supabase - just like tons of other authentication providers!

Protecting your Ionic Angular app with Supabase is a breeze, and through the security rules, you can make sure your data and tables are protected in the best possible way.

If you enjoyed the tutorial, you can find many more tutorials on my YouTube channel where I help web developers build awesome mobile apps.

Until next time and happy coding with Supabase!

Share this article

Last post

Fetching and caching Supabase data in Next.js 13 Server Components

17 November 2022

Next post

Supabase Beta October 2022

2 November 2022

Related articles

Supabase Beta February 2023

Geo Queries with PostGIS in Ionic Angular

Type Constraints in 65 lines of SQL

HappyTeams unlocks better performance and reduces cost with Supabase

How to build a real-time multiplayer game with Flutter Flame

Build in a weekend, scale to millions