├── app ├── src │ ├── assets │ │ └── .gitkeep │ ├── app │ │ ├── home │ │ │ ├── home.component.css │ │ │ ├── home.component.html │ │ │ ├── home.component.spec.ts │ │ │ └── home.component.ts │ │ ├── model │ │ │ ├── user.ts │ │ │ ├── event.ts │ │ │ └── group.ts │ │ ├── group-list │ │ │ ├── group-list.component.css │ │ │ ├── group-list.component.spec.ts │ │ │ ├── group-list.component.ts │ │ │ └── group-list.component.html │ │ ├── group-edit │ │ │ ├── group-edit.component.css │ │ │ ├── group-edit.component.spec.ts │ │ │ ├── group-edit.component.html │ │ │ └── group-edit.component.ts │ │ ├── app.config.ts │ │ ├── app.component.ts │ │ ├── app.routes.ts │ │ ├── app.component.spec.ts │ │ ├── auth.service.ts │ │ ├── app.component.html │ │ └── app.component.css │ ├── favicon.ico │ ├── proxy.conf.js │ ├── main.ts │ ├── index.html │ └── styles.css ├── cypress │ ├── tsconfig.json │ ├── e2e │ │ ├── home.cy.ts │ │ └── groups.cy.ts │ └── support │ │ ├── component-index.html │ │ ├── e2e.ts │ │ ├── commands.ts │ │ └── component.ts ├── tsconfig.app.json ├── cypress.config.ts ├── tsconfig.spec.json ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── README.md ├── package.json └── angular.json ├── src ├── main │ ├── resources │ │ └── application.properties │ └── java │ │ └── com │ │ └── okta │ │ └── developer │ │ └── jugtours │ │ ├── model │ │ ├── UserRepository.java │ │ ├── GroupRepository.java │ │ ├── User.java │ │ ├── Event.java │ │ └── Group.java │ │ ├── JugtoursApplication.java │ │ ├── web │ │ ├── SpaWebFilter.java │ │ ├── CookieCsrfFilter.java │ │ ├── UserController.java │ │ └── GroupController.java │ │ ├── Initializer.java │ │ └── config │ │ └── SecurityConfiguration.java └── test │ └── java │ └── com │ └── okta │ └── developer │ └── jugtours │ ├── JugtoursApplicationTests.java │ └── TestSecurityConfiguration.java ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── static └── spring-boot-angular.webp ├── .gitignore ├── .github └── workflows │ └── main.yml ├── README.md ├── pom.xml ├── mvnw.cmd ├── LICENSE ├── mvnw └── demo.adoc /app/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/app/home/home.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.profiles.active=@spring.profiles.active@ 2 | -------------------------------------------------------------------------------- /app/src/app/model/user.ts: -------------------------------------------------------------------------------- 1 | export class User { 2 | email!: number; 3 | name!: string; 4 | } 5 | -------------------------------------------------------------------------------- /app/src/app/group-list/group-list.component.css: -------------------------------------------------------------------------------- 1 | .mat-column-actions { 2 | flex: 0 0 120px; 3 | } 4 | -------------------------------------------------------------------------------- /app/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oktadev/auth0-spring-boot-angular-crud-example/HEAD/app/src/favicon.ico -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oktadev/auth0-spring-boot-angular-crud-example/HEAD/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /static/spring-boot-angular.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oktadev/auth0-spring-boot-angular-crud-example/HEAD/static/spring-boot-angular.webp -------------------------------------------------------------------------------- /app/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "sourceMap": false, 6 | "types": ["cypress"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/cypress/e2e/home.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Home', () => { 2 | beforeEach(() => { 3 | cy.visit('/') 4 | }); 5 | 6 | it('Visits the initial app page', () => { 7 | cy.contains('JUG Tours') 8 | cy.contains('Logout') 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /app/src/proxy.conf.js: -------------------------------------------------------------------------------- 1 | const PROXY_CONFIG = [ 2 | { 3 | context: ['/api', '/oauth2', '/login'], 4 | target: 'http://localhost:8080', 5 | secure: true, 6 | logLevel: 'debug' 7 | } 8 | ] 9 | 10 | module.exports = PROXY_CONFIG; 11 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/model/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours.model; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | public interface UserRepository extends JpaRepository { 6 | } 7 | -------------------------------------------------------------------------------- /app/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /app/src/app/group-edit/group-edit.component.css: -------------------------------------------------------------------------------- 1 | form, h2 { 2 | min-width: 150px; 3 | max-width: 700px; 4 | width: 100%; 5 | margin: 10px auto; 6 | } 7 | 8 | .alert { 9 | max-width: 660px; 10 | margin: 0 auto; 11 | } 12 | 13 | .full-width { 14 | width: 100%; 15 | } 16 | -------------------------------------------------------------------------------- /app/src/app/model/event.ts: -------------------------------------------------------------------------------- 1 | export class Event { 2 | id: number | null; 3 | date: Date | null; 4 | title: string; 5 | 6 | constructor(event: Partial = {}) { 7 | this.id = event?.id || null; 8 | this.date = event?.date || null; 9 | this.title = event?.title || ''; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /app/src/app/model/group.ts: -------------------------------------------------------------------------------- 1 | import { Event } from './event'; 2 | 3 | export class Group { 4 | id: number | null; 5 | name: string; 6 | events: Event[]; 7 | 8 | constructor(group: Partial = {}) { 9 | this.id = group?.id || null; 10 | this.name = group?.name || ''; 11 | this.events = group?.events || []; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | 'baseUrl': 'http://localhost:4200' 6 | }, 7 | video: false, 8 | component: { 9 | devServer: { 10 | framework: 'angular', 11 | bundler: 'webpack', 12 | }, 13 | specPattern: '**/*.cy.ts' 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /app/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /app/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /app/cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/model/GroupRepository.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours.model; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | import java.util.List; 6 | 7 | public interface GroupRepository extends JpaRepository { 8 | Group findByName(String name); 9 | 10 | List findAllByUserId(String id); 11 | } 12 | -------------------------------------------------------------------------------- /src/test/java/com/okta/developer/jugtours/JugtoursApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest(classes = {JugtoursApplication.class, TestSecurityConfiguration.class}) 7 | class JugtoursApplicationTests { 8 | 9 | @Test 10 | void contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/JugtoursApplication.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class JugtoursApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(JugtoursApplication.class, args); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /app/src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 | @if (user) { 2 |

Welcome, {{user.name}}!

3 | Manage JUG Tour 4 |

5 | 6 | } @else { 7 |

Please log in to manage your JUG Tour.

8 | 9 | } 10 | -------------------------------------------------------------------------------- /app/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@angular/core'; 2 | import { provideAnimations } from '@angular/platform-browser/animations'; 3 | import { provideHttpClient } from '@angular/common/http'; 4 | import { provideRouter } from '@angular/router'; 5 | import { routes } from './app.routes'; 6 | 7 | export const appConfig: ApplicationConfig = { 8 | providers: [provideRouter(routes), provideAnimations(), provideHttpClient()] 9 | }; 10 | -------------------------------------------------------------------------------- /app/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterOutlet } from '@angular/router'; 3 | import { MatToolbarModule } from '@angular/material/toolbar'; 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | standalone: true, 8 | imports: [RouterOutlet, MatToolbarModule], 9 | templateUrl: './app.component.html', 10 | styleUrl: './app.component.css' 11 | }) 12 | export class AppComponent { 13 | title = 'JUG Tours'; 14 | } 15 | -------------------------------------------------------------------------------- /app/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | import './commands'; 2 | 3 | beforeEach(() => { 4 | if (Cypress.env('E2E_USERNAME') === undefined) { 5 | console.error('E2E_USERNAME is not defined'); 6 | alert('E2E_USERNAME is not defined'); 7 | return; 8 | } 9 | cy.visit('/') 10 | cy.get('#login').click() 11 | cy.login( 12 | Cypress.env('E2E_USERNAME'), 13 | Cypress.env('E2E_PASSWORD') 14 | ) 15 | }) 16 | 17 | afterEach(() => { 18 | cy.visit('/') 19 | cy.get('#logout').click() 20 | }) 21 | -------------------------------------------------------------------------------- /app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | App 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | 35 | *.env 36 | *.env.bat 37 | /app/node/ 38 | -------------------------------------------------------------------------------- /app/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { HomeComponent } from './home/home.component'; 3 | import { GroupListComponent } from './group-list/group-list.component'; 4 | import { GroupEditComponent } from './group-edit/group-edit.component'; 5 | 6 | export const routes: Routes = [ 7 | { path: '', redirectTo: '/home', pathMatch: 'full' }, 8 | { 9 | path: 'home', 10 | component: HomeComponent 11 | }, 12 | { 13 | path: 'groups', 14 | component: GroupListComponent 15 | }, 16 | { 17 | path: 'group/:id', 18 | component: GroupEditComponent 19 | } 20 | ]; 21 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /app/src/app/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { HttpClientTestingModule } from '@angular/common/http/testing'; 3 | import { HomeComponent } from './home.component'; 4 | 5 | describe('HomeComponent', () => { 6 | let component: HomeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [HomeComponent, HttpClientTestingModule], 12 | }).compileComponents(); 13 | 14 | fixture = TestBed.createComponent(HomeComponent); 15 | component = fixture.componentInstance; 16 | fixture.detectChanges(); 17 | }); 18 | 19 | it('should create', () => { 20 | expect(component).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /app/cypress/e2e/groups.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Groups', () => { 2 | 3 | beforeEach(() => { 4 | cy.visit('/groups') 5 | }); 6 | 7 | it('add button should exist', () => { 8 | cy.get('#add').should('exist'); 9 | }); 10 | 11 | it('should add a new group', () => { 12 | cy.get('#add').click(); 13 | cy.get('#name').type('Test Group'); 14 | cy.get('#save').click(); 15 | cy.get('.alert-success').should('exist'); 16 | }); 17 | 18 | it('should edit a group', () => { 19 | cy.get('a').last().click(); 20 | cy.get('#name').should('have.value', 'Test Group'); 21 | cy.get('#cancel').click(); 22 | }); 23 | 24 | it('should delete a group', () => { 25 | cy.get('button').last().click(); 26 | cy.on('window:confirm', () => true); 27 | cy.get('.alert-success').should('exist'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /app/src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { MatButtonModule } from '@angular/material/button'; 3 | import { RouterLink } from '@angular/router'; 4 | import { User } from '../model/user'; 5 | import { AuthService } from '../auth.service'; 6 | 7 | @Component({ 8 | selector: 'app-home', 9 | standalone: true, 10 | imports: [MatButtonModule, RouterLink], 11 | templateUrl: './home.component.html', 12 | styleUrl: './home.component.css' 13 | }) 14 | export class HomeComponent implements OnInit { 15 | isAuthenticated!: boolean; 16 | user!: User; 17 | 18 | constructor(public auth: AuthService) { 19 | } 20 | 21 | async ngOnInit() { 22 | this.isAuthenticated = await this.auth.isAuthenticated(); 23 | this.auth.getUser().subscribe(data => this.user = data); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/app/group-edit/group-edit.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { HttpClientTestingModule } from '@angular/common/http/testing'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | import { GroupEditComponent } from './group-edit.component'; 5 | 6 | describe('GroupEditComponent', () => { 7 | let component: GroupEditComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | imports: [GroupEditComponent, HttpClientTestingModule, RouterTestingModule] 13 | }) 14 | .compileComponents(); 15 | 16 | fixture = TestBed.createComponent(GroupEditComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /app/src/app/group-list/group-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { HttpClientTestingModule } from '@angular/common/http/testing'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | import { GroupListComponent } from './group-list.component'; 5 | 6 | describe('GroupListComponent', () => { 7 | let component: GroupListComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | imports: [GroupListComponent, HttpClientTestingModule, RouterTestingModule] 13 | }) 14 | .compileComponents(); 15 | 16 | fixture = TestBed.createComponent(GroupListComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /app/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | /* eslint-disable @typescript-eslint/no-use-before-define */ 3 | // eslint-disable-next-line spaced-comment 4 | /// 5 | 6 | Cypress.Commands.add('login', (username: string, password: string) => { 7 | Cypress.log({ 8 | message: [`🔐 Authenticating: ${username}`], 9 | autoEnd: false, 10 | }) 11 | cy.origin(Cypress.env('E2E_DOMAIN'), {args: {username, password}}, 12 | ({username, password}) => { 13 | cy.get('input[name=username]').type(username); 14 | cy.get('input[name=password]').type(`${password}{enter}`, {log: false}); 15 | } 16 | ); 17 | }); 18 | 19 | declare global { 20 | namespace Cypress { 21 | interface Chainable { 22 | login(username: string, password: string): Cypress.Chainable; 23 | } 24 | } 25 | } 26 | 27 | // Convert this to a module instead of script (allows import/export) 28 | export {}; 29 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "esModuleInterop": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "experimentalDecorators": true, 16 | "moduleResolution": "node", 17 | "importHelpers": true, 18 | "target": "ES2022", 19 | "module": "ES2022", 20 | "useDefineForClassFields": false, 21 | "lib": [ 22 | "ES2022", 23 | "dom" 24 | ] 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip 18 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar 19 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # App 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.0.5. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /app/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { MatToolbarModule } from '@angular/material/toolbar'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | import { AppComponent } from './app.component'; 5 | 6 | describe('AppComponent', () => { 7 | beforeEach(async () => { 8 | await TestBed.configureTestingModule({ 9 | imports: [RouterTestingModule, MatToolbarModule], 10 | }).compileComponents(); 11 | }); 12 | 13 | it('should create the app', () => { 14 | const fixture = TestBed.createComponent(AppComponent); 15 | const app = fixture.componentInstance; 16 | expect(app).toBeTruthy(); 17 | }); 18 | 19 | it(`should have the 'JUG Tours' title`, () => { 20 | const fixture = TestBed.createComponent(AppComponent); 21 | const app = fixture.componentInstance; 22 | expect(app.title).toEqual('JUG Tours'); 23 | }); 24 | 25 | it('should render title', () => { 26 | const fixture = TestBed.createComponent(AppComponent); 27 | fixture.detectChanges(); 28 | const compiled = fixture.nativeElement as HTMLElement; 29 | expect(compiled.querySelector('mat-toolbar > span')?.textContent).toContain('JUG Tours'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/web/SpaWebFilter.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours.web; 2 | 3 | import jakarta.servlet.FilterChain; 4 | import jakarta.servlet.ServletException; 5 | import jakarta.servlet.http.HttpServletRequest; 6 | import jakarta.servlet.http.HttpServletResponse; 7 | import org.springframework.web.filter.OncePerRequestFilter; 8 | 9 | import java.io.IOException; 10 | 11 | public class SpaWebFilter extends OncePerRequestFilter { 12 | 13 | /** 14 | * Forwards any unmapped paths (except those containing a period) to the client {@code index.html}. 15 | */ 16 | @Override 17 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 18 | FilterChain filterChain) throws ServletException, IOException { 19 | String path = request.getRequestURI(); 20 | if (!path.startsWith("/api") && 21 | !path.startsWith("/login") && 22 | !path.startsWith("/oauth2") && 23 | !path.contains(".") && 24 | path.matches("/(.*)")) { 25 | request.getRequestDispatcher("/index.html").forward(request, response); 26 | return; 27 | } 28 | 29 | filterChain.doFilter(request, response); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/web/CookieCsrfFilter.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours.web; 2 | 3 | import jakarta.servlet.FilterChain; 4 | import jakarta.servlet.ServletException; 5 | import jakarta.servlet.http.HttpServletRequest; 6 | import jakarta.servlet.http.HttpServletResponse; 7 | import org.springframework.security.web.csrf.CsrfToken; 8 | import org.springframework.web.filter.OncePerRequestFilter; 9 | 10 | import java.io.IOException; 11 | 12 | /** 13 | * Spring Security 6 doesn't set an XSRF-TOKEN cookie by default. 14 | * This solution is 15 | * 16 | * recommended by Spring Security. 17 | */ 18 | public class CookieCsrfFilter extends OncePerRequestFilter { 19 | 20 | /** 21 | * {@inheritDoc} 22 | */ 23 | @Override 24 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 25 | FilterChain filterChain) throws ServletException, IOException { 26 | CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); 27 | response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken()); 28 | filterChain.doFilter(request, response); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import { mount } from 'cypress/angular' 23 | 24 | // Augment the Cypress namespace to include type definitions for 25 | // your custom command. 26 | // Alternatively, can be defined in cypress/support/component.d.ts 27 | // with a at the top of your spec. 28 | declare global { 29 | namespace Cypress { 30 | interface Chainable { 31 | mount: typeof mount 32 | } 33 | } 34 | } 35 | 36 | Cypress.Commands.add('mount', mount) 37 | 38 | // Example use: 39 | // cy.mount(MyComponent) 40 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/Initializer.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours; 2 | 3 | import com.okta.developer.jugtours.model.Event; 4 | import com.okta.developer.jugtours.model.Group; 5 | import com.okta.developer.jugtours.model.GroupRepository; 6 | import org.springframework.boot.CommandLineRunner; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.time.Instant; 10 | import java.util.Collections; 11 | import java.util.stream.Stream; 12 | 13 | @Component 14 | class Initializer implements CommandLineRunner { 15 | 16 | private final GroupRepository repository; 17 | 18 | public Initializer(GroupRepository repository) { 19 | this.repository = repository; 20 | } 21 | 22 | @Override 23 | public void run(String... strings) { 24 | Stream.of("Utah JUG", "Dallas JUG", "Tampa JUG", "Nashville JUG", "Detroit JUG") 25 | .forEach(name -> repository.save(new Group(name))); 26 | 27 | Group jug = repository.findByName("Tampa JUG"); 28 | Event e = new Event(Instant.parse("2024-04-24T18:00:00.000Z"), 29 | "What the Heck is OAuth?", 30 | "Learn how and where OAuth can benefit your applications."); 31 | jug.setEvents(Collections.singleton(e)); 32 | repository.save(jug); 33 | 34 | repository.findAll().forEach(System.out::println); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | html, body { height: 100%; } 4 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 5 | 6 | /* https://careydevelopment.us/blog/angular-how-to-add-breadcrumbs-to-your-ui */ 7 | ol.breadcrumb { 8 | padding: 0; 9 | list-style-type: none; 10 | margin: 5px 0 0 0; 11 | } 12 | 13 | .breadcrumb-item + .active { 14 | color: inherit; 15 | font-weight: 500; 16 | } 17 | 18 | .breadcrumb-item { 19 | color: #3F51B5; 20 | font-size: 1rem; 21 | text-decoration: underline; 22 | cursor: pointer; 23 | } 24 | 25 | .breadcrumb-item + .breadcrumb-item { 26 | padding-left: 0.5rem; 27 | } 28 | 29 | .breadcrumb-item + .breadcrumb-item::before { 30 | display: inline-block; 31 | padding-right: 0.5rem; 32 | color: rgb(108, 117, 125); 33 | content: "/"; 34 | } 35 | 36 | ol.breadcrumb li { 37 | list-style-type: none; 38 | } 39 | 40 | ol.breadcrumb li { 41 | list-style-type: none; 42 | display: inline 43 | } 44 | 45 | .alert { 46 | padding: 0.75rem 1.25rem; 47 | margin-bottom: 1rem; 48 | border: 1px solid transparent; 49 | } 50 | 51 | .alert-success { 52 | color: #155724; 53 | background-color: #d4edda; 54 | border-color: #c3e6cb; 55 | } 56 | 57 | .alert-error { 58 | color: #721c24; 59 | background-color: #f8d7da; 60 | border-color: #f5c6cb; 61 | } 62 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test", 10 | "e2e": "ng e2e", 11 | "cypress:open": "cypress open", 12 | "cypress:run": "cypress run" 13 | }, 14 | "private": true, 15 | "dependencies": { 16 | "@angular/animations": "^17.0.0", 17 | "@angular/cdk": "^17.0.2", 18 | "@angular/common": "^17.0.0", 19 | "@angular/compiler": "^17.0.0", 20 | "@angular/core": "^17.0.0", 21 | "@angular/forms": "^17.0.0", 22 | "@angular/material": "^17.0.2", 23 | "@angular/platform-browser": "^17.0.0", 24 | "@angular/platform-browser-dynamic": "^17.0.0", 25 | "@angular/router": "^17.0.0", 26 | "rxjs": "~7.8.0", 27 | "tslib": "^2.3.0", 28 | "zone.js": "~0.14.2" 29 | }, 30 | "devDependencies": { 31 | "@angular-devkit/build-angular": "^17.0.5", 32 | "@angular/cli": "^17.0.5", 33 | "@angular/compiler-cli": "^17.0.0", 34 | "@cypress/schematic": "^2.5.1", 35 | "@types/jasmine": "~5.1.0", 36 | "jasmine-core": "~5.1.0", 37 | "karma": "~6.4.0", 38 | "karma-chrome-launcher": "~3.2.0", 39 | "karma-coverage": "~2.2.0", 40 | "karma-jasmine": "~5.1.0", 41 | "karma-jasmine-html-reporter": "~2.1.0", 42 | "typescript": "~5.2.2", 43 | "cypress": "latest" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/app/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Location } from '@angular/common'; 3 | import { BehaviorSubject, lastValueFrom, Observable } from 'rxjs'; 4 | import { HttpClient, HttpHeaders } from '@angular/common/http'; 5 | import { map } from 'rxjs/operators'; 6 | import { User } from './model/user'; 7 | 8 | const headers = new HttpHeaders().set('Accept', 'application/json'); 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class AuthService { 14 | $authenticationState = new BehaviorSubject(false); 15 | 16 | constructor(private http: HttpClient, private location: Location) { 17 | } 18 | 19 | getUser(): Observable { 20 | return this.http.get('/api/user', { headers },) 21 | .pipe(map((response: User) => { 22 | if (response !== null) { 23 | this.$authenticationState.next(true); 24 | } 25 | return response; 26 | }) 27 | ); 28 | } 29 | 30 | async isAuthenticated(): Promise { 31 | const user = await lastValueFrom(this.getUser()); 32 | return user !== null; 33 | } 34 | 35 | login(): void { 36 | location.href = `${location.origin}${this.location.prepareExternalUrl('oauth2/authorization/okta')}`; 37 | } 38 | 39 | logout(): void { 40 | this.http.post('/api/logout', {}, { withCredentials: true }).subscribe((response: any) => { 41 | location.href = response.logoutUrl; 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: JUG Tours CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build and Test 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Set up Java 17 13 | uses: actions/setup-java@v4 14 | with: 15 | distribution: 'temurin' 16 | java-version: 17 17 | cache: 'maven' 18 | - name: Run tests 19 | run: xvfb-run mvn verify -ntp -Pprod 20 | - name: Run e2e tests 21 | uses: cypress-io/github-action@v6 22 | with: 23 | browser: chrome 24 | start: mvn spring-boot:run -Pprod -ntp -f ../pom.xml 25 | install: false 26 | wait-on: http://localhost:8080 27 | wait-on-timeout: 120 28 | config: baseUrl=http://localhost:8080 29 | working-directory: app 30 | env: 31 | OKTA_OAUTH2_ISSUER: ${{ secrets.OKTA_OAUTH2_ISSUER }} 32 | OKTA_OAUTH2_CLIENT_ID: ${{ secrets.OKTA_OAUTH2_CLIENT_ID }} 33 | OKTA_OAUTH2_CLIENT_SECRET: ${{ secrets.OKTA_OAUTH2_CLIENT_SECRET }} 34 | CYPRESS_E2E_DOMAIN: ${{ secrets.CYPRESS_E2E_DOMAIN }} 35 | CYPRESS_E2E_USERNAME: ${{ secrets.CYPRESS_E2E_USERNAME }} 36 | CYPRESS_E2E_PASSWORD: ${{ secrets.CYPRESS_E2E_PASSWORD }} 37 | - name: Upload screenshots 38 | uses: actions/upload-artifact@v3 39 | if: failure() 40 | with: 41 | name: cypress-screenshots 42 | path: app/cypress/screenshots 43 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/model/User.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours.model; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.Id; 5 | import jakarta.persistence.Table; 6 | 7 | import java.util.Objects; 8 | 9 | @Entity 10 | @Table(name = "users") 11 | public class User { 12 | 13 | @Id 14 | private String id; 15 | private String name; 16 | private String email; 17 | 18 | public User() {} 19 | 20 | public User(String id, String name, String email) { 21 | this.id = id; 22 | this.name = name; 23 | this.email = email; 24 | } 25 | 26 | public String getId() { 27 | return id; 28 | } 29 | 30 | public void setId(String id) { 31 | this.id = id; 32 | } 33 | 34 | public String getName() { 35 | return name; 36 | } 37 | 38 | public void setName(String name) { 39 | this.name = name; 40 | } 41 | 42 | public String getEmail() { 43 | return email; 44 | } 45 | 46 | public void setEmail(String email) { 47 | this.email = email; 48 | } 49 | 50 | @Override 51 | public boolean equals(Object o) { 52 | if (this == o) return true; 53 | if (o == null || getClass() != o.getClass()) return false; 54 | 55 | User user = (User) o; 56 | 57 | return Objects.equals(id, user.id); 58 | } 59 | 60 | @Override 61 | public int hashCode() { 62 | return id != null ? id.hashCode() : 0; 63 | } 64 | 65 | @Override 66 | public String toString() { 67 | return "User{" + 68 | "id='" + id + '\'' + 69 | ", name='" + name + '\'' + 70 | ", email='" + email + '\'' + 71 | '}'; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/config/SecurityConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours.config; 2 | 3 | import com.okta.developer.jugtours.web.CookieCsrfFilter; 4 | import com.okta.developer.jugtours.web.SpaWebFilter; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 8 | import org.springframework.security.web.SecurityFilterChain; 9 | import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; 10 | import org.springframework.security.web.csrf.CookieCsrfTokenRepository; 11 | import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; 12 | 13 | import static org.springframework.security.config.Customizer.withDefaults; 14 | 15 | @Configuration 16 | public class SecurityConfiguration { 17 | 18 | @Bean 19 | public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { 20 | http.authorizeHttpRequests((authz) -> authz 21 | .requestMatchers("/", "/index.html", "*.ico", "*.css", "*.js", "/api/user").permitAll() 22 | .anyRequest().authenticated()) 23 | .oauth2Login(withDefaults()) 24 | .oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults())) 25 | .csrf((csrf) -> csrf 26 | .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) 27 | .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())) 28 | .addFilterAfter(new CookieCsrfFilter(), BasicAuthenticationFilter.class) 29 | .addFilterAfter(new SpaWebFilter(), BasicAuthenticationFilter.class); 30 | 31 | return http.build(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/app/group-list/group-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Group } from '../model/group'; 3 | import { HttpClient, HttpClientModule } from '@angular/common/http'; 4 | import { RouterLink } from '@angular/router'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatTableModule } from '@angular/material/table'; 7 | import { MatIconModule } from '@angular/material/icon'; 8 | import { DatePipe } from '@angular/common'; 9 | 10 | @Component({ 11 | selector: 'app-group-list', 12 | standalone: true, 13 | imports: [RouterLink, MatButtonModule, MatTableModule, MatIconModule, DatePipe, HttpClientModule], 14 | templateUrl: './group-list.component.html', 15 | styleUrl: './group-list.component.css' 16 | }) 17 | export class GroupListComponent { 18 | title = 'Group List'; 19 | loading = true; 20 | groups: Group[] = []; 21 | displayedColumns = ['id', 'name', 'events', 'actions']; 22 | feedback: any = {}; 23 | 24 | constructor(private http: HttpClient) { 25 | } 26 | 27 | ngOnInit() { 28 | this.loading = true; 29 | this.http.get('api/groups').subscribe((data: Group[]) => { 30 | this.groups = data; 31 | this.loading = false; 32 | this.feedback = {}; 33 | }); 34 | } 35 | 36 | delete(group: Group): void { 37 | if (confirm(`Are you sure you want to delete ${group.name}?`)) { 38 | this.http.delete(`api/group/${group.id}`).subscribe({ 39 | next: () => { 40 | this.feedback = {type: 'success', message: 'Delete was successful!'}; 41 | setTimeout(() => { 42 | this.ngOnInit(); 43 | }, 1000); 44 | }, 45 | error: () => { 46 | this.feedback = {type: 'warning', message: 'Error deleting.'}; 47 | } 48 | }); 49 | } 50 | } 51 | 52 | protected readonly event = event; 53 | } 54 | -------------------------------------------------------------------------------- /app/src/app/group-list/group-list.component.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | Add Group 9 | 10 |

