Java 3 - 2026

Weeks 7-8

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 - Edit and delete user profile. Login and logout. Password reset.

Lesson 8 - User permission access.

Lesson 9 - Homepage with content from the database. Custom not found and error pages.
Lesson 10 - Update and delete existing objects. Filtering and limiting records by category.
Lesson 11 - Web Sockets. Date and currency formatting.
Lesson 12 - Email and SMS messaging. Failed login attempts.
Lesson 13 - Shopping Cart. Payment processing.

Course Plan

  • Enter this command to view the current remote GitHub URL.
    git remote -v

  • If it says "github.com/spring-petclinic/", you need to change it.

  • Create a new private GitHub repository. Copy the URL.

  • Enter this command:
    git remote set-url origin <paste-your-url>

  • Run these commands:

    • git add .

    • git commit -m 'describe your latest contribution'

    • git push origin main

  • Add "mlhaus" as a collaborator.

  • Make the repository public when you want to use it as part of your employment portfolio.

  • I am working on a lesson plan to use GitHub Actions with Azure.

GitHub

  • Update older tests from "/register" to "/register-student"

  • Add the following unit tests for login and redirect functionality.

AuthControllerTests

import java.security.Principal;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.anyString;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;

// code omitted

@Test
void testInitLoginFormLoadsCorrectly() throws Exception {
	mockMvc.perform(get("/login"))
		.andExpect(status().isOk())
		.andExpect(view().name("auth/loginForm"))
		.andExpect(model().attributeExists("user"));
}

@Test
void testInitLoginFormRemembersFailedEmail() throws Exception {
	// Simulate a request where the session contains a failed login attempt
	mockMvc.perform(get("/login").sessionAttr("LAST_EMAIL", "wrong@kirkwood.edu"))
		.andExpect(status().isOk())
		.andExpect(model().attributeExists("user"))
		// Verify the HTML output actually contains the email in the value attribute
		.andExpect(content().string(containsString("wrong@kirkwood.edu")));
}

@Test
void testLoginSuccessRedirectsToSchool() throws Exception {
	// 1. Setup a fake school for the mock repository to return
	School mockSchool = new School();
	mockSchool.setName("Kirkwood Community College");
	mockSchool.setDomain("kirkwood.edu");

	given(schoolRepository.findByDomain(anyString())).willReturn(Optional.of(mockSchool));

	// 2. Create a simple fake Principal
	Principal mockPrincipal = () -> "student@kirkwood.edu";

	// 3. Perform the GET request, passing the principal directly
	mockMvc.perform(get("/login-success").principal(mockPrincipal))
		.andExpect(status().is3xxRedirection())
		.andExpect(redirectedUrl("/schools/kirkwood"))
		.andExpect(flash().attributeExists("messageSuccess"));
}

@Test
void testLoginSuccessRedirectsToSchoolsListIfNotFound() throws Exception {
	given(schoolRepository.findByDomain(anyString())).willReturn(Optional.empty());

	// Create a fake Principal with an unknown domain
	Principal mockPrincipal = () -> "student@unknown.com";

	mockMvc.perform(get("/login-success").principal(mockPrincipal))
		.andExpect(status().is3xxRedirection())
		.andExpect(redirectedUrl("/schools"))
		.andExpect(flash().attributeExists("messageWarning"));
}
  • @WebMvcTest: This tells Spring to only load your controllers and HTML templates. It ignores the database and heavy services.

  • @MockBean: Since we aren't loading the real database, we use this to create "dummy" versions of your repositories and services. We can tell these dummies exactly what to return using given(...).

  • sessionAttr(...): This injects a variable into the HTTP session right before the test runs, mimicking a custom failure handler.

  • .with(user(...)): This is a Spring Security test tool that automatically generates a Principal object and injects it into your controller method, just like a real successful login would.

AuthControllerTests

  • You are welcome to truncate your user database table to clear out any users you created while testing your registration form.

  • The login code we are about to write uses the inputField.html fragment to keep your UI consistent across the application.

  • However, there is a catch. The inputField fragment in Spring PetClinic is designed to work with Thymeleaf's form binding (th:object and th:field). Standard Spring Security login looks for plain HTML inputs.

  • If you run the program and request "/login" you will get an error saying "Neither BindingResult nor plain target object for bean name 'user' available as request attribute".

  • To make the fragment happy, we need to pass it an empty "dummy" object to bind to, and tell Spring Security to look for the email field instead of its default username field.

