Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.
Week 1 - Setup Pet Clinic App. Deployment using Docker.
Week 2 - Domains, AWS, ER Diagrams and Databases, Understand the Pet Clinic Setup.
Week 3 - MVC, Repository interfaces, Thymeleaf, Internationalization, Controller Unit Tests,
Week 4 - Use Case Narrative, Sequence Diagram, State Chart Diagram, Create new objects.
Lesson 5 - Users, roles, and permissions setup from Java 2. Unit Tests. Show object details.
Lesson 6 - User Registration, flash messages, Session Cookies
Lesson 7 - Login and logout. Midterm Review. Manage the user profile. More form controls.
Lesson 8 - Midterm exam. Prevent duplicate insert records.
Lesson 9 - Edit and delete user profile. Password reset. GitHub Actions.
Lesson 10 - Recipe API. User permissions. Custom not found page.
Lessons 11-12 - Update and delete existing Location objects.
Lesson 13 - Search, sort, filter records.
Lesson 14 - Email and SMS messaging.
Not getting to - Failed login attempts. Web Sockets. Date and currency formatting. Shopping Cart. Payment processing. Event Registration, project-specific features.
http://localhost:8080/schools/xxx
Doesn't show 404 error.
Add a failed to connect toast if cannot connect to db
Sort and filter the list of schools
Add school logo, color
If the user enters an incorrect email or password N times, can their account be disabled?
Create the database entities for the intramural leagues and teams now?
Get the location's lat/lon from LocationIQ
Get weather forecast at the location's lat/lon from the OpenWeatherAPI
Upcoming Leagues & Events
Allow SCHOOL_ADMINS to reuse leagues and events each year
XSS Attacks, other OWASP
First name field
<img src="http://localhost:9999/city.png" onclick="location='http://packt.com'">
<button class="btn btn-danger" onclick="location='http://packt.com'">Click</button>
An internal server error occurred.
could not execute statement [Data truncation: Data too long for column 'first_name' at row 1]
SQL injection
Juice box app
If I make a change to the profile.html file and make a new POST request without refreshing the page, I get a "Whitelabel Error Page" saying:
```
This application has no explicit mapping for /error, so you are seeing this as a fallback.
Sun Mar 01 23:11:58 CST 2026
There was an unexpected error (type=Internal Server Error, status=500).
User not found
java.lang.RuntimeException: User not found
at org.springframework.samples.petclinic.user.ProfileController.lambda$processProfileUpdate$1(ProfileController.java:82)
```
Is there a way to customize the Whitelabel Error Page
Azure Core Services
https://www.cbtnuggets.com/it-training/microsoft-azure/core-services-overview
Azure Fundamentals AZ-900
Azure Developer Associate AZ-204
https://www.cbtnuggets.com/it-training/microsoft-azure/developer-associate
Azure Infrastructure Solutions AZ-305
https://www.cbtnuggets.com/it-training/microsoft-azure/az-305
AWS Cloud Practitioner
https://aws.amazon.com/certification/certified-cloud-practitioner/
https://www.cbtnuggets.com/it-training/aws/cloud-practitioner
AWS Associate Developer
https://aws.amazon.com/certification/certified-developer-associate/
https://www.cbtnuggets.com/it-training/aws/developer-associate-aws
AWS Certified Solutions Architect
https://aws.amazon.com/certification/certified-solutions-architect-professional/
https://www.cbtnuggets.com/it-training/aws/solutions-architect-associate
Cloud Digital Leader
https://www.skills.google/paths/9
https://www.cbtnuggets.com/it-training/google-cloud/cloud-digital-leader
https://cloud.google.com/learn/certification/cloud-digital-leader/
Associate Cloud Engineer
https://www.skills.google/paths/11
https://www.cbtnuggets.com/it-training/google-cloud/associate-cloud-engineer
https://cloud.google.com/learn/certification/cloud-engineer/
Professional Cloud Developer
https://www.skills.google/paths/19
https://cloud.google.com/learn/certification/cloud-developer/
When NOT logged in:
"/schools" displays the list of schools WITHOUT the "Add School" button.
Clicking a school's name takes you to "/schools/schoolname" and displays "Join the Action" with a "Login" and "Register" button on the right side.
Accessing a school by id is currently not allowed because of this line in the SecurityConfig file:
.requestMatchers(HttpMethod.GET, "/schools", "/schools/{slug:[a-zA-Z-]+}").permitAll()
Accessing "/schools/new" will redirect you to the login page.
Accessing "/users/profile" will redirect you to the login page.
Accessing "/xxx" will display the custom 404 page defined in the "system.GlobalExceptionHandler" class
Accessing "/recipes" will display the recipe JSON data
Accessing any route with a slash at the end will redirect without a slash.
When logged in as a STUDENT:
"/schools" displays the list of schools WITHOUT the "Add School" button.
Clicking a school's name takes you to "/schools/schoolname" and displays "My Player Card" with some information about the user.
Trying to access "/schools/new" will display a "Forbidden" error message.
This page can be customized by editing the "system.GlobalExceptionHandler" class and creating an "error/403.html" file.
Trying to access "/users/profile" will display the "Edit Profile" page.
When logged in as a SCHOOL_ADMIN:
"/schools" displays the list of schools WITHOUT the "Add School" button.
Clicking a school's name takes you to "/schools/schoolname" and displays "My Player Card" with some information about the user.
We will change this week to allow the user to C/R/U/D location data.
Trying to access "/schools/new" will display a "Forbidden" error message.
Trying to access "/users/profile" will display the "Edit Profile" page.
When logged in as a SUPER_ADMIN:
"/schools" displays the list of schools WITH the "Add School" button.
Accessing "/schools/new" will display the form and allow submissions.
Note this line of code in the SecurityConfig file:
.requestMatchers("/schools/new")
.hasAuthority("MANAGE_ALL_SCHOOLS")
Note the use of .permitAll(), .hasAuthority(), and .authenticated()
Trying to access "/users/profile" will display the "Edit Profile" page.
Whenever you build a new private page (like an admin dashboard or a user's team settings), you must remember to add an explicit .requestMatchers(...).hasAuthority() rule for it in this config.
Running the UserDetailsServiceImplTest.loadUserByUsername
method fails saying "the return value of "Role.getPermissions()" is null".
Last week when we updated UserDetailsServiceImpl, we told it to look inside every Role and loop through its Permissions.
However, in your test file, we are currently creating a mock studentRole but aren't giving it a list of permissions.
To fix this, we just need to initialize an empty (or populated) Set of permissions for that role in your @BeforeEach setup method.
package org.springframework.samples.petclinic.user;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserDetailsServiceImplTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserDetailsServiceImpl userDetailsService;
private User testUser;
@BeforeEach
void setUp() {
testUser = new User();
testUser.setEmail("example-student@kirkwood.edu");
testUser.setPassword("hashedPassword");
Role studentRole = new Role();
studentRole.setName("STUDENT");
// Initialize a permission and add it to the role
Permission viewLeaguesPermission = new Permission();
viewLeaguesPermission.setName("VIEW_LEAGUES");
studentRole.setPermissions(Set.of(viewLeaguesPermission));
testUser.setRoles(Set.of(studentRole));
}
@Test
void loadUserByUsername() {
// Arrange
when(userRepository.findByEmail(testUser.getEmail())).thenReturn(Optional.of(testUser));
// Act
UserDetails userDetails = userDetailsService.loadUserByUsername(testUser.getEmail());
// Assert
assertNotNull(userDetails);
assertEquals(testUser.getEmail(), userDetails.getUsername());
assertEquals(testUser.getPassword(), userDetails.getPassword());
// Check that the ROLE was loaded correctly (Requires "ROLE_" prefix)
assertTrue(userDetails.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_STUDENT")));
// Check that the PERMISSION was loaded correctly (No prefix)
assertTrue(userDetails.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("VIEW_LEAGUES")));
verify(userRepository, times(1)).findByEmail(testUser.getEmail());
}
}The SchoolControllerTest.testProcessCreationFormSuccess
method fails with an error saying "Error creating bean with name 'schoolController'"
When we updated your SchoolController to fetch the logged-in student, we added UserRepository to its constructor.
Add the UserRepository to your class variables and annotate it with @MockitoBean.
import org.springframework.samples.petclinic.user.UserRepository;
// ... your other imports ...
@WebMvcTest(SchoolController.class)
@Import(SecurityConfig.class)
class SchoolControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private SchoolRepository schools;
@MockitoBean
private UserRepository userRepository;
private School school;
// ...Also, now that we locked down /schools/new so only users with the MANAGE_SCHOOLS authority can access it, your tests are going to fail because the test runner is acting as an anonymous guest.
@WebMvcTest is a "sliced" test that only loads your SchoolController and basic Spring MVC components. It ignores all of your @Configuration classes. Because your SecurityConfig was left out of the test environment, Thymeleaf has no idea how to process Spring Security tags like sec:authorize, so it throws that TemplateProcessingException.
The @Import annotation forces the test to load your custom security rules and the Thymeleaf security dialect.
import org.springframework.samples.petclinic.user.SecurityConfig;
import org.springframework.context.annotation.Import;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
// ... your other imports ...
@WebMvcTest(SchoolController.class)
@Import(SecurityConfig.class)
class SchoolControllerTest {
// ...
@MockitoBean
private UserDetailsService userDetailsService;
@MockitoBean
private AuthenticationConfiguration authenticationConfiguration;
// ...When you run your application normally, Spring Boot's background auto-configuration provides the HttpSecurity builder for you. However, because @WebMvcTest strips that away, your SecurityConfig is left standing there asking for an HttpSecurity object that no longer exists.
Open your SecurityConfig.java file and add the @EnableWebSecurity annotation right below your @Configuration annotation.
Running the SchoolControllerTest.testShowSchoolList method should now pass.
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
// ... your other imports ...
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// ... your existing beans ...
}Back in SchoolControllerTest, we need to add a Security MockMvc Request Post-Processor to allow you to fake a logged-in user with specific authorities just for the duration of a test. It forces the authentication token directly into the exact HTTP request being performed.
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
// ... your other imports ...
@Test
@DisplayName("User clicks \"Add School\" -> GET /schools/new")
void testInitCreationForm() throws Exception {
mockMvc.perform(get("/schools/new")
// 1. INJECT THE SUPER_ADMIN PRIVILEGE DIRECTLY INTO THE REQUEST
.with(user("super_admin@kirkwood.edu")
.authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
.andExpect(status().isOk())
.andExpect(view().name("schools/createOrUpdateSchoolForm"))
.andExpect(model().attributeExists("school"));
}
@Test
@DisplayName("Validation Passed -> verify that the controller tells the repository to save() the school and then redirects us.")
void testProcessCreationFormSuccess() throws Exception {
mockMvc.perform(post("/schools/new")
.param("name", "University of Iowa")
.param("domain", "uiowa.edu")
// 2. INJECT IT INTO THE POST REQUEST
.with(user("super_admin@kirkwood.edu")
.authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/schools"));
verify(schools).save(any(School.class));
}
@Test
@DisplayName("Validation Failed -> send an empty domain and ensure the controller returns us to the form instead of saving.")
void testProcessCreationFormHasErrors() throws Exception {
mockMvc.perform(post("/schools/new")
.param("name", "Bad School")
.param("domain", "")
// 3. INJECT IT INTO THE FAILED POST REQUEST
.with(user("super_admin@kirkwood.edu")
.authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
.andExpect(status().isOk())
.andExpect(model().attributeHasErrors("school"))
.andExpect(model().attributeHasFieldErrors("school", "domain"))
.andExpect(view().name("schools/createOrUpdateSchoolForm"));
}Back in week 3, I asked you to remove some lines from the CrashControllerIntegrationTests class.
Add those lines back and drop the following dummy security bean inside it.
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
// ... your other imports ...
class CrashControllerIntegrationTests {
// code omitted
@Test
void testTriggerExceptionHtml() {
// code omitted
}
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class, HibernateJpaAutoConfiguration.class })
static class TestConfiguration {
// ADD THIS: A dummy security configuration just for this test
@Bean
public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());
return http.build();
}
}
}The CrashControllerIntegrationTests.testTriggerExceptionJson method is failing with an error saying: "Caused by: HttpMessageNotReadableException: JSON parse error: Unrecognized token 'An'.
Instead of letting Spring Boot generate the standard JSON error map that the test expects, the GlobalExceptionHandler is catching the exception and returning a plain text string that starts with "An unexpected error occurred...". When Jackson tries to parse that plain string as a JSON Map, it fails on the first word ("An").
The cleanest solution is to tell this specific isolated test environment to completely ignore all custom exception handlers. You can do this by adding a @ComponentScan exclusion filter to the nested TestConfiguration class.
import org.springframework.context.annotation.ComponentScan;
// -- Other imports --
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class, HibernateJpaAutoConfiguration.class })
// ADD THIS: Instructs the test context to ignore all custom global exception handlers
@ComponentScan(excludeFilters = @ComponentScan.Filter(
type = org.springframework.context.annotation.FilterType.ANNOTATION,
classes = org.springframework.web.bind.annotation.ControllerAdvice.class))
static class TestConfiguration {The MySqlIntegrationTests.testSchoolDetails method fails saying
java.lang.AssertionError:
Expecting actual:
"<!DOCTYPE html>...to contain: "Kirkwood Community College"
Either change RequestEntity.get("/schools/1") to RequestEntity.get("/schools/kirkwood")
or
You need to add the numeric ID pattern to your requestMatchers list in your SecurityConfig.java file . Additionally, you should update the slug pattern in this configuration to match an updated regex to be applied soon to the SchoolController, otherwise schools with numbers in their name (like "kirkwood2") will also be blocked by Spring Security.
.requestMatchers(HttpMethod.GET,
"/schools",
"/schools/{schoolId:\\d+}",
"/schools/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}").permitAll()The PetControllerTests currently fail because our SecurityConfig doesn't allow those requests.
.requestMatchers(
"/",
"/register-student",
"/resources/**",
"/recipes/**",
"/recipes/new",
"/owners/**",
"/pets/**",
"/vets/**",
"/vets.html"
).permitAll()The routes will work, but the tests will still fail because of how the @WebMvcTest annotation behaves. Remember, it creates a "sliced" application context, meaning it only loads the specified controller (PetController) and ignores your custom @Configuration classes—including your SecurityConfig.
Because your SecurityConfig is missing from the test environment, Spring Boot applies its strict default security settings blocking all endpoints (returning 401 Unauthorized for your GET requests) and requires a CSRF token for all data submissions (returning 403 Forbidden for your POST requests).
The most direct solution is to instruct the mock environment to disable its web filters. This bypasses the default security layer and allows the tests to evaluate the controller logic directly.
Add the @AutoConfigureMockMvc(addFilters = false) annotation to your class header:
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
// -- Other imports --
@WebMvcTest(value = PetController.class,
includeFilters = @ComponentScan.Filter(value = PetTypeFormatter.class, type = FilterType.ASSIGNABLE_TYPE))
@AutoConfigureMockMvc(addFilters = false) // <-- Add this line
@DisabledInNativeImage
@DisabledInAotMode
class PetControllerTests {
// ... all existing setup and test methods remain unchanged ...
}The testInitCreationForm method fails saying: `TemplateProcessingException: Exception evaluating SpringEL expression: "opt.key" (template: "fragments/selectField" - line 13, col 15)`.
The original PetClinic fragment was built assuming every select menu would be populated by a List of custom objects (like PetType). However, your user profile controller is passing a Map for the languages.
To resolve this, we can use Thymeleaf's th:if and th:unless attributes to create two separate, isolated blocks of HTML. This guarantees Thymeleaf will never attempt to read a .key property unless it is certain the object is a Map.
<select class="form-select"
th:classappend="${valid ? '' : 'is-invalid'}"
th:field="*{__${name}__}"
th:id="${name}">
<th:block th:if="${options instanceof T(java.util.Map)}">
<option th:each="opt : ${options}"
th:value="${opt.key}"
th:text="${opt.value}">Option</option>
</th:block>
<th:block th:unless="${options instanceof T(java.util.Map)}">
<option th:each="opt : ${options}"
th:value="${opt}"
th:text="${opt}">Option</option>
</th:block>
</select>If I am logged in with a "kirkwood.edu" email address, I want to see my player card on "/schools/kirkwood", but not "/schools/uiowa".
If the user's email address doesn't match the current school, we can update the schoolDetails.html file have an alternate block that says something like "You must have an active uiowa.edu email address to register for University of Iowa intramurals."
We can use Thymeleaf's built-in #strings.endsWith() utility method to compare the end of the user's email address against the specific school's domain dynamically.
Update the right column in your schools/schoolDetails.html file to look like this:
<div class="col-lg-3">
<div th:if="${currentUser == null}" class="card shadow-sm">
<div class="card-body text-center">
<h5 class="card-title">Join the Action</h5>
<p class="card-text text-sm">Log in or register to join teams and view your player stats.</p>
<a th:href="@{/login}" class="btn btn-primary w-100 mb-2">Log In</a>
<a th:href="@{/register-student}" class="btn btn-outline-secondary w-100">Register</a>
</div>
</div>
<div th:if="${currentUser != null and !#strings.endsWith(currentUser.email, '@' + school.domain) and !#strings.endsWith(currentUser.email, '.' + school.domain)}" class="card shadow-sm border-warning">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0">Access Restricted</h5>
</div>
<div class="card-body text-center">
<i class="fa fa-exclamation-triangle text-warning mb-2" style="font-size: 2rem;"></i>
<p class="card-text text-sm"
th:text="'You must have an active ' + ${school.domain} + ' email address to register for ' + ${school.name} + ' intramurals.'">
</p>
</div>
</div>
<div th:if="${currentUser != null and (#strings.endsWith(currentUser.email, '@' + school.domain) or #strings.endsWith(currentUser.email, '.' + school.domain))}" class="card shadow-sm border-primary">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">My Player Card</h5>
<a th:href="@{/users/profile}" class="btn btn-sm btn-light">Edit</a>
</div>
<div class="card-body">
<th:block th:if="${!#strings.isEmpty(currentUser.firstName) or !#strings.isEmpty(currentUser.nickname)}">
<h5 class="card-title text-primary">
<span th:if="${!#strings.isEmpty(currentUser.nickname)}"
th:text="${currentUser.nickname}">Nickname</span>
<span th:if="${#strings.isEmpty(currentUser.nickname)}"
th:text="${currentUser.firstName + (!#strings.isEmpty(currentUser.lastName) ? ' ' + currentUser.lastName : '')}">First Last</span>
</h5>
<hr>
</th:block>
<div class="mb-3 text-sm">
<strong>Email:</strong> <br>
<span th:text="${currentUser.email}">student@school.edu</span>
<span th:if="${currentUser.publicEmail}" class="badge bg-success float-end">Public</span>
</div>
<div class="mb-3 text-sm" th:if="${currentUser.phone}">
<strong>Phone:</strong> <br>
<span th:text="${currentUser.phone}">(319) 555-0199</span>
<span th:if="${currentUser.publicPhone}" class="badge bg-success float-end">Public</span>
</div>
<div class="text-sm">
<strong>Language:</strong>
<span th:switch="${currentUser.preferredLanguage}">
<span th:case="'EN'">English</span>
<span th:case="'ES'">Spanish</span>
<span th:case="'KO'">Korean</span>
<span th:case="*">Not specified</span>
</span>
</div>
</div>
</div>
</div>The string concatenation '@' + school.domain ensures that a user with the email admin@uiowa.edu is not accidentally granted access to a school domain like iowa.edu.
the logic needs to check for two valid conditions:
The email ends with @kirkwood.edu (usually faculty or staff).
The email ends with .kirkwood.edu (usually students on a subdomain).
Adding the . check accurately catches the subdomain without accidentally granting access to spoofed domains (like hacker@badkirkwood.edu).
Since all mappings in the SchoolController starts with "/schools", update it to use a @RequestMapping annotation.
@Controller
@RequestMapping("/schools")
public class SchoolController {
// Code omitted
@GetMapping
public String showSchoolList(@RequestParam(defaultValue = "1") int page, Model model) {
// Code omitted
}
@GetMapping("/new")
public String initCreationForm(Map<String, Object> model) {
// Code omitted
}
@PostMapping("/new")
public String processCreationForm(@Valid School school, BindingResult result) {
// Code omitted
}
// Matches ONLY numbers (e.g., /schools/1)
@GetMapping("/{schoolId:\\d+}")
public ModelAndView showSchoolById(@PathVariable("schoolId") int schoolId) {
// Code omitted
}
// Matches text (e.g., /schools/kirkwood)
@GetMapping("/{slug:[a-zA-Z-]+}")
public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
// Code omitted
}
}SCHOOL_ADMIN users with "MANAGE_FACILITIES" permissions should be able to update only their own school's info.
SUPER_ADMIN users with "MANAGE_ALL_SCHOOLS" permissions will be able to update any school's info.
Add two new methods to the SchoolController to determine if the current user has edit privileges.
// New imports
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
// New methods
private boolean checkEditPermissions(School school) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// Handle unauthenticated users safely
if (auth == null || !auth.isAuthenticated() || auth.getName().equals("anonymousUser")) {
return false;
}
String userEmail = auth.getName();
boolean isSuperAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));
boolean isSchoolAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("MANAGE_FACILITIES"));
boolean belongsToSchool = userEmail.endsWith("@" + school.getDomain()) ||
userEmail.endsWith("." + school.getDomain());
return isSuperAdmin || (isSchoolAdmin && belongsToSchool);
}
private void verifyEditPermissions(School school) {
if (!checkEditPermissions(school)) {
throw new AccessDeniedException("You do not have permission to edit this school.");
}
}In your SchoolController.java, add an initUpdateForm method. They will extract the logged-in user's email and authorities to verify they have the right to edit the requested school.
@GetMapping("/{id}/edit")
public String initUpdateForm(@PathVariable("id") int id, Model model) {
School school = schoolRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
verifyEditPermissions(school);
model.addAttribute("school", school);
return "schools/createOrUpdateSchoolForm";
}Update your two showSchool endpoints to evaluate the school against the logged-in user and pass the canEdit boolean to the model.
@GetMapping("/{schoolId:\\d+}")
public ModelAndView showSchoolById(@PathVariable("schoolId") int schoolId) {
ModelAndView mav = new ModelAndView("schools/schoolDetails");
School school = schoolRepository.findById(schoolId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
mav.addObject(school);
// ADD THIS LINE
mav.addObject("canEdit", checkEditPermissions(school));
return mav;
}Update your two showSchool endpoints to evaluate the school against the logged-in user and pass the canEdit boolean to the model.
@GetMapping("/{slug:[a-zA-Z-]+}")
public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
String fullDomain = slug + ".edu";
ModelAndView mav = new ModelAndView("schools/schoolDetails");
School school = schoolRepository.findByDomain(fullDomain)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
mav.addObject(school);
// ADD THIS LINE
mav.addObject("canEdit", checkEditPermissions(school));
if (principal != null) {
// ... remainder of your existing method ...Here is the updated HTML block in the schoolDetails.html page.
By adding Bootstrap's flexbox utility classes to the container, the heading and the button will automatically align to opposite sides of the screen.
<html xmlns:th="https://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/extras/spring-security"
th:replace="~{fragments/layout :: layout (~{::body},'schools')}">
<!-- Code omitted -->
<div class="mb-4 pb-2 border-bottom d-flex justify-content-between align-items-center">
<h1 class="mb-0"><span th:text="${school.name}">School Name</span> Intramurals</h1>
<a th:href="@{|/schools/${school.id}/edit|}"
th:if="${canEdit}"
class="btn btn-outline-primary">
Edit School
</a>
</div>The | characters in Thymeleaf represent Literal Substitution.
They act exactly like template literals in JavaScript (the backtick ` syntax) or f-strings in Python. They allow you to seamlessly mix plain text and variables without needing to use clunky string concatenation operators.
Without the pipes, you would have to use plus signs and single quotes to build the URL piece by piece, which is harder to read and prone to syntax errors:
@{'/schools/' + ${school.id} + '/edit'}
By wrapping the entire path in pipes, Thymeleaf knows to treat the whole block as a single string while still evaluating the ${school.id} variable inside it:
@{|/schools/${school.id}/edit|}
| charactersA student trying to access "/schools/1/edit" will get an error from the verifyEditPermissions method saying "You do not have permission to edit this school." School and Super Admins will not
Spring Boot provides a built-in mechanism to handle error pages dynamically based on your application's configuration properties.
This means you can display detailed error attributes (like the message and stack trace) during local development and hide them when deployed to production.
Spring Boot uses a feature called "Profiles" to manage different configurations for different environments.
By default, Spring Boot looks for a file named application.properties in your src/main/resources directory. This acts as your base configuration file. Any settings placed here apply to your application universally.
Create an application-dev.properties
# Display detailed error information on localhost
spring.web.error.include-message=always
spring.web.error.include-stacktrace=always
Create an application-prod.properties
# Hide detailed error information in production
spring.web.error.include-message=never
spring.web.error.include-stacktrace=never
When you create files named application-{profile}.properties, Spring Boot loads them alongside the base file when that specific profile is activated.
The profile-specific file takes precedence. If a property exists in both files, the value in the active profile file will override the value in the base file.
Spring Boot needs to be instructed on which profile is currently active.
You can set the active profile directly in your application.properties file.
# Set to dev locally, and prod on a cloud server
spring.profiles.active=${ACTIVE_PROFILE:dev}
Using ${ACTIVE_PROFILE} tells Spring Boot to look for an environment variable with that exact name at runtime.
This configuration tells Spring Boot to use the dev profile if the ACTIVE_PROFILE environment variable is missing. This prevents the application from failing to start during local development if you forget to set the variable on your machine.
To inject an environment variable into your Azure Container App, append the --set-env-vars parameter to your existing command.
az containerapp update `
--name <your-container-app-name> `
--resource-group <your-resource-group-name> `
--image <your-docker-username>/<your-imagename>:<your-version> `
--set-env-vars ACTIVE_PROFILE=prod
If you need to add multiple environment variables in the future, you can pass them as a space-separated list to the same parameter:
--set-env-vars ACTIVE_PROFILE=prod DB_PASSWORD=your_password API_KEY=your_key
Spring Boot's BasicErrorController automatically passes error attributes to views located in the src/main/resources/templates/error/ directory.
Create a new file named 500.html in your templates/error/ directory:
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'error')}">
<body>
<div class="container text-center mt-5">
<h1 class="display-4 text-danger">500</h1>
<h2>Internal Server Error</h2>
<p class="lead">The server encountered an unexpected condition that prevented it from fulfilling the request.</p>
<a th:href="@{/}" class="btn btn-outline-primary mt-3">Return to Home</a>
<div th:if="${trace != null}" class="alert alert-warning mt-5 text-start shadow-sm">
<h4 class="alert-heading">Developer Details</h4>
<hr>
<p class="mb-1"><strong>Path:</strong> <span th:text="${path}">/path</span></p>
<p class="mb-1"><strong>Error:</strong> <span th:text="${error}">Exception Type</span></p>
<p class="mb-3"><strong>Message:</strong> <span th:text="${message}">Exception message goes here</span></p>
<div class="bg-light p-3 border rounded overflow-auto" style="max-height: 400px;">
<pre th:text="${trace}" class="mb-0" style="font-size: 0.85rem;"></pre>
</div>
</div>
</div>
</body>
</html>When your verifyEditPermissions method throws an AccessDeniedException, Spring Security automatically intercepts it and translates it into an HTTP 403 (Forbidden) status code, rather than an HTTP 500 (Internal Server Error).
Create a new file named 403.html in your src/main/resources/templates/error/ directory:
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'error')}">
<body>
<div class="container text-center mt-5">
<h1 class="display-4 text-warning">403</h1>
<h2>Access Denied</h2>
<p class="lead">You do not have the required permissions to view or modify this resource.</p>
<a th:href="@{/}" class="btn btn-outline-primary mt-3">Return to Home</a>
<div th:if="${trace != null}" class="alert alert-warning mt-5 text-start shadow-sm">
<h4 class="alert-heading">Developer Details</h4>
<hr>
<p class="mb-1"><strong>Path:</strong> <span th:text="${path}">/path</span></p>
<p class="mb-1"><strong>Error:</strong> <span th:text="${error}">Exception Type</span></p>
<p class="mb-3"><strong>Message:</strong> <span th:text="${message}">Exception message goes here</span></p>
<div class="bg-light p-3 border rounded overflow-auto" style="max-height: 400px;">
<pre th:text="${trace}" class="mb-0" style="font-size: 0.85rem;"></pre>
</div>
</div>
</div>
</body>
</html>handleGeneralError method from your GlobalExceptionHandler.Spring Boot's BasicErrorController is programmed to automatically look for a file named 500.html inside the templates/error/ directory whenever an unhandled server exception occurs.
By removing your manual interceptor, the default controller will take over, read your spring.web.error.include-stacktrace=always property, and automatically inject the stack trace into the Thymeleaf model.
Login as a student and try to access "/schools/1/edit". You will see the new error page.
To simulate a true 500 Internal Server Error, you can inject a hard crash into one of your existing routes.
Open your SchoolController and add a throw new RuntimeException statement in a new route:
@GetMapping("/bad")
public void badSchoolRequest() {
if (true) {
throw new RuntimeException("This is a simulated database failure to test the 500 page stack trace.");
}
}Visit "/schools/bad" to see this error.
The changes you just made will cause the testTriggerExceptionHtml method to fail.
@GetMapping("/bad")
public void badSchoolRequest() {
if (true) {
throw new RuntimeException("This is a simulated database failure to test the 500 page stack trace.");
}
}The original Pet Clinic test was written to look for the generic <h2>Something happened...</h2> text from the default error.html template. When the application was updated to use a custom 500.html template with the developer details block, the response body no longer matches the expected subsequence.
To fix this, update the containsSubsequence assertion to match the text and layout of your new 500 error page.
@Test
void testTriggerExceptionHtml() {
HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.TEXT_HTML));
ResponseEntity<String> resp = rest.exchange("http://localhost:" + port + "/oups", HttpMethod.GET,
new HttpEntity<>(headers), String.class);
assertThat(resp).isNotNull();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(resp.getBody()).isNotNull();
// Updated to match the custom 500.html structure
assertThat(resp.getBody()).containsSubsequence(
"<h2>", "Internal Server Error", "</h2>",
"Message:", "Expected:", "controller", "used", "to", "showcase", "what", "happens", "when", "an", "exception", "is", "thrown"
);
// Not the whitelabel error page:
assertThat(resp.getBody()).doesNotContain("Whitelabel Error Page",
"This application has no explicit mapping for");
}When a super admin creates a new school, we are now redirecting them to the school's page instead a list of all schools.
Update the testProcessCreationFormSuccess form accordingly.
@Test
@DisplayName("Validation Passed -> verify that the controller tells the repository to save() the school and then redirects us.")
void testProcessCreationFormSuccess() throws Exception {
mockMvc.perform(post("/schools/new")
.param("name", "University of Iowa")
.param("domain", "uiowa.edu")
.with(user("super_admin@kirkwood.edu")
.authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/schools/uiowa"));
// Verify that the repository.save() method was actually called
verify(schools).save(any(School.class));
}Only SUPER_ADMIN users will have the ability to change a school's status from active to inactive or suspended.
In createOrUpdateSchoolForm.html: Wrap the following status dropdown field so it only renders for Super Admins.
Your database and Java entity use an Enum for the school status. In Java, enum constants are strictly case-sensitive and are almost always defined in uppercase (e.g., ACTIVE, INACTIVE, SUSPENDED).
<html xmlns:th="https://www.thymeleaf.org"
xmlns:sec="https://www.thymeleaf.org/extras/spring-security"
th:replace="~{fragments/layout :: layout (~{::body},'schools')}
<!-- Code omitted -->
<div sec:authorize="hasAuthority('MANAGE_ALL_SCHOOLS')" class="mb-3">
<label for="status" class="form-label">School Status</label>
<select class="form-select" id="status" th:field="*{status}">
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
<option value="SUSPENDED">Suspended</option>
</select>
</div>th:field="*{status}" is a Thymeleaf attribute that binds an HTML form input directly to a specific property on your backend Java object.When you use th:field, Thymeleaf generates three standard HTML attributes for that element when the page renders:
id="status": Assigns an ID so your <label for="status"> can connect to the input.
name="status": Tells the browser what key to use when sending the submitted form data back to your Spring controller.
value="...": Automatically pre-fills the input with the current value of the object. If you are editing an existing school, it selects the correct dropdown option based on what is in the database.
*{...})The asterisk is a "selection variable expression." It is designed to be used inside a parent tag that has a th:object attribute defined.
In your createOrUpdateSchoolForm.html file, your main <form> tag likely looks like this:
<form th:object="${school}" method="post">
Because the form has already "selected" the ${school} object, any input inside that form can use the asterisk shorthand. Writing *{status} is simply a shorter, cleaner way of writing ${school.status}.
The <form> tag is missing the th:action attribute. Without it, Thymeleaf does not inject the hidden CSRF (Cross-Site Request Forgery) security token. When you click submit, Spring Security sees a POST request without a token, assumes it is a malicious attack, and silently blocks the submission.
The "Add School" text (heading and button)is static. We need to add Thymeleaf conditional logic to check if the school has an ID.
<h2 th:text="${school.id == null} ? 'Add School' : 'Update School'">Add School</h2>
<form th:object="${school}" class="form-horizontal" id="add-school-form" method="post"
th:action="${school.id == null} ? @{/schools/new} : @{|/schools/${school.id}/edit|}">
<!-- code omitted -->
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button class="btn btn-primary" type="submit"
th:text="${school.id == null} ? 'Add School' : 'Update School'">Add School</button>
</div>
</div>
</form>In your SchoolController.java, add a processUpdateForm method to process the form for logged-in user's that have the right to edit the requested school.
@PostMapping("/{id}/edit")
public String processUpdateForm(@Valid School school, BindingResult result, @PathVariable("id") int id) {
School existingSchool = schoolRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
verifyEditPermissions(existingSchool);
if (result.hasErrors()) {
return "schools/createOrUpdateSchoolForm";
}
// Prevent standard admins from modifying the status via form tampering
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean isSuperAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));
if (!isSuperAdmin) {
school.setStatus(existingSchool.getStatus());
}
school.setId(id);
schoolRepository.save(school);
// Strip ".edu" for the redirect to match your slug regex [a-zA-Z-]+
String slug = school.getDomain().replace(".edu", "");
return "redirect:/schools/" + slug;
}You should also update the redirect at the end of your existing processCreationForm method to use the slug format:
@PostMapping("/new")
public String processCreationForm(@Valid School school, BindingResult result) {
if (result.hasErrors()) {
return "schools/createOrUpdateSchoolForm";
}
schoolRepository.save(school);
// Update redirect to use the slug
String slug = school.getDomain().replace(".edu", "");
return "redirect:/schools/" + slug;
}Assume, you didn't capitalize the values in the select block:
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="suspended">Suspended</option>
The form would no longer submit without displaying errors.
Add this inside the form tag of the createOrUpdateSchoolForm file to display the errors.
Placing this before the form tag will result in a TemplateInputException.
<div th:if="${#fields.hasAnyErrors()}" class="alert alert-danger">
<p class="mb-0">Please correct the highlighted errors before submitting.</p>
<ul>
<li th:each="err : ${#fields.errors('*')}" th:text="${err}">Error message</li>
</ul>
</div>Assume, you didn't capitalize the values in the select block:
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="suspended">Suspended</option>
The form would no longer submit without displaying errors.
Add this inside the form tag of the createOrUpdateSchoolForm file to display the errors.
Placing this before the form tag will result in a TemplateInputException.
<div th:if="${#fields.hasAnyErrors()}" class="alert alert-danger">
<p class="mb-0">Please correct the highlighted errors before submitting.</p>
<ul>
<li th:each="err : ${#fields.errors('*')}" th:text="${err}">Error message</li>
</ul>
</div>When I add a new school named "Kirkwood2" with "kirkwood2.edu" as the domain, the program redirects me to "/schools/kirkwood2" and displays a 404 error.
Your current mapping looks like this:
@GetMapping("/{slug:[a-zA-Z-]+}")
This regex explicitly tells Spring Boot to only accept URLs that contain letters (a-z, A-Z) and hyphens (-). Because "kirkwood2" contains a number, Spring rejects it as an invalid path and throws a 404.
To resolve this, you need to add numbers (0-9) to the allowed characters in your regex pattern.
@GetMapping("/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}")
See an explanation on the next slide.
To keep your clean URLs without causing conflicts between /schools/2 and /schools/kirkwood2, we need to make the slug regex slightly more specific.
We can tell Spring Boot that a slug can contain letters, numbers, and hyphens, but it must contain at least one letter or hyphen. This guarantees it will never match a purely numeric ID.
[a-zA-Z0-9-]*: Allows zero or more alphanumeric characters or hyphens at the start.
[a-zA-Z-]: Requires exactly one letter or hyphen in the middle.
[a-zA-Z0-9-]*: Allows zero or more alphanumeric characters or hyphens at the end
With this change, /schools/1 will route to the ID method, and /schools/kirkwood2 will route to the slug method.
When editing a school, SCHOOL_ADMIN users should be able to manage locations.
A school that is inactive or suspended will have their upcoming leagues and events on the schoolDetails page replaced with a message saying their status.
When guest user accesses "/users/profile" it correctly redirects the user to the login page but doesn't display message.
It would be nice if it displayed a warning message saying something like "You must be logged in to edit your profile"
Spring Security intercepts the guest user and secretly saves the URL they were trying to visit in the session so it knows where to send them after they successfully log in.
We can tap into this "Saved Request" directly inside the Auth Controller to figure out where they came from and display a custom warning message.
Find the controller method that serves your custom login page (@GetMapping("/login")).
Inject the HttpSession and check for that saved request:
import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.servlet.http.HttpSession;
// ... inside your controller ...
@GetMapping("/login")
public String initLoginForm(Model model, HttpSession session) {
// 1. Ask the session if Spring Security saved an intercepted request
SavedRequest savedRequest = (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST");
// 2. If a request was saved, inspect the URL
if (savedRequest != null) {
String attemptedUrl = savedRequest.getRedirectUrl();
// 3. Customize the message based on where they were trying to go
if (attemptedUrl.contains("/users/profile")) {
model.addAttribute("messageWarning", "You must be logged in to edit your profile.");
} else {
model.addAttribute("messageWarning", "Please log in to access that page.");
}
}
User user = new User();
String lastEmail = (String)session.getAttribute("LAST_EMAIL");
if(lastEmail != null) {
user.setEmail(lastEmail);
session.removeAttribute("LAST_EMAIL");
}
model.addAttribute("user", user);
return "auth/loginForm";
}Now, if a user clicks the standard "Login" button in your navbar, they will just see the normal login form. But if they try to sneak into /users/profile, the bouncer will kick them to the login page and display your custom warning.
If your layout.html file doesn't contain a messageWarning alert box element, you can add one to your loginForm.html template and display that messageWarning attribute.
Add this somewhere near the top of your login form container:
<div th:if="${messageWarning}" class="alert alert-warning alert-dismissible fade show" role="alert">
<span th:text="${messageWarning}">Please log in.</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
The "You must be logged in to edit your profile." warning message displays when you visit "/users/profile".
The "Please log in to access that page." warning displays when I visit "/schools/new".
However, when I visit "/login" by clicking the login button, the last warning message re-appears.
When Spring Security intercepts an unauthenticated user, it places that SPRING_SECURITY_SAVED_REQUEST object into their session memory. That object stays in their session until they successfully log in. If a user gets redirected to the login page, sees the warning, decides not to log in, goes back to the homepage, and then later clicks the standard "Login" button... your controller looks into the session, sees the ghost of that old saved request still sitting there, and displays the warning again!
We can drop a second, temporary string into the session to keep track of which URL we just warned them about.
If the attempted URL matches the one we already warned them about, we just skip adding the message to the model.
Update your initLoginForm method to look like this:
@GetMapping("/login")
public String initLoginForm(Model model, HttpSession session) {
// Ask the session if Spring Security saved an intercepted request
SavedRequest savedRequest = (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST");
if (savedRequest != null) {
String attemptedUrl = savedRequest.getRedirectUrl();
// 1. Check if we have already warned them about this specific URL
String warnedUrl = (String) session.getAttribute("WARNED_URL");
// 2. Only show the message if this is a fresh interception
if (!attemptedUrl.equals(warnedUrl)) {
if (attemptedUrl.contains("/users/profile")) {
model.addAttribute("messageWarning", "You must be logged in to edit your profile.");
} else {
model.addAttribute("messageWarning", "Please log in to access that page.");
}
// 3. Mark this URL as "warned" so we don't show the message again
// if they navigate away and come back to the login page manually.
session.setAttribute("WARNED_URL", attemptedUrl);
}
}
User user = new User();
// Grab the failed email attempt from Spring Security's session memory
String lastEmail = (String) session.getAttribute("LAST_EMAIL");
if (lastEmail != null) {
user.setEmail(lastEmail);
session.removeAttribute("LAST_EMAIL");
}
model.addAttribute("user", user);
return "auth/loginForm";
}The guest user visits /users/profile. Spring Security intercepts them and saves the request.
They arrive at /login. The attemptedUrl is /users/profile. warnedUrl is null.
The controller shows the warning and sets WARNED_URL to /users/profile.
The user clicks "Home", then clicks "Login".
They arrive at /login. The attemptedUrl is /users/profile. warnedUrl is /users/profile.
They match! The controller skips the warning, and the user just sees a clean login form.
2027 To Fix - When visiting http://localhost:8080/users/profile twice, the warning shows one time, but not the second time.
To finish building the relationship of Schools and Locations, we will mirror the architectural pattern used for Owners and Pets. The School acts as the parent (like Owner), and the Location acts as the child (like Pet).
With the data layer configured (School.java, Location.java), the next step is to create the LocationRepository interface.
Because we will need to fetch locations specific to the school being viewed, add a custom finder method that filters by the school_id.
This uses a SQL Stored procedure, rather than a SELECT statement.
Using nativeQuery = true maps a stored procedure's result set directly into a List of Java Persistence API (JPA) entities.
package org.springframework.samples.petclinic.school;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface LocationRepository extends CrudRepository<Location, Integer> {
// Call the MySQL stored procedure natively
@Query(value = "CALL get_locations_by_school(:schoolId)", nativeQuery = true)
List<Location> findBySchoolId(@Param("schoolId") int schoolId);
}Spring Data JPA fully supports calling database procedures and mapping the result sets back to your Java entities.
However, please know that native queries and stored procedures completely bypass Hibernate's @SQLRestriction annotation (See line 18 of Location.java and line 24 of School.java).
If you use a stored procedure, you must manually enforce the soft-delete logic inside the SQL, otherwise, your application will start fetching deleted locations.
Add this procedure definition to your MySQL database. Notice that we must explicitly include the deleted_at IS NULL check.
DELIMITER //
CREATE PROCEDURE get_locations_by_school(IN p_school_id INT)
BEGIN
SELECT * FROM locations
WHERE school_id = p_school_id
AND deleted_at IS NULL;
END //
DELIMITER ;It is generally considered a best practice to avoid stored procedures for simple SELECT statements in a Spring Boot application.
When using this code instead...
@Query("SELECT l FROM Location l WHERE l.school.id = :schoolId")
List<Location> findBySchoolId(@Param("schoolId") int schoolId);
...Spring's built-in method naming conventions will...
...keep your business logic inside the Java codebase.
...keep your database agnostic (allowing you to switch to PostgreSQL or others without rewriting SQL)
...ensure annotations like @SQLRestriction function automatically.
Stored procedures are typically reserved for complex, multi-table data processing operations where the database engine can execute the logic faster than the Java application.
The next step is to create the LocationController to handle the routing and the MANAGE_FACILITIES permission checks.
By using Spring's @ModelAttribute annotation at the method level, we can intercept every request to this controller, fetch the parent School entity, and verify the user's permissions before any route-specific logic is executed. This prevents you from having to copy and paste the security check into every method.
package org.springframework.samples.petclinic.school;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@Controller
@RequestMapping("/schools/{schoolId}/locations")
public class LocationController {
private final SchoolRepository schoolRepository;
private final LocationRepository locationRepository;
public LocationController(SchoolRepository schoolRepository, LocationRepository locationRepository) {
this.schoolRepository = schoolRepository;
this.locationRepository = locationRepository;
}
// This executes before every route in this controller to populate the school and check permissions
@ModelAttribute("school")
public School findSchoolAndVerifyPermissions(@PathVariable("schoolId") int schoolId) {
School school = schoolRepository.findById(schoolId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || auth.getName().equals("anonymousUser")) {
throw new AccessDeniedException("Authentication required.");
}
String userEmail = auth.getName();
boolean isSuperAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));
boolean isSchoolAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("MANAGE_FACILITIES"));
boolean belongsToSchool = userEmail.endsWith("@" + school.getDomain()) ||
userEmail.endsWith("." + school.getDomain());
if (!isSuperAdmin && !(isSchoolAdmin && belongsToSchool)) {
throw new AccessDeniedException("You do not have permission to manage facilities for this school.");
}
return school;
}
}Inside the "src/main/resources/templates/schools" directory, create the createOrUpdateLocationForm.html file.
We will write a standard <select> block for the parent location rather than using your selectField fragment. This gives us direct control to map the <option value> to the location's ID, which Spring Data will automatically convert back into a Location entity.
For latitude and longitude, we will change the input type from 'number' to 'text'. Spring Boot is smart enough to parse the text into your BigDecimal variables in Location.java.
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'schools')}">
<body>
<h2 th:text="${location.id == null} ? 'Add Location' : 'Update Location'">Add Location</h2>
<form th:object="${location}" class="form-horizontal" id="add-location-form" method="post"
th:action="${location.id == null} ? @{|/schools/${school.id}/locations/new|} : @{|/schools/${school.id}/locations/${location.id}/edit|}">
<div th:if="${#fields.hasAnyErrors()}" class="alert alert-danger">
<p class="mb-0">Please correct the highlighted errors before submitting.</p>
</div>
<input th:replace="~{fragments/inputField :: input ('Name', 'name', 'text')}" />
<div class="mb-3">
<label for="parentLocation" class="form-label">Parent Location (Optional)</label>
<select class="form-select" id="parentLocation" th:field="*{parentLocation}">
<option value="">-- None --</option>
<option th:each="loc : ${parentLocations}"
th:value="${loc.id}"
th:text="${loc.name}">Parent Name</option>
</select>
</div>
<input th:replace="~{fragments/inputField :: input ('Address', 'address', 'text')}" />
<input th:replace="~{fragments/inputField :: input ('Description', 'description', 'text')}" />
<div class="row">
<div class="col-md-6">
<input th:replace="~{fragments/inputField :: input ('Latitude', 'latitude', 'text')}" />
</div>
<div class="col-md-6">
<input th:replace="~{fragments/inputField :: input ('Longitude', 'longitude', 'text')}" />
</div>
</div>
<div class="mb-3">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" th:field="*{status}">
<option value="ACTIVE">Active</option>
<option value="DRAFT">Draft</option>
<option value="COMING_SOON">Coming Soon</option>
<option value="CLOSED">Closed</option>
</select>
</div>
<div class="form-group mt-4">
<button class="btn btn-primary mt-0" type="submit"
th:text="${location.id == null} ? 'Add Location' : 'Update Location'">Add Location</button>
<a class="btn btn-outline-secondary ms-2" th:href="@{|/schools/${school.id}|}">Cancel</a>
</div>
</form>
</body>
</html>Create methods in the LocationController to display and process the form to create new Location records.
To support the parent location dropdown, we need to pass a list of available locations to the view.
We can use a @ModelAttribute method to automatically inject this list into every request for this controller.
We need to filter out any unsaved locations from the dropdown list.
We also need to filter out the current location so a building cannot be set as its own parent.
import java.util.List;
// -- Other import statements
// Injects the list of potential parent locations into the Thymeleaf model
@ModelAttribute("parentLocations")
public List<Location> populateParentLocations(School school, @PathVariable(required = false) Integer locationId) {
return school.getLocations().stream()
// 1. Filter out the blank, unsaved location used for form binding
.filter(loc -> loc.getId() != null)
// 2. Filter out the current location being edited to prevent infinite loops
.filter(loc -> locationId == null || !loc.getId().equals(locationId))
.toList();
}
@GetMapping("/new")
public String initCreationForm(School school, ModelMap model) {
Location location = new Location();
school.addLocation(location);
model.put("location", location);
return "schools/createOrUpdateLocationForm";
}
@PostMapping("/new")
public String processCreationForm(School school, @Valid Location location, BindingResult result, ModelMap model) {
if (result.hasErrors()) {
model.put("location", location);
return "schools/createOrUpdateLocationForm";
}
school.addLocation(location);
locationRepository.save(location);
String slug = school.getDomain().replace(".edu", "");
return "redirect:/schools/" + slug;
}To display the locations, we need to add a new section to your schoolDetails.html file that will include the "Add Location" button, and a table to list the existing locations.
Add this code inside of, at the bottom of the existing container.
Log in as a School Admin user, and navigate to their assigned school to test adding locations.
<div class="mt-5">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Locations</h2>
<a th:href="@{|/schools/${school.id}/locations/new|}"
th:if="${canEdit}"
class="btn btn-success">
Add Location
</a>
</div>
<div th:if="${school.locations.isEmpty()}" class="alert alert-info">
No locations have been added to this school yet.
</div>
<table th:unless="${school.locations.isEmpty()}" class="table table-striped table-hover mt-3">
<thead class="table-dark">
<tr>
<th>Name</th>
<th>Address</th>
<th>Description</th>
<th th:if="${canEdit}">Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="location : ${school.locations}">
<td th:text="${location.name}">Building Name</td>
<td th:text="${location.address}">123 Main St</td>
<td th:text="${location.description}">Main campus gym</td>
<td th:if="${canEdit}">
<a th:href="@{|/schools/${school.id}/locations/${location.id}/edit|}"
class="btn btn-sm btn-outline-primary">Edit</a>
<form th:action="@{|/schools/${school.id}/locations/${location.id}/delete|}"
method="post" class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this location?');">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>The reason only the name field triggers an error is because your Location class inherits from NamedEntity, which already has a @NotBlank annotation on its name string.
To enforce remaining validation, you need to apply Bean Validation annotations directly to the variables in your Location.java file.
Because your LocationController already has the @Valid annotation and the BindingResult parameter in its POST methods, Spring Boot will automatically intercept these new rules, check the data, and send the custom messages back to the template.
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
// ... (your existing class annotations)
public class Location extends NamedEntity {
@ManyToOne
@JoinColumn(name = "school_id")
private School school;
@ManyToOne
@JoinColumn(name = "parent_location_id")
private Location parentLocation;
@Column(name = "description")
private String description;
@Column(name = "address")
@NotBlank(message = "Please provide a street address.")
private String address;
@Column(name = "latitude")
@NotNull(message = "Latitude is required.")
@DecimalMin(value = "-90.0", message = "Latitude must be a valid number between -90 and 90.")
@DecimalMax(value = "90.0", message = "Latitude must be a valid number between -90 and 90.")
private BigDecimal latitude;
@Column(name = "longitude")
@NotNull(message = "Longitude is required.")
@DecimalMin(value = "-180.0", message = "Longitude must be a valid number between -180 and 180.")
@DecimalMax(value = "180.0", message = "Longitude must be a valid number between -180 and 180.")
private BigDecimal longitude;
@Enumerated(EnumType.STRING)
@Column(name = "status_id")
@NotNull(message = "Please select a valid status.")
private LocationStatus status = LocationStatus.ACTIVE;
// ... (rest of your class)
}@NotBlank: Used for Strings. It ensures the value is not null and is not just empty spaces.
@NotNull: Used for objects like BigDecimal or Enums. It ensures the value exists, but does not check for empty strings.
@DecimalMin / @DecimalMax: Used for numeric types. It ensures the user inputs a valid GPS coordinate range.
You cannot directly add a custom message to the name field inside Location.java because that variable belongs to the parent NamedEntity class. Modifying NamedEntity directly would change the error message for every other entity in the Pet Clinic project (like Owners and Pets).
To customize the message for locations only, Spring Boot allows you to override validation text using a properties file.
Open your src/main/resources/messages.properties file, add this line at the bottom:
NotBlank.location.name=Please enter a name for this location.
Spring Boot follows a specific naming convention: AnnotationName.objectName.fieldName. When the validation fails on a Location object's name field, Spring will look in this file first and use your custom message instead of the default text.
If a parent location is selected, we want to pre-populate the address, latitude, and longitude fields.
For database design, pre-populating the fields is generally better than hiding them. If a child location (Basketball Court) shares the address of its parent (Gymnasium), saving that address directly on the child record prevents you from having to write complex SQL JOIN statements later just to find out where the court is located. It also allows the user to tweak the GPS coordinates slightly for the specific entrance to the court.
Because Thymeleaf renders on the server before the page is sent to the browser, it cannot react to a user clicking a dropdown menu in real time. To achieve this, you need to use a small amount of client-side JavaScript.
Add latitude/longitude values to the first two locations in your db:
Kirkwood Main Campus: 41.9183949219706, -91.6556028184276
Carver-Hawkeye Arena: 41.6640690473, -91.554612631832
To seamlessly pre-populate those fields without requiring an extra call to the server, we can use custom HTML5 data-* attributes to attach the parent's address and coordinates directly to the dropdown options.
Open your createOrUpdateLocationForm.html file and update the <option> tag inside the parent location <select> block.
We need to use the Thymeleaf Elvis Operator (?: ''). This tells the server: "If the database value is null, print an empty string instead of deleting the HTML attribute."
<div class="mb-3">
<label for="parentLocation" class="form-label">Parent Location (Optional)</label>
<select class="form-select" id="parentLocation" th:field="*{parentLocation}">
<option value="">-- None --</option>
<option th:each="loc : ${parentLocations}"
th:value="${loc.id}"
th:text="${loc.name}"
th:data-address="${loc.address ?: ''}"
th:data-lat="${loc.latitude ?: ''}"
th:data-lng="${loc.longitude ?: ''}">Parent Name</option>
</select>
</div>Add a <script> block at the very bottom of your createOrUpdateLocationForm.html file, just above the closing </body> tag.
This script listens for a change in the dropdown menu. When a parent is selected, it extracts the data from those attributes and injects it into the text inputs.
<script>
document.addEventListener('DOMContentLoaded', function() {
const parentSelect = document.getElementById('parentLocation');
const addressInput = document.getElementById('address');
const latInput = document.getElementById('latitude');
const lngInput = document.getElementById('longitude');
parentSelect.addEventListener('change', function() {
const selectedOption = parentSelect.options[parentSelect.selectedIndex];
if (selectedOption.value !== "") {
const pulledAddress = selectedOption.getAttribute('data-address');
const pulledLat = selectedOption.getAttribute('data-lat');
const pulledLng = selectedOption.getAttribute('data-lng');
// Only overwrite if the text box is currently empty
if (!addressInput.value && pulledAddress) addressInput.value = pulledAddress;
if (!latInput.value && pulledLat) latInput.value = pulledLat;
if (!lngInput.value && pulledLng) lngInput.value = pulledLng;
}
});
});
</script>Next, we need to add the "Edit" and "Delete" buttons for each row in your schoolDetails.html file .
We will use the ${canEdit} boolean flag to ensure these administrative actions are only visible to authorized users.
<table th:unless="${school.locations.isEmpty()}" class="table table-striped table-hover mt-3">
<thead class="table-dark">
<tr>
<th>Name</th>
<th>Address</th>
<th>Description</th>
<th th:if="${canEdit}">Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="location : ${school.locations}">
<td th:text="${location.name}">Building Name</td>
<td th:text="${location.address}">123 Main St</td>
<td th:text="${location.description}">Main campus gym</td>
<td th:if="${canEdit}">
<a th:href="@{|/schools/${school.id}/locations/${location.id}/edit|}"
class="btn btn-sm btn-outline-primary">Edit</a>
<form th:action="@{|/schools/${school.id}/locations/${location.id}/delete|}"
method="post" class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this location?');">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
</tr>
</tbody>
</table>The th:if="${canEdit}" tags ensure that standard users (like students) only see the data, while administrators see the action buttons.
Because the LocationController will use a @PostMapping for the delete route (which is a secure best practice to prevent accidental deletions via pre-fetched links), the "Delete" button cannot be a standard <a> link. It must be a mini <form> that submits a POST request.
The onsubmit="return confirm(...);" on the delete form intercepts the click and asks the user to confirm before sending the POST request to the server.
Pre-fetching is a performance optimization technique used by modern web browsers (like Chrome, Safari, and Edge) to make websites feel faster.
Browsers try to predict what you are going to do next. If you are reading a webpage, the browser might look at the links on that page and quietly download those linked pages in the background before you even click them.
It can be triggered in a few ways:
Hovering: When you rest your mouse over a link for a fraction of a second, the browser assumes you are about to click it and starts downloading the destination URL.
HTML Tags: A website can explicitly tell the browser to pre-fetch specific pages using tags like <link rel="prefetch" href="/next-page">.
Browser Prediction: Some browsers learn your habits or use algorithms to guess the most likely link you will click next.
In web development, a standard <a> hyperlink always executes an HTTP GET request. GET requests are strictly supposed to be "safe" operations—meaning they only retrieve data, they never alter or destroy it.
If you build a delete button as a simple link like this:
<a href="/schools/1/locations/5/delete">Delete Location</a>If a user simply hovers their mouse over that "Delete" link while reading the table, their browser might eagerly fire off a GET request to /schools/1/locations/5/delete in the background to pre-fetch the result. Your server receives that request, sees the URL, and deletes the location from the database—even though the user never actually clicked the mouse.
Browsers are programmed to understand that HTTP POST requests are meant for submitting data and changing the state of the server.
Therefore, browsers will never automatically pre-fetch a form submission or a POST request. By changing your delete button into a mini <form method="post">, you guarantee that the browser will only send that destructive request to the server when the user intentionally clicks the button and confirms the action.
This also protects your app from search engine web crawlers that "click" every <a> link they see to index your site, but they do not submit forms. If your delete routes are GET links, a search engine bot could accidentally wipe out your entire database just by indexing the page.
@GetMapping("/{locationId}/edit")
public String initUpdateForm(@PathVariable("locationId") int locationId, ModelMap model) {
Location location = locationRepository.findById(locationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Location not found"));
model.put("location", location);
return "schools/createOrUpdateLocationForm";
}
@PostMapping("/{locationId}/edit")
public String processUpdateForm(@Valid Location location, BindingResult result, School school,
@PathVariable("locationId") int locationId, ModelMap model) {
if (result.hasErrors()) {
location.setId(locationId);
model.put("location", location);
return "schools/createOrUpdateLocationForm";
}
location.setId(locationId);
school.addLocation(location);
locationRepository.save(location);
String slug = school.getDomain().replace(".edu", "");
return "redirect:/schools/" + slug;
}Create methods in the LocationController to display and process the form to update existing Location records.
import org.springframework.transaction.annotation.Transactional;
// -- Other import statements
@PostMapping("/{locationId}/delete")
@Transactional
public String processDeleteForm(@PathVariable("schoolId") int schoolId, @PathVariable("locationId") int locationId) {
Location location = locationRepository.findById(locationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Location not found"));
School school = location.getSchool();
// 1. Sever the parent-to-child link
if (school != null) {
school.getLocations().removeIf(loc -> loc.getId() != null && loc.getId().equals(locationId));
}
// 2. Sever the child-to-parent link
location.setSchool(null);
// 3. Execute the soft-delete within the transaction
locationRepository.delete(location);
return "redirect:/schools/" + schoolId;
}Create a method in the LocationController to process the the delete location forms.
Because Location.java has the @SQLDelete annotation, this will execute an UPDATE query setting the deleted_at column to NOW(), rather than executing a hard DELETE.
Your School entity has a @OneToMany(cascade = CascadeType.ALL) relationship with its locations. When the @ModelAttribute method in your controller fetches the School, it loads all the locations into the current Hibernate session.
If you call locationRepository.delete(location), you are telling Hibernate to delete the child. However, before the transaction commits, Hibernate checks the parent School entity. Because the Location still exists inside the school's in-memory list, the cascade rules override your delete command. Hibernate assumes the entity should still exist and cancels the DELETE SQL statement. Because no DELETE is issued, your @SQLDelete annotation is never triggered.
You must remove the location from the parent school's list before deleting it.
You must wrap the entire method in a single transaction so the unlinking and the deletion commit together.
Furthermore, when dealing with bi-directional relationships, it is best practice to sever the link on both sides of the entity (removing the location from the school, and nullifying the school on the location).
By Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.