Adding User Authentication
If we have done everything correctly, our security rules should now be active when using the emulators and they should also be deployed for our production version. If we run the application now:
npm startWe should see an error like this in the browser console:
FirebaseError:false for 'list' @ L6, false for 'list' @ L12Locked out of our own application, how rude! These errors are interesting to inspect a little closer. Notice that they give you the specific lines where the error occurred:
false for 'list' @ L6false for 'list' @ L12These lines from our rules file, in order, are:
allow read: if isAuthenticated();allow read, write: if falseIn all cases, these if conditions are failing, which makes sense because we
haven’t implemented any kind of authentication system yet. To regain the use of
our own application, we are going to have to:
- Create a mechanism to allow creating an account with email/password
- Create a mechanism that allows users to log in
Creating Accounts with Email/Password in Firebase
We are going to need to allow the user to create an account before we can create a login mechanism so we will start with that. But, our create account page is going to be accessed from our login page, so we are going to need to implement at least some of the login page before being able to complete our create account functionality.
Creating the Auth Service
Let’s start by creating our AuthService. It might sound like this is going to
be a scary/complex part of the application, but this service is actually going
to be quite simple — all of the hard stuff is handled for us by Firebase.
First, we are going to need to create another interface for the credentials the user will use to log in.
export interface Credentials { email: string; password: string;}import { Injectable, computed, inject, signal } from '@angular/core';import { from, defer, merge } from 'rxjs';import { map } from 'rxjs/operators';import { User, createUserWithEmailAndPassword, signInWithEmailAndPassword, signOut,} from 'firebase/auth';import { authState } from 'rxfire/auth';import { Credentials } from '../interfaces/credentials';import { connect } from 'ngxtension/connect';import { AUTH } from '../../app.config';
export type AuthUser = User | null | undefined;
interface AuthState { user: AuthUser;}
@Injectable({ providedIn: 'root',})export class AuthService { private auth = inject(AUTH);
// sources private user$ = authState(this.auth);
// state private state = signal<AuthState>({ user: undefined, });
// selectors user = computed(() => this.state().user);
constructor() { const nextState$ = merge(this.user$.pipe(map((user) => ({ user }))));
connect(this.state).with(nextState$); }
login(credentials: Credentials) { return from( defer(() => signInWithEmailAndPassword( this.auth, credentials.email, credentials.password ) ) ); }
logout() { signOut(this.auth); }
createAccount(credentials: Credentials) { return from( defer(() => createUserWithEmailAndPassword( this.auth, credentials.email, credentials.password ) ) ); }}This is the entire service — we won’t need to make any more changes to this file for the rest of the build. There are three important aspects to this file:
user$— this will emit the active user from Firebase or nulllogin— we will use this to authenticate a user using their credentialslogout— we will use this to sign them out with FirebasecreateAccount— we will use this to create a new account using credentials
Mostly this is just our normal state management set up, and again we are
converting promises from Firebase into observables with from and defer.
We are using the authState for our user$ source — this is provided by
Firebase and will emit either the User if the user is logged in, or null if
they are not. We can react to this to determine if the user is authenticated or
not.
When we create an account or login a user, we do not need to worry about
handling anything — we just call the methods and that is it. Our authState
will automatically update when the auth state changes, and we can react to that.
The Register Service
Our register feature is going to have its own service for managing state,
let’s create that now.
This is going to be a reasonably standard service, but it can still be hard to think through the design. I am going to give you a basic outline of the service, and you can see how much of it you can create on your own.
- It should store a single state property called
status - The type of
statuscan bepending,creating,success, orerror - There should be a
createUser$source that can be nexted withCredentials - There should be a
userCreated$source that takes those credentials and calls thecreateAccountmethod from theAuthService - The
statusstate should be updated appropriately in response to the sources
Click here to reveal solution
Solution
import { Injectable, computed, inject, signal } from '@angular/core';import { connect } from 'ngxtension/connect';import { EMPTY, Subject, catchError, map, merge, switchMap } from 'rxjs';import { AuthService } from '../../../shared/data-access/auth.service';import { Credentials } from '../../../shared/interfaces/credentials';
export type RegisterStatus = 'pending' | 'creating' | 'success' | 'error';
interface RegisterState { status: RegisterStatus;}
@Injectable()export class RegisterService { private authService = inject(AuthService);
// sources error$ = new Subject<any>(); createUser$ = new Subject<Credentials>();
userCreated$ = this.createUser$.pipe( switchMap((credentials) => this.authService.createAccount(credentials).pipe( catchError((err) => { this.error$.next(err); return EMPTY; }) ) ) );
// state private state = signal<RegisterState>({ status: 'pending', });
// selectors status = computed(() => this.state().status);
constructor() { // reducers const nextState$ = merge( this.userCreated$.pipe(map(() => ({ status: 'success' as const }))), this.createUser$.pipe(map(() => ({ status: 'creating' as const }))), this.error$.pipe(map(() => ({ status: 'error' as const }))) );
connect(this.state).with(nextState$); }}I suspect that most people will still be struggling at this point to do it entirely on their own. If you are struggling, and if you feel like practicing some more, it will be helpful to just delete some or all of this service and try again without looking at the solution. This should generally work well with any example.
The Register Form
Let’s focus on creating the form for registering our users now — this will be a dumb component.
import { Component, input, output, inject } from '@angular/core';import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';import { MatButtonModule } from '@angular/material/button';import { MatIconModule } from '@angular/material/icon';import { MatInputModule } from '@angular/material/input';import { MatFormFieldModule } from '@angular/material/form-field';import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';import { RegisterStatus } from '../data-access/register.service';import { Credentials } from '../../../shared/interfaces/credentials';
@Component({ selector: 'app-register-form', template: ` <form [formGroup]="registerForm" (ngSubmit)="onSubmit()" #form="ngForm"> <mat-form-field appearance="fill"> <mat-label>email</mat-label> <input matNativeControl formControlName="email" type="email" placeholder="email" /> <mat-icon matPrefix>email</mat-icon> </mat-form-field> <mat-form-field> <mat-label>password</mat-label> <input matNativeControl formControlName="password" data-test="create-password-field" type="password" placeholder="password" /> <mat-icon matPrefix>lock</mat-icon> </mat-form-field> <mat-form-field> <mat-label>confirm password</mat-label> <input matNativeControl formControlName="confirmPassword" type="password" placeholder="confirm password" /> <mat-icon matPrefix>lock</mat-icon> </mat-form-field>
<button mat-raised-button color="accent" type="submit" [disabled]="status() === 'creating'" > Submit </button> </form> `, imports: [ ReactiveFormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatIconModule, MatProgressSpinnerModule, ], styles: [ ` form { display: flex; flex-direction: column; align-items: center; }
button { width: 100%; }
mat-error { margin: 5px 0; }
mat-spinner { margin: 1rem 0; } `, ],})export class RegisterFormComponent { status = input.required<RegisterStatus>(); register = output<Credentials>();
private fb = inject(FormBuilder);
registerForm = this.fb.nonNullable.group({ email: ['', [Validators.email, Validators.required]], password: ['', [Validators.minLength(8), Validators.required]], confirmPassword: ['', [Validators.required]], });
onSubmit() { if (this.registerForm.valid) { const { confirmPassword, ...credentials } = this.registerForm.getRawValue(); this.register.emit(credentials); } }}This is reasonably similar to the sorts of dumb form components we have already created — we build a form and emit an event with the value when the user submits it. An important missing piece here is that we have not yet implemented some important details with regard to form validations and showing the user errors — we are going to tackle that in the next lesson. Our goal for now is just to get it working, and we will provide a better user experience in the next lesson.
However, one interesting thing here is this:
if (this.registerForm.valid) { const { confirmPassword, ...credentials } = this.registerForm.getRawValue(); this.register.emit(credentials); }We have discussed this approach of using getRawValue to take the values from
the form… but we haven’t seen this:
const { confirmPassword, ...credentials } = this.registerForm.getRawValue();What this does is remove the confirmPassword property from the getRawValue()
object. We don’t need to submit the confirmPassword when creating an account
— this is just a local check to make sure the user typed in the password
correctly.
We are using some fancy destructing syntax here to achieve what we want. If we were to do this:
const { confirmPassword } = this.registerForm.getRawValue();This means we want to get the confirmPassword property from getRawValue and
have it be available on the variable confirmPassword. By doing this:
const { confirmPassword, ...credentials } = this.registerForm.getRawValue();We are first assigning confirmPassword separately, and then we are saying to
assign “everything else” to a variable named credentials. This means that the
credentials variable will have all of the values except confirmPassword.
If this syntax confuses you, you can also just do this:
const values = this.registerForm.getRawValue();
const credentials: Credentials = { email: values.email, password: values.password}The Register Smart Component
Now let’s create our smart/routed component that will make use of this form.
Again this is going to be reasonably standard. It will mostly just display the
form, and we will also need to inject the RegisterService so that we can pass
the status into the form so that we can next the createUser$ source
when our form emits.
There is one little trap to keep in mind, and that is that our RegisterService
is not provided in root so we need to manually provide it somewhere.
See how far you can get with that.
Click here to reveal solution
Solution
import { Component, inject } from '@angular/core';import { RegisterFormComponent } from './ui/register-form.component';import { RegisterService } from './data-access/register.service';
@Component({ selector: 'app-register', template: ` <div class="container gradient-bg"> <app-register-form [status]="registerService.status()" (register)="registerService.createUser$.next($event)" /> </div> `, providers: [RegisterService], imports: [RegisterFormComponent],})export default class RegisterComponent { public registerService = inject(RegisterService);}Notice that we have added RegisterService to the providers for this
component — that means it will get its own instantiation of the
RegisterService and when this component is destroyed the RegisterService
will be too.
Also notice that we have added some classes to the template for styling later.
Create the Login Page
Now we’re pretty much just going to do the exact same thing for our login
feature. We will need to create:
- A
LoginServiceto hold the state - A
LoginFormComponentfor the form - A
LoginComponentas the smart component
There will be some differences here like:
- Our
LoginServiceis going to trigger theloginmethod not thecreateAccountmethod - The login form will have slightly different fields
- Things are going to be named a little differently
Again, this will be a great opportunity to try out independently. I know I haven’t given you a whole lot of information to go on here — I am intentionally being a bit vague. This is probably the most ambitious task I have given you yet, but give it a go and see how far you get with it.
Click here to reveal solution
Solution
import { Injectable, computed, inject, signal } from '@angular/core';import { EMPTY, Subject, merge, switchMap } from 'rxjs';import { catchError, map } from 'rxjs/operators';import { connect } from 'ngxtension/connect';import { AuthService } from '../../../shared/data-access/auth.service';import { Credentials } from '../../../shared/interfaces/credentials';
export type LoginStatus = 'pending' | 'authenticating' | 'success' | 'error';
interface LoginState { status: LoginStatus;}
@Injectable()export class LoginService { private authService = inject(AuthService);
// sources error$ = new Subject<any>(); login$ = new Subject<Credentials>();
userAuthenticated$ = this.login$.pipe( switchMap((credentials) => this.authService.login(credentials).pipe( catchError((err) => { this.error$.next(err); return EMPTY; }) ) ) );
// state private state = signal<LoginState>({ status: 'pending', });
// selectors status = computed(() => this.state().status);
constructor() { // reducers const nextState$ = merge( this.userAuthenticated$.pipe(map(() => ({ status: 'success' as const }))), this.login$.pipe(map(() => ({ status: 'authenticating' as const }))), this.error$.pipe(map(() => ({ status: 'error' as const }))) );
connect(this.state).with(nextState$); }}import { Component, input, output, inject } from '@angular/core';import { FormBuilder, ReactiveFormsModule } from '@angular/forms';import { MatButtonModule } from '@angular/material/button';import { MatIconModule } from '@angular/material/icon';import { MatInputModule } from '@angular/material/input';import { MatFormFieldModule } from '@angular/material/form-field';import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';import { LoginStatus } from '../data-access/login.service';import { Credentials } from '../../../shared/interfaces/credentials';
@Component({ selector: 'app-login-form', template: ` <form [formGroup]="loginForm" (ngSubmit)="login.emit(loginForm.getRawValue())" > <mat-form-field appearance="fill"> <mat-label>email</mat-label> <input matNativeControl formControlName="email" type="email" placeholder="email" /> <mat-icon matPrefix>mail</mat-icon> </mat-form-field> <mat-form-field appearance="fill"> <mat-label>password</mat-label> <input matNativeControl formControlName="password" type="password" placeholder="password" /> <mat-icon matPrefix>lock</mat-icon> </mat-form-field>
<button mat-raised-button color="accent" type="submit" [disabled]="loginStatus() === 'authenticating'" > Login </button> </form> `, imports: [ ReactiveFormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatIconModule, MatProgressSpinnerModule, ], styles: [ ` form { display: flex; flex-direction: column; align-items: center; }
button { width: 100%; }
mat-error { margin: 5px 0; }
mat-spinner { margin: 1rem 0; } `, ],})export class LoginFormComponent { loginStatus = input.required<LoginStatus>(); login = output<Credentials>();
private fb = inject(FormBuilder);
loginForm = this.fb.nonNullable.group({ email: [''], password: [''], });}import { Component, inject } from '@angular/core';import { AuthService } from '../../shared/data-access/auth.service';import { Router, RouterModule } from '@angular/router';import { LoginFormComponent } from './ui/login-form.component';import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';import { LoginService } from './data-access/login.service';
@Component({ selector: 'app-login', template: ` <div class="container gradient-bg"> <app-login-form [loginStatus]="loginService.status()" (login)="loginService.login$.next($event)" /> <a routerLink="/auth/register">Create account</a> </div> `, providers: [LoginService], imports: [RouterModule, LoginFormComponent, MatProgressSpinnerModule], styles: [ ` a { margin: 2rem; color: var(--accent-darker-color); } `, ],})export default class LoginComponent { public loginService = inject(LoginService); public authService = inject(AuthService); private router = inject(Router);}Again, even though it is similar to the register feature, putting all this
together is by no means an easy task. If you managed to get it, great — but if
you didn’t, don’t worry about it. Just take notice of the things you got stuck
on and work on those.
The Auth Routes
So far, we have just been adding routes to a single app.routes.ts file.
However, sometimes we may want to have features in an application that have
their own routing — as we are doing in this application.
We have an auth feature that contains multiple features within it: login and
register.
It is still possible to define the routing for these in the same way that we
have been using. We could just add all of the routes to the single
app.routes.ts file like this:
import { Routes } from '@angular/router';
export const routes: Routes = [ { path: 'home', loadComponent: () => import('./home/home.component'), }, { path: 'auth/login', loadComponent: () => import('./auth/login/login.component'), }, { path: 'auth/register', loadComponent: () => import('./auth/register/register.component'), }, { path: '', redirectTo: 'home', pathMatch: 'full', },];But, in general, it is nicer to co-locate our code when possible — meaning that
all/most of the code related to a particular feature can be found close to that
feature. This is why we are using this structure of each feature having its own
data-access, ui, and utils folder rather than just having all of the ui
components in one place.
The same can go for routing — it is just a bit nicer and more organised if we
can control the routes for the auth feature from within the auth folder.
This does also have some more practical benefits in larger applications/teams as
well. Imagine that we have many different features each with their own routing
— our app.routes.ts file could quickly become bloated and we might have
multiple teams frequently updating the same file. Not necessarily a problem, but
having features manage their own routes does make things a bit more organised.
What we will do instead is create a routes files specifically for our auth
features.
import { Route } from '@angular/router';
export const AUTH_ROUTES: Route[] = [ { path: 'login', loadComponent: () => import('./login/login.component') }, { path: 'register', loadComponent: () => import('./register/register.component'), }, { path: '', redirectTo: 'login', pathMatch: 'full', },];What we are doing here is exactly the same as what we are doing in our main
routes file. The trick is that we can now load all of these AUTH_ROUTES into
our main routes file.
import { Routes } from '@angular/router';
export const routes: Routes = [ { path: 'auth', loadChildren: () => import('./auth/auth.routes').then((m) => m.AUTH_ROUTES), }, { path: 'home', loadComponent: () => import('./home/home.component'), }, { path: '', redirectTo: 'auth', pathMatch: 'full', },];Now instead of using loadComponent we use loadChildren to load our
auth.routes file and access the array of routes. Now the routes will work just
as if we had defined them directly in the app.routes file.
Update Messages Service
Before we wrap up this lesson, we are going to make one more change. Currently
in our MessageService we are just using a dummy email for the author. Let’s
get the real email of the logged in user now.
private addMessage(message: string) { const newMessage = { author: this.authService.user()?.email, content: message, created: Date.now().toString(), };
const messagesCollection = collection(this.firestore, 'messages'); return defer(() => addDoc(messagesCollection, newMessage)); }Although it is now technically possible to create an account and post a message now, the UX of the application is so bad that it is barely usable. We will address all of this in the coming lessons.