Input Field

  • Update your @GetMapping  in AuthController.java to pass a new, empty User object to the model. This gives the inputField fragment something to attach to without crashing.

  • Additionally, we need to update the @GetMapping to check for a saved session variable and stick it into the empty User object before handing it over to Thymeleaf. That way, if a user enters an invalid email/password, their email will stay in the form.

Empty User

import org.springframework.ui.Model;

// ...

    @GetMapping("/login")
    public String initLoginForm(Model model, HttpSession session) {
        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"); // Clean up the session
        }
        
        model.addAttribute("user", user);
        return "auth/loginForm";
    }
  • Create resources/templates/auth/loginForm.html

  • This form uses standard Thymeleaf. Notice how we check for the param.error and param.logout URL parameters to display the correct alert messages. Spring Security automatically appends these to the URL depending on what just happened.

  • Now we can wrap the form in th:object="${user}" and use the fragments.

Login

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
      th:replace="~{fragments/layout :: layout (~{::body},'login')}">
<body>

    <h2 class="text-center">Please Log In</h2>

    <div class="container mt-4" style="max-width: 500px;">
        
        <div th:if="${param.error}" class="alert alert-danger">
            Invalid email or password.
        </div>
        <div th:if="${param.logout}" class="alert alert-success">
            You have been logged out successfully.
        </div>

        <form th:action="@{/login}" 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">Log In</button>
            </div>
            
            <div class="text-center mt-3">
                <p>Don't have an account? <a th:href="@{/register-student}">Register here</a></p>
            </div>
            
        </form>
    </div>

</body>
</html>
  • Because the inputField fragment uses th:field="*{email}", it generates an HTML input with name="email".

    • By default, Spring Security ignores this because it is strictly looking for an input named username.

  • Edit SecurityConfig.java to tell your security configuration to look for email instead.

  • Add a custom failureHandler to your form login configuration. This intercepts the failed login, saves the email the user typed into the session, and then redirects them to the error page.

Update Spring Security

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(authorize -> authorize
            // ... your permissions ...
            .requestMatchers("/login", "/register-student").permitAll()
            .anyRequest().authenticated()
        )
        // Ensure all auto-challenge mechanisms are disabled
		.httpBasic(AbstractHttpConfigurer::disable) // Disable the login popup
		// .formLogin(AbstractHttpConfigurer::disable)
		.formLogin(form -> form
			.loginPage("/login") // Tells Spring where your custom HTML is
			.usernameParameter("email") // Tells your security configuration to look for email instead of username.
			.defaultSuccessUrl("/") // Where to go after successful login
            .failureHandler((request, response, exception) -> {
                request.getSession().setAttribute("LAST_EMAIL", request.getParameter("email"));
                response.sendRedirect("/login?error");
            })
			.permitAll()
		)
		.logout(logout -> logout
			.logoutUrl("/logout")
			.logoutSuccessUrl("/login?logout") // Triggers the green alert box
			.permitAll()
		);
        
    return http.build();
}
  • Start up the application and test the login flow to ensure it correctly redirects you after entering your credentials.

  • The Form Action: Your form sends a POST to /login.

    • You did not need to write a @PostMapping for the login. In Spring Security, the framework automatically intercepts the POST request to /login and handles the password checking, session creation, and redirection for you. 

  • The Interception: Spring Security's hidden UsernamePasswordAuthenticationFilter catches this POST before it ever reaches your controllers.

  • The Data Validation: It looks for parameters exactly named username and password in the HTTP request.

  • The Check: It asks your UserDetailsService to look up the user by that email and verifies the password hash.

  • The Redirect: If it matches, it redirects to the defaultSuccessUrl. If it fails, it redirects to /login?error.

How It Works

  • Because Spring Security’s configuration file isn't meant to handle database lookups (like searching for a school domain), the cleanest way to do this is to tell Spring Security to redirect successful logins to a new, temporary URL in your controller. Then, your controller can reuse the exact same routing logic written for the registration process.

  • We need to change the defaultSuccessUrl to point to a new endpoint (e.g., /login-success). We will also add , true as the second parameter, which forces Spring to always go to this endpoint after login, rather than trying to send them to the homepage.

  • In SecurityConfig.java, change: .defaultSuccessUrl("/")

  • To: .defaultSuccessUrl("/login-success", true)

