developers

Build a Beautiful CRUD App with Spring Boot and Angular

Learn how to build a secure CRUD app with Spring Boot and Angular. You'll use Auth0 for authentication and authorization and Cypress to verify it all works.

May 19, 20231 min read

Angular is one of my favorite frameworks for building single-page applications (SPAs). As a Java developer, its separation of components, services, and pipes made a lot of sense to me. It's a web framework that allows you to declaratively describe your UI by creating small, reusable components. I believe it was a huge influencer in TypeScript's popularity. It's also backed by Google, which means it will likely be around for a long time.

I like to build CRUD (create, read, update, and delete) apps to understand frameworks. I think they show a lot of the base functionality you need when creating an app. Once you complete the basics of CRUD, most of the integration work is finished, and you can move on to implementing the necessary business logic.

In this tutorial, you'll learn how to build a secure CRUD app with Spring Boot and Angular. The final will result will use OAuth 2.0 authorization code flow and package the Angular app in the Spring Boot app for distribution as a single artifact. At the same time, I'll show you how to keep Angular's productive workflow when developing locally.

You'll need to install several tools to follow along with this tutorial.

Prerequisites:

You can also watch a demo of the completed example in the screencast below:

Configure and Run a Spring Boot and Angular App

I'm a frequent speaker at conferences and Java User Groups (JUGs) around the world. I've been a Java developer for 20+ years and ❤️ the Java community. I've found that speaking at JUGs is a great way to interact with the community and get raw feedback on presentations.

Why am I telling you this? Because I thought it'd be fun to create a "JUG Tours" app that allows you to create/edit/delete JUGs and view upcoming events.

I realize that taking 20 minutes to build this app can be cumbersome, so I've already built it in @oktadev/auth0-spring-boot-angular-crud-example. The project uses Spring Boot 3.2.0 and Angular 17. I hope this helps you clone, configure, and run! Seeing something running is such a joyful experience. 🤗

git clone https://github.com/oktadev/auth0-spring-boot-angular-crud-example jugtours
cd jugtours

Open a terminal window and run

auth0 login
to configure the Auth0 CLI to get an API key for your tenant. Then, run
auth0 apps create
to register this app with the appropriate URLs:

auth0 apps create \
  --name "Bootiful Angular" \
  --description "Spring Boot + Angular = ❤️" \
  --type regular \
  --callbacks http://localhost:8080/login/oauth2/code/okta,http://localhost:4200/login/oauth2/code/okta \
  --logout-urls http://localhost:8080,http://localhost:4200 \
  --reveal-secrets

Copy the outputted values from this command into a new

.okta.env
file:

export OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/
export OKTA_OAUTH2_CLIENT_ID=<your-client-id>
export OKTA_OAUTH2_CLIENT_SECRET=<your-client-secret>

If you're on Windows, use

set
instead of
export
to set these environment variables, and name the file
.okta.env.bat
:

set OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/
set OKTA_OAUTH2_CLIENT_ID=<your-client-id>
set OKTA_OAUTH2_CLIENT_SECRET=<your-client-secret>

Then, run

source .okta.env
(or
.okta.env.bat
on Windows) to set these environment variables in your current shell.

Finally, run

./mvnw
(or
mvnw
on Windows) to start the app.

source .okta.env # run .okta.env.bat on Windows
./mvnw -Pprod # use mvnw -Pprod on Windows

To view the app, you can then open

http://localhost:8080
in your favorite browser.

JUG Tours homepage

Click Login, and you'll be prompted to log in with Auth0. You'll also be asked for consent. This is because the app is requesting access to your profile and email address. Click Accept to continue.

Auth0 consent

Once you're authenticated, you'll see a link to manage your JUG Tours.

Manage JUG Tours

You should be able to add new groups and events, as well as edit and delete them.

List of JUG Tours

Verify Cypress end-to-end tests pass

You can verify everything works by executing the Cypress tests that are included in the project. First, add environment variables for your credentials to the

.okta.env
(or
.okta.env.bat
) file you created earlier.

export CYPRESS_E2E_DOMAIN=<your-auth0-domain> # use the raw value, no https prefix
export CYPRESS_E2E_USERNAME=<your-email>
export CYPRESS_E2E_PASSWORD=<your-password>

Then, run

source .okta.env
(or
.okta.env.bat
on Windows) to set these environment variables.

Finally, run

npm run e2e
to start the app and run the Cypress tests.

cd app
npm run e2e

Cypress tests running in Chrome

Pretty slick, don't you think? 🤠

Read on if you'd like to see how I created this app!

Create a Java REST API with Spring Boot

The easiest way to create a new Spring Boot app is to navigate to start.spring.io and make the following selections:

  • Project:
    Maven Project
  • Group:
    com.okta.developer
  • Artifact:
    jugtours
  • Dependencies:
    JPA
    ,
    H2
    ,
    Web
    ,
    Validation

Click Generate Project, expand

jugtours.zip
after downloading, and open the project in your favorite IDE.

You can also use this link or HTTPie to create the project from the command line:

https start.spring.io/starter.tgz type==maven-project bootVersion==3.2.0 \
  dependencies==data-jpa,h2,web,validation \
  language==java platformVersion==17 \
  name==jugtours artifactId==jugtours \
  groupId==com.okta.developer packageName==com.okta.developer.jugtours \
  baseDir==jugtours | tar -xzvf -

Add a JPA domain model

Open the jugtours project in your favorite IDE. Create a

src/main/java/com/okta/developer/jugtours/model
directory and a
Group.java
class in it.

package com.okta.developer.jugtours.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;

import java.util.Set;

@Entity
@Table(name = "user_group")
public class Group {

    @Id
    @GeneratedValue
    private Long id;
    @NotNull
    private String name;
    private String address;
    private String city;
    private String stateOrProvince;
    private String country;
    private String postalCode;
    @ManyToOne(cascade = CascadeType.PERSIST)
    private User user;
    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    private Set<Event> events;

    // JPA requires a default constructor
    public Group() {}
  
    public Group(String name) {
        this.name = name;
    }

    // getters and setters, equals, hashcode, and toString omitted for brevity
    // Why not Lombok? See https://twitter.com/mariofusco/status/1650439733212766208
    // Want Lombok anyway? See https://bit.ly/3HkaYMm and revert
}

Create an

Event.java
class in the same package.

package com.okta.developer.jugtours.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToMany;

import java.time.Instant;
import java.util.Set;

@Entity
public class Event {

    @Id
    @GeneratedValue
    private Long id;
    private Instant date;
    private String title;
    private String description;

    @ManyToMany
    private Set<User> attendees;

    public Event() {}
     
    public Event(Instant date, String title, String description) {
        this.date = date;
        this.title = title;
        this.description = description;
    }

    // you can generate the getters and setters using your IDE!
}

And a

User.java
class.

package com.okta.developer.jugtours.model;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.util.Objects;

@Entity
@Table(name = "users")
public class User {

    @Id
    private String id;
    private String name;
    private String email;

