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 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.
Create a new file at
src/app/shared/interfaces/credentials.ts
and add the following:
export interface Credentials {
email: string;
password: string;
}
Create a new file at
src/app/shared/data-access/auth.service.ts
and add the following:
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
status
can 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 thecreateAccount
method from theAuthService
- The
status
state should be updated appropriately in response to the sources
Create a new file at
src/app/auth/register/data-access/register.service.ts
and add the following:
NOTE: Notice that we have created our register
featured inside of a folder
called auth
. This is the first time we are creating a feature that will have
child routes — auth
will hold both our register
and login
routes. We
will set up the routing to handle this soon.
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.
Create a new file at
src/app/auth/register/ui/register-form.component.ts
and add the following:
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);
}
}
}