Customize Redirect

  • Now we just create the @GetMapping for /login-success. Spring automatically gives us a Principal object, which contains the email address of the user who just successfully logged in.

  • Add this method to your AuthController.java (you will also need to add import java.security.Principal; at the top if it isn't there already):

AuthController Routing Endpoint

    @GetMapping("/login-success")
    public String processLoginSuccess(Principal principal, RedirectAttributes redirectAttributes) {
        // 1. Get the logged-in user's email
        String email = principal.getName(); 
        
        // 2. Reuse your existing method to find their school
        Optional<School> school = findSchoolByRecursiveDomain(email);

        // 3. Redirect them exactly like you did in the registration POST method
        if(school.isPresent()) {
            redirectAttributes.addFlashAttribute("messageSuccess",
                "Welcome back! You have been redirected to " + school.get().getName() + ".");
            return "redirect:/schools/" + school.get().getDomain().substring(0, school.get().getDomain().length() - 4);
        } else {
            redirectAttributes.addFlashAttribute("messageWarning",
                "Welcome back! We could not find a school matching your email domain.");
            return "redirect:/schools";
        }
    }
  • For 10% of the midterm (Week 3) 

    • You will be given SQL CREATE TABLE and INSERT INTO statements to run in your database.

  • For 50% of the midterm (Week 4)

    • You will create one repository interface that includes methods to retrieve all objects. You will create one controller class that defines a @GetMapping method to retrieve a paginated list of objects when "/the-object" is requested. You will create a new HTML template that displays your data as a table with pagination buttons.

    • You will not be asked to update the fragment.html file, update messages.properties files, write unit tests, or build/deploy an updated container image to Azure. 

Midterm Exam Review

  • For 25% of the midterm (Week 5)

    • You will be given a Use Case Narrative and Sequence Diagram. You will be given an HTML template that contains a form to display when "/the-object/new" is requested.

    • You will update the HTML template to include a button to add a new object. You will add GET and POST methods to your Controller representing the process depicted in the sequence diagram. You will add a save method to the repository. You will display custom error messages when invalid input is entered. Entering valid data displays your new data in a paginated table.

    • You will not be asked to update messages.properties files, write unit tests, or build/deploy an updated container image to Azure. 

Midterm Exam Review

  • For 15% of the midterm (Week 6)

    • You will be given an HTML template that contains a view to display the content of an individual object when "/the-object/{id}" is requested.

    • You will update the Repository and Controller to handle getting a single object record by ID. You will update your list of objects to include hyperlinks or button links to view object details.

    • You will not be asked to update messages.properties files, write unit tests, or build/deploy an updated container image to Azure. 

Midterm Exam Review

  • Primary Actor: Authenticated User (Student/Participant)

  • Goal: To view, update, or remove their personal profile information, system preferences, and authentication credentials.

  • Preconditions: The user is actively logged into the application.

  • Main Success Scenario (Profile Update):

    • The user navigates to the Profile page (/users/profile).

    • The system retrieves the user's current data from the database and populates the form (First Name, Last Name, Nickname, Phone, Email, and Preferred Language). The password field is left blank for security.

    • The user modifies their personal details or selects a new preferred language (English, Spanish, or Korean) and submits the form.

    • The system validates the input (e.g., ensuring the email format is correct and the nickname is valid).

    • The system updates the user's record in the database.

    • The system redirects the user back to the profile page with a success message.

Use Case: Manage User Profile

  • Alternative Flow A: Email Change Conflict

    • Step 4a: The user changes their email to one that is already registered to another account.

    • Step 4b: The system detects the duplicate email constraint.

    • Step 4c: The system rejects the update, re-renders the form, and displays a specific error message on the email field.

  • Alternative Flow B: Password Update

    • Step 3a: The user enters a new password in the password field.

    • Step 4a: The system hashes the new password before attaching it to the user object.

    • Step 5a: The system updates the database with the new password hash.

Use Case: Manage User Profile

  • Alternative Flow C: Account Deletion

    • Step 3a: The user clicks the "Delete Account" button.

    • Step 4a: The system prompts for a final confirmation to prevent accidental deletion.

    • Step 5a: The system performs a soft delete (populating the deleted_at timestamp) or a hard delete, depending on the application's data retention rules.

    • Step 6a: The system invalidates the user's active HTTP session (logging them out) and redirects them to the public homepage with a farewell message.

Use Case: Manage User Profile

  • Here is the step-by-step technical flow of how the Spring MVC components will interact during a profile update.

Sequence Flow

sequenceDiagram
    autonumber
    actor Browser
    participant Controller as ProfileController
    participant Service as UserService
    participant Repo as UserRepository
    participant DB as Database

    %% GET Profile Flow
    Browser->>Controller: GET /users/profile
    Controller->>Service: getUserByEmail(principal.getName())
    Service->>Repo: findByEmail(email)
    Repo->>DB: SELECT * FROM users
    DB-->>Repo: User Record
    Repo-->>Service: User Entity
    Service-->>Controller: User Object
    Controller-->>Browser: Render users/profile.html

    %% POST Update Flow
    Browser->>Controller: POST /users/profile (Form Data)
    Controller->>Controller: @Valid Input Check
    
    alt Invalid Input
        Controller-->>Browser: Return form with validation errors
    else Valid Input
        Controller->>Service: updateProfile(userData)
        
        opt If Email Changed
            Service->>Repo: existsByEmail(newEmail)
            Repo->>DB: Query Email
            DB-->>Repo: Result
            alt Email Already Taken
                Repo-->>Service: true
                Service-->>Controller: Throws DuplicateEmailException
                Controller-->>Browser: Return form with duplicate error message
            end
        end

        opt If New Password Provided
            Service->>Service: Hash new password (PasswordEncoder)
        end

        Service->>Repo: save(updatedUser)
        Repo->>DB: UPDATE users SET ...
        DB-->>Repo: Success
        Repo-->>Service: Saved Entity
        Service-->>Controller: Success
        Controller-->>Browser: Redirect (302) to /users/profile with success message
    end
  • To keep the password annotations on the entity, you have to use Spring Validation Groups. This tells Spring: "Only enforce these password rules when a user is registering, but ignore them when updating a profile.

  • Spring Validation Groups allow you to reuse the exact same User entity for different scenarios by telling Spring when to enforce certain rules.

  • Create a new infterface in your validation package called OnRegister.java

  • This will be an empty interface that acts as a tag to group our validation rules together. We will make it extend Default so that standard rules (like your email validation) still apply.

Spring Validation Groups

package org.springframework.samples.petclinic.validation;

import jakarta.validation.groups.Default;

// This interface is just a tag. By extending Default, 
// any validation without a specific group will still run alongside this one.
public interface OnRegister extends Default {
}
  • Open your User entity. We will add the groups attribute to your three password annotations.

  • By tagging them with OnRegister.class, we are telling Spring: "Only enforce these three password rules when the OnRegister group is active."

Tag the Password Annotations

@Column(name="password_hash", nullable = true, length = 255)
// 1. Add the groups parameter to all three annotations:
@NotEmpty(message = "Password is required", groups = OnRegister.class)
@Size(min = 8, message = "Password must be at least 8 characters", groups = OnRegister.class)
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,}$",
	message = "Password must contain uppercase, lowercase, and number",
	groups = OnRegister.class)