    public User() {}
  
    public User(String id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    // getters and setters omitted for brevity
}

Create a

GroupRepository.java
to manage the group entity.

package com.okta.developer.jugtours.model;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface GroupRepository extends JpaRepository<Group, Long> {
    Group findByName(String name);
}

To load some default data, create an

Initializer.java
class in the
com.okta.developer.jugtours
package.

package com.okta.developer.jugtours;

import com.okta.developer.jugtours.model.Event;
import com.okta.developer.jugtours.model.Group;
import com.okta.developer.jugtours.model.GroupRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.Collections;
import java.util.stream.Stream;

@Component
class Initializer implements CommandLineRunner {

    private final GroupRepository repository;

    public Initializer(GroupRepository repository) {
        this.repository = repository;
    }

    @Override
    public void run(String... strings) {
        Stream.of("Utah JUG", "Dallas JUG", "Tampa JUG", "Nashville JUG", "Detroit JUG")
            .forEach(name -> repository.save(new Group(name)));

        Group jug = repository.findByName("Tampa JUG");
        Event e = new Event(Instant.parse("2024-04-24T18:00:00.000Z"),
            "What the Heck is OAuth?",
            "Learn how and where OAuth can benefit your applications.");
        jug.setEvents(Collections.singleton(e));
        repository.save(jug);

        repository.findAll().forEach(System.out::println);
    }
}

Start your app with

mvn spring-boot:run
, and you should see groups and events being created.

Add a

GroupController.java
class (in
src/main/java/.../jugtours/web
) that allows you to CRUD groups.

package com.okta.developer.jugtours.web;

import com.okta.developer.jugtours.model.Group;
import com.okta.developer.jugtours.model.GroupRepository;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collection;
import java.util.Optional;

@RestController
@RequestMapping("/api")
class GroupController {

    private final Logger log = LoggerFactory.getLogger(GroupController.class);
    private final GroupRepository groupRepository;

    public GroupController(GroupRepository groupRepository) {
        this.groupRepository = groupRepository;
    }

    @GetMapping("/groups")
    Collection<Group> groups() {
        return groupRepository.findAll();
    }

    @GetMapping("/group/{id}")
    ResponseEntity<?> getGroup(@PathVariable Long id) {
        Optional<Group> group = groupRepository.findById(id);
        return group.map(response -> ResponseEntity.ok().body(response)).orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
    }

    @PostMapping("/group")
    ResponseEntity<Group> createGroup(@Valid @RequestBody Group group) throws URISyntaxException {
        log.info("Request to create group: {}", group);
        Group result = groupRepository.save(group);
        return ResponseEntity.created(new URI("/api/group/" + result.getId())).body(result);
    }

    @PutMapping("/group/{id}")
    ResponseEntity<Group> updateGroup(@Valid @RequestBody Group group) {
        log.info("Request to update group: {}", group);
        Group result = groupRepository.save(group);
        return ResponseEntity.ok().body(result);
    }

    @DeleteMapping("/group/{id}")
    public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
        log.info("Request to delete group: {}", id);
        groupRepository.deleteById(id);
        return ResponseEntity.ok().build();
    }
}

Restart the app, hit

http://localhost:8080/api/groups
with HTTPie, and you should see the list of groups.

http :8080/api/groups

You can create, read, update, and delete groups with the following commands.

http POST :8080/api/group name='Toronto JUG' city='Toronto' country=CA
http :8080/api/group/6
http PUT :8080/api/group/6 id=6 name='Toronto JUG' address='16 York St'
http DELETE :8080/api/group/6

Create an Angular App with the Angular CLI

The Angular CLI was a revolutionary tool when it was released in 2016. It's now the standard way to create new Angular projects and the easiest way to get started with Angular. Many web frameworks have adopted similar tools to improve their developer experience.

You don't have to install Angular CLI globally. The

npx
command can install and run it for you.

npx @angular/cli@17 new app --routing --style css --ssr false

Of course, you can use the tried-and-true

npm i -g @angular/cli
and
ng new app --routing --style css --ssr false
if you prefer. You can even remove the version number if you want to live on the edge.

After the app creation process completes, navigate into the

app
directory and install Angular Material to make the UI look beautiful, particularly on mobile devices.

cd app
ng add @angular/material

You'll be prompted to choose a theme, set up typography styles, and include animations. Select the defaults.

Modify

app/src/app/app.component.html
and move the CSS at the top to
app.component.css
.

Call your Spring Boot API and display the results

Update

app.component.ts
to fetch the list of groups when it loads.

import { Component, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { Group } from './model/group';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, HttpClientModule],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css'
})
export class AppComponent implements OnInit {
  title = 'JUG Tours';
  loading = true;
  groups: Group[] = [];

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
    this.loading = true;
    this.http.get<Group[]>('api/groups').subscribe((data: Group[]) => {
      this.groups = data;
      this.loading = false;
    });
  }
}

Before this compiles, you'll need to create a

app/src/app/model/group.ts
file with the following contents:

export class Group {
  id: number | null;
  name: string;
  
  constructor(group: Partial<Group> = {}) {
    this.id = group?.id || null;
    this.name = group?.name || '';
  }
}

Then, modify the

app.component.html
file to display the list of groups.

<main class="main">
  <div class="content">
    <div class="left-side">
      <h1>{{title}}</h1>
    </div>
    <div class="divider" role="separator" aria-label="Divider"></div>
    <div class="right-side">
      <div class="pill-group">
        @if (loading) {
        <p>Loading...</p>
        }
        @for (group of groups; track group) {
        <span class="pill">{{group.name}}</span>
        }
      </div>
    </div>
  </div>
</main>
<router-outlet></router-outlet>

Create a file called

proxy.conf.js
in the
src
folder of your Angular project and use it to define your proxies:

const PROXY_CONFIG = [
  {
    context: ['/api'],
    target: 'http://localhost:8080',
    secure: true,
    logLevel: 'debug'
  }
]

module.exports = PROXY_CONFIG;

Update

angular.json
and its
serve
command to use the proxy.

"serve": {
  "builder": "@angular-devkit/build-angular:dev-server",
  "configurations": {
    "production": {
      "buildTarget": "app:build:production"
    },
    "development": {
      "buildTarget": "app:build:development",
      "proxyConfig": "src/proxy.conf.js"
    }
  },
  "defaultConfiguration": "development"
},

Stop your app with

Ctrl+C
and restart it with
npm start
. Now you should see a list of groups in your Angular app!

JUG Tours list

Build an Angular
GroupList
component

Angular is a component framework that allows you to separate concerns easily. You don't want to render everything in your main

AppComponent
, so create a new component to display the list of groups.

ng g c group-list

This command will create a new component in

src/app/group-list
with a TypeScript file, HTML template, CSS file, and a test file. Standalone components are created by default in Angular 17. They allow you to isolate components to be self-contained and, therefore, easier to distribute.

Replace the code in

group-list.component.ts
with the following:

