Skip to content

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:

Terminal window
npm start

We should see an error like this in the browser console:

FirebaseError:
false for 'list' @ L6, false for 'list' @ L12

Locked 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' @ L6
false for 'list' @ L12

These lines from our rules file, in order, are:

allow read: if isAuthenticated();
allow read, write: if false

In 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 null
  • login — we will use this to authenticate a user using their credentials
  • logout — we will use this to sign them out with Firebase
  • createAccount — 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 status can be pending, creating, success, or error
  • There should be a createUser$ source that can be nexted with Credentials
  • There should be a userCreated$ source that takes those credentials and calls the createAccount method from the AuthService
  • The status state should be updated appropriately in response to the sources
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.

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 LoginService to hold the state
  • A LoginFormComponent for the form
  • A LoginComponent as the smart component

There will be some differences here like:

  • Our LoginService is going to trigger the login method not the createAccount method
  • 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.

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.