private String password;
  • We need to update the AuthController to tell Spring which set of rules to fire.

  • In the @PostMapping processRegistrationForm method, replace @Valid with @Validated(OnRegister.class) which allows us to specify groups.

  • When a user is registering, we want the password rules to fire.

  • The result: Spring validates the email/names (Default) AND the strict password rules (OnRegister).

AuthController Registration

// Add these imports
import org.springframework.samples.petclinic.validation.OnRegister;
import org.springframework.validation.annotation.Validated; 
import org.springframework.web.bind.annotation.ModelAttribute;

// ... inside AuthController ...

    @PostMapping("/register-student")
    public String processRegisterForm(
        @Validated(OnRegister.class) @ModelAttribute("user") User user,
        BindingResult result,
        RedirectAttributes redirectAttributes,
		HttpServletRequest request) {
  • Here is the updated User.java entity mapped to the demo project's database schema. Add additional attributes for your project.

Update User

package org.springframework.samples.petclinic.user;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.samples.petclinic.model.BaseEntity;
import org.springframework.samples.petclinic.validation.OnRegister;

import java.time.LocalDateTime;
import java.util.Set;

@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
public class User extends BaseEntity {

	@Column(name="first_name", nullable = true, length = 50)
	private String firstName;

	@Column(name="last_name", nullable = true, length = 50)
	private String lastName;

	@Column(name = "nickname", length = 50)
	private String nickname;

	@Column(name = "nickname_is_flagged")
	private Boolean nicknameIsFlagged;

	@Column(nullable = false, unique = true, length = 255)
	@NotEmpty(message = "Email is required")
	@Email(message = "Please enter a valid email")
	private String email;

	@Column(name = "public_email")
	private Boolean publicEmail;

	@Column(name = "phone", length = 255)
    @Pattern(regexp = "^$|^(?:\\+\\d{1,3}\\s?)?\\(?\\d{3}\\)?[\\s.-]?\\d{3}[\\s.-]?\\d{4}$", 
             message = "Please enter a valid phone number")
    private String phone;

	@Column(name = "public_phone")
	private Boolean publicPhone;

	// NOTE: You will need to add this column to your schema.sql
	@Column(name = "preferred_language", length = 50)
	private String preferredLanguage;

	@Column(name="password_hash", nullable = true, length = 255)
	// 1. Add the groups parameter to all three annotations:
	@NotEmpty(message = "Password is required", groups = OnRegister.class)
	@Size(min = 8, message = "Password must be at least 8 characters", groups = OnRegister.class)
	@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,}$",
		message = "Password must contain uppercase, lowercase, and number",
		groups = OnRegister.class)
	private String password;

	@CreationTimestamp
	@Column(name = "created_at", updatable = false)
	private LocalDateTime createdAt;

	@UpdateTimestamp
	@Column(name = "updated_at")
	private LocalDateTime updatedAt;

	@Column(name = "deleted_at")
	private LocalDateTime deletedAt;

	// Many-to-Many Relationship with Role
	@ManyToMany(fetch = FetchType.EAGER) // Fetch roles immediately when a user is loaded
	@JoinTable(
		name = "user_roles", // Name of the junction table in MySQL
		joinColumns = @JoinColumn(name = "user_id"), // Column in user_roles that references the 'users' table
		inverseJoinColumns = @JoinColumn(name = "role_id") // Column in user_roles that references the 'roles' table
	)
	@EqualsAndHashCode.Exclude
	private Set<Role> roles;

}
  • Timestamps Added: I added Hibernate's @CreationTimestamp and @UpdateTimestamp annotations. These automatically manage your created_at and updated_at fields so you never have to set them manually in your Java code.

  • Missing Database Column: I will add a preferred language dropdown, but preferred_language is missing from the database schema. You will need to add this to your schema.sql file.
    preferred_language varchar(50) null,

  • Run this query:
    ALTER TABLE users ADD COLUMN preferred_language VARCHAR(50) NULL;