import { Component } from '@angular/core';
import { Group } from '../model/group';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatIconModule } from '@angular/material/icon';
import { DatePipe } from '@angular/common';

@Component({
  selector: 'app-group-list',
  standalone: true,
  imports: [RouterLink, MatButtonModule, MatTableModule, MatIconModule, DatePipe, HttpClientModule],
  templateUrl: './group-list.component.html',
  styleUrl: './group-list.component.css'
})
export class GroupListComponent {
  title = 'Group List';
  loading = true;
  groups: Group[] = [];
  displayedColumns = ['id', 'name', 'events', 'actions'];
  feedback: any = {};

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
    this.loading = true;
    this.http.get<Group[]>('api/groups').subscribe((data: Group[]) => {
      this.groups = data;
      this.loading = false;
      this.feedback = {};
    });
  }

  delete(group: Group): void {
    if (confirm(`Are you sure you want to delete ${group.name}?`)) {
      this.http.delete(`api/group/${group.id}`).subscribe({
        next: () => {
          this.feedback = {type: 'success', message: 'Delete was successful!'};
          setTimeout(() => {
            this.ngOnInit();
          }, 1000);
        },
        error: () => {
          this.feedback = {type: 'warning', message: 'Error deleting.'};
        }
      });
    }
  }

  protected readonly event = event;
}

Update its HTML template to use Angular Material's table component.

<nav aria-label="breadcrumb">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a routerLink="/">Home</a></li>
    <li class="breadcrumb-item active">Groups</li>
  </ol>
</nav>

<a [routerLink]="['/group/new']" mat-raised-button color="primary" style="float: right" id="add">Add Group</a>

<h2>{{title}}</h2>
@if (loading) {
<div>
  <p>Loading...</p>
</div>
} @else {
  @if (feedback.message) {
    <div class="alert alert-{{feedback.type}}">{{ feedback.message }}</div>
  }
  <table mat-table [dataSource]="groups">
    <ng-container matColumnDef="id">
      <mat-header-cell *matHeaderCellDef> ID </mat-header-cell>
      <mat-cell *matCellDef="let item"> {{ item.id }} </mat-cell>
    </ng-container>
    <ng-container matColumnDef="name">
      <mat-header-cell *matHeaderCellDef> Name </mat-header-cell>
      <mat-cell *matCellDef="let item"> {{ item.name }} </mat-cell>
    </ng-container>
    <ng-container matColumnDef="events">
      <mat-header-cell *matHeaderCellDef> Events </mat-header-cell>
      <mat-cell *matCellDef="let item">
        @for (event of item.events; track event) {
          {{event.date | date }}: {{ event.title }}
          <br/>
        }
      </mat-cell>
    </ng-container>
    <ng-container matColumnDef="actions">
      <mat-header-cell *matHeaderCellDef> Actions </mat-header-cell>
      <mat-cell *matCellDef="let item">
        <a [routerLink]="['../group', item.id ]" mat-raised-button color="accent">Edit</a>&nbsp;
        <button (click)="delete(item)" mat-button color="warn"><mat-icon>delete</mat-icon></button>
      </mat-cell>
    </ng-container>
    <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
    <mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
  </table>
}

Create a

HomeComponent
to display a welcome message and a link to the groups page. This component will be the default route for the app.

ng g c home

Update

home.component.html
with the following:

<a mat-button color="primary" href="/groups">Manage JUG Tour</a>

Add an import for

MatButtonModule
to
home.component.ts
:

import { MatButtonModule } from '@angular/material/button';

@Component({
  selector: 'app-home',
  standalone: true,
  imports: [MatButtonModule],
  ...
})

Change

app.component.html
to remove the list of groups above
<router-outlet>
:

<div class="content" role="main">
  <router-outlet></router-outlet>
</div>

And remove the groups fetching logic from

app.component.ts
:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css'
})
export class AppComponent {
  title = 'JUG Tours';
}

Add a route for the

HomeComponent
and
GroupListComponent
to
app.routes.ts
:

import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { GroupListComponent } from './group-list/group-list.component';

export const routes: Routes = [
  { path: '', redirectTo: '/home', pathMatch: 'full' },
  {
    path: 'home',
    component: HomeComponent
  },
  {
    path: 'groups',
    component: GroupListComponent
  }
];

Update the CSS in

styles.css
to have rules for the
breadcrumb
and
alert
classes:

/* https://careydevelopment.us/blog/angular-how-to-add-breadcrumbs-to-your-ui */
ol.breadcrumb {
  padding: 0;
  list-style-type: none;
  margin: 5px 0 0 0;
}

.breadcrumb-item + .active {
  color: inherit;
  font-weight: 500;
}

.breadcrumb-item {
  color: #3F51B5;
  font-size: 1rem;
  text-decoration: underline;
  cursor: pointer;
}

.breadcrumb-item + .breadcrumb-item {
  padding-left: 0.5rem;
}

.breadcrumb-item + .breadcrumb-item::before {
  display: inline-block;
  padding-right: 0.5rem;
  color: rgb(108, 117, 125);
  content: "/";
}

ol.breadcrumb li {
  list-style-type: none;
}

ol.breadcrumb li {
  list-style-type: none;
  display: inline
}

.alert {
  padding: 0.75rem 1.25rem;
  margin-bottom: 1rem;
  border: 1px solid transparent;
}

.alert-success {
  color: #155724;
  background-color: #d4edda;
  border-color: #c3e6cb;
}

.alert-error {
  color: #721c24;
  background-color: #f8d7da;
  border-color: #f5c6cb;
}

Run

npm start
in your
app
directory to see how everything looks. Click on Manage JUG Tour, and you should see a list of the default groups.

JUG Tours list

To squish the Actions column to the right, add the following to

group-list.component.css
:

.mat-column-actions {
  flex: 0 0 120px;
}

Your Angular app should update itself as you make changes.

Group list with squished actions column

It's great to see your Spring Boot API's data in your Angular app, but it's no fun if you can't modify it!

Build an Angular
GroupEdit
component

Create a

group-edit
component and use Angular's
HttpClient
to fetch the group resource with the ID from the URL.

ng g c group-edit

Add a route for this component to

app.routes.ts
:

import { GroupEditComponent } from './group-edit/group-edit.component';

export const routes: Routes = [
  ...
  {
    path: 'group/:id',
    component: GroupEditComponent
  }
];

Replace the code in

group-edit.component.ts
with the following:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { map, of, switchMap } from 'rxjs';
import { Group } from '../model/group';
import { Event } from '../model/event';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatIconModule } from '@angular/material/icon';
import { MatNativeDateModule } from '@angular/material/core';
import { MatTooltipModule } from '@angular/material/tooltip';

@Component({
  selector: 'app-group-edit',
  standalone: true,
  imports: [
    FormsModule,
    HttpClientModule,
    MatInputModule,
    MatButtonModule,
    MatDatepickerModule,
    MatIconModule,
    MatNativeDateModule,
    MatTooltipModule,
    RouterLink
  ],
  templateUrl: './group-edit.component.html',
  styleUrl: './group-edit.component.css'
})
export class GroupEditComponent implements OnInit {
  group!: Group;
  feedback: any = {};