{{title}}

11 | @if (loading) { 12 |
13 |

Loading...

14 |
15 | } @else { 16 | @if (feedback.message) { 17 |
{{ feedback.message }}
18 | } 19 | 20 | 21 | ID 22 | {{ item.id }} 23 | 24 | 25 | Name 26 | {{ item.name }} 27 | 28 | 29 | Events 30 | 31 | @for (event of item.events; track event) { 32 | {{event.date | date }}: {{ event.title }} 33 |
34 | } 35 |
36 |
37 | 38 | Actions 39 | 40 | Edit  41 | 42 | 43 | 44 | 45 | 46 |
47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/web/UserController.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours.web; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import org.springframework.http.HttpHeaders; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 8 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 9 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 10 | import org.springframework.security.oauth2.core.user.OAuth2User; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.PostMapping; 13 | import org.springframework.web.bind.annotation.RestController; 14 | 15 | import java.text.MessageFormat; 16 | 17 | import static java.util.Map.of; 18 | 19 | @RestController 20 | public class UserController { 21 | private final ClientRegistration registration; 22 | 23 | public UserController(ClientRegistrationRepository registrations) { 24 | this.registration = registrations.findByRegistrationId("okta"); 25 | } 26 | 27 | @GetMapping("/api/user") 28 | public ResponseEntity getUser(@AuthenticationPrincipal OAuth2User user) { 29 | if (user == null) { 30 | return new ResponseEntity<>("", HttpStatus.OK); 31 | } else { 32 | return ResponseEntity.ok().body(user.getAttributes()); 33 | } 34 | } 35 | 36 | @PostMapping("/api/logout") 37 | public ResponseEntity logout(HttpServletRequest request) { 38 | // send logout URL to client so they can initiate logout 39 | var issuerUri = registration.getProviderDetails().getIssuerUri(); 40 | var originUrl = request.getHeader(HttpHeaders.ORIGIN); 41 | Object[] params = {issuerUri, registration.getClientId(), originUrl}; 42 | var logoutUrl = MessageFormat.format("{0}v2/logout?client_id={1}&returnTo={2}", params); 43 | request.getSession().invalidate(); 44 | return ResponseEntity.ok().body(of("logoutUrl", logoutUrl)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | {{ title }} 11 |
12 | 13 | 19 | 20 | 21 | 27 | 28 |
29 |
30 | 31 |
32 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/model/Event.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours.model; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.GeneratedValue; 5 | import jakarta.persistence.Id; 6 | import jakarta.persistence.ManyToMany; 7 | 8 | import java.time.Instant; 9 | import java.util.Objects; 10 | import java.util.Set; 11 | 12 | @Entity 13 | public class Event { 14 | 15 | @Id 16 | @GeneratedValue 17 | private Long id; 18 | private Instant date; 19 | private String title; 20 | private String description; 21 | 22 | @ManyToMany 23 | private Set attendees; 24 | 25 | public Event() {} 26 | 27 | public Event(Instant date, String title, String description) { 28 | this.date = date; 29 | this.title = title; 30 | this.description = description; 31 | } 32 | 33 | public Long getId() { 34 | return id; 35 | } 36 | 37 | public void setId(Long id) { 38 | this.id = id; 39 | } 40 | 41 | public Instant getDate() { 42 | return date; 43 | } 44 | 45 | public void setDate(Instant date) { 46 | this.date = date; 47 | } 48 | 49 | public String getTitle() { 50 | return title; 51 | } 52 | 53 | public void setTitle(String title) { 54 | this.title = title; 55 | } 56 | 57 | public String getDescription() { 58 | return description; 59 | } 60 | 61 | public void setDescription(String description) { 62 | this.description = description; 63 | } 64 | 65 | public Set getAttendees() { 66 | return attendees; 67 | } 68 | 69 | public void setAttendees(Set attendees) { 70 | this.attendees = attendees; 71 | } 72 | 73 | @Override 74 | public boolean equals(Object o) { 75 | if (this == o) return true; 76 | if (o == null || getClass() != o.getClass()) return false; 77 | 78 | Event event = (Event) o; 79 | 80 | return Objects.equals(id, event.id); 81 | } 82 | 83 | @Override 84 | public int hashCode() { 85 | return id != null ? id.hashCode() : 0; 86 | } 87 | 88 | @Override 89 | public String toString() { 90 | return "Event{" + 91 | "id=" + id + 92 | ", date=" + date + 93 | ", title='" + title + '\'' + 94 | ", description='" + description + 95 | '}'; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /app/src/app/group-edit/group-edit.component.html: -------------------------------------------------------------------------------- 1 | 8 | 9 |

Group Information

10 | @if (feedback.message) { 11 |
{{ feedback.message }}
12 | } 13 | @if (group) { 14 |
15 | @if (group.id) { 16 | 17 | ID 18 | 19 | 20 | } 21 | 22 | Name 23 | 24 | 25 | @if (group.events.length) { 26 |

Events

27 | } 28 | @for (event of group.events; track event; let i = $index) { 29 |
30 | 31 | Date 32 | 34 | 35 | 36 | 37 | 38 | Title 39 | 40 | 41 | 45 |
46 | } 47 |
48 | @if (group.id) { 49 | 54 | } 55 | 56 | 57 |
58 |
59 | } 60 | -------------------------------------------------------------------------------- /app/src/app/group-edit/group-edit.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, Router, RouterLink } from '@angular/router'; 3 | import { map, of, switchMap } from 'rxjs'; 4 | import { Group } from '../model/group'; 5 | import { Event } from '../model/event'; 6 | import { HttpClient, HttpClientModule } from '@angular/common/http'; 7 | import { MatInputModule } from '@angular/material/input'; 8 | import { FormsModule } from '@angular/forms'; 9 | import { MatButtonModule } from '@angular/material/button'; 10 | import { MatDatepickerModule } from '@angular/material/datepicker'; 11 | import { MatIconModule } from '@angular/material/icon'; 12 | import { MatNativeDateModule } from '@angular/material/core'; 13 | import { MatTooltipModule } from '@angular/material/tooltip'; 14 | 15 | @Component({ 16 | selector: 'app-group-edit', 17 | standalone: true, 18 | imports: [ 19 | FormsModule, 20 | HttpClientModule, 21 | MatInputModule, 22 | MatButtonModule, 23 | MatDatepickerModule, 24 | MatIconModule, 25 | MatNativeDateModule, 26 | MatTooltipModule, 27 | RouterLink 28 | ], 29 | templateUrl: './group-edit.component.html', 30 | styleUrl: './group-edit.component.css' 31 | }) 32 | export class GroupEditComponent implements OnInit { 33 | group!: Group; 34 | feedback: any = {}; 35 | 36 | constructor(private route: ActivatedRoute, private router: Router, 37 | private http: HttpClient) { 38 | } 39 | 40 | ngOnInit() { 41 | this.route.params.pipe( 42 | map(p => p['id']), 43 | switchMap(id => { 44 | if (id === 'new') { 45 | return of(new Group()); 46 | } 47 | return this.http.get(`api/group/${id}`); 48 | }) 49 | ).subscribe({ 50 | next: group => { 51 | this.group = group; 52 | this.feedback = {}; 53 | }, 54 | error: () => { 55 | this.feedback = {type: 'warning', message: 'Error loading'}; 56 | } 57 | }); 58 | } 59 | 60 | save() { 61 | const id = this.group.id; 62 | const method = id ? 'put' : 'post'; 63 | 64 | this.http[method](`/api/group${id ? '/' + id : ''}`, this.group).subscribe({ 65 | next: () => { 66 | this.feedback = {type: 'success', message: 'Save was successful!'}; 67 | setTimeout(async () => { 68 | await this.router.navigate(['/groups']); 69 | }, 1000); 70 | }, 71 | error: () => { 72 | this.feedback = {type: 'error', message: 'Error saving'}; 73 | } 74 | }); 75 | } 76 | 77 | async cancel() { 78 | await this.router.navigate(['/groups']); 79 | } 80 | 81 | addEvent() { 82 | this.group.events.push(new Event()); 83 | } 84 | 85 | removeEvent(index: number) { 86 | this.group.events.splice(index, 1); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/test/java/com/okta/developer/jugtours/TestSecurityConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours; 2 | 3 | import org.springframework.boot.test.context.TestConfiguration; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService; 6 | import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; 7 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 8 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 9 | import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; 10 | import org.springframework.security.oauth2.core.AuthorizationGrantType; 11 | import org.springframework.security.oauth2.core.ClientAuthenticationMethod; 12 | import org.springframework.security.oauth2.jwt.JwtDecoder; 13 | 14 | import java.util.HashMap; 15 | import java.util.Map; 16 | 17 | import static org.mockito.Mockito.mock; 18 | 19 | /** 20 | * This class allows you to run unit and integration tests without an IdP. 21 | */ 22 | @TestConfiguration 23 | public class TestSecurityConfiguration { 24 | 25 | @Bean 26 | ClientRegistration clientRegistration() { 27 | return clientRegistrationBuilder().build(); 28 | } 29 | 30 | @Bean 31 | ClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) { 32 | return new InMemoryClientRegistrationRepository(clientRegistration); 33 | } 34 | 35 | private ClientRegistration.Builder clientRegistrationBuilder() { 36 | Map metadata = new HashMap<>(); 37 | metadata.put("end_session_endpoint", "https://example.org/logout"); 38 | 39 | return ClientRegistration.withRegistrationId("oidc") 40 | .issuerUri("{baseUrl}") 41 | .redirectUri("{baseUrl}/{action}/oauth2/code/{registrationId}") 42 | .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) 43 | .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) 44 | .scope("read:user") 45 | .authorizationUri("https://example.org/login/oauth/authorize") 46 | .tokenUri("https://example.org/login/oauth/access_token") 47 | .jwkSetUri("https://example.org/oauth/jwk") 48 | .userInfoUri("https://api.example.org/user") 49 | .providerConfigurationMetadata(metadata) 50 | .userNameAttributeName("id") 51 | .clientName("Client Name") 52 | .clientId("client-id") 53 | .clientSecret("client-secret"); 54 | } 55 | 56 | @Bean 57 | JwtDecoder jwtDecoder() { 58 | return mock(JwtDecoder.class); 59 | } 60 | 61 | @Bean 62 | OAuth2AuthorizedClientService authorizedClientService(ClientRegistrationRepository clientRegistrationRepository) { 63 | return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/web/GroupController.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours.web; 2 | 3 | import com.okta.developer.jugtours.model.Group; 4 | import com.okta.developer.jugtours.model.GroupRepository; 5 | import com.okta.developer.jugtours.model.User; 6 | import com.okta.developer.jugtours.model.UserRepository; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.http.ResponseEntity; 11 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 12 | import org.springframework.security.oauth2.core.user.OAuth2User; 13 | import org.springframework.web.bind.annotation.*; 14 | 15 | import jakarta.validation.Valid; 16 | import java.net.URI; 17 | import java.net.URISyntaxException; 18 | import java.security.Principal; 19 | import java.util.Collection; 20 | import java.util.Map; 21 | import java.util.Optional; 22 | 23 | @RestController 24 | @RequestMapping("/api") 25 | class GroupController { 26 | 27 | private final Logger log = LoggerFactory.getLogger(GroupController.class); 28 | private final GroupRepository groupRepository; 29 | private final UserRepository userRepository; 30 | 31 | public GroupController(GroupRepository groupRepository, UserRepository userRepository) { 32 | this.groupRepository = groupRepository; 33 | this.userRepository = userRepository; 34 | } 35 | 36 | @GetMapping("/groups") 37 | Collection groups(Principal principal) { 38 | return groupRepository.findAllByUserId(principal.getName()); 39 | } 40 | 41 | @GetMapping("/group/{id}") 42 | ResponseEntity getGroup(@PathVariable Long id) { 43 | Optional group = groupRepository.findById(id); 44 | return group.map(response -> ResponseEntity.ok().body(response)) 45 | .orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND)); 46 | } 47 | 48 | @PostMapping("/group") 49 | ResponseEntity createGroup(@Valid @RequestBody Group group, 50 | @AuthenticationPrincipal OAuth2User principal) throws URISyntaxException { 51 | log.info("Request to create group: {}", group); 52 | Map details = principal.getAttributes(); 53 | String userId = details.get("sub").toString(); 54 | 55 | // check to see if user already exists 56 | Optional user = userRepository.findById(userId); 57 | group.setUser(user.orElse(new User(userId, 58 | details.get("name").toString(), details.get("email").toString()))); 59 | 60 | Group result = groupRepository.save(group); 61 | return ResponseEntity.created(new URI("/api/group/" + result.getId())) 62 | .body(result); 63 | } 64 | 65 | @PutMapping("/group/{id}") 66 | ResponseEntity updateGroup(@Valid @RequestBody Group group) { 67 | log.info("Request to update group: {}", group); 68 | Group result = groupRepository.save(group); 69 | return ResponseEntity.ok().body(result); 70 | } 71 | 72 | @DeleteMapping("/group/{id}") 73 | public ResponseEntity deleteGroup(@PathVariable Long id) { 74 | log.info("Request to delete group: {}", id); 75 | groupRepository.deleteById(id); 76 | return ResponseEntity.ok().build(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/com/okta/developer/jugtours/model/Group.java: -------------------------------------------------------------------------------- 1 | package com.okta.developer.jugtours.model; 2 | 3 | import jakarta.persistence.*; 4 | import jakarta.validation.constraints.NotNull; 5 | 6 | import java.util.Objects; 7 | import java.util.Set; 8 | 9 | @Entity 10 | @Table(name = "user_group") 11 | public class Group { 12 | 13 | @Id 14 | @GeneratedValue 15 | private Long id; 16 | @NotNull 17 | private String name; 18 | private String address; 19 | private String city; 20 | private String stateOrProvince; 21 | private String country; 22 | private String postalCode; 23 | @ManyToOne(cascade = CascadeType.PERSIST) 24 | private User user; 25 | 26 | @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL) 27 | private Set events; 28 | 29 | public Group() {} 30 | 31 | public Group(String name) { 32 | this.name = name; 33 | } 34 | 35 | public Long getId() { 36 | return id; 37 | } 38 | 39 | public void setId(Long id) { 40 | this.id = id; 41 | } 42 | 43 | public String getName() { 44 | return name; 45 | } 46 | 47 | public void setName(String name) { 48 | this.name = name; 49 | } 50 | 51 | public String getAddress() { 52 | return address; 53 | } 54 | 55 | public void setAddress(String address) { 56 | this.address = address; 57 | } 58 | 59 | public String getCity() { 60 | return city; 61 | } 62 | 63 | public void setCity(String city) { 64 | this.city = city; 65 | } 66 | 67 | public String getStateOrProvince() { 68 | return stateOrProvince; 69 | } 70 | 71 | public void setStateOrProvince(String stateOrProvince) { 72 | this.stateOrProvince = stateOrProvince; 73 | } 74 | 75 | public String getCountry() { 76 | return country; 77 | } 78 | 79 | public void setCountry(String country) { 80 | this.country = country; 81 | } 82 | 83 | public String getPostalCode() { 84 | return postalCode; 85 | } 86 | 87 | public void setPostalCode(String postalCode) { 88 | this.postalCode = postalCode; 89 | } 90 | 91 | public User getUser() { 92 | return user; 93 | } 94 | 95 | public void setUser(User user) { 96 | this.user = user; 97 | } 98 | 99 | public Set getEvents() { 100 | return events; 101 | } 102 | 103 | public void setEvents(Set events) { 104 | this.events = events; 105 | } 106 | 107 | @Override 108 | public boolean equals(Object o) { 109 | if (this == o) return true; 110 | if (o == null || getClass() != o.getClass()) return false; 111 | 112 | Group group = (Group) o; 113 | 114 | return Objects.equals(id, group.id); 115 | } 116 | 117 | @Override 118 | public int hashCode() { 119 | return id != null ? id.hashCode() : 0; 120 | } 121 | 122 | @Override 123 | public String toString() { 124 | return "Group{" + 125 | "id=" + id + 126 | ", name='" + name + '\'' + 127 | ", address='" + address + '\'' + 128 | ", city='" + city + '\'' + 129 | ", stateOrProvince='" + stateOrProvince + '\'' + 130 | ", country='" + country + '\'' + 131 | ", postalCode='" + postalCode + '\'' + 132 | ", user=" + user + 133 | ", events=" + events + 134 | '}'; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Angular and Spring Boot CRUD Example 2 | 3 | This example app shows how to create a Spring Boot API and CRUD (create, read, update, and delete) its data with a beautiful Angular + Angular Material app. 4 | 5 | Please read [Build a Beautiful CRUD App with Spring Boot and Angular](https://auth0.com/blog/spring-boot-angular-crud) to see how it was created or follow [this demo script](demo.adoc). 6 | 7 | You can also watch a demo of this example in the screencast below: 8 | 9 | [![Building a CRUD app with Spring Boot and Angular!](static/spring-boot-angular.webp)](https://youtu.be/0pnSVdVn_NM) 10 | 11 | **Prerequisites:** [Java 17](http://sdkman.io) and [Node.js 18+](https://nodejs.org/) 12 | 13 | * [Getting Started](#getting-started) 14 | * [Links](#links) 15 | * [Help](#help) 16 | * [License](#license) 17 | 18 | ## Getting Started 19 | 20 | To install this example application, run the following commands: 21 | 22 | ```bash 23 | git clone https://github.com/oktadev/auth0-spring-boot-angular-crud-example.git jugtours 24 | cd jugtours 25 | ``` 26 | 27 | This will get a copy of the project installed locally. You'll need to configure the application with a registered OIDC app for it to start. Luckily, Auth0 makes this easy! 28 | 29 | ### Use Auth0 for OpenID Connect 30 | 31 | Install the [Auth0 CLI](https://github.com/auth0/auth0-cli) and run `auth0 login` in a terminal. 32 | 33 | Next, run `auth0 apps create`: 34 | 35 | ```shell 36 | auth0 apps create \ 37 | --name "Bootiful Angular" \ 38 | --description "Spring Boot + Angular = ❤️" \ 39 | --type regular \ 40 | --callbacks http://localhost:8080/login/oauth2/code/okta,http://localhost:4200/login/oauth2/code/okta \ 41 | --logout-urls http://localhost:8080,http://localhost:4200 \ 42 | --reveal-secrets 43 | ``` 44 | 45 | > **TIP**: You can also use your [Auth0 dashboard](https://manage.auth0.com) to register your application. Just make sure to use the same URLs as above. 46 | 47 | Copy the results from the CLI into an `.okta.env` file: 48 | 49 | ```shell 50 | export OKTA_OAUTH2_ISSUER=https:/// 51 | export OKTA_OAUTH2_CLIENT_ID= 52 | export OKTA_OAUTH2_CLIENT_SECRET= 53 | ``` 54 | 55 | If you're on Windows, name the file `.okta.env.bat` and use `set` instead of `export`: 56 | 57 | ```shell 58 | set OKTA_OAUTH2_ISSUER=https:/// 59 | set OKTA_OAUTH2_CLIENT_ID= 60 | set OKTA_OAUTH2_CLIENT_SECRET= 61 | ``` 62 | 63 | Then, run `source .okta.env` (or run `.okta.env.bat` on Windows) to set the environment variables. Start your app and log in at `http://localhost:8080`: 64 | 65 | ```shell 66 | source .okta.env 67 | mvn spring-boot:run -Pprod 68 | ``` 69 | 70 | You can prove everything works by running this project's Cypress tests. Add environment variables with your credentials to the `.okta.env` (or `.okta.env.bat`) file you created earlier. 71 | 72 | ```shell 73 | export CYPRESS_E2E_DOMAIN= # use the raw value, no https prefix 74 | export CYPRESS_E2E_USERNAME= 75 | export CYPRESS_E2E_PASSWORD= 76 | ``` 77 | 78 | Then, run the Cypress tests and watch them pass: 79 | 80 | ```shell 81 | source .okta.env 82 | cd app 83 | ng e2e 84 | ``` 85 | 86 | You can [view this project's CI pipeline](.github/workflows/main.yml) and see that all its [workflows are passing too](https://github.com/oktadev/auth0-spring-boot-angular-crud-example/actions). 😇 87 | 88 | ## Links 89 | 90 | This example uses the following open source libraries: 91 | 92 | * [Angular](https://angular.io) 93 | * [Angular Material](https://material.angular.io) 94 | * [Spring Boot](https://spring.io/projects/spring-boot) 95 | * [Spring Security](https://spring.io/projects/spring-security) 96 | 97 | ## Help 98 | 99 | Please post any questions as comments on the [blog post](https://auth0.com/blog/spring-boot-angular-crud), or visit our [Auth0 Community Forums](https://community.auth0.com/). 100 | 101 | ## License 102 | 103 | Apache 2.0, see [LICENSE](LICENSE). 104 | -------------------------------------------------------------------------------- /app/src/app/app.component.css: -------------------------------------------------------------------------------- 1 | :host { 2 | --bright-blue: oklch(51.01% 0.274 263.83); 3 | --electric-violet: oklch(53.18% 0.28 296.97); 4 | --french-violet: oklch(47.66% 0.246 305.88); 5 | --vivid-pink: oklch(69.02% 0.277 332.77); 6 | --hot-red: oklch(61.42% 0.238 15.34); 7 | --orange-red: oklch(63.32% 0.24 31.68); 8 | 9 | --gray-900: oklch(19.37% 0.006 300.98); 10 | --gray-700: oklch(36.98% 0.014 302.71); 11 | --gray-400: oklch(70.9% 0.015 304.04); 12 | 13 | --red-to-pink-to-purple-vertical-gradient: linear-gradient( 14 | 180deg, 15 | var(--orange-red) 0%, 16 | var(--vivid-pink) 50%, 17 | var(--electric-violet) 100% 18 | ); 19 | 20 | --red-to-pink-to-purple-horizontal-gradient: linear-gradient( 21 | 90deg, 22 | var(--orange-red) 0%, 23 | var(--vivid-pink) 50%, 24 | var(--electric-violet) 100% 25 | ); 26 | 27 | --pill-accent: var(--bright-blue); 28 | 29 | font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 30 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 31 | "Segoe UI Symbol"; 32 | box-sizing: border-box; 33 | -webkit-font-smoothing: antialiased; 34 | -moz-osx-font-smoothing: grayscale; 35 | } 36 | 37 | h1 { 38 | font-size: 3.125rem; 39 | color: var(--gray-900); 40 | font-weight: 500; 41 | line-height: 100%; 42 | letter-spacing: -0.125rem; 43 | margin: 0; 44 | font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 45 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 46 | "Segoe UI Symbol"; 47 | } 48 | 49 | p { 50 | margin: 0; 51 | color: var(--gray-700); 52 | } 53 | 54 | main { 55 | width: 100%; 56 | min-height: 100%; 57 | display: flex; 58 | justify-content: center; 59 | align-items: center; 60 | padding: 1rem; 61 | box-sizing: inherit; 62 | position: relative; 63 | } 64 | 65 | .angular-logo { 66 | max-width: 9.2rem; 67 | } 68 | 69 | .content { 70 | display: flex; 71 | margin: 10px auto; 72 | padding: 0 16px; 73 | max-width: 960px; 74 | flex-direction: column; 75 | align-items: stretch; 76 | } 77 | 78 | .content h1 { 79 | margin-top: 1.75rem; 80 | } 81 | 82 | .content p { 83 | margin-top: 1.5rem; 84 | } 85 | 86 | .divider { 87 | width: 1px; 88 | background: var(--red-to-pink-to-purple-vertical-gradient); 89 | margin-inline: 0.5rem; 90 | } 91 | 92 | .pill-group { 93 | display: flex; 94 | flex-direction: column; 95 | align-items: start; 96 | flex-wrap: wrap; 97 | gap: 1.25rem; 98 | } 99 | 100 | .pill { 101 | display: flex; 102 | align-items: center; 103 | --pill-accent: var(--bright-blue); 104 | background: color-mix(in srgb, var(--pill-accent) 5%, transparent); 105 | color: var(--pill-accent); 106 | padding-inline: 0.75rem; 107 | padding-block: 0.375rem; 108 | border-radius: 2.75rem; 109 | border: 0; 110 | transition: background 0.3s ease; 111 | font-family: var(--inter-font); 112 | font-size: 0.875rem; 113 | font-style: normal; 114 | font-weight: 500; 115 | line-height: 1.4rem; 116 | letter-spacing: -0.00875rem; 117 | text-decoration: none; 118 | } 119 | 120 | .pill:hover { 121 | background: color-mix(in srgb, var(--pill-accent) 15%, transparent); 122 | } 123 | 124 | .pill-group .pill:nth-child(6n + 1) { 125 | --pill-accent: var(--bright-blue); 126 | } 127 | .pill-group .pill:nth-child(6n + 2) { 128 | --pill-accent: var(--french-violet); 129 | } 130 | .pill-group .pill:nth-child(6n + 3), 131 | .pill-group .pill:nth-child(6n + 4), 132 | .pill-group .pill:nth-child(6n + 5) { 133 | --pill-accent: var(--hot-red); 134 | } 135 | 136 | .pill-group svg { 137 | margin-inline-start: 0.25rem; 138 | } 139 | 140 | .social-links { 141 | display: flex; 142 | align-items: center; 143 | gap: 0.73rem; 144 | margin-top: .5rem; 145 | } 146 | 147 | .spacer { 148 | flex: 1 1 auto 149 | } 150 | 151 | @media screen and (max-width: 650px) { 152 | .content { 153 | flex-direction: column; 154 | width: max-content; 155 | } 156 | 157 | .divider { 158 | height: 1px; 159 | width: 100%; 160 | background: var(--red-to-pink-to-purple-horizontal-gradient); 161 | margin-block: 1.5rem; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /app/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "app": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:application", 15 | "options": { 16 | "outputPath": "dist/app", 17 | "index": "src/index.html", 18 | "browser": "src/main.ts", 19 | "polyfills": [ 20 | "zone.js" 21 | ], 22 | "tsConfig": "tsconfig.app.json", 23 | "assets": [ 24 | "src/favicon.ico", 25 | "src/assets" 26 | ], 27 | "styles": [ 28 | "@angular/material/prebuilt-themes/indigo-pink.css", 29 | "src/styles.css" 30 | ], 31 | "scripts": [] 32 | }, 33 | "configurations": { 34 | "production": { 35 | "budgets": [ 36 | { 37 | "type": "initial", 38 | "maximumWarning": "500kb", 39 | "maximumError": "1mb" 40 | }, 41 | { 42 | "type": "anyComponentStyle", 43 | "maximumWarning": "2kb", 44 | "maximumError": "4kb" 45 | } 46 | ], 47 | "outputHashing": "all" 48 | }, 49 | "development": { 50 | "optimization": false, 51 | "extractLicenses": false, 52 | "sourceMap": true 53 | } 54 | }, 55 | "defaultConfiguration": "production" 56 | }, 57 | "serve": { 58 | "builder": "@angular-devkit/build-angular:dev-server", 59 | "configurations": { 60 | "production": { 61 | "buildTarget": "app:build:production" 62 | }, 63 | "development": { 64 | "buildTarget": "app:build:development", 65 | "proxyConfig": "src/proxy.conf.js" 66 | } 67 | }, 68 | "defaultConfiguration": "development" 69 | }, 70 | "extract-i18n": { 71 | "builder": "@angular-devkit/build-angular:extract-i18n", 72 | "options": { 73 | "buildTarget": "app:build" 74 | } 75 | }, 76 | "test": { 77 | "builder": "@angular-devkit/build-angular:karma", 78 | "options": { 79 | "polyfills": [ 80 | "zone.js", 81 | "zone.js/testing" 82 | ], 83 | "tsConfig": "tsconfig.spec.json", 84 | "assets": [ 85 | "src/favicon.ico", 86 | "src/assets" 87 | ], 88 | "styles": [ 89 | "@angular/material/prebuilt-themes/indigo-pink.css", 90 | "src/styles.css" 91 | ], 92 | "scripts": [] 93 | } 94 | }, 95 | "cypress-run": { 96 | "builder": "@cypress/schematic:cypress", 97 | "options": { 98 | "devServerTarget": "app:serve" 99 | }, 100 | "configurations": { 101 | "production": { 102 | "devServerTarget": "app:serve:production" 103 | } 104 | } 105 | }, 106 | "cypress-open": { 107 | "builder": "@cypress/schematic:cypress", 108 | "options": { 109 | "watch": true, 110 | "headless": false 111 | } 112 | }, 113 | "ct": { 114 | "builder": "@cypress/schematic:cypress", 115 | "options": { 116 | "devServerTarget": "app:serve", 117 | "watch": true, 118 | "headless": false, 119 | "testingType": "component" 120 | }, 121 | "configurations": { 122 | "development": { 123 | "devServerTarget": "app:serve:development" 124 | } 125 | } 126 | }, 127 | "e2e": { 128 | "builder": "@cypress/schematic:cypress", 129 | "options": { 130 | "devServerTarget": "app:serve", 131 | "watch": true, 132 | "headless": false 133 | }, 134 | "configurations": { 135 | "production": { 136 | "devServerTarget": "app:serve:production" 137 | } 138 | } 139 | } 140 | } 141 | } 142 | }, 143 | "cli": { 144 | "schematicCollections": [ 145 | "@cypress/schematic", 146 | "@schematics/angular" 147 | ] 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.2.0 9 | 10 | 11 | com.okta.developer 12 | jugtours 13 | 0.0.1-SNAPSHOT 14 | jugtours 15 | Track your JUG Tours! 16 | 17 | 17 18 | 1.15.0 19 | v18.18.2 20 | 9.8.1 21 | 22 | 23 | 24 | org.springframework.boot 25 | spring-boot-starter-data-jpa 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-starter-validation 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-web 34 | 35 | 36 | com.okta.spring 37 | okta-spring-boot-starter 38 | 3.0.6 39 | 40 | 41 | com.h2database 42 | h2 43 | runtime 44 | 45 | 46 | org.springframework.boot 47 | spring-boot-starter-test 48 | test 49 | 50 | 51 | 52 | 53 | spring-boot:run 54 | 55 | 56 | org.springframework.boot 57 | spring-boot-maven-plugin 58 | 59 | 60 | 61 | 62 | 63 | 64 | dev 65 | 66 | true 67 | 68 | 69 | dev 70 | 71 | 72 | 73 | prod 74 | 75 | 76 | 77 | maven-resources-plugin 78 | 79 | 80 | copy-resources 81 | process-classes 82 | 83 | copy-resources 84 | 85 | 86 | ${basedir}/target/classes/static 87 | 88 | 89 | app/dist/app/browser 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | com.github.eirslett 98 | frontend-maven-plugin 99 | ${frontend-maven-plugin.version} 100 | 101 | app 102 | 103 | 104 | 105 | install node 106 | 107 | install-node-and-npm 108 | 109 | 110 | ${node.version} 111 | ${npm.version} 112 | 113 | 114 | 115 | npm install 116 | 117 | npm 118 | 119 | generate-resources 120 | 121 | 122 | npm test 123 | 124 | npm 125 | 126 | test 127 | 128 | test -- --watch=false 129 | 130 | 131 | 132 | npm build 133 | 134 | npm 135 | 136 | compile 137 | 138 | run build 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | prod 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM http://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Apache Maven Wrapper startup batch script, version 3.2.0 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 28 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 29 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 30 | @REM e.g. to debug Maven itself, use 31 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 32 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 33 | @REM ---------------------------------------------------------------------------- 34 | 35 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 36 | @echo off 37 | @REM set title of command window 38 | title %0 39 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 40 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 41 | 42 | @REM set %HOME% to equivalent of $HOME 43 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 44 | 45 | @REM Execute a user defined script before this one 46 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 47 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 48 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 49 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 50 | :skipRcPre 51 | 52 | @setlocal 53 | 54 | set ERROR_CODE=0 55 | 56 | @REM To isolate internal variables from possible post scripts, we use another setlocal 57 | @setlocal 58 | 59 | @REM ==== START VALIDATION ==== 60 | if not "%JAVA_HOME%" == "" goto OkJHome 61 | 62 | echo. 63 | echo Error: JAVA_HOME not found in your environment. >&2 64 | echo Please set the JAVA_HOME variable in your environment to match the >&2 65 | echo location of your Java installation. >&2 66 | echo. 67 | goto error 68 | 69 | :OkJHome 70 | if exist "%JAVA_HOME%\bin\java.exe" goto init 71 | 72 | echo. 73 | echo Error: JAVA_HOME is set to an invalid directory. >&2 74 | echo JAVA_HOME = "%JAVA_HOME%" >&2 75 | echo Please set the JAVA_HOME variable in your environment to match the >&2 76 | echo location of your Java installation. >&2 77 | echo. 78 | goto error 79 | 80 | @REM ==== END VALIDATION ==== 81 | 82 | :init 83 | 84 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 85 | @REM Fallback to current working directory if not found. 86 | 87 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 88 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 89 | 90 | set EXEC_DIR=%CD% 91 | set WDIR=%EXEC_DIR% 92 | :findBaseDir 93 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 94 | cd .. 95 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 96 | set WDIR=%CD% 97 | goto findBaseDir 98 | 99 | :baseDirFound 100 | set MAVEN_PROJECTBASEDIR=%WDIR% 101 | cd "%EXEC_DIR%" 102 | goto endDetectBaseDir 103 | 104 | :baseDirNotFound 105 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 106 | cd "%EXEC_DIR%" 107 | 108 | :endDetectBaseDir 109 | 110 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 111 | 112 | @setlocal EnableExtensions EnableDelayedExpansion 113 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 114 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 115 | 116 | :endReadAdditionalConfig 117 | 118 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 123 | 124 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 125 | IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B 126 | ) 127 | 128 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 129 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 130 | if exist %WRAPPER_JAR% ( 131 | if "%MVNW_VERBOSE%" == "true" ( 132 | echo Found %WRAPPER_JAR% 133 | ) 134 | ) else ( 135 | if not "%MVNW_REPOURL%" == "" ( 136 | SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 137 | ) 138 | if "%MVNW_VERBOSE%" == "true" ( 139 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 140 | echo Downloading from: %WRAPPER_URL% 141 | ) 142 | 143 | powershell -Command "&{"^ 144 | "$webclient = new-object System.Net.WebClient;"^ 145 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 146 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 147 | "}"^ 148 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ 149 | "}" 150 | if "%MVNW_VERBOSE%" == "true" ( 151 | echo Finished downloading %WRAPPER_JAR% 152 | ) 153 | ) 154 | @REM End of extension 155 | 156 | @REM If specified, validate the SHA-256 sum of the Maven wrapper jar file 157 | SET WRAPPER_SHA_256_SUM="" 158 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 159 | IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B 160 | ) 161 | IF NOT %WRAPPER_SHA_256_SUM%=="" ( 162 | powershell -Command "&{"^ 163 | "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ 164 | "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ 165 | " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ 166 | " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ 167 | " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ 168 | " exit 1;"^ 169 | "}"^ 170 | "}" 171 | if ERRORLEVEL 1 goto error 172 | ) 173 | 174 | @REM Provide a "standardized" way to retrieve the CLI args that will 175 | @REM work with both Windows and non-Windows executions. 176 | set MAVEN_CMD_LINE_ARGS=%* 177 | 178 | %MAVEN_JAVA_EXE% ^ 179 | %JVM_CONFIG_MAVEN_PROPS% ^ 180 | %MAVEN_OPTS% ^ 181 | %MAVEN_DEBUG_OPTS% ^ 182 | -classpath %WRAPPER_JAR% ^ 183 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 184 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 185 | if ERRORLEVEL 1 goto error 186 | goto end 187 | 188 | :error 189 | set ERROR_CODE=1 190 | 191 | :end 192 | @endlocal & set ERROR_CODE=%ERROR_CODE% 193 | 194 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 195 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 196 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 197 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 198 | :skipRcPost 199 | 200 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 201 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 202 | 203 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 204 | 205 | cmd /C exit /B %ERROR_CODE% 206 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023-Present Okta, Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.2.0 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | # e.g. to debug Maven itself, use 32 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | # ---------------------------------------------------------------------------- 35 | 36 | if [ -z "$MAVEN_SKIP_RC" ] ; then 37 | 38 | if [ -f /usr/local/etc/mavenrc ] ; then 39 | . /usr/local/etc/mavenrc 40 | fi 41 | 42 | if [ -f /etc/mavenrc ] ; then 43 | . /etc/mavenrc 44 | fi 45 | 46 | if [ -f "$HOME/.mavenrc" ] ; then 47 | . "$HOME/.mavenrc" 48 | fi 49 | 50 | fi 51 | 52 | # OS specific support. $var _must_ be set to either true or false. 53 | cygwin=false; 54 | darwin=false; 55 | mingw=false 56 | case "$(uname)" in 57 | CYGWIN*) cygwin=true ;; 58 | MINGW*) mingw=true;; 59 | Darwin*) darwin=true 60 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 61 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 62 | if [ -z "$JAVA_HOME" ]; then 63 | if [ -x "/usr/libexec/java_home" ]; then 64 | JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME 65 | else 66 | JAVA_HOME="/Library/Java/Home"; export JAVA_HOME 67 | fi 68 | fi 69 | ;; 70 | esac 71 | 72 | if [ -z "$JAVA_HOME" ] ; then 73 | if [ -r /etc/gentoo-release ] ; then 74 | JAVA_HOME=$(java-config --jre-home) 75 | fi 76 | fi 77 | 78 | # For Cygwin, ensure paths are in UNIX format before anything is touched 79 | if $cygwin ; then 80 | [ -n "$JAVA_HOME" ] && 81 | JAVA_HOME=$(cygpath --unix "$JAVA_HOME") 82 | [ -n "$CLASSPATH" ] && 83 | CLASSPATH=$(cygpath --path --unix "$CLASSPATH") 84 | fi 85 | 86 | # For Mingw, ensure paths are in UNIX format before anything is touched 87 | if $mingw ; then 88 | [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && 89 | JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" 90 | fi 91 | 92 | if [ -z "$JAVA_HOME" ]; then 93 | javaExecutable="$(which javac)" 94 | if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then 95 | # readlink(1) is not available as standard on Solaris 10. 96 | readLink=$(which readlink) 97 | if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then 98 | if $darwin ; then 99 | javaHome="$(dirname "\"$javaExecutable\"")" 100 | javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" 101 | else 102 | javaExecutable="$(readlink -f "\"$javaExecutable\"")" 103 | fi 104 | javaHome="$(dirname "\"$javaExecutable\"")" 105 | javaHome=$(expr "$javaHome" : '\(.*\)/bin') 106 | JAVA_HOME="$javaHome" 107 | export JAVA_HOME 108 | fi 109 | fi 110 | fi 111 | 112 | if [ -z "$JAVACMD" ] ; then 113 | if [ -n "$JAVA_HOME" ] ; then 114 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 115 | # IBM's JDK on AIX uses strange locations for the executables 116 | JAVACMD="$JAVA_HOME/jre/sh/java" 117 | else 118 | JAVACMD="$JAVA_HOME/bin/java" 119 | fi 120 | else 121 | JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" 122 | fi 123 | fi 124 | 125 | if [ ! -x "$JAVACMD" ] ; then 126 | echo "Error: JAVA_HOME is not defined correctly." >&2 127 | echo " We cannot execute $JAVACMD" >&2 128 | exit 1 129 | fi 130 | 131 | if [ -z "$JAVA_HOME" ] ; then 132 | echo "Warning: JAVA_HOME environment variable is not set." 133 | fi 134 | 135 | # traverses directory structure from process work directory to filesystem root 136 | # first directory with .mvn subdirectory is considered project base directory 137 | find_maven_basedir() { 138 | if [ -z "$1" ] 139 | then 140 | echo "Path not specified to find_maven_basedir" 141 | return 1 142 | fi 143 | 144 | basedir="$1" 145 | wdir="$1" 146 | while [ "$wdir" != '/' ] ; do 147 | if [ -d "$wdir"/.mvn ] ; then 148 | basedir=$wdir 149 | break 150 | fi 151 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 152 | if [ -d "${wdir}" ]; then 153 | wdir=$(cd "$wdir/.." || exit 1; pwd) 154 | fi 155 | # end of workaround 156 | done 157 | printf '%s' "$(cd "$basedir" || exit 1; pwd)" 158 | } 159 | 160 | # concatenates all lines of a file 161 | concat_lines() { 162 | if [ -f "$1" ]; then 163 | # Remove \r in case we run on Windows within Git Bash 164 | # and check out the repository with auto CRLF management 165 | # enabled. Otherwise, we may read lines that are delimited with 166 | # \r\n and produce $'-Xarg\r' rather than -Xarg due to word 167 | # splitting rules. 168 | tr -s '\r\n' ' ' < "$1" 169 | fi 170 | } 171 | 172 | log() { 173 | if [ "$MVNW_VERBOSE" = true ]; then 174 | printf '%s\n' "$1" 175 | fi 176 | } 177 | 178 | BASE_DIR=$(find_maven_basedir "$(dirname "$0")") 179 | if [ -z "$BASE_DIR" ]; then 180 | exit 1; 181 | fi 182 | 183 | MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR 184 | log "$MAVEN_PROJECTBASEDIR" 185 | 186 | ########################################################################################## 187 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 188 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 189 | ########################################################################################## 190 | wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" 191 | if [ -r "$wrapperJarPath" ]; then 192 | log "Found $wrapperJarPath" 193 | else 194 | log "Couldn't find $wrapperJarPath, downloading it ..." 195 | 196 | if [ -n "$MVNW_REPOURL" ]; then 197 | wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 198 | else 199 | wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 200 | fi 201 | while IFS="=" read -r key value; do 202 | # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) 203 | safeValue=$(echo "$value" | tr -d '\r') 204 | case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; 205 | esac 206 | done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" 207 | log "Downloading from: $wrapperUrl" 208 | 209 | if $cygwin; then 210 | wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") 211 | fi 212 | 213 | if command -v wget > /dev/null; then 214 | log "Found wget ... using wget" 215 | [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" 216 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 217 | wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 218 | else 219 | wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 220 | fi 221 | elif command -v curl > /dev/null; then 222 | log "Found curl ... using curl" 223 | [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" 224 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 225 | curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" 226 | else 227 | curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" 228 | fi 229 | else 230 | log "Falling back to using Java to download" 231 | javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" 232 | javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" 233 | # For Cygwin, switch paths to Windows format before running javac 234 | if $cygwin; then 235 | javaSource=$(cygpath --path --windows "$javaSource") 236 | javaClass=$(cygpath --path --windows "$javaClass") 237 | fi 238 | if [ -e "$javaSource" ]; then 239 | if [ ! -e "$javaClass" ]; then 240 | log " - Compiling MavenWrapperDownloader.java ..." 241 | ("$JAVA_HOME/bin/javac" "$javaSource") 242 | fi 243 | if [ -e "$javaClass" ]; then 244 | log " - Running MavenWrapperDownloader.java ..." 245 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" 246 | fi 247 | fi 248 | fi 249 | fi 250 | ########################################################################################## 251 | # End of extension 252 | ########################################################################################## 253 | 254 | # If specified, validate the SHA-256 sum of the Maven wrapper jar file 255 | wrapperSha256Sum="" 256 | while IFS="=" read -r key value; do 257 | case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; 258 | esac 259 | done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" 260 | if [ -n "$wrapperSha256Sum" ]; then 261 | wrapperSha256Result=false 262 | if command -v sha256sum > /dev/null; then 263 | if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then 264 | wrapperSha256Result=true 265 | fi 266 | elif command -v shasum > /dev/null; then 267 | if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then 268 | wrapperSha256Result=true 269 | fi 270 | else 271 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." 272 | echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." 273 | exit 1 274 | fi 275 | if [ $wrapperSha256Result = false ]; then 276 | echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 277 | echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 278 | echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 279 | exit 1 280 | fi 281 | fi 282 | 283 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 284 | 285 | # For Cygwin, switch paths to Windows format before running java 286 | if $cygwin; then 287 | [ -n "$JAVA_HOME" ] && 288 | JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") 289 | [ -n "$CLASSPATH" ] && 290 | CLASSPATH=$(cygpath --path --windows "$CLASSPATH") 291 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 292 | MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") 293 | fi 294 | 295 | # Provide a "standardized" way to retrieve the CLI args that will 296 | # work with both Windows and non-Windows executions. 297 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" 298 | export MAVEN_CMD_LINE_ARGS 299 | 300 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 301 | 302 | # shellcheck disable=SC2086 # safe args 303 | exec "$JAVACMD" \ 304 | $MAVEN_OPTS \ 305 | $MAVEN_DEBUG_OPTS \ 306 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 307 | "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 308 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 309 | -------------------------------------------------------------------------------- /demo.adoc: -------------------------------------------------------------------------------- 1 | :experimental: 2 | :commandkey: ⌘ 3 | :toc: macro 4 | :source-highlighter: highlight.js 5 | 6 | = Build a Beautiful CRUD App with Spring Boot and Angular 7 | 8 | In this demo, you'll see how to build a secure full stack CRUD app with Spring Boot and Angular. The final result will use OAuth 2.0 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. 9 | 10 | Features: 11 | 12 | 💖 Beauty by Angular Material + 13 | 🪺 Editing nested objects + 14 | 🔐 Most secure OAuth security + 15 | 🧪 E2E testing with Cypress + 16 | ✅ GitHub Actions to prove it works 17 | 18 | _Check the description below this video for links to the blog post and this demo script._ 19 | 20 | **Prerequisites**: 21 | 22 | - https://adoptium.net/[Java 17]: I recommend using https://sdkman.io/[SDKMAN!] to manage and install multiple versions of Java. 23 | - https://nodejs.org/[Node 18]: I recommend using https://github.com/nvm-sh/nvm[Node Version Manager] to manage multiple versions of Node. 24 | - https://httpie.io/cli[HTTPie] 25 | - https://github.com/auth0/auth0-cli#installation[Auth0 CLI] and https://auth0.com/signup[an Auth0 account] 26 | 27 | toc::[] 28 | 29 | [TIP] 30 | ==== 31 | The brackets at the end of some steps indicate the IntelliJ Live Templates to use. You can find the template definitions at https://github.com/mraible/idea-live-templates[mraible/idea-live-templates]. 32 | 33 | You can also expand the file names to see the full code. 34 | ==== 35 | 36 | **Fast Track**: https://github.com/oktadev/auth0-spring-boot-angular-crud-example[Clone the repo] and follow the instructions in its `README` to configure everything. _Show it running_. 37 | 38 | == Create a Java REST API with Spring Boot 39 | 40 | The easiest way to create a new Spring Boot app is to navigate to https://start.spring.io[start.spring.io] and make the following selections: 41 | 42 | * **Project:** `Maven Project` 43 | * **Group:** `com.okta.developer` 44 | * **Artifact:** `jugtours` 45 | * **Dependencies**: `JPA`, `H2`, `Web`, `Validation` 46 | 47 | Click **Generate Project**, expand `jugtours.zip` after downloading, and open the project in your favorite IDE. 48 | 49 | You can also use https://start.spring.io/#!type=maven-project&language=java&platformVersion=3.2.0&packaging=jar&jvmVersion=17&groupId=com.okta.developer&artifactId=jugtours&name=jugtours&description=Track%20your%20JUG%20Tours!&packageName=com.okta.developer.jugtours&dependencies=data-jpa,h2,web,validation[this link] or HTTPie to create the project from the command line: 50 | 51 | [source,shell] 52 | ---- 53 | https start.spring.io/starter.tgz type==maven-project bootVersion==3.2.0 \ 54 | dependencies==data-jpa,h2,web,validation \ 55 | language==java platformVersion==17 \ 56 | name==jugtours artifactId==jugtours \ 57 | groupId==com.okta.developer packageName==com.okta.developer.jugtours \ 58 | baseDir==jugtours | tar -xzvf - 59 | ---- 60 | 61 | === Add a JPA domain model 62 | 63 | . 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. [`sba-group`] 64 | + 65 | .`Group.java` 66 | [%collapsible] 67 | ==== 68 | [source,java] 69 | ---- 70 | package com.okta.developer.jugtours.model; 71 | 72 | import jakarta.persistence.*; 73 | import jakarta.validation.constraints.NotNull; 74 | 75 | import java.util.Objects; 76 | import java.util.Set; 77 | 78 | @Entity 79 | @Table(name = "user_group") 80 | public class Group { 81 | 82 | @Id 83 | @GeneratedValue 84 | private Long id; 85 | @NotNull 86 | private String name; 87 | private String address; 88 | private String city; 89 | private String stateOrProvince; 90 | private String country; 91 | private String postalCode; 92 | @ManyToOne(cascade = CascadeType.PERSIST) 93 | private User user; 94 | 95 | @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL) 96 | private Set events; 97 | 98 | public Group() {} 99 | 100 | public Group(String name) { 101 | this.name = name; 102 | } 103 | 104 | public Long getId() { 105 | return id; 106 | } 107 | 108 | public void setId(Long id) { 109 | this.id = id; 110 | } 111 | 112 | public String getName() { 113 | return name; 114 | } 115 | 116 | public void setName(String name) { 117 | this.name = name; 118 | } 119 | 120 | public String getAddress() { 121 | return address; 122 | } 123 | 124 | public void setAddress(String address) { 125 | this.address = address; 126 | } 127 | 128 | public String getCity() { 129 | return city; 130 | } 131 | 132 | public void setCity(String city) { 133 | this.city = city; 134 | } 135 | 136 | public String getStateOrProvince() { 137 | return stateOrProvince; 138 | } 139 | 140 | public void setStateOrProvince(String stateOrProvince) { 141 | this.stateOrProvince = stateOrProvince; 142 | } 143 | 144 | public String getCountry() { 145 | return country; 146 | } 147 | 148 | public void setCountry(String country) { 149 | this.country = country; 150 | } 151 | 152 | public String getPostalCode() { 153 | return postalCode; 154 | } 155 | 156 | public void setPostalCode(String postalCode) { 157 | this.postalCode = postalCode; 158 | } 159 | 160 | public User getUser() { 161 | return user; 162 | } 163 | 164 | public void setUser(User user) { 165 | this.user = user; 166 | } 167 | 168 | public Set getEvents() { 169 | return events; 170 | } 171 | 172 | public void setEvents(Set events) { 173 | this.events = events; 174 | } 175 | 176 | @Override 177 | public boolean equals(Object o) { 178 | if (this == o) return true; 179 | if (o == null || getClass() != o.getClass()) return false; 180 | 181 | Group group = (Group) o; 182 | 183 | return Objects.equals(id, group.id); 184 | } 185 | 186 | @Override 187 | public int hashCode() { 188 | return id != null ? id.hashCode() : 0; 189 | } 190 | 191 | @Override 192 | public String toString() { 193 | return "Group{" + 194 | "id=" + id + 195 | ", name='" + name + '\'' + 196 | ", address='" + address + '\'' + 197 | ", city='" + city + '\'' + 198 | ", stateOrProvince='" + stateOrProvince + '\'' + 199 | ", country='" + country + '\'' + 200 | ", postalCode='" + postalCode + '\'' + 201 | ", user=" + user + 202 | ", events=" + events + 203 | '}'; 204 | } 205 | } 206 | ---- 207 | ==== 208 | 209 | . Create an `Event.java` class in the same package. [`sba-event`] 210 | + 211 | .`.Event.java` 212 | [%collapsible] 213 | ==== 214 | [source,java] 215 | ---- 216 | package com.okta.developer.jugtours.model; 217 | 218 | import jakarta.persistence.Entity; 219 | import jakarta.persistence.GeneratedValue; 220 | import jakarta.persistence.Id; 221 | import jakarta.persistence.ManyToMany; 222 | 223 | import java.time.Instant; 224 | import java.util.Objects; 225 | import java.util.Set; 226 | 227 | @Entity 228 | public class Event { 229 | 230 | @Id 231 | @GeneratedValue 232 | private Long id; 233 | private Instant date; 234 | private String title; 235 | private String description; 236 | 237 | @ManyToMany 238 | private Set attendees; 239 | 240 | public Event() {} 241 | 242 | public Event(Instant date, String title, String description) { 243 | this.date = date; 244 | this.title = title; 245 | this.description = description; 246 | } 247 | 248 | public Long getId() { 249 | return id; 250 | } 251 | 252 | public void setId(Long id) { 253 | this.id = id; 254 | } 255 | 256 | public Instant getDate() { 257 | return date; 258 | } 259 | 260 | public void setDate(Instant date) { 261 | this.date = date; 262 | } 263 | 264 | public String getTitle() { 265 | return title; 266 | } 267 | 268 | public void setTitle(String title) { 269 | this.title = title; 270 | } 271 | 272 | public String getDescription() { 273 | return description; 274 | } 275 | 276 | public void setDescription(String description) { 277 | this.description = description; 278 | } 279 | 280 | public Set getAttendees() { 281 | return attendees; 282 | } 283 | 284 | public void setAttendees(Set attendees) { 285 | this.attendees = attendees; 286 | } 287 | 288 | @Override 289 | public boolean equals(Object o) { 290 | if (this == o) return true; 291 | if (o == null || getClass() != o.getClass()) return false; 292 | 293 | Event event = (Event) o; 294 | 295 | return Objects.equals(id, event.id); 296 | } 297 | 298 | @Override 299 | public int hashCode() { 300 | return id != null ? id.hashCode() : 0; 301 | } 302 | 303 | @Override 304 | public String toString() { 305 | return "Event{" + 306 | "id=" + id + 307 | ", date=" + date + 308 | ", title='" + title + '\'' + 309 | ", description='" + description + 310 | '}'; 311 | } 312 | } 313 | ---- 314 | ==== 315 | 316 | . And a `User.java` class. [`sba-user`] 317 | + 318 | .`User.java` 319 | [%collapsible] 320 | ==== 321 | [source,java] 322 | ---- 323 | package com.okta.developer.jugtours.model; 324 | 325 | import jakarta.persistence.Entity; 326 | import jakarta.persistence.Id; 327 | import jakarta.persistence.Table; 328 | 329 | import java.util.Objects; 330 | 331 | @Entity 332 | @Table(name = "users") 333 | public class User { 334 | 335 | @Id 336 | private String id; 337 | private String name; 338 | private String email; 339 | 340 | public User() {} 341 | 342 | public User(String id, String name, String email) { 343 | this.id = id; 344 | this.name = name; 345 | this.email = email; 346 | } 347 | 348 | public String getId() { 349 | return id; 350 | } 351 | 352 | public void setId(String id) { 353 | this.id = id; 354 | } 355 | 356 | public String getName() { 357 | return name; 358 | } 359 | 360 | public void setName(String name) { 361 | this.name = name; 362 | } 363 | 364 | public String getEmail() { 365 | return email; 366 | } 367 | 368 | public void setEmail(String email) { 369 | this.email = email; 370 | } 371 | 372 | @Override 373 | public boolean equals(Object o) { 374 | if (this == o) return true; 375 | if (o == null || getClass() != o.getClass()) return false; 376 | 377 | User user = (User) o; 378 | 379 | return Objects.equals(id, user.id); 380 | } 381 | 382 | @Override 383 | public int hashCode() { 384 | return id != null ? id.hashCode() : 0; 385 | } 386 | 387 | @Override 388 | public String toString() { 389 | return "User{" + 390 | "id='" + id + '\'' + 391 | ", name='" + name + '\'' + 392 | ", email='" + email + '\'' + 393 | '}'; 394 | } 395 | } 396 | ---- 397 | ==== 398 | 399 | . Create a `GroupRepository.java` interface to manage the group entity. [`sba-group-repo`] 400 | + 401 | .`GroupRepository.java` 402 | [%collapsible] 403 | ==== 404 | [source,java] 405 | ---- 406 | package com.okta.developer.jugtours.model; 407 | 408 | import org.springframework.data.jpa.repository.JpaRepository; 409 | 410 | import java.util.List; 411 | 412 | public interface GroupRepository extends JpaRepository { 413 | Group findByName(String name); 414 | } 415 | ---- 416 | ==== 417 | 418 | . To load some default data, create an `Initializer.java` class in the `com.okta.developer.jugtours` package. [`sba-init`] 419 | + 420 | .`Initializer.java` 421 | [%collapsible] 422 | ==== 423 | [source,java] 424 | ---- 425 | package com.okta.developer.jugtours; 426 | 427 | import com.okta.developer.jugtours.model.Event; 428 | import com.okta.developer.jugtours.model.Group; 429 | import com.okta.developer.jugtours.model.GroupRepository; 430 | import org.springframework.boot.CommandLineRunner; 431 | import org.springframework.stereotype.Component; 432 | 433 | import java.time.Instant; 434 | import java.util.Collections; 435 | import java.util.stream.Stream; 436 | 437 | @Component 438 | class Initializer implements CommandLineRunner { 439 | 440 | private final GroupRepository repository; 441 | 442 | public Initializer(GroupRepository repository) { 443 | this.repository = repository; 444 | } 445 | 446 | @Override 447 | public void run(String... strings) { 448 | Stream.of("Utah JUG", "Dallas JUG", "Tampa JUG", "Nashville JUG", "Detroit JUG") 449 | .forEach(name -> repository.save(new Group(name))); 450 | 451 | Group jug = repository.findByName("Tampa JUG"); 452 | Event e = new Event(Instant.parse("2024-04-24T18:00:00.000Z"), 453 | "What the Heck is OAuth?", 454 | "Learn how and where OAuth can benefit your applications."); 455 | jug.setEvents(Collections.singleton(e)); 456 | repository.save(jug); 457 | 458 | repository.findAll().forEach(System.out::println); 459 | } 460 | } 461 | ---- 462 | ==== 463 | 464 | . Start your app with `mvn spring-boot:run`, and you should see groups and events being created. 465 | 466 | . Add a `GroupController.java` class (in `src/main/java/.../jugtours/web`) that allows you to CRUD groups. [`sba-group-controller`] 467 | + 468 | .`GroupController.java` 469 | [%collapsible] 470 | ==== 471 | [source,java] 472 | ---- 473 | package com.okta.developer.jugtours.web; 474 | 475 | import com.okta.developer.jugtours.model.Group; 476 | import com.okta.developer.jugtours.model.GroupRepository; 477 | import jakarta.validation.Valid; 478 | import org.slf4j.Logger; 479 | import org.slf4j.LoggerFactory; 480 | import org.springframework.http.HttpStatus; 481 | import org.springframework.http.ResponseEntity; 482 | import org.springframework.web.bind.annotation.*; 483 | 484 | import java.net.URI; 485 | import java.net.URISyntaxException; 486 | import java.util.Collection; 487 | import java.util.Optional; 488 | 489 | @RestController 490 | @RequestMapping("/api") 491 | class GroupController { 492 | 493 | private final Logger log = LoggerFactory.getLogger(GroupController.class); 494 | private final GroupRepository groupRepository; 495 | 496 | public GroupController(GroupRepository groupRepository) { 497 | this.groupRepository = groupRepository; 498 | } 499 | 500 | @GetMapping("/groups") 501 | Collection groups() { 502 | return groupRepository.findAll(); 503 | } 504 | 505 | @GetMapping("/group/{id}") 506 | ResponseEntity getGroup(@PathVariable Long id) { 507 | Optional group = groupRepository.findById(id); 508 | return group.map(response -> ResponseEntity.ok().body(response)).orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND)); 509 | } 510 | 511 | @PostMapping("/group") 512 | ResponseEntity createGroup(@Valid @RequestBody Group group) throws URISyntaxException { 513 | log.info("Request to create group: {}", group); 514 | Group result = groupRepository.save(group); 515 | return ResponseEntity.created(new URI("/api/group/" + result.getId())).body(result); 516 | } 517 | 518 | @PutMapping("/group/{id}") 519 | ResponseEntity updateGroup(@Valid @RequestBody Group group) { 520 | log.info("Request to update group: {}", group); 521 | Group result = groupRepository.save(group); 522 | return ResponseEntity.ok().body(result); 523 | } 524 | 525 | @DeleteMapping("/group/{id}") 526 | public ResponseEntity deleteGroup(@PathVariable Long id) { 527 | log.info("Request to delete group: {}", id); 528 | groupRepository.deleteById(id); 529 | return ResponseEntity.ok().build(); 530 | } 531 | } 532 | ---- 533 | ==== 534 | 535 | . Restart the app, hit `http://localhost:8080/api/groups` with HTTPie, and you should see the list of groups. 536 | + 537 | http :8080/api/groups 538 | 539 | . You can create, read, update, and delete groups with the following commands. 540 | + 541 | [source,shell] 542 | ---- 543 | http POST :8080/api/group name='Toronto JUG' city='Toronto' country=CA 544 | http :8080/api/group/6 545 | http PUT :8080/api/group/6 id=6 name='Toronto JUG' address='16 York St' 546 | http DELETE :8080/api/group/6 547 | ---- 548 | 549 | == Create an Angular App with the Angular CLI 550 | 551 | . You don't have to install Angular CLI globally. The `npx` command can install and run it for you. 552 | + 553 | [source,shell] 554 | ---- 555 | npx @angular/cli@17 new app --routing --style css --ssr false 556 | ---- 557 | 558 | . Navigate into the `app` directory and install https://material.angular.io/[Angular Material] to make the UI look beautiful, particularly on mobile devices. 559 | + 560 | [source,shell] 561 | ---- 562 | cd app 563 | ng add @angular/material 564 | ---- 565 | + 566 | You'll be prompted to choose a theme, set up typography styles, and include animations. Select the defaults. 567 | 568 | . Start the app with `npm start` and open `http://localhost:4200`. 569 | 570 | . Modify `app/src/app/app.component.html` and move the CSS at the top to `app.component.css`: 571 | 572 | === Call your Spring Boot API and display the results 573 | 574 | . Update `app.component.ts` to fetch the list of groups when it loads. [`sba-app`] 575 | + 576 | .`app.component.ts` 577 | [%collapsible] 578 | ==== 579 | [source,typescript] 580 | ---- 581 | import { Component, OnInit } from '@angular/core'; 582 | import { RouterOutlet } from '@angular/router'; 583 | import { HttpClient, HttpClientModule } from '@angular/common/http'; 584 | import { Group } from './model/group'; 585 | 586 | @Component({ 587 | selector: 'app-root', 588 | standalone: true, 589 | imports: [RouterOutlet, HttpClientModule], 590 | templateUrl: './app.component.html', 591 | styleUrl: './app.component.css' 592 | }) 593 | export class AppComponent implements OnInit { 594 | title = 'JUG Tours'; 595 | loading = true; 596 | groups: Group[] = []; 597 | 598 | constructor(private http: HttpClient) { 599 | } 600 | 601 | ngOnInit() { 602 | this.loading = true; 603 | this.http.get('api/groups').subscribe((data: Group[]) => { 604 | this.groups = data; 605 | this.loading = false; 606 | }); 607 | } 608 | } 609 | ---- 610 | ==== 611 | 612 | . Before this compiles, you'll need to create a `app/src/app/model/group.ts` file: [`sba-group-ts`] 613 | + 614 | [source,typescript] 615 | ---- 616 | export class Group { 617 | id: number | null; 618 | name: string; 619 | 620 | constructor(group: Partial = {}) { 621 | this.id = group?.id || null; 622 | this.name = group?.name || ''; 623 | } 624 | } 625 | ---- 626 | 627 | . Modify the `app.component.html` file to display the list of groups. [`sba-app-html`] 628 | + 629 | [source,html] 630 | ---- 631 |
632 |
633 |
634 |

{{title}}

635 |
636 | 637 |
638 |
639 | @if (loading) { 640 |

Loading...

641 | } 642 | @for (group of groups; track group) { 643 | {{group.name}} 644 | } 645 |
646 |
647 |
648 |
649 | 650 | ---- 651 | 652 | . Create a file called `proxy.conf.js` in the `src` folder of your Angular project and use it to define your proxies: [`sba-proxy`] 653 | + 654 | [source,js] 655 | ---- 656 | const PROXY_CONFIG = [ 657 | { 658 | context: ['/api'], 659 | target: 'http://localhost:8080', 660 | secure: true, 661 | logLevel: 'debug' 662 | } 663 | ] 664 | 665 | module.exports = PROXY_CONFIG; 666 | ---- 667 | 668 | . Update `angular.json` and its `serve` command to use the proxy. 669 | + 670 | [source,json] 671 | ---- 672 | "serve": { 673 | "builder": "@angular-devkit/build-angular:dev-server", 674 | "configurations": { 675 | "production": { 676 | "buildTarget": "app:build:production" 677 | }, 678 | "development": { 679 | "buildTarget": "app:build:development", 680 | "proxyConfig": "src/proxy.conf.js" 681 | } 682 | }, 683 | "defaultConfiguration": "development" 684 | }, 685 | ---- 686 | 687 | . Stop your app with kbd:[Ctrl+c] and restart it with `npm start`. Now you should see a list of groups in your Angular app! 688 | 689 | === Build an Angular `GroupList` component 690 | 691 | . 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. 692 | + 693 | [source,shell] 694 | ---- 695 | ng g c group-list 696 | ---- 697 | 698 | . Replace the code in `group-list.component.ts`: [`sba-group-list`] 699 | + 700 | .`group-list.component.ts` 701 | [%collapsible] 702 | ==== 703 | [source,typescript] 704 | ---- 705 | import { Component } from '@angular/core'; 706 | import { Group } from '../model/group'; 707 | import { HttpClient, HttpClientModule } from '@angular/common/http'; 708 | import { RouterLink } from '@angular/router'; 709 | import { MatButtonModule } from '@angular/material/button'; 710 | import { MatTableModule } from '@angular/material/table'; 711 | import { MatIconModule } from '@angular/material/icon'; 712 | import { DatePipe } from '@angular/common'; 713 | 714 | @Component({ 715 | selector: 'app-group-list', 716 | standalone: true, 717 | imports: [CommonModule, RouterLink, MatButtonModule, MatTableModule, MatIconModule, DatePipe, HttpClientModule], 718 | templateUrl: './group-list.component.html', 719 | styleUrl: './group-list.component.css' 720 | }) 721 | export class GroupListComponent { 722 | title = 'Group List'; 723 | loading = true; 724 | groups: Group[] = []; 725 | displayedColumns = ['id', 'name', 'events', 'actions']; 726 | feedback: any = {}; 727 | 728 | constructor(private http: HttpClient) { 729 | } 730 | 731 | ngOnInit() { 732 | this.loading = true; 733 | this.http.get('api/groups').subscribe((data: Group[]) => { 734 | this.groups = data; 735 | this.loading = false; 736 | this.feedback = {}; 737 | }); 738 | } 739 | 740 | delete(group: Group): void { 741 | if (confirm(`Are you sure you want to delete ${group.name}?`)) { 742 | this.http.delete(`api/group/${group.id}`).subscribe({ 743 | next: () => { 744 | this.feedback = {type: 'success', message: 'Delete was successful!'}; 745 | setTimeout(() => { 746 | this.ngOnInit(); 747 | }, 1000); 748 | }, 749 | error: () => { 750 | this.feedback = {type: 'warning', message: 'Error deleting.'}; 751 | } 752 | }); 753 | } 754 | } 755 | 756 | protected readonly event = event; 757 | } 758 | ---- 759 | ==== 760 | 761 | . Update its HTML template to use Angular Material's table component. [`sba-group-list-html`] 762 | + 763 | .`group-list.component.html` 764 | [%collapsible] 765 | ==== 766 | [source,html] 767 | ---- 768 | 774 | 775 | Add Group 776 | 777 |

{{title}}

778 | @if (loading) { 779 |
780 |

Loading...

781 |
782 | } @else { 783 | @if (feedback.message) { 784 |
{{ feedback.message }}
785 | } 786 | 787 | 788 | ID 789 | {{ item.id }} 790 | 791 | 792 | Name 793 | {{ item.name }} 794 | 795 | 796 | Events 797 | 798 | @for (event of item.events; track event) { 799 | {{event.date | date }}: {{ event.title }} 800 |
801 | } 802 |
803 |
804 | 805 | Actions 806 | 807 | Edit  808 | 809 | 810 | 811 | 812 | 813 |
814 | } 815 | ---- 816 | ==== 817 | 818 | . 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. 819 | + 820 | [source,bash] 821 | ---- 822 | ng g c home 823 | ---- 824 | 825 | . Update `home.component.html`: 826 | + 827 | [source,html] 828 | ---- 829 | Manage JUG Tour 830 | ---- 831 | 832 | . Add an import for `MatButtonModule` to `home.component.ts`: 833 | + 834 | [source,typescript] 835 | ---- 836 | import { MatButtonModule } from '@angular/material/button'; 837 | 838 | @Component({ 839 | selector: 'app-home', 840 | standalone: true, 841 | imports: [MatButtonModule], 842 | ... 843 | }) 844 | ---- 845 | 846 | . Change `app.component.html` to remove the list of groups above ``: 847 | + 848 | [source,html] 849 | ---- 850 |
851 | 852 |
853 | ---- 854 | 855 | . Remove the groups fetching logic from `app.component.ts`: 856 | + 857 | [source,typescript] 858 | ---- 859 | import { Component } from '@angular/core'; 860 | import { RouterOutlet } from '@angular/router'; 861 | 862 | @Component({ 863 | selector: 'app-root', 864 | standalone: true, 865 | imports: [RouterOutlet], 866 | templateUrl: './app.component.html', 867 | styleUrl: './app.component.css' 868 | }) 869 | export class AppComponent { 870 | title = 'JUG Tours'; 871 | } 872 | ---- 873 | 874 | . Add a route for the `HomeComponent` and `GroupListComponent` to `app.routes.ts`: [`sba-routes`] 875 | + 876 | [source,typescript] 877 | ---- 878 | import { Routes } from '@angular/router'; 879 | import { HomeComponent } from './home/home.component'; 880 | import { GroupListComponent } from './group-list/group-list.component'; 881 | 882 | export const routes: Routes = [ 883 | { path: '', redirectTo: '/home', pathMatch: 'full' }, 884 | { 885 | path: 'home', 886 | component: HomeComponent 887 | }, 888 | { 889 | path: 'groups', 890 | component: GroupListComponent 891 | } 892 | ]; 893 | ---- 894 | 895 | . Update the CSS in `styles.css` to have rules for the `breadcrumb` and `alert` classes: [`sba-css`] 896 | + 897 | .`styles.css` 898 | [%collapsible] 899 | ==== 900 | [source,css] 901 | ---- 902 | /* https://careydevelopment.us/blog/angular-how-to-add-breadcrumbs-to-your-ui */ 903 | ol.breadcrumb { 904 | padding: 0; 905 | list-style-type: none; 906 | margin: 5px 0 0 0; 907 | } 908 | 909 | .breadcrumb-item + .active { 910 | color: inherit; 911 | font-weight: 500; 912 | } 913 | 914 | .breadcrumb-item { 915 | color: #3F51B5; 916 | font-size: 1rem; 917 | text-decoration: underline; 918 | cursor: pointer; 919 | } 920 | 921 | .breadcrumb-item + .breadcrumb-item { 922 | padding-left: 0.5rem; 923 | } 924 | 925 | .breadcrumb-item + .breadcrumb-item::before { 926 | display: inline-block; 927 | padding-right: 0.5rem; 928 | color: rgb(108, 117, 125); 929 | content: "/"; 930 | } 931 | 932 | ol.breadcrumb li { 933 | list-style-type: none; 934 | } 935 | 936 | ol.breadcrumb li { 937 | list-style-type: none; 938 | display: inline 939 | } 940 | 941 | .alert { 942 | padding: 0.75rem 1.25rem; 943 | margin-bottom: 1rem; 944 | border: 1px solid transparent; 945 | } 946 | 947 | .alert-success { 948 | color: #155724; 949 | background-color: #d4edda; 950 | border-color: #c3e6cb; 951 | } 952 | 953 | .alert-error { 954 | color: #721c24; 955 | background-color: #f8d7da; 956 | border-color: #f5c6cb; 957 | } 958 | ---- 959 | ==== 960 | 961 | . 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. 962 | 963 | . To squish the **Actions** column to the right, add the following to `group-list.component.css`: 964 | + 965 | [source,css] 966 | ---- 967 | .mat-column-actions { 968 | flex: 0 0 120px; 969 | } 970 | ---- 971 | + 972 | Your Angular app should update itself as you make changes. 973 | 974 | 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! 975 | 976 | === Build an Angular `GroupEdit` component 977 | 978 | . Create a `group-edit` component and use Angular's `HttpClient` to fetch the group resource with the ID from the URL. 979 | + 980 | [source,shell] 981 | ---- 982 | ng g c group-edit 983 | ---- 984 | 985 | . Add a route for this component to `app.routes.ts`: 986 | + 987 | [source,typescript] 988 | ---- 989 | import { GroupEditComponent } from './group-edit/group-edit.component'; 990 | 991 | export const routes: Routes = [ 992 | ... 993 | { 994 | path: 'group/:id', 995 | component: GroupEditComponent 996 | } 997 | ]; 998 | ---- 999 | 1000 | . Replace the code in `group-edit.component.ts`: [`sba-group-edit`] 1001 | + 1002 | .`group-edit.component.ts` 1003 | [%collapsible] 1004 | ==== 1005 | [source,typescript] 1006 | ---- 1007 | import { Component, OnInit } from '@angular/core'; 1008 | import { ActivatedRoute, Router, RouterLink } from '@angular/router'; 1009 | import { map, of, switchMap } from 'rxjs'; 1010 | import { Group } from '../model/group'; 1011 | import { Event } from '../model/event'; 1012 | import { HttpClient, HttpClientModule } from '@angular/common/http'; 1013 | import { MatInputModule } from '@angular/material/input'; 1014 | import { FormsModule } from '@angular/forms'; 1015 | import { MatButtonModule } from '@angular/material/button'; 1016 | import { MatDatepickerModule } from '@angular/material/datepicker'; 1017 | import { MatIconModule } from '@angular/material/icon'; 1018 | import { MatNativeDateModule } from '@angular/material/core'; 1019 | import { MatTooltipModule } from '@angular/material/tooltip'; 1020 | 1021 | @Component({ 1022 | selector: 'app-group-edit', 1023 | standalone: true, 1024 | imports: [ 1025 | FormsModule, 1026 | HttpClientModule, 1027 | MatInputModule, 1028 | MatButtonModule, 1029 | MatDatepickerModule, 1030 | MatIconModule, 1031 | MatNativeDateModule, 1032 | MatTooltipModule, 1033 | RouterLink 1034 | ], 1035 | templateUrl: './group-edit.component.html', 1036 | styleUrl: './group-edit.component.css' 1037 | }) 1038 | export class GroupEditComponent implements OnInit { 1039 | group!: Group; 1040 | feedback: any = {}; 1041 | 1042 | constructor(private route: ActivatedRoute, private router: Router, 1043 | private http: HttpClient) { 1044 | } 1045 | 1046 | ngOnInit() { 1047 | this.route.params.pipe( 1048 | map(p => p['id']), 1049 | switchMap(id => { 1050 | if (id === 'new') { 1051 | return of(new Group()); 1052 | } 1053 | return this.http.get(`api/group/${id}`); 1054 | }) 1055 | ).subscribe({ 1056 | next: group => { 1057 | this.group = group; 1058 | this.feedback = {}; 1059 | }, 1060 | error: () => { 1061 | this.feedback = {type: 'warning', message: 'Error loading'}; 1062 | } 1063 | }); 1064 | } 1065 | 1066 | save() { 1067 | const id = this.group.id; 1068 | const method = id ? 'put' : 'post'; 1069 | 1070 | this.http[method](`/api/group${id ? '/' + id : ''}`, this.group).subscribe({ 1071 | next: () => { 1072 | this.feedback = {type: 'success', message: 'Save was successful!'}; 1073 | setTimeout(async () => { 1074 | await this.router.navigate(['/groups']); 1075 | }, 1000); 1076 | }, 1077 | error: () => { 1078 | this.feedback = {type: 'error', message: 'Error saving'}; 1079 | } 1080 | }); 1081 | } 1082 | 1083 | async cancel() { 1084 | await this.router.navigate(['/groups']); 1085 | } 1086 | 1087 | addEvent() { 1088 | this.group.events.push(new Event()); 1089 | } 1090 | 1091 | removeEvent(index: number) { 1092 | this.group.events.splice(index, 1); 1093 | } 1094 | } 1095 | ---- 1096 | ==== 1097 | 1098 | . Create a `model/event.ts` file so this component will compile. [`sba-event-ts`] 1099 | + 1100 | [source,typescript] 1101 | ---- 1102 | export class Event { 1103 | id: number | null; 1104 | date: Date | null; 1105 | title: string; 1106 | 1107 | constructor(event: Partial = {}) { 1108 | this.id = event?.id || null; 1109 | this.date = event?.date || null; 1110 | this.title = event?.title || ''; 1111 | } 1112 | } 1113 | ---- 1114 | 1115 | . Update `model/group.ts` to include the `Event` class. 1116 | + 1117 | [source,typescript] 1118 | ---- 1119 | import { Event } from './event'; 1120 | 1121 | export class Group { 1122 | id: number | null; 1123 | name: string; 1124 | events: Event[]; 1125 | 1126 | constructor(group: Partial = {}) { 1127 | this.id = group?.id || null; 1128 | this.name = group?.name || ''; 1129 | this.events = group?.events || []; 1130 | } 1131 | } 1132 | ---- 1133 | 1134 | . The `GroupEditComponent` needs to render a form, so update `group-edit.component.html`: [`sba-group-edit-html`] 1135 | + 1136 | .`group-edit.component.html` 1137 | [%collapsible] 1138 | ==== 1139 | [source,html] 1140 | ---- 1141 | 1148 | 1149 |

Group Information

1150 | @if (feedback.message) { 1151 |
{{ feedback.message }}
1152 | } 1153 | @if (group) { 1154 |
1155 | @if (group.id) { 1156 | 1157 | ID 1158 | 1159 | 1160 | } 1161 | 1162 | Name 1163 | 1164 | 1165 | @if (group.events.length) { 1166 |

Events

1167 | } 1168 | @for (event of group.events; track event; let i = $index) { 1169 |
1170 | 1171 | Date 1172 | 1174 | 1175 | 1176 | 1177 | 1178 | Title 1179 | 1180 | 1181 | 1185 |
1186 | } 1187 |
1188 | @if (group.id) { 1189 | 1194 | } 1195 | 1196 | 1197 |
1198 |
1199 | } 1200 | ---- 1201 | ==== 1202 | + 1203 | If you look closely, you'll notice this component allows you to edit events for a group. ✨ 1204 | 1205 | . Update `group-edit.component.css` to make things look better on all devices: [`sba-group-edit-css`] 1206 | + 1207 | [source,css] 1208 | ---- 1209 | form, h2 { 1210 | min-width: 150px; 1211 | max-width: 700px; 1212 | width: 100%; 1213 | margin: 10px auto; 1214 | } 1215 | 1216 | .alert { 1217 | max-width: 660px; 1218 | margin: 0 auto; 1219 | } 1220 | 1221 | .full-width { 1222 | width: 100%; 1223 | } 1224 | ---- 1225 | + 1226 | Now, with your Angular app running, you should be able to add and edit groups! 1227 | 1228 | . To make the navbar at the top use Angular Material colors, update `app.component.html`: [`sba-toolbar`] 1229 | + 1230 | .`app.component.html` 1231 | [%collapsible] 1232 | ==== 1233 | [source,html] 1234 | ---- 1235 | 1236 | 1244 | {{ title }} 1245 |
1246 | 1247 | 1253 | 1254 | 1255 | 1261 | 1262 |
1263 | ---- 1264 | ==== 1265 | 1266 | . Since this is not a standalone component, you must import `MatToolbarModule` in `app.component.ts`. 1267 | + 1268 | [source,typescript] 1269 | ---- 1270 | import { MatToolbarModule } from '@angular/material/toolbar'; 1271 | 1272 | @Component({ 1273 | selector: 'app-root', 1274 | standalone: true, 1275 | imports: [RouterOutlet, MatToolbarModule], 1276 | ... 1277 | }) 1278 | ---- 1279 | 1280 | . Make some adjustments in `app.component.css` to make the toolbar look nicer. 1281 | 1282 | 1. Change the margin for `.social-links` to `.5rem` and remove the rules for `.social-links path` and `.social-links a:hover` below it. 1283 | 2. Add a `.spacer` rule with `flex: 1 1 auto` in its properties. 1284 | 3. Change the `.content` rule to be as follows: 1285 | + 1286 | [source,css] 1287 | ---- 1288 | .content { 1289 | display: flex; 1290 | margin: 10px auto; 1291 | padding: 0 16px; 1292 | max-width: 960px; 1293 | flex-direction: column; 1294 | align-items: stretch; 1295 | } 1296 | ---- 1297 | 1298 | Now the app fills the screen more, and the toolbar has matching colors. 1299 | 1300 | == Secure Spring Boot with OpenID Connect and OAuth 1301 | 1302 | 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! 1303 | 1304 | 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. 1305 | 1306 | . Add the Okta Spring Boot starter to do OIDC authentication in your `pom.xml`. This will also add Spring Security to your app. [`okta-maven-boot`] 1307 | + 1308 | [source,xml] 1309 | ---- 1310 | 1311 | com.okta.spring 1312 | okta-spring-boot-starter 1313 | 3.0.6 1314 | 1315 | ---- 1316 | 1317 | . Install the https://github.com/auth0/auth0-cli[Auth0 CLI] and run `auth0 login` in a shell. 1318 | 1319 | . Next, run `auth0 apps create` to register a new OIDC app with appropriate callbacks: 1320 | + 1321 | [source,shell] 1322 | ---- 1323 | auth0 apps create \ 1324 | --name "Bootiful Angular" \ 1325 | --description "Spring Boot + Angular = ❤️" \ 1326 | --type regular \ 1327 | --callbacks http://localhost:8080/login/oauth2/code/okta,http://localhost:4200/login/oauth2/code/okta \ 1328 | --logout-urls http://localhost:8080,http://localhost:4200 \ 1329 | --reveal-secrets 1330 | ---- 1331 | 1332 | . Copy the returned values from this command into an `.okta.env` file: 1333 | + 1334 | [source,shell] 1335 | ---- 1336 | export OKTA_OAUTH2_ISSUER=https:/// 1337 | export OKTA_OAUTH2_CLIENT_ID= 1338 | export OKTA_OAUTH2_CLIENT_SECRET= 1339 | ---- 1340 | + 1341 | [TIP] 1342 | ==== 1343 | If you're on Windows, use `set` instead of `export` to set these environment variables and name the file `.okta.env.bat`: 1344 | 1345 | [source,shell] 1346 | ---- 1347 | set OKTA_OAUTH2_ISSUER=https:/// 1348 | set OKTA_OAUTH2_CLIENT_ID= 1349 | set OKTA_OAUTH2_CLIENT_SECRET= 1350 | ---- 1351 | ==== 1352 | 1353 | . Add `*.env` to your `.gitignore` file, so you don't accidentally expose your client secret. 1354 | 1355 | . Run `source .okta.env` (or `.okta.env.bat` on Windows) to set these environment variables in your current shell. 1356 | 1357 | . Run `./mvnw` (or `mvnw` on Windows) to start the app. 1358 | + 1359 | [source,shell] 1360 | ---- 1361 | source .okta.env 1362 | ./mvnw spring-boot:run 1363 | ---- 1364 | 1365 | . Open `http://localhost:8080` in your favorite browser. After logging in, you'll see a 404 error from Spring Boot since you have nothing mapped to the default `/` route. 1366 | 1367 | === Configure Spring Security for maximum protection 1368 | 1369 | . To make Spring Security Angular-friendly, create a `SecurityConfiguration.java` file in `src/main/java/.../jugtours/config`. [`sba-security-config`] 1370 | + 1371 | .`SecurityConfiguration.java` 1372 | [%collapsible] 1373 | ==== 1374 | [source,java] 1375 | ---- 1376 | package com.okta.developer.jugtours.config; 1377 | 1378 | import com.okta.developer.jugtours.web.CookieCsrfFilter; 1379 | import org.springframework.context.annotation.Bean; 1380 | import org.springframework.context.annotation.Configuration; 1381 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 1382 | import org.springframework.security.web.SecurityFilterChain; 1383 | import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; 1384 | import org.springframework.security.web.csrf.CookieCsrfTokenRepository; 1385 | import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; 1386 | 1387 | import static org.springframework.security.config.Customizer.withDefaults; 1388 | 1389 | @Configuration 1390 | public class SecurityConfiguration { 1391 | 1392 | @Bean 1393 | public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { 1394 | http.authorizeHttpRequests((authz) -> authz 1395 | .requestMatchers("/", "/index.html", "*.ico", "*.css", "*.js", "/api/user").permitAll() 1396 | .anyRequest().authenticated()) 1397 | .oauth2Login(withDefaults()) 1398 | .oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults())) 1399 | .csrf((csrf) -> csrf 1400 | .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) 1401 | .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())) 1402 | .addFilterAfter(new CookieCsrfFilter(), BasicAuthenticationFilter.class); 1403 | 1404 | return http.build(); 1405 | } 1406 | } 1407 | ---- 1408 | ==== 1409 | 1410 | . 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. [`sba-csrf`] 1411 | + 1412 | .`CookieCsrfFilter.java` 1413 | [%collapsible] 1414 | ==== 1415 | [source,java] 1416 | ---- 1417 | package com.okta.developer.jugtours.web; 1418 | 1419 | import jakarta.servlet.FilterChain; 1420 | import jakarta.servlet.ServletException; 1421 | import jakarta.servlet.http.HttpServletRequest; 1422 | import jakarta.servlet.http.HttpServletResponse; 1423 | import org.springframework.security.web.csrf.CsrfToken; 1424 | import org.springframework.web.filter.OncePerRequestFilter; 1425 | 1426 | import java.io.IOException; 1427 | 1428 | /** 1429 | * Spring Security 6 doesn't set an XSRF-TOKEN cookie by default. 1430 | * This solution is 1431 | * 1432 | * recommended by Spring Security. 1433 | */ 1434 | public class CookieCsrfFilter extends OncePerRequestFilter { 1435 | 1436 | /** 1437 | * {@inheritDoc} 1438 | */ 1439 | @Override 1440 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 1441 | FilterChain filterChain) throws ServletException, IOException { 1442 | CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); 1443 | response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken()); 1444 | filterChain.doFilter(request, response); 1445 | } 1446 | } 1447 | ---- 1448 | ==== 1449 | 1450 | . Create `src/main/java/.../jugtours/web/UserController.java`. Angular will use this API to 1) find out if a user is authenticated and 2) perform global logout. [`sba-user-controller`] 1451 | + 1452 | .`UserController.java` 1453 | [%collapsible] 1454 | ==== 1455 | [source,java] 1456 | ---- 1457 | package com.okta.developer.jugtours.web; 1458 | 1459 | import jakarta.servlet.http.HttpServletRequest; 1460 | import org.springframework.http.HttpHeaders; 1461 | import org.springframework.http.HttpStatus; 1462 | import org.springframework.http.ResponseEntity; 1463 | import org.springframework.security.core.annotation.AuthenticationPrincipal; 1464 | import org.springframework.security.oauth2.client.registration.ClientRegistration; 1465 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 1466 | import org.springframework.security.oauth2.core.user.OAuth2User; 1467 | import org.springframework.web.bind.annotation.GetMapping; 1468 | import org.springframework.web.bind.annotation.PostMapping; 1469 | import org.springframework.web.bind.annotation.RestController; 1470 | 1471 | import java.text.MessageFormat; 1472 | 1473 | import static java.util.Map.of; 1474 | 1475 | @RestController 1476 | public class UserController { 1477 | private final ClientRegistration registration; 1478 | 1479 | public UserController(ClientRegistrationRepository registrations) { 1480 | this.registration = registrations.findByRegistrationId("okta"); 1481 | } 1482 | 1483 | @GetMapping("/api/user") 1484 | public ResponseEntity getUser(@AuthenticationPrincipal OAuth2User user) { 1485 | if (user == null) { 1486 | return new ResponseEntity<>("", HttpStatus.OK); 1487 | } else { 1488 | return ResponseEntity.ok().body(user.getAttributes()); 1489 | } 1490 | } 1491 | 1492 | @PostMapping("/api/logout") 1493 | public ResponseEntity logout(HttpServletRequest request) { 1494 | // send logout URL to client so they can initiate logout 1495 | var issuerUri = registration.getProviderDetails().getIssuerUri(); 1496 | var originUrl = request.getHeader(HttpHeaders.ORIGIN); 1497 | Object[] params = {issuerUri, registration.getClientId(), originUrl}; 1498 | // Yes! We @ Auth0 should have an end_session_endpoint in our OIDC metadata. 1499 | // It's not included at the time of this writing, but will be coming soon! 1500 | var logoutUrl = MessageFormat.format("{0}v2/logout?client_id={1}&returnTo={2}", params); 1501 | request.getSession().invalidate(); 1502 | return ResponseEntity.ok().body(of("logoutUrl", logoutUrl)); 1503 | } 1504 | } 1505 | ---- 1506 | ==== 1507 | 1508 | . 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`. [`sba-user-repo`] 1509 | + 1510 | [source,java] 1511 | ---- 1512 | package com.okta.developer.jugtours.model; 1513 | 1514 | import org.springframework.data.jpa.repository.JpaRepository; 1515 | 1516 | public interface UserRepository extends JpaRepository { 1517 | } 1518 | ---- 1519 | 1520 | . Add a new `findAllByUserId(String id)` method to `GroupRepository.java`. 1521 | + 1522 | [source,java] 1523 | ---- 1524 | List findAllByUserId(String id); 1525 | ---- 1526 | 1527 | . 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. 1528 | + 1529 | [source,java] 1530 | ---- 1531 | @RestController 1532 | @RequestMapping("/api") 1533 | class GroupController { 1534 | 1535 | ... 1536 | private final UserRepository userRepository; 1537 | 1538 | public GroupController(GroupRepository groupRepository, UserRepository userRepository) { 1539 | this.groupRepository = groupRepository; 1540 | this.userRepository = userRepository; 1541 | } 1542 | 1543 | @GetMapping("/groups") 1544 | Collection groups(Principal principal) { 1545 | return groupRepository.findAllByUserId(principal.getName()); 1546 | } 1547 | 1548 | ... 1549 | 1550 | @PostMapping("/group") 1551 | ResponseEntity createGroup(@Valid @RequestBody Group group, 1552 | @AuthenticationPrincipal OAuth2User principal) throws URISyntaxException { 1553 | log.info("Request to create group: {}", group); 1554 | Map details = principal.getAttributes(); 1555 | String userId = details.get("sub").toString(); 1556 | 1557 | // check to see if user already exists 1558 | Optional user = userRepository.findById(userId); 1559 | group.setUser(user.orElse(new User(userId, 1560 | details.get("name").toString(), details.get("email").toString()))); 1561 | 1562 | Group result = groupRepository.save(group); 1563 | return ResponseEntity.created(new URI("/api/group/" + result.getId())) 1564 | .body(result); 1565 | } 1566 | 1567 | ... 1568 | } 1569 | ---- 1570 | 1571 | === Update Angular to handle CSRF and be identity-aware 1572 | 1573 | 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! 1574 | 1575 | 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. 1576 | 1577 | === Update your Angular app's authentication mechanism 1578 | 1579 | . 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`. [`sba-auth-service`] 1580 | + 1581 | .`auth.service.ts` 1582 | [%collapsible] 1583 | ==== 1584 | [source,typescript] 1585 | ---- 1586 | import { Injectable } from '@angular/core'; 1587 | import { Location } from '@angular/common'; 1588 | import { BehaviorSubject, lastValueFrom, Observable } from 'rxjs'; 1589 | import { HttpClient, HttpHeaders } from '@angular/common/http'; 1590 | import { map } from 'rxjs/operators'; 1591 | import { User } from './model/user'; 1592 | 1593 | const headers = new HttpHeaders().set('Accept', 'application/json'); 1594 | 1595 | @Injectable({ 1596 | providedIn: 'root' 1597 | }) 1598 | export class AuthService { 1599 | $authenticationState = new BehaviorSubject(false); 1600 | 1601 | constructor(private http: HttpClient, private location: Location) { 1602 | } 1603 | 1604 | getUser(): Observable { 1605 | return this.http.get('/api/user', {headers}, ) 1606 | .pipe(map((response: User) => { 1607 | if (response !== null) { 1608 | this.$authenticationState.next(true); 1609 | } 1610 | return response; 1611 | }) 1612 | ); 1613 | } 1614 | 1615 | async isAuthenticated(): Promise { 1616 | const user = await lastValueFrom(this.getUser()); 1617 | return user !== null; 1618 | } 1619 | 1620 | login(): void { 1621 | location.href = `${location.origin}${this.location.prepareExternalUrl('oauth2/authorization/okta')}`; 1622 | } 1623 | 1624 | logout(): void { 1625 | this.http.post('/api/logout', {}, { withCredentials: true }).subscribe((response: any) => { 1626 | location.href = response.logoutUrl; 1627 | }); 1628 | } 1629 | } 1630 | ---- 1631 | ==== 1632 | 1633 | . Add the referenced `User` class to `app/src/app/model/user.ts`. [`sba-user-ts`] 1634 | + 1635 | [source,typescript] 1636 | ---- 1637 | export class User { 1638 | email!: number; 1639 | name!: string; 1640 | } 1641 | ---- 1642 | 1643 | . `AuthService` depends on `HttpClient`, so you must update `app.config.ts` to import and use `provideHttpClient`. 1644 | + 1645 | [source,typescript] 1646 | ---- 1647 | import { provideHttpClient } from '@angular/common/http'; 1648 | 1649 | export const appConfig: ApplicationConfig = { 1650 | providers: [provideRouter(routes), provideAnimations(), provideHttpClient()] 1651 | }; 1652 | ---- 1653 | 1654 | . Modify `home.component.ts` to use `AuthService` to see if the user is logged in. [`sba-home`] 1655 | + 1656 | .`home.component.ts` 1657 | [%collapsible] 1658 | ==== 1659 | [source,typescript] 1660 | ---- 1661 | import { Component, OnInit } from '@angular/core'; 1662 | import { MatButtonModule } from '@angular/material/button'; 1663 | import { AuthService } from '../auth.service'; 1664 | import { User } from '../model/user'; 1665 | import { RouterLink } from '@angular/router'; 1666 | import { User } from '../model/user'; 1667 | import { AuthService } from '../auth.service'; 1668 | 1669 | @Component({ 1670 | selector: 'app-home', 1671 | standalone: true, 1672 | imports: [MatButtonModule, RouterLink], 1673 | templateUrl: './home.component.html', 1674 | styleUrl: './home.component.css' 1675 | }) 1676 | export class HomeComponent implements OnInit { 1677 | isAuthenticated!: boolean; 1678 | user!: User; 1679 | 1680 | constructor(public auth: AuthService) { 1681 | } 1682 | 1683 | async ngOnInit() { 1684 | this.isAuthenticated = await this.auth.isAuthenticated(); 1685 | this.auth.getUser().subscribe(data => this.user = data); 1686 | } 1687 | } 1688 | ---- 1689 | ==== 1690 | 1691 | . Modify `home.component.html` to show the Login button if the user is not logged in. Otherwise, show a Logout button. [`sba-home-html`] 1692 | + 1693 | [source,html] 1694 | ---- 1695 | @if (user) { 1696 |

Welcome, {{ user.name }}!

1697 | Manage JUG Tour 1698 |

1699 | 1700 | } @else { 1701 |

Please log in to manage your JUG Tour.

1702 | 1703 | } 1704 | ---- 1705 | 1706 | . Update `app/src/proxy.conf.js` to have additional proxy paths for `/oauth2` and `/login`: 1707 | + 1708 | [source,javascript] 1709 | ---- 1710 | const PROXY_CONFIG = [ 1711 | { 1712 | context: ['/api', '/oauth2', '/login'], 1713 | ... 1714 | } 1715 | ] 1716 | ---- 1717 | 1718 | 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! 1719 | 1720 | == Configure Maven to Package Angular with Spring Boot 1721 | 1722 | . To build and package your React app with Maven, you can use the frontend-maven-plugin and Maven's profiles to activate it. Add properties for versions and a `` section to your `pom.xml`. [`sba-properties` and `sba-profiles`] 1723 | + 1724 | .`pom.xml` 1725 | [%collapsible] 1726 | ==== 1727 | [source,xml] 1728 | ---- 1729 | 1730 | ... 1731 | 1.15.0 1732 | v18.18.2 1733 | 9.8.1 1734 | 1735 | 1736 | ... 1737 | 1738 | 1739 | 1740 | dev 1741 | 1742 | true 1743 | 1744 | 1745 | dev 1746 | 1747 | 1748 | 1749 | prod 1750 | 1751 | 1752 | 1753 | maven-resources-plugin 1754 | 1755 | 1756 | copy-resources 1757 | process-classes 1758 | 1759 | copy-resources 1760 | 1761 | 1762 | ${basedir}/target/classes/static 1763 | 1764 | 1765 | app/dist/app/browser 1766 | 1767 | 1768 | 1769 | 1770 | 1771 | 1772 | 1773 | com.github.eirslett 1774 | frontend-maven-plugin 1775 | ${frontend-maven-plugin.version} 1776 | 1777 | app 1778 | 1779 | 1780 | 1781 | install node 1782 | 1783 | install-node-and-npm 1784 | 1785 | 1786 | ${node.version} 1787 | ${npm.version} 1788 | 1789 | 1790 | 1791 | npm install 1792 | 1793 | npm 1794 | 1795 | generate-resources 1796 | 1797 | 1798 | npm test 1799 | 1800 | npm 1801 | 1802 | test 1803 | 1804 | test -- --watch=false 1805 | 1806 | 1807 | 1808 | npm build 1809 | 1810 | npm 1811 | 1812 | compile 1813 | 1814 | run build 1815 | 1816 | 1817 | 1818 | 1819 | 1820 | 1821 | 1822 | prod 1823 | 1824 | 1825 | 1826 | ---- 1827 | ==== 1828 | 1829 | . Add the active profile setting to `src/main/resources/application.properties`: 1830 | + 1831 | [source,properties] 1832 | ---- 1833 | spring.profiles.active=@spring.profiles.active@ 1834 | ---- 1835 | 1836 | . After adding this, you should be able to run `mvn spring-boot:run -Pprod` and see your app running at `http://localhost:8080`. 1837 | + 1838 | 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`. 1839 | 1840 | . To fix this, add a `SpaWebFilter` that conditionally forwards to the Angular app. [`sba-filter`] 1841 | + 1842 | .`SpaWebFilter.java` 1843 | [%collapsible] 1844 | ==== 1845 | [source,java] 1846 | ---- 1847 | package com.okta.developer.jugtours.web; 1848 | 1849 | import jakarta.servlet.FilterChain; 1850 | import jakarta.servlet.ServletException; 1851 | import jakarta.servlet.http.HttpServletRequest; 1852 | import jakarta.servlet.http.HttpServletResponse; 1853 | import org.springframework.web.filter.OncePerRequestFilter; 1854 | 1855 | import java.io.IOException; 1856 | 1857 | public class SpaWebFilter extends OncePerRequestFilter { 1858 | 1859 | /** 1860 | * Forwards any unmapped paths (except those containing a period) to the client {@code index.html}. 1861 | */ 1862 | @Override 1863 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 1864 | FilterChain filterChain) throws ServletException, IOException { 1865 | String path = request.getRequestURI(); 1866 | if (!path.startsWith("/api") && 1867 | !path.startsWith("/login") && 1868 | !path.startsWith("/oauth2") && 1869 | !path.contains(".") && 1870 | path.matches("/(.*)")) { 1871 | request.getRequestDispatcher("/index.html").forward(request, response); 1872 | return; 1873 | } 1874 | 1875 | filterChain.doFilter(request, response); 1876 | } 1877 | } 1878 | ---- 1879 | ==== 1880 | 1881 | . And add to your `SecurityConfiguration.java` class: 1882 | + 1883 | [source,java] 1884 | ---- 1885 | .addFilterAfter(new SpaWebFilter(), BasicAuthenticationFilter.class); 1886 | ---- 1887 | 1888 | Now, if you restart, login, and navigate to `/groups`, refreshing will work as expected. 🤩 1889 | 1890 | == Verify Everything Works with Cypress 1891 | 1892 | . Add Cypress to your project: 1893 | 1894 | ng add @cypress/schematic 1895 | 1896 | . Update `app/cypress/support/commands.ts` to add a `login(username, password)` method: [`sba-commands`] 1897 | + 1898 | .`commands.ts` 1899 | [%collapsible] 1900 | ==== 1901 | [source,ts] 1902 | ---- 1903 | /* eslint-disable @typescript-eslint/no-namespace */ 1904 | /* eslint-disable @typescript-eslint/no-use-before-define */ 1905 | // eslint-disable-next-line spaced-comment 1906 | /// 1907 | 1908 | Cypress.Commands.add('login', (username: string, password: string) => { 1909 | Cypress.log({ 1910 | message: [`🔐 Authenticating: ${username}`], 1911 | autoEnd: false, 1912 | }) 1913 | cy.origin(Cypress.env('E2E_DOMAIN'), {args: {username, password}}, 1914 | ({username, password}) => { 1915 | cy.get('input[name=username]').type(username); 1916 | cy.get('input[name=password]').type(`${password}{enter}`, {log: false}); 1917 | } 1918 | ); 1919 | }); 1920 | 1921 | declare global { 1922 | namespace Cypress { 1923 | interface Chainable { 1924 | login(username: string, password: string): Cypress.Chainable; 1925 | } 1926 | } 1927 | } 1928 | 1929 | // Convert this to a module instead of script (allows import/export) 1930 | export {}; 1931 | ---- 1932 | ==== 1933 | 1934 | . Update `app/cypress/support/e2e.ts` to log in before each test and log out after. [`sba-e2e`] 1935 | + 1936 | .`e2e.ts` 1937 | [%collapsible] 1938 | ==== 1939 | [source,ts] 1940 | ---- 1941 | import './commands'; 1942 | 1943 | beforeEach(() => { 1944 | if (Cypress.env('E2E_USERNAME') === undefined) { 1945 | console.error('E2E_USERNAME is not defined'); 1946 | alert('E2E_USERNAME is not defined'); 1947 | return; 1948 | } 1949 | cy.visit('/') 1950 | cy.get('#login').click() 1951 | cy.login( 1952 | Cypress.env('E2E_USERNAME'), 1953 | Cypress.env('E2E_PASSWORD') 1954 | ) 1955 | }) 1956 | 1957 | afterEach(() => { 1958 | cy.visit('/') 1959 | cy.get('#logout').click() 1960 | }) 1961 | ---- 1962 | ==== 1963 | 1964 | . Rename `app/cypress/e2e/spec.cy.ts` to `home.cy.ts` and use it to verify that the home page loads. [`sba-e2e-home`] 1965 | + 1966 | [source,ts] 1967 | ---- 1968 | describe('Home', () => { 1969 | beforeEach(() => { 1970 | cy.visit('/') 1971 | }); 1972 | 1973 | it('Visits the initial app page', () => { 1974 | cy.contains('JUG Tours') 1975 | cy.contains('Logout') 1976 | }) 1977 | }) 1978 | ---- 1979 | 1980 | . Create a `groups.cy.ts` in the same directory to test CRUD on groups. [`sb-e2e-groups`] 1981 | + 1982 | .`groups.cy.ts` 1983 | [%collapsible] 1984 | ==== 1985 | [source,ts] 1986 | ---- 1987 | describe('Groups', () => { 1988 | 1989 | beforeEach(() => { 1990 | cy.visit('/groups') 1991 | }); 1992 | 1993 | it('add button should exist', () => { 1994 | cy.get('#add').should('exist'); 1995 | }); 1996 | 1997 | it('should add a new group', () => { 1998 | cy.get('#add').click(); 1999 | cy.get('#name').type('Test Group'); 2000 | cy.get('#save').click(); 2001 | cy.get('.alert-success').should('exist'); 2002 | }); 2003 | 2004 | it('should edit a group', () => { 2005 | cy.get('a').last().click(); 2006 | cy.get('#name').should('have.value', 'Test Group'); 2007 | cy.get('#cancel').click(); 2008 | }); 2009 | 2010 | it('should delete a group', () => { 2011 | cy.get('button').last().click(); 2012 | cy.on('window:confirm', () => true); 2013 | cy.get('.alert-success').should('exist'); 2014 | }); 2015 | }); 2016 | ---- 2017 | ==== 2018 | 2019 | . Add environment variables with your credentials to the `.okta.env` (or `.okta.env.bat`) file you created earlier. 2020 | + 2021 | [source,shell] 2022 | ---- 2023 | export CYPRESS_E2E_DOMAIN= # use the raw value, no https prefix 2024 | export CYPRESS_E2E_USERNAME= 2025 | export CYPRESS_E2E_PASSWORD= 2026 | ---- 2027 | 2028 | . Run `source .okta.env` (or `.okta.env.bat` on Windows) to set these environment variables and start the app. 2029 | + 2030 | [source,shell] 2031 | ---- 2032 | mvn spring-boot:run -Pprod 2033 | ---- 2034 | 2035 | . In another terminal window, run the Cypress tests with Electron. 2036 | + 2037 | [source,shell] 2038 | ---- 2039 | source .okta.env 2040 | cd app 2041 | npx cypress run --browser electron --config baseUrl=http://localhost:8080 2042 | ---- 2043 | 2044 | === Fix Unit Tests 2045 | 2046 | . If you run `npm test`, you'll see several failures. Fix them: 2047 | - Update `home.component.spec.ts` to import `HttpClientTestingModule` 2048 | - Update `app.component.spec.ts` to import `MatToolBarModule` and look for `JUG Tours` 2049 | - Update group components to import `HttpClientTestingModule` and `RouterTestingModule` 2050 | 2051 | . Run `mvn test` without setting environment variables and your tests will fail. 2052 | - Add `TestSecurityConfiguration` to mock an OAuth provider [`sba-test-config`] 2053 | - Update `JugToursApplicationTests`: 2054 | + 2055 | [source,java] 2056 | ---- 2057 | @SpringBootTest(classes = {JugtoursApplication.class, TestSecurityConfiguration.class}) 2058 | ---- 2059 | 2060 | . Run `mvn test` again, and your tests will pass. 🎉 2061 | 2062 | == Use GitHub Actions to Build and Test Your App 2063 | 2064 | . Add a GitHub workflow at `.github/workflows/main.yml` to prove that your tests run in CI. [`sba-ci`] 2065 | + 2066 | .`main.yml` 2067 | [%collapsible] 2068 | ==== 2069 | [source,yaml] 2070 | ---- 2071 | name: JUG Tours CI 2072 | 2073 | on: [push, pull_request] 2074 | 2075 | jobs: 2076 | build: 2077 | name: Build and Test 2078 | runs-on: ubuntu-latest 2079 | steps: 2080 | - name: Checkout 2081 | uses: actions/checkout@v4 2082 | - name: Set up Java 17 2083 | uses: actions/setup-java@v4 2084 | with: 2085 | distribution: 'temurin' 2086 | java-version: 17 2087 | cache: 'maven' 2088 | - name: Run tests 2089 | run: xvfb-run mvn verify -ntp -Pprod 2090 | - name: Run e2e tests 2091 | uses: cypress-io/github-action@v6 2092 | with: 2093 | browser: chrome 2094 | start: mvn spring-boot:run -Pprod -ntp -f ../pom.xml 2095 | install: false 2096 | wait-on: http://localhost:8080 2097 | wait-on-timeout: 120 2098 | config: baseUrl=http://localhost:8080 2099 | working-directory: app 2100 | env: 2101 | OKTA_OAUTH2_ISSUER: ${{ secrets.OKTA_OAUTH2_ISSUER }} 2102 | OKTA_OAUTH2_CLIENT_ID: ${{ secrets.OKTA_OAUTH2_CLIENT_ID }} 2103 | OKTA_OAUTH2_CLIENT_SECRET: ${{ secrets.OKTA_OAUTH2_CLIENT_SECRET }} 2104 | CYPRESS_E2E_DOMAIN: ${{ secrets.CYPRESS_E2E_DOMAIN }} 2105 | CYPRESS_E2E_USERNAME: ${{ secrets.CYPRESS_E2E_USERNAME }} 2106 | CYPRESS_E2E_PASSWORD: ${{ secrets.CYPRESS_E2E_PASSWORD }} 2107 | - name: Upload screenshots 2108 | uses: actions/upload-artifact@v3 2109 | if: failure() 2110 | with: 2111 | name: cypress-screenshots 2112 | path: app/cypress/screenshots 2113 | ---- 2114 | ==== 2115 | 2116 | . Add environment variables for the above secrets to your GitHub repository at *Settings* > *Secrets and variables* > *Actions* > *New Repository Secret*. 2117 | 2118 | . Push your changes to GitHub and watch the https://github.com/oktadev/auth0-spring-boot-angular-crud-example/actions/workflows/main.yml[main workflow] run. ✅ 2119 | 2120 | == Build Something Fabulous with Spring Boot and Angular! 2121 | 2122 | I hope you enjoyed this demo, and it helped you learn how to use Spring Boot with Angular securely. 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! 2123 | 2124 | 🅰️ Find the source code on GitHub: https://github.com/oktadev/auth0-spring-boot-angular-crud-example[@oktadev/auth0-spring-boot-angular-crud-example] 2125 | 2126 | 🍃 Read the blog post: https://auth0.com/blog/spring-boot-angular-crud/[Build a Beautiful CRUD App with Spring Boot and Angular] 2127 | 2128 | If you like Spring Boot and Angular, you might love the InfoQ mini-books I've written: 2129 | 2130 | - https://www.infoq.com/minibooks/jhipster-mini-book/[The JHipster Mini-Book]: Shows how I built https://www.21-points.com[21-Points Health] with JHipster (Angular, Spring Boot, Bootstrap, and more). It includes a chapter on microservices with Spring Boot, React, and Auth0. 2131 | - https://www.infoq.com/minibooks/angular-mini-book/[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. 2132 | --------------------------------------------------------------------------------