Update User

  • It is a very common Java instinct to use primitive boolean (which defaults to false) instead of the wrapper class Boolean.

  • However, in Java, a primitive boolean can only be true or false. It cannot be null.

  • Look at your database schema:
    public_email tinyint default 0 null

  • Notice the null at the end. Your database allows those columns to be empty. If a record is inserted into the database via a raw SQL script, or if an old record exists before you added this feature, it might have a NULL value in that column.

  • If Hibernate fetches that row and tries to map a database NULL to your Java primitive boolean publicEmail, your application will crash with an Exception because primitives cannot hold nulls.

  • Therefore, we will use Boolean instead of boolean.

Booleans and Database Null

  • Since the Spring PetClinic uses Bootstrap for its styling, these fragments are built using standard Bootstrap 5 form classes (form-select, form-check, etc.) so they match your existing inputField.html.

  • Create a selectField.html file in your src/main/resources/templates/fragments/ folder.

  • This fragment accepts a label, the field name, and an options map.

  • If you created a selectField template for a previous assignment, simply give this file a different name.

  • When we build your profile page, call this fragment like this:
    <div th:replace="~{fragments/selectField :: select ('Preferred Language', 'preferredLanguage', ${languageOptions})}"></div>

select menu fragment

<html xmlns:th="https://www.thymeleaf.org">
<body>
<th:block th:fragment="select (label, name, options)">
  <div class="mb-3" th:with="valid=${!#fields.hasErrors(name)}">
    <label class="form-label" th:for="${name}" th:text="${label}">Label</label>

    <select class="form-select"
            th:classappend="${valid ? '' : 'is-invalid'}"
            th:field="*{__${name}__}"
            th:id="${name}">
      <option th:each="opt : ${options}"
              th:value="${opt.key}"
              th:text="${opt.value}">Option</option>
    </select>

    <div class="invalid-feedback" th:if="${!valid}" th:errors="*{__${name}__}">Error message</div>
  </div>
</th:block>
</body>
</html>
  • Create a checkboxField.html file in your /templates/fragments/ folder.

  • Checkboxes don't need options, just a label and the boolean field it binds to (like your publicEmail field).

  • When we build your profile page, call this fragment like this:
    <div th:replace="~{fragments/checkboxField :: checkbox ('Make Email Public', 'publicEmail')}"></div>