  constructor(private route: ActivatedRoute, private router: Router,
              private http: HttpClient) {
  }

  ngOnInit() {
    this.route.params.pipe(
      map(p => p['id']),
      switchMap(id => {
        if (id === 'new') {
          return of(new Group());
        }
        return this.http.get<Group>(`api/group/${id}`);
      })
    ).subscribe({
      next: group => {
        this.group = group;
        this.feedback = {};
      },
      error: () => {
        this.feedback = {type: 'warning', message: 'Error loading'};
      }
    });
  }

  save() {
    const id = this.group.id;
    const method = id ? 'put' : 'post';

    this.http[method](`/api/group${id ? '/' + id : ''}`, this.group).subscribe({
      next: () => {
        this.feedback = {type: 'success', message: 'Save was successful!'};
        setTimeout(async () => {
          await this.router.navigate(['/groups']);
        }, 1000);
      },
      error: () => {
        this.feedback = {type: 'error', message: 'Error saving'};
      }
    });
  }

  async cancel() {
    await this.router.navigate(['/groups']);
  }

  addEvent() {
    this.group.events.push(new Event());
  }

  removeEvent(index: number) {
    this.group.events.splice(index, 1);
  }
}

Create a

model/event.ts
file so this component will compile.

export class Event {
  id: number | null;
  date: Date | null;
  title: string;

  constructor(event: Partial<Event> = {}) {
    this.id = event?.id || null;
    this.date = event?.date || null;
    this.title = event?.title || '';
  }
}

Update

model/group.ts
to include the
Event
class.

import { Event } from './event';

export class Group {
  id: number | null;
  name: string;
  events: Event[];

  constructor(group: Partial<Group> = {}) {
    this.id = group?.id || null;
    this.name = group?.name || '';
    this.events = group?.events || [];
  }
}

The

GroupEditComponent
needs to render a form, so update
group-edit.component.html
with the following:

<nav aria-label="breadcrumb">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a routerLink="/">Home</a></li>
    <li class="breadcrumb-item"><a routerLink="/groups">Groups</a></li>
    <li class="breadcrumb-item active">Edit Group</li>
  </ol>
</nav>

<h2>Group Information</h2>
@if (feedback.message) {
<div class="alert alert-{{feedback.type}}">{{ feedback.message }}</div>
}
@if (group) {
<form #editForm="ngForm" (ngSubmit)="save()">
  @if (group.id) {
  <mat-form-field class="full-width">
    <mat-label>ID</mat-label>
    <input matInput [(ngModel)]="group.id" id="id" name="id" placeholder="ID" readonly>
  </mat-form-field>
  }
  <mat-form-field class="full-width">
    <mat-label>Name</mat-label>
    <input matInput [(ngModel)]="group.name" id="name" name="name" placeholder="Name" required>
  </mat-form-field>
  @if (group.events.length) {
  <h3>Events</h3>
  }
  @for (event of group.events; track event; let i = $index) {
  <div class="full-width">
    <mat-form-field style="width: 35%">
      <mat-label>Date</mat-label>
      <input matInput [matDatepicker]="picker"
             [(ngModel)]="group.events[i].date" name="group.events[{{i}}].date" placeholder="Date">
      <mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
      <mat-datepicker #picker></mat-datepicker>
    </mat-form-field>
    <mat-form-field style="width: 65%">
      <mat-label>Title</mat-label>
      <input matInput [(ngModel)]="group.events[i].title" name="group.events[{{i}}].title" placeholder="Title">
    </mat-form-field>
    <button mat-icon-button (click)="removeEvent(i)" aria-label="Remove Event"
            style="float: right; margin: -70px -5px 0 0">
      <mat-icon>delete</mat-icon>
    </button>
  </div>
  }
  <div class="button-row" role="group">
    @if (group.id) {
    <button type="button" mat-mini-fab color="accent" (click)="addEvent()"
            aria-label="Add Event" matTooltip="Add Event"
            style="float: right; margin-top: -4px">
      <mat-icon>add</mat-icon>
    </button>
    }
    <button type="submit" mat-raised-button color="primary" [disabled]="!editForm.form.valid" id="save">Save</button>
    <button type="button" mat-button color="accent" (click)="cancel()" id="cancel">Cancel</button>
  </div>
</form>
}

If you look closely, you'll notice this component allows you to edit events for a group. This component is an excellent example of how to handle nested objects in Angular.

Update

group-edit.component.css
to make things look better on all devices:

form, h2 {
  min-width: 150px;
  max-width: 700px;
  width: 100%;
  margin: 10px auto;
}

.alert {
  max-width: 660px;
  margin: 0 auto;
}

.full-width {
  width: 100%;
}

Now, with your Angular app running, you should be able to add and edit groups! Yaasss! 👏👏👏

Edit a group and add events

To make the navbar at the top use Angular Material colors, add the following to the top of

app.component.html
:

<mat-toolbar role="banner" color="primary">
  <svg id="angular-logo" height="35" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960" fill="#fff">
    <g>
      <polygon points="562.6,109.8 804.1,629.5 829.2,233.1"/>
      <polygon points="624.9,655.9 334.3,655.9 297.2,745.8 479.6,849.8 662,745.8"/>
      <polygon points="384.1,539.3 575.2,539.3 479.6,307"/>
      <polygon points="396.6,109.8 130,233.1 155.1,629.5"/>
    </g>
  </svg>
  <span>{{ title }}</span>
  <div class="spacer"></div>
  <a aria-label="OktaDev on Twitter" target="_blank" rel="noopener" href="https://twitter.com/oktadev" title="Twitter">
    <svg id="twitter-logo" height="35" data-name="Logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
      <rect width="400" height="400" fill="none"/>
      <path
        d="M153.62,301.59c94.34,0,145.94-78.16,145.94-145.94,0-2.22,0-4.43-.15-6.63A104.36,104.36,0,0,0,325,122.47a102.38,102.38,0,0,1-29.46,8.07,51.47,51.47,0,0,0,22.55-28.37,102.79,102.79,0,0,1-32.57,12.45,51.34,51.34,0,0,0-87.41,46.78A145.62,145.62,0,0,1,92.4,107.81a51.33,51.33,0,0,0,15.88,68.47A50.91,50.91,0,0,1,85,169.86c0,.21,0,.43,0,.65a51.31,51.31,0,0,0,41.15,50.28,51.21,51.21,0,0,1-23.16.88,51.35,51.35,0,0,0,47.92,35.62,102.92,102.92,0,0,1-63.7,22A104.41,104.41,0,0,1,75,278.55a145.21,145.21,0,0,0,78.62,23"
        fill="#fff"/>
    </svg>
  </a>
  <a aria-label="OktaDev on YouTube" target="_blank" rel="noopener" href="https://youtube.com/oktadev" title="YouTube">
    <svg id="youtube-logo" height="35" data-name="Logo" xmlns="http://www.w3.org/2000/svg"
         viewBox="0 0 24 24" fill="#fff">
      <path d="M0 0h24v24H0V0z" fill="none"/>
      <path
        d="M21.58 7.19c-.23-.86-.91-1.54-1.77-1.77C18.25 5 12 5 12 5s-6.25 0-7.81.42c-.86.23-1.54.91-1.77 1.77C2 8.75 2 12 2 12s0 3.25.42 4.81c.23.86.91 1.54 1.77 1.77C5.75 19 12 19 12 19s6.25 0 7.81-.42c.86-.23 1.54-.91 1.77-1.77C22 15.25 22 12 22 12s0-3.25-.42-4.81zM10 15V9l5.2 3-5.2 3z"/>
    </svg>
  </a>
