Java 3 - 2026

Week 9

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.

Course Plan

  • 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

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

Registration Error Msg Disappear

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

New Email, User Not Found

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

Refresh the Session

 // 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")

Phone Number Normalization

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

Sanitize for Database

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

Display logged-in user's info

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

How it works w/ ModelAndView

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

Update the School Template

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

Bootstrap Styling

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

Handling null values

      <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:

Handling null values

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:

@PostMapping("/users/delete")

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.

@PostMapping("/users/delete")

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

Reject Soft-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();
}
  • Now, if a student decides to delete their account, their historical intramural data stays intact in your database tables.
  • However, the exact millisecond they hit that "Delete Account" button, their session is destroyed, and the 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.

Form to Delete the 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.

Edit Profile for Guest Users

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

Edit Profile for Guest Users

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.

Edit Profile for Guest Users

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

Go to My School

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

Add the Button to the HTML

        <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)

Language Saved

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:

Change Lang on Profile Update

        // ... (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!

How it works

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

GitHub Actions

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

Delete other Workflow Files

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

Delete other Workflow Files

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

Set up Secrets

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

Create the Workflow File

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.

Trigger it

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

Azure Step in YAML

az ad sp create-for-rbac `
  --name "github-actions-<project-name>" `
  --role contributor `
  --scopes /subscriptions/<your-subscription-id>/resourceGroups/<your-resource-group-rg> `
  --json-auth
  • Go to Azure, click "Azure for Students" to see your subscription id.
  • To 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.

GitHub Secrets

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

Append to your YAML File

# 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

MySQL database credentials

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

MySQL database credentials

# 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 jar
  • The 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

Spring Java Format plugin

  • 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

Task :checkstyleNohttp FAILED

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

Trigger it

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

SiteGround Database

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

GitHub Actions

Java 3 - Week 9

By Marc Hauschildt

Java 3 - Week 9

  • 143