checkbox fragment

<html xmlns:th="https://www.thymeleaf.org">
<body>
<th:block th:fragment="checkbox (label, name)">
  <div class="mb-3 form-check" th:with="valid=${!#fields.hasErrors(name)}">

    <input type="checkbox"
           class="form-check-input"
           th:classappend="${valid ? '' : 'is-invalid'}"
           th:field="*{__${name}__}"
           th:id="${name}" />

    <label class="form-check-label" th:for="${name}" th:text="${label}">Label</label>

    <div class="invalid-feedback" th:if="${!valid}" th:errors="*{__${name}__}">Error message</div>
  </div>
</th:block>
</body>
</html>
  • Create a radioField.html file in your /templates/fragments/ folder.

  • This is a hybrid of the two above. It takes options like a select menu but displays them as a row of inline checkboxes

radio fragment

<html xmlns:th="https://www.thymeleaf.org">
<body>
<th:block th:fragment="radio (label, name, options)">
  <div class="mb-3" th:with="valid=${!#fields.hasErrors(name)}">
    <label class="form-label d-block" th:text="${label}">Label</label>

    <div class="form-check form-check-inline" th:each="opt : ${options}">
      <input type="radio"
             class="form-check-input"
             th:classappend="${valid ? '' : 'is-invalid'}"
             th:field="*{__${name}__}"
             th:value="${opt.key}"
             th:id="${name + '_' + opt.key}" />

      <label class="form-check-label"
             th:for="${name + '_' + opt.key}"
             th:text="${opt.value}">Option</label>
    </div>

    <div class="invalid-feedback d-block" th:if="${!valid}" th:errors="*{__${name}__}">Error message</div>
  </div>
</th:block>
</body>
</html>
  • Create src/main/java/.../user/ProfileController.java:

  • Create a @GetMapping to display a form.

ProfileController

import jakarta.validation.Valid;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.security.Principal;
import java.util.LinkedHashMap;
import java.util.Map;

@Controller
@RequestMapping("/users")
public class ProfileController {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public ProfileController(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    /**
     * This @ModelAttribute makes the language map available to ALL views in this controller.
     * It pairs with the new selectField fragment we just built.
     */
    @ModelAttribute("languageOptions")
    public Map<String, String> populateLanguageOptions() {
        Map<String, String> options = new LinkedHashMap<>();
        options.put("EN", "English");
        options.put("ES", "Spanish");
        options.put("KO", "Korean");
        return options;
    }

    @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("");

        model.addAttribute("user", user);
        return "users/profile";
    }

}
  • The populateLanguageOptions method is a fantastic piece of Spring MVC magic. It relies entirely on how the @ModelAttribute annotation behaves when it is placed on a method instead of a parameter.

  • Normally, you see @ModelAttribute used as a parameter inside a mapping, like @ModelAttribute("user") User user. That tells Spring to bind incoming form data to an object.

  • However, when  @ModelAttribute("languageOptions") is placed above a method, it changes behavior to actsas a pre-execution hook.

  • Spring runs populateLanguageOptions() first before executing any @GetMapping or @PostMapping in ProfileController.

  • Using a LinkedHashMap instead of a standard HashMap is a deliberate choice for UI elements. A standard HashMap does not guarantee order, which means your dropdown list might randomly shuffle "Spanish", "Korean", and "English" every time the page loads. The LinkedHashMap preserves the exact order, ensuring your dropdown is always the same.

populateLanguageOptions 

  • When the populateLanguageOptions method finishes, Spring automatically takes the returned Map and injects it into the shared Model object under the name "languageOptions".

  • It is functionally identical to writing model.addAttribute("languageOptions", options); inside every single mapping in your controller.

  • Once the controller finishes its work and passes the Model to the view, Thymeleaf intercepts the languageOptions object.

  • It passes that Map directly into the options parameter of your fragment. The fragment then loops through it:
    <option th:each="opt : ${options}" th:value="${opt.key}" th:text="${opt.value}">Option</option>

    • opt.key grabs "EN" and puts it in the value="..." attribute (what gets saved to the database).

    • opt.value grabs "English" and puts it between the tags (what the user actually reads).

