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:
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:
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.
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:
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!
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:
/* 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!
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.
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:
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:
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!