</mat-toolbar>

Since this is not a standalone component, you must import

MatToolbarModule
in
app.component.ts
.

import { MatToolbarModule } from '@angular/material/toolbar';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, MatToolbarModule],
  ...
})

Make some adjustments in

app.component.css
to make the toolbar look nicer.

  1. Change the margin for
    .social-links
    to
    .5rem
    and remove the rules for
    .social-links path
    and
    .social-links a:hover
    below it.
  2. Add a
    .spacer
    rule with
    flex: 1 1 auto
    in its properties.
  3. Change the
    .content
    rule to be as follows:

.content {
  display: flex;
  margin: 10px auto;
  padding: 0 16px;
  max-width: 960px;
  flex-direction: column;
  align-items: stretch;
}

Now the app fills the screen more, and the toolbar has matching colors.

Toolbar colors match

Secure Spring Boot with OpenID Connect and OAuth

I love building simple CRUD apps to learn a new tech stack, but I think it's even cooler to build a secure one. So let's do that!

Spring Security added support for OpenID Connect (OIDC) in version 5.0, circa 2017. This is awesome because it means you can use Spring Security to secure your app with a third-party identity provider (IdP) like Auth0. This is a much better option than trying to build your own authentication system and store user credentials.

Add the Okta Spring Boot starter to do OIDC authentication in your

pom.xml
. This will also add Spring Security to your app.

<dependency>
    <groupId>com.okta.spring</groupId>
    <artifactId>okta-spring-boot-starter</artifactId>
    <version>3.0.6</version>
</dependency>

Install the Auth0 CLI (if you haven't already) and run

auth0 login
in a shell.

Next, run

auth0 apps create
to register a new OIDC app with appropriate callbacks:

auth0 apps create \
  --name "Bootiful Angular" \
  --description "Spring Boot + Angular = ❤️" \
  --type regular \
  --callbacks http://localhost:8080/login/oauth2/code/okta,http://localhost:4200/login/oauth2/code/okta \
  --logout-urls http://localhost:8080,http://localhost:4200 \
  --reveal-secrets

Copy the returned values from this command into an

.okta.env
file:

export OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/
export OKTA_OAUTH2_CLIENT_ID=<your-client-id>
export OKTA_OAUTH2_CLIENT_SECRET=<your-client-secret>

If you're on Windows, use

set
instead of
export
to set these environment variables and name the file
.okta.env.bat
:

set OKTA_OAUTH2_ISSUER=https://<your-auth0-domain>/
set OKTA_OAUTH2_CLIENT_ID=<your-client-id>
set OKTA_OAUTH2_CLIENT_SECRET=<your-client-secret>

Add

*.env
to your
.gitignore
file so you don't accidentally expose your client secret.

Then, run

source .okta.env
(or
.okta.env.bat
on Windows) to set these environment variables in your current shell.

Finally, run

./mvnw
(or
mvnw
on Windows) to start the app.

source .okta.env
./mvnw spring-boot:run

You can then open

http://localhost:8080
in your favorite browser. You'll be redirected to authenticate and returned afterward. You'll see a 404 error from Spring Boot since you have nothing mapped to the default
/
route.

Spring Boot 404

Configure Spring Security for maximum protection

To make Spring Security Angular-friendly, create a

SecurityConfiguration.java
file in
src/main/java/.../jugtours/config
. Create the
config
directory and put this class in it.

package com.okta.developer.jugtours.config;

import com.okta.developer.jugtours.web.CookieCsrfFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((authz) -> authz
                .requestMatchers("/", "/index.html", "*.ico", "*.css", "*.js", "/api/user").permitAll()
                .anyRequest().authenticated())
            .oauth2Login(withDefaults())
            .oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults()))
            .csrf((csrf) -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
            .addFilterAfter(new CookieCsrfFilter(), BasicAuthenticationFilter.class);

        return http.build();
    }
}

This class has a lot going on, so let me explain a few things. In previous versions of Spring Security, there was an

authorizeRequests()
lambda you could use to secure paths. Since Spring Security 3.1, it's deprecated, and you should use
authorizeHttpRequests()
. The
authorizeRequests()
lambda is permissive by default, which means any paths you don't specify will be allowed. The recommended way, shown here with
authorizeHttpRequests()
, denies by default. This means you have to specify the resources you want to allow Spring Security to serve up, as well as the ones that the Angular app has.

The

requestMatchers
line defines the URLs allowed for anonymous users. You will soon configure things so your Spring Boot app serves up your Angular app, hence the reason for allowing
/
,
/index.html
, and web files. You might also notice an exposed
/api/user
path.

Configuring CSRF (cross-site request forgery) protection with

CookieCsrfTokenRepository.withHttpOnlyFalse()
means that the
XSRF-TOKEN
cookie won't be marked HTTP-only, so Angular can read it and send it back when it tries to manipulate data. The
CsrfTokenRequestAttributeHandler
is no longer the default, so you have to configure it as the request handler. To learn more, you can read this Stack Overflow answer. Basically, since we're not sending the CSRF token to an HTML page, we don't have to worry about BREACH attacks. This means we can revert to the previous default from Spring Security 5.

You'll need to create the

CookieCsrfFilter
class that's added because Spring Security 6 no longer sets the cookie for you. Create it in the
web
package.

package com.okta.developer.jugtours.web;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * Spring Security 6 doesn't set an XSRF-TOKEN cookie by default.
 * This solution is
 * <a href="https://github.com/spring-projects/spring-security/issues/12141#issuecomment-1321345077">
 * recommended by Spring Security.</a>
 */
public class CookieCsrfFilter extends OncePerRequestFilter {