populateLanguageOptions 

  • The biggest reason we use a method-level @ModelAttribute for dropdowns is to handle validation failures gracefully.

  • Imagine a user submits the profile form, but they type an invalid phone number. The @PostMapping catches the error and returns "users/profile" to show the form again.

  • If we had only populated the language map inside the @GetMapping, the map wouldn't exist during the @PostMapping's return trip. The page would crash trying to render the select menu because ${languageOptions} would be null!

  • Because populateLanguageOptions() runs before every request, the map is always guaranteed to be in the model, whether the user is loading the page for the first time or bouncing back from a validation error.

populateLanguageOptions 

  • Create this file at /resources/templates/users/profile.html:

  • This page brings together all the pieces we have built: the custom fragments for checkboxes and select menus, the language options from the controller, and the layout styling. I have also included a "Danger Zone" section at the bottom for the account deletion requirement.

profile.html template

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
      th:replace="~{fragments/layout :: layout (~{::body},'profile')}">
<body>

    <div class="container mt-4" style="max-width: 700px;">
        
        <h2 class="mb-4">Edit Profile</h2>

        <div class="card mb-4">
            <div class="card-body">
                <form th:action="@{/users/profile}" th:object="${user}" method="post">
                    
                    <h5 class="card-title text-muted mb-3">Personal Information</h5>
                    <div class="row">
                        <div class="col-md-6">
                            <div th:replace="~{fragments/inputField :: input ('First Name', 'firstName', 'text')}"></div>
                        </div>
                        <div class="col-md-6">
                            <div th:replace="~{fragments/inputField :: input ('Last Name', 'lastName', 'text')}"></div>
                        </div>
                    </div>

                    <div class="row">
                        <div class="col-md-6">
                            <div th:replace="~{fragments/inputField :: input ('Nickname', 'nickname', 'text')}"></div>
                        </div>
                        <div class="col-md-6">
                            <div th:replace="~{fragments/selectField :: select ('Preferred Language', 'preferredLanguage', ${languageOptions})}"></div>
                        </div>
                    </div>

                    <hr class="my-4">

                    <h5 class="card-title text-muted mb-3">Contact Information</h5>
                    
                    <div th:replace="~{fragments/inputField :: input ('Email Address', 'email', 'text')}"></div>
                    <div th:replace="~{fragments/checkboxField :: checkbox ('Make Email Public to other students', 'publicEmail')}"></div>
                    
                    <div class="mt-3"></div>

                    <div th:replace="~{fragments/inputField :: input ('Phone Number', 'phone', 'text')}"></div>
                    <div th:replace="~{fragments/checkboxField :: checkbox ('Make Phone Public to other students', 'publicPhone')}"></div>

                    <hr class="my-4">

                    <h5 class="card-title text-muted mb-3">Security</h5>
                    <div class="alert alert-info py-2 text-sm">
                        <small>Leave the password field blank unless you want to change it.</small>
                    </div>
                    <div th:replace="~{fragments/inputField :: input ('New Password', 'password', 'password')}"></div>

                    <div class="d-grid gap-2 mt-4">
                        <button type="submit" class="btn btn-primary">Save Changes</button>
                    </div>

                </form>
            </div>
        </div>

        <!-- Delete Account block will go here -->

    </div>

</body>
</html>
  • Grid Layout: Wrapping the First Name / Last Name and Nickname / Language fields in <div class="row"> and <div class="col-md-6"> places them side-by-side on desktop screens, saving vertical space, but they will automatically stack on top of each other on mobile devices.

  • Fragment Power: Notice how clean the HTML is! Instead of writing 15 lines of HTML for the Language select menu (including error handling and mapping), it is achieved in a single line.

  • Client-Side Confirmation: The delete button uses a simple JavaScript onsubmit confirmation dialog. This prevents accidental clicks without needing a complex modal window.

profile.html template

  • Every timewe update an html file with the server running, we get a Build error saying: Execution failed for task ':processResources'. > Failed to clean up stale outputs"

  • Why it's happening? Your running Spring Boot server (or your IDE) is putting a "read lock" on the compiled files in your build/ directory. When you save an HTML file, Gradle tries to copy the fresh file into that directory, hits the lock, and crashes.

  • You can prevent this and set up true "hot reloading" so your HTML changes show up the second you refresh your browser.

  • We just need to tell IntelliJ to use its own native builder instead.

    • Go to File > Settings (or IntelliJ IDEA > Preferences on Mac).

    • Navigate to Build, Execution, Deployment > Build Tools > Gradle.

    • Find the dropdowns for "Build and run using" and "Run tests using".

    • Change both of them from Gradle to IntelliJ IDEA.

    • Click Apply and OK.

