Overview
The goal of this example is to build a more powerful authentication system in our Supabase Angular applications by leveraging RxJS Observables.
Supabase has a great tutorial that explains how to set up your Angular app to work with Supabase, but while that tutorial works with Angular, I wouldn't say it's built for Angular.
When you create a new Angular app with the Angular CLI, baked-in is the powerful library, RxJS. Let's combine the ease of Supabase with the power of RxJS.
Another important addition that I will lay out is the ability to seamlessly combine a public.profiles
table with your auth.users
table from Supabase Auth. Many (if not most) applications need to store more data about their users than what sits in the auth.users
table in your database, which is where Supabase Auth pulls from. With RxJS Observables and Supabase Realtime, any changes to our user's profile can immediately be reflected across our entire application.
This tutorial assumes you have an understanding of Angular, at least a minimal understanding of RxJS, and an existing Supabase project.
Quick definitions
- Supabase user: the user object returned by Supabase Auth
- Profile: the profile data for our user as found in our
public.profiles
table in our database
Full source code can be found at https://github.com/kylerummens/angular-supabase-auth
Project setup
Create an Angular application with the Angular CLI
ng new angular-supabase-auth
Install the Supabase javascript client library
npm install @supabase/supabase-js
Create a supabase service and initialize the Supabase client
ng generate service services/supabase --skip-tests
// supabase.service.ts
import { Injectable } from '@angular/core';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { environment } from 'src/environments/environment';
@Injectable({
providedIn: 'root'
})
export class SupabaseService {
public client: SupabaseClient;
constructor() {
this.client = createClient(environment.supabaseUrl, environment.supabaseKey);
}
}
Add your API URL and the anon key from your Supabase dashboard to your environment variables
// environment.ts
export const environment = {
production: false,
supabaseUrl: 'YOUR_SUPABASE_URL',
supabaseKey: 'YOUR_SUPABASE_KEY'
};
Auth service
Now that we're all set up, we can get to the fun stuff. Let's create our Auth service. This is where the bulk of our authentication logic will sit, and if we do it right, building the rest of our application will be a breeze.
ng generate service services/auth --skip-tests
// auth.service.ts
import { Injectable } from '@angular/core';
import { RealtimeChannel, User } from '@supabase/supabase-js';
import { BehaviorSubject, first, Observable, skipWhile } from 'rxjs';
import { SupabaseService } from './supabase.service';
export interface Profile {
user_id: string;
photo_url: string;
email: string;
first_name: string;
last_name: string;
}
@Injectable({
providedIn: 'root'
})
export class AuthService {
constructor(private supabase: SupabaseService) { }
}
The above code should be pretty straightforward. We created a Profile
interface to match our public.profiles
table from the database, and we injected our SupabaseService
into the AuthService
.
Now let's define the state properties on our auth service.
// auth.service.ts
/* ... imports, etc. ... */
export class AuthService {
// Supabase user state
private _$user = new BehaviorSubject<User | null | undefined>(undefined);
$user = this._$user.pipe(skipWhile(_ => typeof _ === 'undefined')) as Observable<User | null>;
private user_id?: string;
// Profile state
private _$profile = new BehaviorSubject<Profile | null | undefined>(undefined);
$profile = this._$profile.pipe(skipWhile(_ => typeof _ === 'undefined')) as Observable<Profile | null>;
private profile_subscription?: RealtimeChannel;
constructor(private supabase: SupabaseService) { }
}
It is very important that we understand the above code before moving on. We are creating properties for the Supabase user as well as the user's profile from our public.profiles
table. For both the Supabase user and the profile, we are creating the following:
- a private BehaviorSubject
- a public Observable, created from piping the BehaviorSubject
The BehaviorSubject will be used to store the value of the current user/profile. When we receive changes from Supabase Auth or Realtime, we can update the value by calling the .next()
method. As you can see, we are providing a type parameter of User | null | undefined
on the BehaviorSubject, and then setting the initial value as undefined
. Our BehaviorSubject will contain those types under the following circumstances:
-
undefined
is used to represent the state where we don't yet know if there is a signed-in user or not. That is why our BehaviorSubject is initialized in theundefined
state. -
null
is used when we know that there is no user signed in - When there is a user, the value will be an object of type
User
The values that we want to expose to the rest of our application ($user
and $profile
) use the RxJS skipWhile
pipe to only emit events when the value is not undefined
. In other words, the rest of our application will only be notified once we're confident of the state. Either there is a user, or not.
Now let's hook up our _$user
BehaviorSubject to Supabase Auth by adding the following code inside of our auth service's constructor:
constructor(private supabase: SupabaseService) {
// Initialize Supabase user
// Get initial user from the current session, if exists
this.supabase.client.auth.getUser().then(({ data, error }) => {
this._$user.next(data && data.user && !error ? data.user : null);
// After the initial value is set, listen for auth state changes
this.supabase.client.auth.onAuthStateChange((event, session) => {
this._$user.next(session?.user ?? null);
});
});
}
We are using two Supabase Auth methods here:
-
getUser
returns a Promise with the current value of the user from the session, if it exists -
onAuthStateChange
listens to changes to the user state, such as sign-in and sign-out
When getUser
gives us its value, we are populating the _$user
with either the user data or null
. After we get the initial value, if there are any changes to the session we will update the _$user
again.
The next step is the trickiest. For the user's profile, we need to:
- Subscribe to the Supabase user
- Make a one-time API call to Supabase to get the user's profile from the
profiles
table - Listen to changes to the
profiles
table, and update the_$profile
value
Add the following code to the auth service constructor after the above code:
// Initialize the user's profile
// The state of the user's profile is dependent on their being a user. If no user is set, there shouldn't be a profile.
this.$user.subscribe(user => {
if (user) {
// We only make changes if the user is different
if (user.id !== this.user_id) {
const user_id = user.id;
this.user_id = user_id;
// One-time API call to Supabase to get the user's profile
this.supabase
.client
.from('profiles')
.select('*')
.match({ user_id })
.single()
.then(res => {
// Update our profile BehaviorSubject with the current value
this._$profile.next(res.data ?? null);
// Listen to any changes to our user's profile using Supabase Realtime
this.profile_subscription = this.supabase
.client
.channel('public:profiles')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'profiles',
filter: 'user_id=eq.' + user.id
}, (payload: any) => {
// Update our profile BehaviorSubject with the newest value
this._$profile.next(payload.new);
})
.subscribe()
})
}
}
else {
// If there is no user, update the profile BehaviorSubject, delete the user_id, and unsubscribe from Supabase Realtime
this._$profile.next(null);
delete this.user_id;
if (this.profile_subscription) {
this.supabase.client.removeChannel(this.profile_subscription).then(res => {
console.log('Removed profile channel subscription with status: ', res);
});
}
}
})
Since our _$profile
BehaviorSubject is dependent on the $user
Observable, when we sign out our user, the _$profile
is automatically updated. We can now use the $profile
Observable everywhere in our app.
Lastly, for the sake of this example, add the following methods to allow us to login with email/password, and logout:
signIn(email: string, password: string) {
return new Promise<void>((resolve, reject) => {
// Set _$profile back to undefined. This will mean that $profile will wait to emit a value
this._$profile.next(undefined);
this.supabase.client.auth.signInWithPassword({ email, password })
.then(({ data, error }) => {
if (error || !data) reject('Invalid email/password combination');
// Wait for $profile to be set again.
// We don't want to proceed until our API request for the user's profile has completed
this.$profile.pipe(first()).subscribe(() => {
resolve();
});
})
})
}
logout() {
return this.supabase.client.auth.signOut()
}
Protecting routes using guards
Let's create some components for our app, and a guard to protect our routes.
ng generate guard guards/profile --implements CanActivate --skip-tests
ng generate component pages/login --skip-tests
ng generate component pages/dashboard --skip-tests
Update our app's routing module:
// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ProfileGuard } from './guards/profile.guard';
import { DashboardComponent } from './pages/dashboard/dashboard.component';
import { LoginComponent } from './pages/login/login.component';
const routes: Routes = [
{ path: '', component: DashboardComponent, canActivate: [ProfileGuard] },
{ path: 'login', component: LoginComponent }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
Now we add logic to our ProfileGuard
so that it only allows users to continue if they are signed-in and their profile has loaded:
// profile.guard.ts
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { first, map, Observable } from 'rxjs';
import { AuthService } from '../services/auth.service';
@Injectable({
providedIn: 'root'
})
export class ProfileGuard implements CanActivate {
constructor(
private router: Router,
private authService: AuthService) { }
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return this.authService.$profile.pipe(
// We only want to get the first emitted value from the $profile
first(),
map(profile => {
// Allow access if the user's profile is set
if (profile) return true;
// If the user is not signed in and does not have a profile, do not allow access
else {
// Redirect to the /login route, while capturing the current url so we can redirect after login
this.router.navigate(['/login'], {
queryParams: { redirect_url: state.url }
});
return false;
}
})
)
}
}
Login component:
// login.component.ts
import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from 'src/app/services/auth.service';
@Component({
selector: 'app-login',
template: `
<form [formGroup]="login_form" (ngSubmit)="onSubmit()">
<div>
<label for="email">Email</label>
<input id="email" type="email" formControlName="email">
</div>
<div>
<label for="password">Password</label>
<input id="password" type="password" formControlName="password">
</div>
<div style="color:red" *ngIf="error">{{ error }}</div>
<button type="submit" [disabled]="login_form.invalid">Submit</button>
</form>
`
})
export class LoginComponent {
login_form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required])
});
error?: string;
constructor(
private router: Router,
private authService: AuthService) { }
onSubmit() {
if (this.login_form.valid) {
delete this.error;
const { email, password } = this.login_form.value;
this.authService.signIn(email!, password!)
.then(() => {
this.router.navigate(['/']);
})
.catch(err => {
this.error = err;
})
}
}
}
Dashboard component:
// dashboard.component.ts
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from 'src/app/services/auth.service';
@Component({
selector: 'app-dashboard',
template: `
<h1>Dashboard</h1>
<ng-container *ngIf="authService.$profile | async as profile">
<div>{{ profile | json }}</div>
</ng-container>
<button (click)="logout()">Logout</button>
`
})
export class DashboardComponent {
constructor(
public authService: AuthService,
private router: Router) { }
logout() {
this.authService.logout().then(() => {
this.router.navigate(['/login']);
})
}
}
Top comments (0)