    /**
     * {@inheritDoc}
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
        response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
        filterChain.doFilter(request, response);
    }
}

Create

src/main/java/.../jugtours/web/UserController.java
and populate it with the following code. Angular will use this API to 1) find out if a user is authenticated and 2) perform global logout.

package com.okta.developer.jugtours.web;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

import java.text.MessageFormat;

import static java.util.Map.of;

@RestController
public class UserController {
    private final ClientRegistration registration;

    public UserController(ClientRegistrationRepository registrations) {
        this.registration = registrations.findByRegistrationId("okta");
    }

    @GetMapping("/api/user")
    public ResponseEntity<?> getUser(@AuthenticationPrincipal OAuth2User user) {
        if (user == null) {
            return new ResponseEntity<>("", HttpStatus.OK);
        } else {
            return ResponseEntity.ok().body(user.getAttributes());
        }
    }

    @PostMapping("/api/logout")
    public ResponseEntity<?> logout(HttpServletRequest request) {
        // send logout URL to client so they can initiate logout
        var issuerUri = registration.getProviderDetails().getIssuerUri();
        var originUrl = request.getHeader(HttpHeaders.ORIGIN);
        Object[] params = {issuerUri, registration.getClientId(), originUrl};
        // Yes! We @ Auth0 should have an end_session_endpoint in our OIDC metadata.
        // It's not included at the time of this writing, but will be coming soon!
        var logoutUrl = MessageFormat.format("{0}v2/logout?client_id={1}&returnTo={2}", params);
        request.getSession().invalidate();
        return ResponseEntity.ok().body(of("logoutUrl", logoutUrl));
    }
}

You'll also want to add user information when creating groups so that you can filter by your JUG tour. Add a

UserRepository.java
in the same directory as
GroupRepository.java
.

package com.okta.developer.jugtours.model;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, String> {
}

Add a new

findAllByUserId(String id)
method to
GroupRepository.java
.

List<Group> findAllByUserId(String id);

Then inject

UserRepository
into
GroupController.java
and use it to create (or grab an existing user) when adding a new group. While you're there, modify the
groups()
method to filter by user.

package com.okta.developer.jugtours.web;

import com.okta.developer.jugtours.model.Group;
import com.okta.developer.jugtours.model.GroupRepository;
import com.okta.developer.jugtours.model.User;
import com.okta.developer.jugtours.model.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.*;

import jakarta.validation.Valid;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.Principal;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;

@RestController
@RequestMapping("/api")
class GroupController {

    private final Logger log = LoggerFactory.getLogger(GroupController.class);
    private final GroupRepository groupRepository;
    private final UserRepository userRepository;

    public GroupController(GroupRepository groupRepository, UserRepository userRepository) {
        this.groupRepository = groupRepository;
        this.userRepository = userRepository;
    }

    @GetMapping("/groups")
    Collection<Group> groups(Principal principal) {
        return groupRepository.findAllByUserId(principal.getName());
    }

    @GetMapping("/group/{id}")
    ResponseEntity<?> getGroup(@PathVariable Long id) {
        Optional<Group> group = groupRepository.findById(id);
        return group.map(response -> ResponseEntity.ok().body(response))
            .orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
    }

    @PostMapping("/group")
    ResponseEntity<Group> createGroup(@Valid @RequestBody Group group,
                                      @AuthenticationPrincipal OAuth2User principal) throws URISyntaxException {
        log.info("Request to create group: {}", group);
        Map<String, Object> details = principal.getAttributes();
        String userId = details.get("sub").toString();

        // check to see if user already exists
        Optional<User> user = userRepository.findById(userId);
        group.setUser(user.orElse(new User(userId,
            details.get("name").toString(), details.get("email").toString())));

        Group result = groupRepository.save(group);
        return ResponseEntity.created(new URI("/api/group/" + result.getId()))
            .body(result);
    }

    @PutMapping("/group/{id}")
    ResponseEntity<Group> updateGroup(@Valid @RequestBody Group group) {
        log.info("Request to update group: {}", group);
        Group result = groupRepository.save(group);
        return ResponseEntity.ok().body(result);
    }

    @DeleteMapping("/group/{id}")
    public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
        log.info("Request to delete group: {}", id);
        groupRepository.deleteById(id);
        return ResponseEntity.ok().build();
    }
}

To highlight the changes, please review the

groups()
and
createGroup()
methods above. I think it's pretty slick that Spring JPA will create the
findAllByUserId()
method/query for you.

Update Angular to handle CSRF and be identity-aware

I like Angular because it's a secure-first framework. It has built-in support for CSRF, and it's easy to make it identity-aware. Let's do both!

Angular's

HttpClient
supports the client-side half of the CSRF protection. It'll read the cookie sent by Spring Boot and return it in an
X-XSRF-TOKEN
header. You can read more about this at Angular's Security docs.

Update your Angular app's authentication mechanism

Create a new

AuthService
class to communicate with your Spring Boot API for authentication information. Add the following code to a new file at
app/src/app/auth.service.ts
.

import { Injectable } from '@angular/core';
import { Location } from '@angular/common';
import { BehaviorSubject, lastValueFrom, Observable } from 'rxjs';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { map } from 'rxjs/operators';
import { User } from './model/user';

const headers = new HttpHeaders().set('Accept', 'application/json');

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  $authenticationState = new BehaviorSubject<boolean>(false);

  constructor(private http: HttpClient, private location: Location) {
  }

  getUser(): Observable<User> {
    return this.http.get<User>('/api/user', {headers}, )
      .pipe(map((response: User) => {
          if (response !== null) {
            this.$authenticationState.next(true);
          }
          return response;
        })
      );
  }

  async isAuthenticated(): Promise<boolean> {
    const user = await lastValueFrom(this.getUser());
    return user !== null;
  }

  login(): void {
    location.href = `${location.origin}${this.location.prepareExternalUrl('oauth2/authorization/okta')}`;
  }

  logout(): void {
    this.http.post('/api/logout', {}, { withCredentials: true }).subscribe((response: any) => {
      location.href = response.logoutUrl;
    });
  }
}

Add the referenced

User
class to
app/src/app/model/user.ts
.

export class User {
  email!: number;
  name!: string;
}

AuthService
depends on
HttpClient
, so you must update
app.config.ts
to import and use
provideHttpClient
.

import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
   providers: [provideRouter(routes), provideAnimations(), provideHttpClient()]
};

Modify

home.component.ts
to use
AuthService
to see if the user is logged in.

import { Component, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterLink } from '@angular/router';
import { User } from '../model/user';
import { AuthService } from '../auth.service';

@Component({
  selector: 'app-home',
  standalone: true,
  imports: [MatButtonModule, RouterLink],
  templateUrl: './home.component.html',
  styleUrl: './home.component.css'
})
export class HomeComponent implements OnInit {
  isAuthenticated!: boolean;
  user!: User;

  constructor(public auth: AuthService) {
  }

  async ngOnInit() {
    this.isAuthenticated = await this.auth.isAuthenticated();
    this.auth.getUser().subscribe(data => this.user = data);
  }
}

Modify

home.component.html
to show the Login button if the user is not logged in. Otherwise, show a Logout button.

@if (user) {
  <h2>Welcome, {{user.name}}!</h2>
  <a mat-button color="primary" routerLink="/groups">Manage JUG Tour</a>
  <br/><br/>
  <button mat-raised-button color="primary" (click)="auth.logout()" id="logout">Logout</button>
} @else {
  <p>Please log in to manage your JUG Tour.</p>
  <button mat-raised-button color="primary" (click)="auth.login()" id="login">Login</button>
}

Update

app/src/proxy.conf.js
to have additional proxy paths for
/oauth2
and
/login
.

const PROXY_CONFIG = [
  {
    context: ['/api', '/oauth2', '/login'],
    ...
  }
]

After all these changes, you should be able to restart both Spring Boot and Angular and witness the glory of securely planning your very own JUG Tour!

Angular app with Login

Angular app with Logout

Configure Maven to Package Angular with Spring Boot

To build and package your Angular app with Maven, you can use the frontend-maven-plugin and Maven's profiles to activate it. Add properties for versions and a

<profiles>
section to your
pom.xml
.

<properties>
    ...
    <frontend-maven-plugin.version>1.15.0</frontend-maven-plugin.version>
    <node.version>v18.18.2</node.version>
    <npm.version>9.8.1</npm.version>
</properties>

... 

<profiles>
    <profile>
        <id>dev</id>
        <activation>
            <activeByDefault>true</activeByDefault>
        </activation>
        <properties>
            <spring.profiles.active>dev</spring.profiles.active>
        </properties>
    </profile>
    <profile>
        <id>prod</id>
        <build>
            <plugins>
                <plugin>
                    <artifactId>maven-resources-plugin</artifactId>
                    <executions>
                        <execution>
                            <id>copy-resources</id>
                            <phase>process-classes</phase>
                            <goals>
                                <goal>copy-resources</goal>
                            </goals>
                            <configuration>
                                <outputDirectory>${basedir}/target/classes/static</outputDirectory>
                                <resources>
                                    <resource>
                                        <directory>app/dist/app/browser</directory>
                                    </resource>
                                </resources>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
                <plugin>
                    <groupId>com.github.eirslett</groupId>
                    <artifactId>frontend-maven-plugin</artifactId>
                    <version>${frontend-maven-plugin.version}</version>
                    <configuration>
                        <workingDirectory>app</workingDirectory>
                    </configuration>
                    <executions>
                        <execution>
                            <id>install node</id>
                            <goals>
                                <goal>install-node-and-npm</goal>
                            </goals>
                            <configuration>
                                <nodeVersion>${node.version}</nodeVersion>
                                <npmVersion>${npm.version}</npmVersion>
                            </configuration>
                        </execution>
                        <execution>
                            <id>npm install</id>
                            <goals>
                                <goal>npm</goal>
                            </goals>
                            <phase>generate-resources</phase>
                        </execution>
                        <execution>
                            <id>npm test</id>
                            <goals>
                                <goal>npm</goal>
                            </goals>
                            <phase>test</phase>
                            <configuration>
                                <arguments>test -- --watch=false</arguments>
                            </configuration>
                        </execution>
                        <execution>
                            <id>npm build</id>
                            <goals>
                                <goal>npm</goal>
                            </goals>
                            <phase>compile</phase>
                            <configuration>
                                <arguments>run build</arguments>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
        <properties>
            <spring.profiles.active>prod</spring.profiles.active>
        </properties>
    </profile>
</profiles>

While you're at it, add the active profile setting to

src/main/resources/application.properties
:

spring.profiles.active=@spring.profiles.active@

After adding this, you should be able to run

mvn spring-boot:run -Pprod
and see your app running at
http://localhost:8080
.

If you start at the root, everything will work fine since Angular will handle routing. However, if you refresh the page when you're at

http://localhost:8080/groups
, you'll get a 404 error since Spring Boot doesn't have a route for
/groups
. To fix this, add a
SpaWebFilter
that conditionally forwards to the Angular app.

package com.okta.developer.jugtours.web;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

public class SpaWebFilter extends OncePerRequestFilter {

    /**
     * Forwards any unmapped paths (except those containing a period) to the client {@code index.html}.
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {
        String path = request.getRequestURI();
        if (!path.startsWith("/api") &&
            !path.startsWith("/login") &&
            !path.startsWith("/oauth2") &&
            !path.contains(".") &&
            path.matches("/(.*)")) {
            request.getRequestDispatcher("/index.html").forward(request, response);
            return;
        }

        filterChain.doFilter(request, response);
    }
}

And add to your

SecurityConfiguration.java
class:

.addFilterAfter(new SpaWebFilter(), BasicAuthenticationFilter.class);

Now, if you restart, login, and navigate to

/groups
, refreshing will work as expected. 🤩

Verify Everything Works with Cypress

In this section, you'll learn how to integrate Cypress into this project to support end-to-end tests. Open a terminal, navigate to the

app
directory, and add the Cypress Angular Schematic:

ng add @cypress/schematic

Select the default answers when prompted. Then, update

app/cypress/support/commands.ts
to add a
login(username, password)
method:

/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-use-before-define */
// eslint-disable-next-line spaced-comment
/// <reference types="cypress" />

Cypress.Commands.add('login', (username: string, password: string) => {
  Cypress.log({
    message: [`🔐 Authenticating: ${username}`],
    autoEnd: false,
  })
  cy.origin(Cypress.env('E2E_DOMAIN'), {args: {username, password}},
    ({username, password}) => {
      cy.get('input[name=username]').type(username);
      cy.get('input[name=password]').type(`${password}{enter}`, {log: false});
    }
  );
});

declare global {
  namespace Cypress {
    interface Chainable {
      login(username: string, password: string): Cypress.Chainable;
    }
  }
}

// Convert this to a module instead of script (allows import/export)
export {};

Update

app/cypress/support/e2e.ts
to log in before each test and log out after.

import './commands';

beforeEach(() => {
  if (Cypress.env('E2E_USERNAME') === undefined) {
    console.error('E2E_USERNAME is not defined');
    alert('E2E_USERNAME is not defined');
    return;
  }
  cy.visit('/')
  cy.get('#login').click()
  cy.login(
    Cypress.env('E2E_USERNAME'),
    Cypress.env('E2E_PASSWORD')
  )
})

afterEach(() => {
  cy.visit('/')
  cy.get('#logout').click()
})

Rename

app/cypress/e2e/spec.cy.ts
to
home.cy.ts
and use it to verify that the home page loads.

describe('Home', () => {
  beforeEach(() => {
    cy.visit('/')
  });

  it('Visits the initial app page', () => {
    cy.contains('JUG Tours')
    cy.contains('Logout')
  })
})

Create a

groups.cy.ts
in the same directory to test CRUD on groups.

describe('Groups', () => {

  beforeEach(() => {
    cy.visit('/groups')
  });

  it('add button should exist', () => {
    cy.get('#add').should('exist');
  });

  it('should add a new group', () => {
    cy.get('#add').click();
    cy.get('#name').type('Test Group');
    cy.get('#save').click();
    cy.get('.alert-success').should('exist');
  });

  it('should edit a group', () => {
    cy.get('a').last().click();
    cy.get('#name').should('have.value', 'Test Group');
    cy.get('#cancel').click();
  });

  it('should delete a group', () => {
    cy.get('button').last().click();
    cy.on('window:confirm', () => true);
    cy.get('.alert-success').should('exist');
  });
});

Add environment variables with your credentials to the

.okta.env
(or
.okta.env.bat
) file you created earlier.

export CYPRESS_E2E_DOMAIN=<your-auth0-domain> # use the raw value, no https prefix
export CYPRESS_E2E_USERNAME=<your-email>
export CYPRESS_E2E_PASSWORD=<your-password>

Then, run

source .okta.env
(or
.okta.env.bat
on Windows) to set these environment variables and start the app.

mvn spring-boot:run -Pprod

In another terminal window, run the Cypress tests with Electron.

source .okta.env
cd app
npx cypress run --browser electron --config baseUrl=http://localhost:8080

Cypress tests running in Electron

Fix Unit Tests

If you run

npm test
, you'll see several failures. That's because the components have dependencies that aren't imported in the tests. Update
home.component.spec.ts
to import
HttpClientTestingModule
:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { HomeComponent } from './home.component';

describe('HomeComponent', () => {
  let component: HomeComponent;
  let fixture: ComponentFixture<HomeComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [HomeComponent, HttpClientTestingModule],
    }).compileComponents();
    