Updating HTML with Server On

  • Now we need to tell Thymeleaf to stop caching your HTML templates. By default, Thymeleaf loads your HTML once when the server starts and doesn't look at the file again.

  • Create an application-dev.properties file in your src/main/resources/ folder for setting up Spring Profiles.

  • When you deploy your project to a live server, you do not want your production environment running with Thymeleaf caching disabled, showing verbose debug logs, or trying to connect to a local dev database.

  • By using Spring Profiles, you create a strict boundary between your local development environment and your production environment.

application-dev.properties

# Disable Thymeleaf caching for instant HTML updates
spring.thymeleaf.cache=false

# Show SQL queries in the console for debugging
spring.jpa.show-sql=true

# Local database connection (if you want a development database separate from your production database)
# spring.datasource.url=jdbc:mysql://localhost:3306/athleagues_dev
  • Now, you just need to tell Spring Boot which profile it should use when you click "Run" in your IDE.

  • Open your main application.properties file and add this single line at the very top:
    spring.profiles.active=dev

  • Spring Boot DevTools (already installed in your build.gradle file), is useful when a Java file changes, it reloads automatically without a full server restart.

  • When DevTools is active, it automatically disables Thymeleaf caching for you, so you wouldn't even need the previous step.

Activate the Dev Profile

  • The  resources/db/mysql/data.sql file contains this:

    INSERT IGNORE INTO schools (name, domain, status_id) VALUES

    ('Kirkwood Community College', 'kirkwood.edu', 'active'),

    ('University of Iowa', 'uiowa.edu', 'active')...

     

    INSERT IGNORE INTO locations (school_id, name, description, address, status_id) VALUES

    (1, 'Main Campus', 'The primary campus in Cedar Rapids', '6301 Kirkwood Blvd SW, Cedar Rapids, IA', 'active');

    ...

  • Every time we run the program, new locations get added, but new schools do not. 

  • You are actually witnessing a feature, not a bug! The script is doing exactly what it was told, but our database is missing a rule.

Duplicate Locations

  • INSERT IGNORE does not know if a row is a duplicate by looking at the text. It only ignores an insert if adding the row would violate a UNIQUE constraint or a Primary Key.

  • Your schools table is behaving correctly because it has a UNIQUE constraint on the domain column.

  • When you restart the server, MySQL tries to insert Kirkwood again. It sees that kirkwood.edu already exists, hits that UNIQUE rule, and IGNORE tells it to silently skip the row and move on.

  • However, your locations table is duplicating because it does not have a unique constraint (other than its auto-incrementing ID).

  • When MySQL reads INSERT IGNORE INTO locations... 'Main Campus', it checks the table. Since there is no rule stating that School #1 can only have one 'Main Campus', it just generates a brand new ID (like ID 15, then 16, then 17) and inserts a new row every single time you restart the application.

Duplicate Locations

  • The best way to fix this is to add a Composite Unique Constraint on both the school_id and the name. This means Kirkwood (School 1) can have a "Main Campus", and the University of Iowa (School 2) can also have a "Main Campus", but Kirkwood can never have two "Main Campuses".

  • Update your schema.sql file, with a UNIQUE KEY:

The Fix

CREATE TABLE IF NOT EXISTS locations (
  id INT AUTO_INCREMENT PRIMARY KEY,
  school_id INT NOT NULL,
  parent_location_id INT NULL,
  name VARCHAR(255) NOT NULL,
  description TEXT,
  address VARCHAR(255),
  latitude DECIMAL(8,4),
  longitude DECIMAL(8,4),
  status_id ENUM('DRAFT', 'ACTIVE', 'CLOSED', 'COMING_SOON') DEFAULT 'ACTIVE',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted_at DATETIME DEFAULT NULL,
  CONSTRAINT fk_locations_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
  CONSTRAINT fk_locations_parent FOREIGN KEY (parent_location_id) REFERENCES locations(id) ON DELETE SET NULL,

  -- ADD THIS LINE:
  UNIQUE KEY uk_school_location (school_id, name)
);
  • Truncate the locations table to delete the duplicate rows that were already created. 

    • Right click the table, choose "Object Actions", and choose Truncate.

  • Once the duplicates are gone, run this query in your database console:
    ALTER TABLE locations ADD CONSTRAINT uk_school_location UNIQUE (school_id, name);

The Fix

  • 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.

ProfileController

@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.

ProfileController

Java 3 - Week 7

By Marc Hauschildt

Java 3 - Week 7

  • 175