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. Recipe API.
Lesson 10 - User permission access.
Lesson 11 - Update and delete existing objects. Filtering and limiting records by category.
Lesson 12 - Web Sockets. Date and currency formatting.
Lesson 13 - Email and SMS messaging. Failed login attempts.
Lesson 14 - Shopping Cart. Payment processing. Homepage with content from the database. Custom not found and error pages.
Add this abstract method to the UserRepository interface:
boolean existsByEmail(String email);
This @PostMapping manages fetching the user data for the form and carefully processing the updates. It handles the specific alternative flows outlined in the sequence diagram: checking for duplicate emails and only hashing the password if the user actually typed a new one.
@PostMapping("/profile")
public String processProfileUpdate(@Valid @ModelAttribute("user") User updatedUser,
BindingResult result,
Principal principal,
RedirectAttributes redirectAttributes) {
String currentEmail = principal.getName();
User currentUser = userRepository.findByEmail(currentEmail)
.orElseThrow(() -> new RuntimeException("User not found"));
// 1. Check for duplicate email ONLY if they are changing their email address
if (!currentEmail.equalsIgnoreCase(updatedUser.getEmail())) {
if (userRepository.existsByEmail(updatedUser.getEmail())) {
result.rejectValue("email", "duplicateEmail", "This email is already taken.");
}
}
// 2. Validate password strength manually
String newPassword = updatedUser.getPassword();
boolean isUpdatingPassword = newPassword != null && !newPassword.trim().isEmpty();
if (isUpdatingPassword) {
if (!newPassword.matches("^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,}$")) {
// Add this regex check to enforce the character rules
result.rejectValue("password", "weakPassword", "Password must be at least 8 characters and must contain uppercase, lowercase, and number");
}
}
// 3. Return to form if there are validation errors
if (result.hasErrors()) {
return "users/profile";
}
// 4. Safely apply the updates to the entity fetched from the DB
currentUser.setFirstName(updatedUser.getFirstName());
currentUser.setLastName(updatedUser.getLastName());
currentUser.setNickname(updatedUser.getNickname());
currentUser.setEmail(updatedUser.getEmail());
currentUser.setPhone(updatedUser.getPhone());
currentUser.setPublicEmail(updatedUser.getPublicEmail());
currentUser.setPublicPhone(updatedUser.getPublicPhone());
currentUser.setPreferredLanguage(updatedUser.getPreferredLanguage());
if (isUpdatingPassword) {
currentUser.setPassword(passwordEncoder.encode(newPassword));
}
// 5. Save and redirect
userRepository.save(currentUser);
redirectAttributes.addFlashAttribute("messageSuccess", "Your profile has been updated successfully.");
return "redirect:/users/profile";
}When a user is updating their profile, we do not want the strict password rules to fire automatically (so they can leave it blank).
Because we used standard @Valid, Spring only validates the Default group. It completely ignores the @NotEmpty password rules, allowing the form to submit successfully even if the password field is blank!
Because we bypass the entity annotations during profile updates, if the user does type a new password to change it, the manual checks we wrote in the ProfileController earlier—like checking if (!newPassword.matches("^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,}$"))—will step in to protect the system.
Since we updated your loginForm and profile pages to use the new fragments after you had built your registration page, your registerForm.html is still using old HTML inputs that don't know how to look for the BindingResult errors.
Open your registerForm.html. Replace your manual inputs with the fragment calls.
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'register')}">
<body>
<h2 class="text-center">Student Registration</h2>
<div class="container mt-4" style="max-width: 500px;">
<form th:action="@{/register-student}" th:object="${user}" method="post" class="form-horizontal">
<div th:replace="~{fragments/inputField :: input ('Email', 'email', 'text')}"></div>
<div th:replace="~{fragments/inputField :: input ('Password', 'password', 'password')}"></div>
<div class="d-grid gap-2 mt-4">
<button type="submit" class="btn btn-primary">Register</button>
</div>
<div class="text-center mt-3">
<p>Already have an account? <a th:href="@{/login}">Log in here</a></p>
</div>
</form>
</div>
</body>
</html>
Spring Security keeps a SecurityContext in the server's memory for the logged-in user, which has the user's old email written on it.
A POST "/users/profile" successfully changes their email in the database.
The user is redirected back to the GET /users/profile page.
Your controller asks Spring Security, "Who is this?" Spring Security looks at the badge and says, "This is [old email]."
Your controller asks the database to find the user with the old email.
The database says, "User not found" (because you just changed it), which triggers your .orElseThrow() exception.
The ProfileController needs to use your UserDetailsService to rebuild the Spring Security user object.
// Add this import
import org.springframework.security.core.userdetails.UserDetailsService;
// Add this inside ProfileController ...
private final UserDetailsService userDetailsService;
// 2. Update the constructor
public ProfileController(UserRepository userRepository,
PasswordEncoder passwordEncoder,
UserDetailsService userDetailsService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.userDetailsService = userDetailsService;
}Scroll down to the bottom of your @PostMapping("/profile") method. Right after you save the user to the database, we will check if the email changed. If it did, we dynamically swap out the old security token for a new one.
// import these
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
// ... previous validation logic ...
// 5. Save the updates to the database
userRepository.save(currentUser);
// 6. Update the Spring Security Context if the email changed
if (!currentEmail.equalsIgnoreCase(currentUser.getEmail())) {
// Fetch the freshly updated user details
UserDetails newPrincipal = userDetailsService.loadUserByUsername(currentUser.getEmail());
Authentication currentAuth = SecurityContextHolder.getContext().getAuthentication();
// Create a new authentication token with the new email
Authentication newAuth = new UsernamePasswordAuthenticationToken(
newPrincipal,
currentAuth.getCredentials(),
newPrincipal.getAuthorities());
// Replace the old token in the session memory
SecurityContextHolder.getContext().setAuthentication(newAuth);
}
// 7. Redirect
redirectAttributes.addFlashAttribute("messageSuccess", "Your profile has been updated successfully.");
return "redirect:/users/profile";
}All of these phone numbers are perfectly valid: 3199999999, 319-999-9999, (319) 999-9999, 319.999.9999, and even +1 (319) 999-9999
It is highly recommended to normalize data input.
By stripping away the formatting before saving to the database, your data remains clean and predictable. Then, you can always format it back into a readable string exactly when you need to display it.
In the ProfileController, update your @GetMapping("/profile")
@GetMapping("/profile")
public String showProfileForm(Model model, Principal principal) {
String email = principal.getName();
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
// Clear the password hash so it doesn't get sent to the HTML form
user.setPassword("");
// Intercept the 10-digit database string and inject a parentheses
// and hyphen before handing it to Thymeleaf
String phone = user.getPhone();
if (phone != null && phone.length() == 10) {
// Converts 3199999999 into (319) 999-9999
String formattedPhone = phone.replaceFirst("(\\d{3})(\\d{3})(\\d{4})", "($1) $2-$3");
user.setPhone(formattedPhone);
}
model.addAttribute("user", user);
return "users/profile";
}When the user submits the form, the @Pattern regex we just wrote will still do its job verifying that the input is a valid phone number.
Once it passes validation, we will strip out everything except the digits (\D means "non-digit") right before saving it to the database entity.
Update the updating block inside @PostMapping("/profile") method:
// 4. Safely apply the updates to the entity fetched from the DB
currentUser.setFirstName(updatedUser.getFirstName());
currentUser.setLastName(updatedUser.getLastName());
currentUser.setNickname(updatedUser.getNickname());
currentUser.setEmail(updatedUser.getEmail());
String submittedPhone = updatedUser.getPhone();
if (submittedPhone != null && !submittedPhone.trim().isEmpty()) {
currentUser.setPhone(submittedPhone.replaceAll("\\D", "")); // Strips all non-numbers
} else {
currentUser.setPhone(null);
}
currentUser.setPublicEmail(updatedUser.getPublicEmail());
currentUser.setPublicPhone(updatedUser.getPublicPhone());
currentUser.setPreferredLanguage(updatedUser.getPreferredLanguage());
if (isUpdatingPassword) {
currentUser.setPassword(passwordEncoder.encode(newPassword));
}To display the logged-in student's information on their specific school's page, we need to pass their User object to the view and then use Thymeleaf to format it. We can also add some visual indicators so the student knows which of their contact details are public to other players.
Open SchoolController.java. We need to capture the Principal object, look up the user, and add them to the model.
Because a school page might be public (viewable by people who aren't logged in), we should check if the Principal is null before trying to query the database.
// Add these imports
import java.security.Principal;
import org.springframework.samples.petclinic.user.UserRepository;
// Inject a UserRepository
@Controller
class SchoolController {
private final SchoolRepository schoolRepository;
private final UserRepository userRepository;
public SchoolController(SchoolRepository schoolRepository, UserRepository userRepository) {
this.schoolRepository = schoolRepository;
this.userRepository = userRepository;
}
// ... inside your SchoolController ...
@GetMapping("/schools/{slug:[a-zA-Z-]+}")
public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
// Reconstruct the domain (User asked to assume ".edu")
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);
// Fetch and add the logged-in user if they are authenticated
if (principal != null) {
userRepository.findByEmail(principal.getName()).ifPresent(user -> {
// Format the 10-digit database phone number for display
String phone = user.getPhone();
if (phone != null && phone.length() == 10) {
user.setPhone(phone.replaceFirst("(\\d{3})(\\d{3})(\\d{4})", "($1) $2-$3"));
}
mav.addObject("currentUser", user);
});
}
return mav;
}principal != null: This ensures that if a random guest views the school page, the application doesn't crash trying to look up a non-existent user.
ifPresent(user -> ...): Since your findByEmail returns an Optional<User>, this is a very clean way to say, "If you found the user, run this block of code."
mav.addObject("currentUser", user): This explicitly names the object currentUser, which will matche the th:if="${currentUser != null}" check in the HTML snippet we will build next.
If you want the phone number to display as (319) 999-9999 on this school page, you can either format it in the SchoolController (just like we did in the ProfileController), or you can use a custom Thymeleaf utility. For now, formatting it in the Controller right before adding it to the model is the quickest method.
Now, open the schoolDetails.html file for your school page.
We will wrap the user's information in a th:if="${currentUser != null}" block. If a guest views the page, this section will safely remain hidden. If a logged-in student views it, they will see their "Player Card."
Here is a Bootstrap-styled card. It includes logic to prioritize their nickname over their firstName, and adds helpful "Public/Private" badges for their contact info.
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'schools')}">
<body>
<div class="container mt-4">
<div class="mb-4 pb-2 border-bottom">
<h1><span th:text="${school.name}">School Name</span> Intramurals</h1>
</div>
<div class="row">
<div class="col-lg-9 mb-4">
<div class="card shadow-sm h-100">
<div class="card-body text-center text-muted p-5">
<i class="fa fa-calendar-alt fa-3x mb-3"></i>
<h3>Upcoming Leagues & Events</h3>
<p>Registration and league schedules will appear here soon!</p>
</div>
</div>
</div>
<div class="col-lg-3">
<div th:if="${currentUser != null}" 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">
<h5 class="card-title text-primary"
th:text="(${currentUser.nickname} ?: ${currentUser.firstName}) + ' ' + ${currentUser.lastName}">
Player Name
</h5>
<hr>
<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 th:unless="${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>
</div>
</div>
</body>
</html>
A two-column design keeps the focus on the active leagues while giving the user quick access to their profile and status on the side.
Since you are using Bootstrap, we can achieve this easily using its 12-column grid system. We will create a <div class="row">, assign 9 columns (75%) to the left side for your future events, and 3 columns (25%) to the right side for the Player Card.
col-lg-9 and col-lg-3: The lg (large) breakpoint tells the browser to strictly enforce the 75%/25% split on desktop screens and large tablets.
Mobile Stacking: Because we didn't specify smaller breakpoints like col-sm or col-12, Bootstrap automatically defaults to stacking the columns vertically on smaller screens. The main content (leagues) will display first, and the Player Card will neatly slide underneath it.
shadow-sm: I added a slight drop shadow to the cards to give the dashboard a bit more depth and a polished, modern feel.
If the first and last name are null in the database, the program displays "null null".
To fix this, we can use the Thymeleaf <th:block> tag to group multiple HTML elements together to apply Thymeleaf logic (like an if statement) to all of them at once.
Replace your current <h5> and <hr> tags with this block:
<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>The th:if check: It checks if any of those three name fields exist in the database before deciding to render the block.
The #strings.isEmpty() check: It checks for both null and "" at the same time.
When you submit an HTML form, browsers don't send null for empty text boxes; they send an empty string "".
While the Thymeleaf fix solves the display issue, you really don't want your database filling up with empty strings instead of proper null values. It makes writing SQL queries down the road harder.
Spring Boot has a built-in tool called the StringTrimmerEditor that intercepts form submissions, trims off excess whitespace, and converts empty strings back into null before they ever hit your Entity or Database.
Open your ProfileController.java and add this single method anywhere inside the class:
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
// ... inside ProfileController ...
@InitBinder
public void initBinder(WebDataBinder binder) {
// The 'true' tells it to convert empty strings to null
binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
}Because the User entity has a deletedAt timestamp field, the best practice here is to perform a "Soft Delete." Instead of actually wiping the record from the database (which can orphan records like past league scores or team rosters that depend on this user's ID), we simply stamp the deletedAt column with the current date and time.
After updating the database, we must also explicitly log the user out so their active session is destroyed.
Then, add the new @PostMapping method and imports inside the class:
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import java.time.LocalDateTime;
// code omitted
@PostMapping("/delete")
public String deleteAccount(Principal principal,
HttpServletRequest request,
HttpServletResponse response,
RedirectAttributes redirectAttributes) {
// 1. Find the user
String email = principal.getName();
User currentUser = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
// 2. Perform the Soft Delete
currentUser.setDeletedAt(LocalDateTime.now());
// Optionally, you can scramble personal info if you want to anonymize the record:
// currentUser.setEmail("deleted_" + currentUser.getId() + "@example.com");
// currentUser.setFirstName("Deleted");
// currentUser.setLastName("User");
userRepository.save(currentUser);
// 3. Log the user out programmatically
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
new SecurityContextLogoutHandler().logout(request, response, auth);
}
// 4. Redirect to the homepage with a farewell message
redirectAttributes.addFlashAttribute("messageSuccess",
"Your account has been successfully deleted. We're sorry to see you go!");
return "redirect:/";
}SecurityContextLogoutHandler: This is a Spring Security utility that handles the messy work of clearing the SecurityContext, invalidating the HTTP Session, and removing any "Remember Me" cookies if you have them configured.
Soft Deletion (setDeletedAt): The user remains in your database so you don't lose historical sports data, but the timestamp flags them as inactive.
Because we only soft-deleted the user, they still technically exist in the database with their password intact. If they try to log in again right now, Spring Security will likely let them in!
We need to update your UserDetailsServiceImpl (the class that loads the user during login) to reject users whose deleted_at column is not null.
Locate your loadUserByUsername method.
Treat the user as if they don't exist
If you throw a UsernameNotFoundException, Spring Security treats the login attempt exactly as if the email was never registered. This prevents malicious actors from guessing which emails belong to deleted accounts.
import org.springframework.security.core.userdetails.UsernameNotFoundException;
// ... inside your UserDetailsService class ...
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
// 1. Find the user via the UserRepository
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("Invalid email or password."));
// 2. THE NEW CHECK: Block deleted accounts
if (user.getDeletedAt() != null) {
throw new UsernameNotFoundException("Invalid email or password.");
}
// 3. Convert your custom User model into the UserDetails object
return org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPassword())
.roles(user.getRoles().stream().map(role -> role.getName()).toArray(String[]::new))
.build();
}UserDetailsService acts as a bouncer, permanently preventing them from logging back in with those credentials.Update your profile.html file with a DANGER ZONE and a red button to delete a user account.
<div class="card border-danger mb-5">
<div class="card-body text-danger">
<h5 class="card-title">Danger Zone</h5>
<p class="card-text">Once you delete your account, there is no going back. Please be certain.</p>
<form th:action="@{/users/delete}" method="post" onsubmit="return confirm('Are you absolutely sure you want to delete your account? This action cannot be undone.');">
<button type="submit" class="btn btn-outline-danger">Delete Account</button>
</form>
</div>
</div>If a guest user visits "/users/profile", the program displays "An internal server error occurred. ... because "principal" is null"`
Spring Security doesn't have a logged-in user for your controller, so it passes a null Principal. When the code calls principal.getName(), Java throws a NullPointerException and crashes.
To fix this, we need to tell Spring Security to act as a bouncer and protect that URL for GET and POST requests. When configured correctly, Spring Security will intercept the guest before they even reach your controller and automatically redirect them to the login page.
Edit the HTTP authorization rules in your SecurityConfig SecurityFilterChain bean.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
// Public pages anyone can see
.requestMatchers("/", "/schools/**", "/register-student", "/css/**", "/images/**").permitAll()
// ADD THIS LINE: Require login for the profile and any other user settings
.requestMatchers("/users/profile", "/users/delete").authenticated()
// Catch-all (depends on how you set up your app)
.anyRequest().authenticated() // or .anyRequest().permitAll()
)
.formLogin(login -> login
.loginPage("/login") // This is where guests get redirected!
.permitAll()
)
// ... rest of your config ...
;
return http.build();
}Currently, Spring Security redirects the guest user to the login page before we have a chance to attach a warning message or flash attribute.
Spring Security has a feature called "SPRING_SECURITY_SAVED_REQUEST" that, when it intercepts a user, saves the URL they were trying to visit in the session so it knows where to send them after they successfully log in.
In case the user doesn't log in right away, we can drop a second temporary string "WARNED_URL" into the session to keep track of which URL we just warned them about.
Update the AuthController.initLoginForm method as follows.
import org.springframework.security.web.savedrequest.SavedRequest;
// ... inside your controller ...
@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";
}How this flow works now:
The user sneaks into /users/profile. Spring Security intercepts them and saves the request.
They arrive at /login. 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. attemptedUrl is /users/profile. warnedUrl is /users/profile.
Because they match, the controller skips the warning, and the user sees a clean login form.
Add a "Go to my school" button on Edit Profile page to improve the user's experience.
We can easily extract the "slug" from their email address and pass it to the view.
Open your ProfileController.java and update your @GetMapping("/profile") method. We will grab the user's email, extract everything between the @ symbol and the .edu (the slug), and add it to the model.
@GetMapping("/profile")
public String showProfileForm(Model model, Principal principal) {
if (principal == null) {
return "redirect:/login";
}
String email = principal.getName();
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
user.setPassword("");
// Format phone number
String phone = user.getPhone();
if (phone != null && phone.length() == 10) {
user.setPhone(phone.replaceFirst("(\\d{3})(\\d{3})(\\d{4})", "($1) $2-$3"));
}
// ADD THIS BLOCK: Extract the slug from the email (e.g., student@kirkwood.edu -> kirkwood)
String domain = email.substring(email.indexOf("@") + 1); // kirkwood.edu
String slug = domain.contains(".") ? domain.substring(0, domain.lastIndexOf(".")) : domain; // kirkwood.edu
slug = slug.contains(".") ? slug.substring(slug.indexOf(".") + 1) : slug; // student.kirkwood.edu
model.addAttribute("schoolSlug", slug);
model.addAttribute("user", user);
return "users/profile";
}Open your users/profile.html file. We can add this button right at the top next to the "Edit Profile" header so it acts like a "Back" button.
Find your <h2>Edit Profile</h2> tag near the top of the container, and replace it with this flexbox header:
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Edit Profile</h2>
<a th:if="${schoolSlug != null}"
th:href="@{/schools/{slug}(slug=${schoolSlug})}"
class="btn btn-outline-primary">
<i class="fa fa-arrow-left me-1"></i> Go to My School
</a>
</div>We need to create a configuration file that tells Spring to listen for that ?lang= parameter in the address bar and store it in a cookie.
Create a new file named LocaleConfig.java (perhaps in a config package)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import java.time.Duration;
import java.util.Locale;
@Configuration
public class LocaleConfig implements WebMvcConfigurer {
@Bean
public LocaleResolver localeResolver() {
// Creates a cookie named "PREFERRED_LANGUAGE"
CookieLocaleResolver resolver = new CookieLocaleResolver("PREFERRED_LANGUAGE");
resolver.setDefaultLocale(Locale.ENGLISH);
resolver.setCookieMaxAge(Duration.ofDays(365)); // Remembers them for a year!
return resolver;
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
// This tells Spring to watch the address bar for "?lang="
interceptor.setParamName("lang");
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
}Now, we need to tell your ProfileController to append that ?lang= parameter to the redirect URL right after it saves the user's new preference to the database.
Open your ProfileController.java and scroll to the very bottom of the @PostMapping("/profile") method. Update the return statement:
// ... (previous save and authentication logic) ...
redirectAttributes.addFlashAttribute("messageSuccess", "Your profile has been updated successfully.");
// Grab the 2-letter code from the database object and make it lowercase
String langCode = currentUser.getPreferredLanguage();
if (langCode != null) {
// This triggers the LocaleChangeInterceptor we just built!
return "redirect:/users/profile?lang=" + langCode.toLowerCase();
}
return "redirect:/users/profile";
}A user selects "Korean" from your dropdown and clicks "Save Changes".
Your controller saves KO to the preferred_language column in the database.
The controller redirects the browser to /users/profile?lang=ko.
The LocaleChangeInterceptor sees ?lang=ko, instantly switches the application's locale to Korean, and drops a 1-year cookie in the user's browser.
If the user logs out and comes back next month, their browser automatically sends the cookie, and Spring greets them in Korean!
Instead of you manually running gradle build, docker build, and docker push on your laptop, GitHub Actions can be used to do them automatically every time you push code.
Here is the workflow:
You push code to your GitHub repository.
GitHub Actions wakes up, spins up a temporary server (runner).
It builds your jar using Gradle.
It logs in to Docker Hub (using secrets you provide).
It builds and pushes your Docker image.
Open your GitHub repository. Click the Actions tab. Click one of the red X's. The action names (gradle-build.yml and maven-build.yml) come from your ".github/workflows" folder. You may delete these files.
The petclinic app comes with three workflows `deploy-and-test-cluster.yml`, `gradle-build.yml`, and `maven-build.yml`.
Here is exactly why it is safe to remove them:
maven-build.yml: You already committed to Gradle for this project, making the Maven pipeline completely redundant.
gradle-build.yml: By default, this workflow runs ./gradlew build, which automatically executes all of the Spring Boot integration tests. Because you updated the application properties to require environment variables for your external MySQL database, the GitHub Actions server cannot start the application context to run the tests, which will cause the entire run to crash. Furthermore, your custom deploy.yml file already handles the necessary Gradle building for you (while intentionally skipping those tests using the -x test flag).
deploy-and-test-cluster.yml: This is a highly specialized workflow built for deploying the application to a Kubernetes cluster (specifically using a tool called standard kind). Since you are using Azure Container Apps, this file serves no purpose for your infrastructure.
You should never put your Docker Hub password directly in the file. You use GitHub Secrets.
Go to your GitHub repository.
Click Settings -> Secrets and variables -> Actions.
Click New repository secret.
Add these two:
Name: DOCKERHUB_USERNAME
Secret Value: your_actual_username
Name: DOCKERHUB_TOKEN
Secret Value: (Go to Docker Hub -> Account Settings -> Personal access tokens -> Generate New Token)
Type "GitHub Actions" as the access token description.
For access permissions, select "Read & Write".
Use this token instead of your real password.
In your project, create this yml file: .github/workflows/deploy.yml
Paste this content in, customized for Gradle and Java 17.
Change petclinic (lines 48, 61-64) to your image name.
To keep your v1, v2, v3 pattern going without having to manually type it every time, you can use a built-in GitHub variable called github.run_number. (See step 6)
name: Build and Push Docker Image
on:
push:
branches: [ "main" ]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
# 1. Download your code
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Java 17 (Same as your local environment)
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'microsoft' # or 'temurin' if you prefer
# 3. Build the JAR file with Gradle
# We skip tests here to speed it up, but you can remove '-x test' to be safer
- name: Build with Gradle
run: ./gradlew clean bootJar -x test -x jar
# 4. Set up Docker Buildx (required for modern Docker builds)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 5. Log in to Docker Hub
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 6. Build and Push the Docker image
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/petclinic:v${{ github.run_number }}
Commit and push this file to GitHub.
Go to the "Actions" tab in your GitHub repository. You will see the workflow running. You may click the workflow and annotation name to see its progress.
If it turns green, check Docker Hub—your image will have been freshly updated!
If it turns red, click the workflow run name. Click the annotation name with a red X. Figure out which step caused a problem. Click the "Re-run jobs" button.
By doing this, every single push (or merged pull request) to your main branch will create a brand-new, uniquely numbered tag (starting with v1).
Right now, GitHub pushes the image to Docker, but Azure doesn't know about it yet.
You can add additional steps to the GitHub Action that runs az containerapp update to tell Azure to pull the new image immediately.
To make this work, GitHub Actions needs an "ID badge" (a Service Principal) so Azure trusts it enough to accept the remote update command.
Run this command in your PowerShell terminal to create a dedicated service account that only has access to your specific resource group, keeping your full Azure account secure.
az ad sp create-for-rbac `
--name "github-actions-<project-name>" `
--role contributor `
--scopes /subscriptions/<your-subscription-id>/resourceGroups/<your-resource-group-rg> `
--json-authTo view the service principal, open your resource group, click Access control (IAM), then Role Assignments.
Go back to your GitHub repository.
Navigate to Settings -> Secrets and variables -> Actions.
Click New repository secret.
Name: AZURE_CREDENTIALS
Value: (Paste the entire JSON block here)
Click Add secret.
Add these final two steps to the bottom of your .github/workflows/deploy.yml file.
Since your GitHub Actions workflow runs on a Linux machine (ubuntu-latest), we use the bash line-continuation character \ here instead of the PowerShell backtick character `.
Change petclinic to your image name.
# 7. Log in to Azure
- name: Log in to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
# 8. Tell Azure to pull the new image
- name: Update Azure Container App
run: |
az config set extension.use_dynamic_install=yes_without_prompt
az containerapp update \
--name petclinic-app \
--resource-group petclinic-rg \
--image ${{ secrets.DOCKERHUB_USERNAME }}/petclinic:v${{ github.run_number }}To securely run your automated tests against your external MySQL database, we need to inject those credentials into the GitHub Actions runner right before Gradle executes.
Go to your GitHub repository.
Navigate to Settings -> Secrets and variables -> Actions.
Click New repository secret and add these three secrets one by one:
Name: MYSQL_URL, Value: jdbc:mysql://YOUR_WEB_DB_HOST:3306/YOUR_DB_NAME?useSSL=true
Name: MYSQL_USER, Value: YOUR_DB_USERNAME
Name: MYSQL_PASS, Value: YOUR_DB_PASSWORD
or MYSQL_PASSWORD
Now, we need to update Step 3 in your .github/workflows/deploy.yml file.
Provide the secrets to the step using the env: block.
Change the run command to ./gradlew clean build -x jar. (Using build instead of bootJar tells Gradle to explicitly run the test phase before packaging the application).
If someone submits a pull request or pushes broken code to your repository, the test phase will fail. When a step fails in GitHub Actions, the entire workflow immediately stops. This guarantees that a broken image is never built, pushed to Docker Hub, or deployed to Azure.
# 3. Build and Test the application with Gradle
- name: Build and Test with Gradle
env:
MYSQL_URL: ${{ secrets.MYSQL_URL }}
MYSQL_USER: ${{ secrets.MYSQL_USER }}
MYSQL_PASS: ${{ secrets.MYSQL_PASS }}
run: ./gradlew clean build -x jarThe project uses the Spring Java Format plugin. This is a strict code-styling enforcer that the Spring team uses to guarantee all code looks exactly the same (spacing, indentation, bracket placement, etc.).
When your GitHub Action runs./gradlew clean build, it triggers the checkFormatMain task. The plugin scans new Java files you add and fails if they don't perfectly match the official Spring style guide.
You do not need to format these files by hand. The Gradle plugin can fix them for you automatically.
Open the terminal in IntelliJ and run the automatic formatter command:
Windows (PowerShell): .\gradlew format
Mac/Linux: ./gradlew format
The Spring PetClinic project includes a plugin called NoHttp. Its sole purpose is to scan every single file in your project (Java, HTML, XML, Markdown, etc.) and fail the build if it finds a URL that starts with http:// instead of the secure https://.
This guarantees that the application never accidentally loads resources over an unencrypted, insecure connection.
Open the file mentioned in the error: src\main\resources\templates\fragments\layout.html
Go to line 3.
Change the http to https so it looks exactly like this: https://www.thymeleaf.org/extras/spring-security
Commit and push this updated YAML file to your main branch. This will immediately trigger the workflow.
If successful, GitHub will build your app, push the dynamically tagged image to Docker Hub, and seamlessly tell Azure to swap to that exact numbered version!
If it failed, GitHub provides detailed logs for every single command it runs.
Click on the workflow run that has a red X next to it.
On the left side under "Jobs", click build-and-push.
You will see a list of steps. Click on the step that has the red X (likely Build and Test with Gradle) to expand its terminal output.
Scroll to the bottom of that expanded log. You are looking for a specific Java or Gradle error.
Our SiteGround databases currently allow access from any IP address, which is not secure.
If I limited the IP addresses, while your laptop might be allowed to connect, the GitHub Actions server is a random machine in the cloud. SiteGround will look at GitHub's IP address and block the connection, causing your Gradle tests to hang and fail.
If it is a firewall issue, you generally have two paths forward:
Option A (Skip Tests in CI): The easiest fix. You can change the YAML run command back to ./gradlew clean bootJar -x test -x jar. You simply rely on running your tests locally on your computer before you push.
Option B (Testcontainers): The professional standard. Instead of connecting to your external SiteGround database during the GitHub test phase, you configure Spring Boot to spin up a temporary, throwaway Docker container with MySQL inside the GitHub runner itself just for the tests.
Later, when you are setting up your CI/CD deployment pipeline to push this application to a live server, you can simply pass an environment variable telling the server to run with spring.profiles.active=prod, and it will completely ignore all your local development tweaks.
By Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.