    fixture = TestBed.createComponent(HomeComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

In

app.component.spec.ts
, import
MatToolBarModule
and look for
JUG Tours
in the page.

import { TestBed } from '@angular/core/testing';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';

describe('AppComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [RouterTestingModule, MatToolbarModule],
    }).compileComponents();
  });

  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it(`should have the 'JUG Tours' title`, () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app.title).toEqual('JUG Tours');
  });

  it('should render title', () => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('mat-toolbar > span')?.textContent).toContain('JUG Tours');
  });
});

Then, update both group component tests to import

HttpClientTestingModule
and
RouterTestingModule
.

import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RouterTestingModule } from '@angular/router/testing';

describe('...', () => {
  ...

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [..., HttpClientTestingModule, RouterTestingModule]
    });
    ...
  });

  ...
});

Now,

npm test
should pass.

If you run

mvn test
without setting environment variables, your Java tests will fail too. To fix this, add a
src/test/java/com/okta/developer/jugtours/TestSecurityConfiguration.java
class to mock the OAuth provider.

package com.okta.developer.jugtours;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.jwt.JwtDecoder;

import java.util.HashMap;
import java.util.Map;

import static org.mockito.Mockito.mock;

/**
 * This class allows you to run unit and integration tests without an IdP.
 */
