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.
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.
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.
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 usersgroups
: Keep track of user-created chat groupsmessages
: 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:
_19create table users (_19 id uuid not null primary key,_19 email text_19);_19_19create table groups (_19 id bigint generated by default as identity primary key,_19 creator uuid references public.users not null default auth.uid(),_19 title text not null,_19 created_at timestamp with time zone default timezone('utc'::text, now()) not null_19);_19_19create table messages (_19 id bigint generated by default as identity primary key,_19 user_id uuid references public.users not null default auth.uid(),_19 text text check (char_length(text) > 0),_19 group_id bigint references groups on delete cascade not null,_19 created_at timestamp with time zone default timezone('utc'::text, now()) not null_19);
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:
_25-- Secure tables_25alter table users enable row level security;_25alter table groups enable row level security;_25alter table messages enable row level security;_25_25-- User Policies_25create policy "Users can read the user email." on users_25 for select using (true);_25_25-- Group Policies_25create policy "Groups are viewable by everyone." on groups_25 for select using (true);_25_25create policy "Authenticated users can create groups." on groups for_25 insert to authenticated with check (true);_25_25create policy "The owner can delete a group." on groups for_25 delete using (auth.uid() = creator);_25_25-- Message Policies_25create policy "Authenticated users can read messages." on messages_25 for select to authenticated using (true);_25_25create policy "Authenticated users can create messages." on messages_25 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:
_13-- Function for handling new users_13create or replace function public.handle_new_user()_13returns trigger as $$_13begin_13 insert into public.users (id, email)_13 values (new.id, new.email);_13 return new;_13end;_13$$ language plpgsql security definer;_13_13create trigger on_auth_user_created_13 after insert on auth.users_13 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:
_10begin;_10 -- remove the supabase_realtime publication_10 drop publication if exists supabase_realtime;_10_10 -- re-create the supabase_realtime publication with no tables and only for insert_10 create publication supabase_realtime with (publish = 'insert');_10commit;_10_10-- add a table to the publication_10alter 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:
_15ionic start supaAuth blank --type=angular_15npm install @supabase/supabase-js_15_15# Add some pages_15ionic g page pages/login_15ionic g page pages/register_15ionic g page pages/groups_15ionic g page pages/messages_15_15# Generate services_15ionic g service services/auth_15ionic g service services/data_15_15# Add a guard to protect routes_15ionic 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:
_36import { AuthGuard } from './guards/auth.guard'_36import { NgModule } from '@angular/core'_36import { PreloadAllModules, RouterModule, Routes } from '@angular/router'_36_36const routes: Routes = [_36 {_36 path: '',_36 loadChildren: () => import('./pages/login/login.module').then((m) => m.LoginPageModule),_36 },_36 {_36 path: 'register',_36 loadChildren: () =>_36 import('./pages/register/register.module').then((m) => m.RegisterPageModule),_36 },_36 {_36 path: 'groups',_36 loadChildren: () => import('./pages/groups/groups.module').then((m) => m.GroupsPageModule),_36 },_36 {_36 path: 'groups/:groupid',_36 loadChildren: () =>_36 import('./pages/messages/messages.module').then((m) => m.MessagesPageModule),_36 canActivate: [AuthGuard],_36 },_36 {_36 path: '',_36 redirectTo: 'home',_36 pathMatch: 'full',_36 },_36]_36_36@NgModule({_36 imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })],_36 exports: [RouterModule],_36})_36export 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:
_10export const environment = {_10 production: false,_10 supabaseUrl: 'https://YOUR-APP.supabase.co',_10 supabaseKey: 'YOUR-ANON-KEY',_10}
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:
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:
_79/* eslint-disable @typescript-eslint/naming-convention */_79import { Injectable } from '@angular/core'_79import { Router } from '@angular/router'_79import { isPlatform } from '@ionic/angular'_79import { createClient, SupabaseClient, User } from '@supabase/supabase-js'_79import { BehaviorSubject, Observable } from 'rxjs'_79import { environment } from '../../environments/environment'_79_79@Injectable({_79 providedIn: 'root',_79})_79export class AuthService {_79 private supabase: SupabaseClient_79 private currentUser: BehaviorSubject<User | boolean> = new BehaviorSubject(null)_79_79 constructor(private router: Router) {_79 this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey)_79_79 this.supabase.auth.onAuthStateChange((event, sess) => {_79 if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {_79 console.log('SET USER')_79_79 this.currentUser.next(sess.user)_79 } else {_79 this.currentUser.next(false)_79 }_79 })_79_79 // Trigger initial session load_79 this.loadUser()_79 }_79_79 async loadUser() {_79 if (this.currentUser.value) {_79 // User is already set, no need to do anything else_79 return_79 }_79 const user = await this.supabase.auth.getUser()_79_79 if (user.data.user) {_79 this.currentUser.next(user.data.user)_79 } else {_79 this.currentUser.next(false)_79 }_79 }_79_79 signUp(credentials: { email; password }) {_79 return this.supabase.auth.signUp(credentials)_79 }_79_79 signIn(credentials: { email; password }) {_79 return this.supabase.auth.signInWithPassword(credentials)_79 }_79_79 sendPwReset(email) {_79 return this.supabase.auth.resetPasswordForEmail(email)_79 }_79_79 async signOut() {_79 await this.supabase.auth.signOut()_79 this.router.navigateByUrl('/', { replaceUrl: true })_79 }_79_79 getCurrentUser(): Observable<User | boolean> {_79 return this.currentUser.asObservable()_79 }_79_79 getCurrentUserId(): string {_79 if (this.currentUser.value) {_79 return (this.currentUser.value as User).id_79 } else {_79 return null_79 }_79 }_79_79 signInWithEmail(email: string) {_79 return this.supabase.auth.signInWithOtp({ email })_79 }_79}
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:
_15import { NgModule } from '@angular/core'_15import { CommonModule } from '@angular/common'_15import { FormsModule, ReactiveFormsModule } from '@angular/forms'_15_15import { IonicModule } from '@ionic/angular'_15_15import { LoginPageRoutingModule } from './login-routing.module'_15_15import { LoginPage } from './login.page'_15_15@NgModule({_15 imports: [CommonModule, FormsModule, IonicModule, LoginPageRoutingModule, ReactiveFormsModule],_15 declarations: [LoginPage],_15})_15export 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:
_61import { AuthService } from './../../services/auth.service'_61import { Component } from '@angular/core'_61import { FormBuilder, Validators } from '@angular/forms'_61import { Router } from '@angular/router'_61import { LoadingController, AlertController } from '@ionic/angular'_61_61@Component({_61 selector: 'app-login',_61 templateUrl: './login.page.html',_61 styleUrls: ['./login.page.scss'],_61})_61export class LoginPage {_61 credentials = this.fb.nonNullable.group({_61 email: ['', Validators.required],_61 password: ['', Validators.required],_61 })_61_61 constructor(_61 private fb: FormBuilder,_61 private authService: AuthService,_61 private loadingController: LoadingController,_61 private alertController: AlertController,_61 private router: Router_61 ) {_61 this.authService.getCurrentUser().subscribe((user) => {_61 if (user) {_61 this.router.navigateByUrl('/groups', { replaceUrl: true })_61 }_61 })_61 }_61_61 get email() {_61 return this.credentials.controls.email_61 }_61_61 get password() {_61 return this.credentials.controls.password_61 }_61_61 async login() {_61 const loading = await this.loadingController.create()_61 await loading.present()_61_61 this.authService.signIn(this.credentials.getRawValue()).then(async (data) => {_61 await loading.dismiss()_61_61 if (data.error) {_61 this.showAlert('Login failed', data.error.message)_61 }_61 })_61 }_61_61 async showAlert(title, msg) {_61 const alert = await this.alertController.create({_61 header: title,_61 message: msg,_61 buttons: ['OK'],_61 })_61 await alert.present()_61 }_61}
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:
_79 async forgotPw() {_79 const alert = await this.alertController.create({_79 header: "Receive a new password",_79 message: "Please insert your email",_79 inputs: [_79 {_79 type: "email",_79 name: "email",_79 },_79 ],_79 buttons: [_79 {_79 text: "Cancel",_79 role: "cancel",_79 },_79 {_79 text: "Reset password",_79 handler: async (result) => {_79 const loading = await this.loadingController.create();_79 await loading.present();_79 const { data, error } = await this.authService.sendPwReset(_79 result.email_79 );_79 await loading.dismiss();_79_79 if (error) {_79 this.showAlert("Failed", error.message);_79 } else {_79 this.showAlert(_79 "Success",_79 "Please check your emails for further instructions!"_79 );_79 }_79 },_79 },_79 ],_79 });_79 await alert.present();_79 }_79_79 async getMagicLink() {_79 const alert = await this.alertController.create({_79 header: "Get a Magic Link",_79 message: "We will send you a link to magically log in!",_79 inputs: [_79 {_79 type: "email",_79 name: "email",_79 },_79 ],_79 buttons: [_79 {_79 text: "Cancel",_79 role: "cancel",_79 },_79 {_79 text: "Get Magic Link",_79 handler: async (result) => {_79 const loading = await this.loadingController.create();_79 await loading.present();_79 const { data, error } = await this.authService.signInWithEmail(_79 result.email_79 );_79 await loading.dismiss();_79_79 if (error) {_79 this.showAlert("Failed", error.message);_79 } else {_79 this.showAlert(_79 "Success",_79 "Please check your emails for further instructions!"_79 );_79 }_79 },_79 },_79 ],_79 });_79 await alert.present();_79 }
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:
_63<ion-header>_63 <ion-toolbar color="primary">_63 <ion-title>Supa Chat</ion-title>_63 </ion-toolbar>_63</ion-header>_63_63<ion-content scrollY="false">_63 <ion-card>_63 <ion-card-content>_63 <form (ngSubmit)="login()" [formGroup]="credentials">_63 <ion-item>_63 <ion-label position="stacked">Your Email</ion-label>_63 <ion-input_63 type="email"_63 inputmode="email"_63 placeholder="Email"_63 formControlName="email"_63 ></ion-input>_63 <ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors"_63 >Please insert your email</ion-note_63 >_63 </ion-item>_63 <ion-item>_63 <ion-label position="stacked">Password</ion-label>_63 <ion-input type="password" placeholder="Password" formControlName="password"></ion-input>_63 <ion-note slot="error" *ngIf="(password.dirty || password.touched) && password.errors"_63 >Please insert your password</ion-note_63 >_63 </ion-item>_63 <ion-button type="submit" expand="block" strong="true" [disabled]="!credentials.valid"_63 >Sign in</ion-button_63 >_63_63 <div class="ion-margin-top">_63 <ion-button_63 type="button"_63 expand="block"_63 color="primary"_63 fill="outline"_63 routerLink="register"_63 >_63 <ion-icon name="person-outline" slot="start"></ion-icon>_63 Create Account_63 </ion-button>_63_63 <ion-button type="button" expand="block" color="secondary" (click)="forgotPw()">_63 <ion-icon name="key-outline" slot="start"></ion-icon>_63 Forgot password?_63 </ion-button>_63_63 <ion-button type="button" expand="block" color="tertiary" (click)="getMagicLink()">_63 <ion-icon name="mail-outline" slot="start"></ion-icon>_63 Get a Magic Link_63 </ion-button>_63 <ion-button type="button" expand="block" color="warning" routerLink="groups">_63 <ion-icon name="arrow-forward" slot="start"></ion-icon>_63 Start without account_63 </ion-button>_63 </div>_63 </form>_63 </ion-card-content>_63 </ion-card>_63</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:
_10ion-content {_10 --padding-top: 20%;_10 --padding-start: 5%;_10 --padding-end: 5%;_10 --background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.7)),_10 url('https://images.unsplash.com/photo-1508964942454-1a56651d54ac?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1035&q=80')_10 no-repeat;_10}
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.
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:
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:
_15import { NgModule } from '@angular/core'_15import { CommonModule } from '@angular/common'_15import { FormsModule, ReactiveFormsModule } from '@angular/forms'_15_15import { IonicModule } from '@ionic/angular'_15_15import { RegisterPageRoutingModule } from './register-routing.module'_15_15import { RegisterPage } from './register.page'_15_15@NgModule({_15 imports: [CommonModule, FormsModule, IonicModule, RegisterPageRoutingModule, ReactiveFormsModule],_15 declarations: [RegisterPage],_15})_15export 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:
_57import { Component } from '@angular/core'_57import { Validators, FormBuilder } from '@angular/forms'_57import { LoadingController, AlertController, NavController } from '@ionic/angular'_57import { AuthService } from 'src/app/services/auth.service'_57_57@Component({_57 selector: 'app-register',_57 templateUrl: './register.page.html',_57 styleUrls: ['./register.page.scss'],_57})_57export class RegisterPage {_57 credentials = this.fb.nonNullable.group({_57 email: ['', [Validators.required, Validators.email]],_57 password: ['', [Validators.required, Validators.minLength(6)]],_57 })_57_57 constructor(_57 private fb: FormBuilder,_57 private authService: AuthService,_57 private loadingController: LoadingController,_57 private alertController: AlertController,_57 private navCtrl: NavController_57 ) {}_57_57 get email() {_57 return this.credentials.controls.email_57 }_57_57 get password() {_57 return this.credentials.controls.password_57 }_57_57 async createAccount() {_57 const loading = await this.loadingController.create()_57 await loading.present()_57_57 this.authService.signUp(this.credentials.getRawValue()).then(async (data) => {_57 await loading.dismiss()_57_57 if (data.error) {_57 this.showAlert('Registration failed', data.error.message)_57 } else {_57 this.showAlert('Signup success', 'Please confirm your email now!')_57 this.navCtrl.navigateBack('')_57 }_57 })_57 }_57_57 async showAlert(title, msg) {_57 const alert = await this.alertController.create({_57 header: title,_57 message: msg,_57 buttons: ['OK'],_57 })_57 await alert.present()_57 }_57}
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:
_46<ion-header>_46 <ion-toolbar color="primary">_46 <ion-buttons slot="start">_46 <ion-back-button defaultHref="/"></ion-back-button>_46 </ion-buttons>_46 <ion-title>Supa Chat</ion-title>_46 </ion-toolbar>_46</ion-header>_46_46<ion-content scrollY="false">_46 <ion-card>_46 <ion-card-content>_46 <form (ngSubmit)="createAccount()" [formGroup]="credentials">_46 <ion-item>_46 <ion-label position="stacked">Your Email</ion-label>_46 <ion-input_46 type="email"_46 inputmode="email"_46 placeholder="Email"_46 formControlName="email"_46 ></ion-input>_46 <ion-note slot="error" *ngIf="(email.dirty || email.touched) && email.errors"_46 >Please insert a valid email</ion-note_46 >_46 </ion-item>_46 <ion-item>_46 <ion-label position="stacked">Password</ion-label>_46 <ion-input type="password" placeholder="Password" formControlName="password"></ion-input>_46 <ion-note_46 slot="error"_46 *ngIf="(password.dirty || password.touched) && password.errors?.required"_46 >Please insert a password</ion-note_46 >_46 <ion-note_46 slot="error"_46 *ngIf="(password.dirty || password.touched) && password.errors?.minlength"_46 >Minlength 6 characters</ion-note_46 >_46 </ion-item>_46 <ion-button type="submit" expand="block" strong="true" [disabled]="!credentials.valid"_46 >Create my account</ion-button_46 >_46 </form>_46 </ion-card-content>_46 </ion-card>_46</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:
_10ion-content {_10 --padding-top: 20%;_10 --padding-start: 5%;_10 --padding-end: 5%;_10 --background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.7)),_10 url('https://images.unsplash.com/photo-1508964942454-1a56651d54ac?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1035&q=80')_10 no-repeat;_10}
As a result, we have a clean registration page with decent error messages!
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.
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:
_43/* eslint-disable @typescript-eslint/naming-convention */_43import { Injectable } from '@angular/core'_43import { SupabaseClient, createClient } from '@supabase/supabase-js'_43import { Subject } from 'rxjs'_43import { environment } from 'src/environments/environment'_43_43const GROUPS_DB = 'groups'_43const MESSAGES_DB = 'messages'_43_43export interface Message {_43 created_at: string_43 group_id: number_43 id: number_43 text: string_43 user_id: string_43}_43_43@Injectable({_43 providedIn: 'root',_43})_43export class DataService {_43 private supabase: SupabaseClient_43_43 constructor() {_43 this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey)_43 }_43_43 getGroups() {_43 return this.supabase_43 .from(GROUPS_DB)_43 .select(`title,id, users:creator ( email )`)_43 .then((result) => result.data)_43 }_43_43 async createGroup(title) {_43 const newgroup = {_43 creator: (await this.supabase.auth.getUser()).data.user.id,_43 title,_43 }_43_43 return this.supabase.from(GROUPS_DB).insert(newgroup).select().single()_43 }_43}
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:
_75import { Router } from '@angular/router'_75import { AuthService } from './../../services/auth.service'_75import { AlertController, NavController, LoadingController } from '@ionic/angular'_75import { DataService } from './../../services/data.service'_75import { Component, OnInit } from '@angular/core'_75_75@Component({_75 selector: 'app-groups',_75 templateUrl: './groups.page.html',_75 styleUrls: ['./groups.page.scss'],_75})_75export class GroupsPage implements OnInit {_75 user = this.authService.getCurrentUser()_75 groups = []_75_75 constructor(_75 private authService: AuthService,_75 private data: DataService,_75 private alertController: AlertController,_75 private loadingController: LoadingController,_75 private navController: NavController,_75 private router: Router_75 ) {}_75_75 ngOnInit() {}_75_75 async ionViewWillEnter() {_75 this.groups = await this.data.getGroups()_75 }_75_75 async createGroup() {_75 const alert = await this.alertController.create({_75 header: 'Start Chat Group',_75 message: 'Enter a name for your group. Note that all groups are public in this app!',_75 inputs: [_75 {_75 type: 'text',_75 name: 'title',_75 placeholder: 'My cool group',_75 },_75 ],_75 buttons: [_75 {_75 text: 'Cancel',_75 role: 'cancel',_75 },_75 {_75 text: 'Create group',_75 handler: async (data) => {_75 const loading = await this.loadingController.create()_75 await loading.present()_75_75 const newGroup = await this.data.createGroup(data.title)_75 if (newGroup) {_75 this.groups = await this.data.getGroups()_75 await loading.dismiss()_75_75 this.router.navigateByUrl(`/groups/${newGroup.data.id}`)_75 }_75 },_75 },_75 ],_75 })_75_75 await alert.present()_75 }_75_75 signOut() {_75 this.authService.signOut()_75 }_75_75 openLogin() {_75 this.navController.navigateBack('/')_75 }_75}
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:
_28<ion-header>_28 <ion-toolbar color="primary">_28 <ion-title>Supa Chat Groups</ion-title>_28 <ion-buttons slot="end">_28 <ion-button (click)="signOut()" *ngIf="user | async">_28 <ion-icon name="log-out-outline" slot="icon-only"></ion-icon>_28 </ion-button>_28_28 <ion-button (click)="openLogin()" *ngIf="(user | async) === false"> Sign in </ion-button>_28 </ion-buttons>_28 </ion-toolbar>_28</ion-header>_28_28<ion-content>_28 <ion-list>_28 <ion-item *ngFor="let group of groups" [routerLink]="[group.id]" button>_28 <ion-label_28 >{{group.title }}_28 <p>By {{group.users.email}}</p>_28 </ion-label>_28 </ion-item>_28 </ion-list>_28 <ion-fab vertical="bottom" horizontal="end" slot="fixed" *ngIf="user | async">_28 <ion-fab-button (click)="createGroup()">_28 <ion-icon name="add"></ion-icon>_28 </ion-fab-button>_28 </ion-fab>_28</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:
_109/* eslint-disable @typescript-eslint/naming-convention */_109import { Injectable } from '@angular/core'_109import { SupabaseClient, createClient, RealtimeChannel } from '@supabase/supabase-js'_109import { Subject } from 'rxjs'_109import { environment } from 'src/environments/environment'_109_109const GROUPS_DB = 'groups'_109const MESSAGES_DB = 'messages'_109_109export interface Message {_109 created_at: string_109 group_id: number_109 id: number_109 text: string_109 user_id: string_109}_109_109@Injectable({_109 providedIn: 'root',_109})_109export class DataService {_109 private supabase: SupabaseClient_109 // ADD_109 private realtimeChannel: RealtimeChannel_109_109 constructor() {_109 this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey)_109 }_109_109 getGroups() {_109 return this.supabase_109 .from(GROUPS_DB)_109 .select(`title,id, users:creator ( email )`)_109 .then((result) => result.data)_109 }_109_109 async createGroup(title) {_109 const newgroup = {_109 creator: (await this.supabase.auth.getUser()).data.user.id,_109 title,_109 }_109_109 return this.supabase.from(GROUPS_DB).insert(newgroup).select().single()_109 }_109_109 // ADD NEW FUNCTIONS_109 getGroupById(id) {_109 return this.supabase_109 .from(GROUPS_DB)_109 .select(`created_at, title, id, users:creator ( email, id )`)_109 .match({ id })_109 .single()_109 .then((result) => result.data)_109 }_109_109 async addGroupMessage(groupId, message) {_109 const newMessage = {_109 text: message,_109 user_id: (await this.supabase.auth.getUser()).data.user.id,_109 group_id: groupId,_109 }_109_109 return this.supabase.from(MESSAGES_DB).insert(newMessage)_109 }_109_109 getGroupMessages(groupId) {_109 return this.supabase_109 .from(MESSAGES_DB)_109 .select(`created_at, text, id, users:user_id ( email, id )`)_109 .match({ group_id: groupId })_109 .limit(25) // Limit to 25 messages for our app_109 .then((result) => result.data)_109 }_109_109 listenToGroup(groupId) {_109 const changes = new Subject()_109_109 this.realtimeChannel = this.supabase_109 .channel('public:messages')_109 .on(_109 'postgres_changes',_109 { event: '*', schema: 'public', table: 'messages' },_109 async (payload) => {_109 console.log('DB CHANGE: ', payload)_109_109 if (payload.new && (payload.new as Message).group_id === +groupId) {_109 const msgId = (payload.new as any).id_109_109 const msg = await this.supabase_109 .from(MESSAGES_DB)_109 .select(`created_at, text, id, users:user_id ( email, id )`)_109 .match({ id: msgId })_109 .single()_109 .then((result) => result.data)_109 changes.next(msg)_109 }_109 }_109 )_109 .subscribe()_109_109 return changes.asObservable()_109 }_109_109 unsubscribeGroupChanges() {_109 if (this.realtimeChannel) {_109 this.supabase.removeChannel(this.realtimeChannel)_109 }_109 }_109}
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:
_54import { AuthService } from './../../services/auth.service'_54import { DataService } from './../../services/data.service'_54import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'_54import { ActivatedRoute } from '@angular/router'_54import { IonContent } from '@ionic/angular'_54_54@Component({_54 selector: 'app-messages',_54 templateUrl: './messages.page.html',_54 styleUrls: ['./messages.page.scss'],_54})_54export class MessagesPage implements OnInit, AfterViewInit, OnDestroy {_54 @ViewChild(IonContent) content: IonContent_54 group = null_54 messages = []_54 currentUserId = null_54 messageText = ''_54_54 constructor(_54 private route: ActivatedRoute,_54 private data: DataService,_54 private authService: AuthService_54 ) {}_54_54 async ngOnInit() {_54 const groupid = this.route.snapshot.paramMap.get('groupid')_54 this.group = await this.data.getGroupById(groupid)_54 this.currentUserId = this.authService.getCurrentUserId()_54 this.messages = await this.data.getGroupMessages(groupid)_54 this.data.listenToGroup(groupid).subscribe((msg) => {_54 this.messages.push(msg)_54 setTimeout(() => {_54 this.content.scrollToBottom(200)_54 }, 100)_54 })_54 }_54_54 ngAfterViewInit(): void {_54 setTimeout(() => {_54 this.content.scrollToBottom(200)_54 }, 300)_54 }_54_54 loadMessages() {}_54_54 async sendMessage() {_54 await this.data.addGroupMessage(this.group.id, this.messageText)_54 this.messageText = ''_54 }_54_54 ngOnDestroy(): void {_54 this.data.unsubscribeGroupChanges()_54 }_54}
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:
_48<ion-header>_48 <ion-toolbar color="primary">_48 <ion-buttons slot="start">_48 <ion-back-button defaultHref="/groups"></ion-back-button>_48 </ion-buttons>_48 <ion-title>{{ group?.title}}</ion-title>_48 </ion-toolbar>_48</ion-header>_48_48<ion-content class="ion-padding">_48 <ion-row *ngFor="let message of messages">_48 <ion-col size="10" *ngIf="message.users.id !== currentUserId" class="message other-message">_48 <span>{{ message.text }} </span>_48_48 <div class="time ion-text-right"><br />{{ message.created_at | date:'shortTime' }}</div>_48 </ion-col>_48_48 <ion-col_48 offset="2"_48 size="10"_48 *ngIf="message.users.id === currentUserId"_48 class="message my-message"_48 >_48 <span>{{ message.text }} </span>_48 <div class="time ion-text-right"><br />{{ message.created_at | date:'shortTime' }}</div>_48 </ion-col>_48 </ion-row>_48</ion-content>_48_48<ion-footer>_48 <ion-toolbar color="light">_48 <ion-row class="ion-align-items-center">_48 <ion-col size="10">_48 <ion-textarea_48 class="message-input"_48 autoGrow="true"_48 rows="1"_48 [(ngModel)]="messageText"_48 ></ion-textarea>_48 </ion-col>_48 <ion-col size="2" class="ion-text-center">_48 <ion-button fill="clear" (click)="sendMessage()">_48 <ion-icon slot="icon-only" name="send-outline" color="primary" size="large"></ion-icon>_48 </ion-button>_48 </ion-col>_48 </ion-row>_48 </ion-toolbar>_48</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:
_44ion-content {_44 --background: url('../../../assets/pattern.png') no-repeat;_44}_44_44.message-input {_44 border: 1px solid #c3c3c3;_44 border-radius: 20px;_44 background: #fff;_44 box-shadow: 2px 2px 5px 0px rgb(0 0 0 / 5%);_44}_44_44ion-textarea {_44 --padding-start: 20px;_44 --padding-top: 4px;_44 --padding-bottom: 4px;_44_44 min-height: 30px;_44}_44_44.message {_44 padding: 10px !important;_44 border-radius: 10px !important;_44 margin-bottom: 8px !important;_44_44 img {_44 width: 100%;_44 }_44}_44_44.my-message {_44 background: #dbf7c5;_44 color: #000;_44}_44_44.other-message {_44 background: #fff;_44 color: #000;_44}_44_44.time {_44 color: #cacaca;_44 float: right;_44 font-size: small;_44}
You can find the full code of this tutorial on Github including the file for that pattern so your page looks almost like WhatsApp!
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:
_38import { AuthService } from './../services/auth.service'_38import { Injectable } from '@angular/core'_38import { ActivatedRouteSnapshot, CanActivate, Router, UrlTree } from '@angular/router'_38import { Observable } from 'rxjs'_38import { filter, map, take } from 'rxjs/operators'_38import { ToastController } from '@ionic/angular'_38_38@Injectable({_38 providedIn: 'root',_38})_38export class AuthGuard implements CanActivate {_38 constructor(_38 private auth: AuthService,_38 private router: Router,_38 private toastController: ToastController_38 ) {}_38_38 canActivate(route: ActivatedRouteSnapshot): Observable<boolean | UrlTree> {_38 return this.auth.getCurrentUser().pipe(_38 filter((val) => val !== null), // Filter out initial Behavior subject value_38 take(1), // Otherwise the Observable doesn't complete!_38 map((isAuthenticated) => {_38 if (isAuthenticated) {_38 return true_38 } else {_38 this.toastController_38 .create({_38 message: 'You are not allowed to access this!',_38 duration: 2000,_38 })_38 .then((toast) => toast.present())_38_38 return this.router.createUrlTree(['/groups'])_38 }_38 })_38 )_38 }_38}
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.
We already applied this guard to our routing in the beginning, but now it finally serves the real purpose!
Magic Links for Native Apps
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:
_10ionic build_10ionic cap add ios_10ionic 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:
_10 <key>CFBundleURLTypes</key>_10 <array>_10 <dict>_10 <key>CFBundleURLSchemes</key>_10 <array>_10 <string>supachat</string>_10 </array>_10 </dict>_10 </array>
For Android, we first define a new string inside the android/app/src/main/res/values/strings.xml:
_10<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:
_10<intent-filter android:autoVerify="true">_10 <action android:name="android.intent.action.VIEW" />_10 <category android:name="android.intent.category.DEFAULT" />_10 <category android:name="android.intent.category.BROWSABLE" />_10 <data android:scheme="@string/custom_url_scheme" />_10</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:
_14 signInWithEmail(email: string) {_14 const redirectTo = isPlatform("capacitor")_14 ? "supachat://login"_14 : `${window.location.origin}/groups`;_14_14 return this.supabase.auth.signInWithOtp({_14 email,_14 options: { emailRedirectTo: redirectTo },_14 });_14 }_14_14 async setSession(access_token, refresh_token) {_14 return this.supabase.auth.setSession({ access_token, refresh_token });_14 }
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:
_10supachat://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:
_31import { AuthService } from 'src/app/services/auth.service'_31import { Router } from '@angular/router'_31import { Component, NgZone } from '@angular/core'_31import { App, URLOpenListenerEvent } from '@capacitor/app'_31_31@Component({_31 selector: 'app-root',_31 templateUrl: 'app.component.html',_31 styleUrls: ['app.component.scss'],_31})_31export class AppComponent {_31 constructor(private zone: NgZone, private router: Router, private authService: AuthService) {_31 this.setupListener()_31 }_31_31 setupListener() {_31 App.addListener('appUrlOpen', async (data: URLOpenListenerEvent) => {_31 console.log('app opened with URL: ', data)_31_31 const openUrl = data.url_31 const access = openUrl.split('#access_token=').pop().split('&')[0]_31 const refresh = openUrl.split('&refresh_token=').pop().split('&')[0]_31_31 await this.authService.setSession(access, refresh)_31_31 this.zone.run(() => {_31 this.router.navigateByUrl('/groups', { replaceUrl: true })_31 })_31 })_31 }_31}
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:
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!