@TestConfiguration
public class TestSecurityConfiguration {

    @Bean
    ClientRegistration clientRegistration() {
        return clientRegistrationBuilder().build();
    }

    @Bean
    ClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
        return new InMemoryClientRegistrationRepository(clientRegistration);
    }

    private ClientRegistration.Builder clientRegistrationBuilder() {
        Map<String, Object> metadata = new HashMap<>();
        metadata.put("end_session_endpoint", "https://example.org/logout");

        return ClientRegistration.withRegistrationId("oidc")
            .issuerUri("{baseUrl}")
            .redirectUri("{baseUrl}/{action}/oauth2/code/{registrationId}")
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .scope("read:user")
            .authorizationUri("https://example.org/login/oauth/authorize")
            .tokenUri("https://example.org/login/oauth/access_token")
            .jwkSetUri("https://example.org/oauth/jwk")
            .userInfoUri("https://api.example.org/user")
            .providerConfigurationMetadata(metadata)
            .userNameAttributeName("id")
            .clientName("Client Name")
            .clientId("client-id")
            .clientSecret("client-secret");
    }

    @Bean
    JwtDecoder jwtDecoder() {
        return mock(JwtDecoder.class);
    }

    @Bean
    OAuth2AuthorizedClientService authorizedClientService(ClientRegistrationRepository clientRegistrationRepository) {
        return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository);
    }
}

Then, update

JugToursApplicationTests.java
in the same directory to use the new configuration.

@SpringBootTest(classes = {JugtoursApplication.class, TestSecurityConfiguration.class})

Run

mvn test
again, and your tests will pass. 🎉

Use GitHub Actions to Build and Test Your App

Add a GitHub workflow at

.github/workflows/main.yml
to prove that your tests run in CI.

name: JUG Tours CI

on: [push, pull_request]

jobs:
  build:
    name: Build and Test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Set up Java 17
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: 17
          cache: 'maven'
      - name: Run tests
        run: xvfb-run mvn verify -ntp -Pprod
      - name: Run e2e tests
        uses: cypress-io/github-action@v6
        with:
          browser: chrome
          start: mvn spring-boot:run -Pprod -ntp -f ../pom.xml
          install: false
          wait-on: http://localhost:8080
          wait-on-timeout: 120
          config: baseUrl=http://localhost:8080
          working-directory: app
        env:
          OKTA_OAUTH2_ISSUER: ${{ secrets.OKTA_OAUTH2_ISSUER }}
          OKTA_OAUTH2_CLIENT_ID: ${{ secrets.OKTA_OAUTH2_CLIENT_ID }}
          OKTA_OAUTH2_CLIENT_SECRET: ${{ secrets.OKTA_OAUTH2_CLIENT_SECRET }}
          CYPRESS_E2E_DOMAIN: ${{ secrets.CYPRESS_E2E_DOMAIN }}
          CYPRESS_E2E_USERNAME: ${{ secrets.CYPRESS_E2E_USERNAME }}
          CYPRESS_E2E_PASSWORD: ${{ secrets.CYPRESS_E2E_PASSWORD }}
      - name: Upload screenshots
        uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: cypress-screenshots
          path: app/cypress/screenshots

You can see this workflow in action on GitHub, or you can try it yourself by creating a new GitHub repo and pushing your code to it.

Add environment variables for the above secrets to your GitHub repository at Settings > Secrets and variables > Actions > New Repository Secret.

GitHub repository secrets

Push your changes to GitHub and watch the CI workflow run. ✅

Build Something Fabulous with Spring Boot and Angular!

I hope this post has helped you learn how to build secure Angular and Spring Boot apps. Using OpenID Connect is a recommended practice for authenticating full-stack apps like this one, and Auth0 makes it easy to do. Adding CSRF protection and packaging your Spring Boot + Angular app as a single artifact is super cool too!

We've written some other fun Spring Boot, Angular, and JHipster tutorials. Check them out!

I've also written a couple of InfoQ mini-books that you might find useful:

  • The JHipster Mini-Book: Shows how I built 21-Points Health with JHipster (Angular, Spring Boot, Bootstrap, and more). It includes a chapter on microservices with Spring Boot, React, and Auth0.
  • The Angular Mini-Book: A practical guide to Angular, Bootstrap, and Spring Boot. It uses Kotlin and Gradle, recommended security practices, and contains several cloud deployment guides.

If you have any questions, please leave a comment below. If you want to see the completed code for this tutorial, check out its GitHub repo. Follow us on Twitter and YouTube for more content like this.