Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
*
* Copyright (c) 2025 Green Button Alliance, Inc.
*
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package org.greenbuttonalliance.espi.datacustodian.web;

import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity;
import org.greenbuttonalliance.espi.common.service.RetailCustomerService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.security.Principal;

/**
* Self-service password change for any signed-in account (#180). One controller serves both portals
* — the customer page ({@code /customer/password}) and the custodian/admin page
* ({@code /custodian/password}) — sharing a single change routine (DRY); only the rendered view
* (which navbar) differs. Accounts are unified {@link RetailCustomerEntity} rows distinguished by
* role, so the logic is identical.
*
* <p>A user may change only their own password: the account is resolved from the authenticated
* principal and the current password must be verified before the new one is accepted.</p>
*/
@Controller
@PreAuthorize("isAuthenticated()")
public class PasswordController {

private final RetailCustomerService retailCustomerService;
private final PasswordEncoder customerPasswordEncoder;

public PasswordController(RetailCustomerService retailCustomerService,
PasswordEncoder customerPasswordEncoder) {
this.retailCustomerService = retailCustomerService;
this.customerPasswordEncoder = customerPasswordEncoder;
}

@GetMapping("/customer/password")
public String customerForm() {
return "customer/password";
}

@GetMapping("/custodian/password")
public String custodianForm() {
return "custodian/password";
}

@PostMapping("/customer/password")
public String changeCustomer(Principal principal,
@RequestParam(required = false) String currentPassword,
@RequestParam(required = false) String newPassword,
@RequestParam(required = false) String confirmPassword,
Model model, RedirectAttributes redirectAttributes) {
return change("customer/password", principal, currentPassword, newPassword, confirmPassword,
model, redirectAttributes);
}

@PostMapping("/custodian/password")
public String changeCustodian(Principal principal,
@RequestParam(required = false) String currentPassword,
@RequestParam(required = false) String newPassword,
@RequestParam(required = false) String confirmPassword,
Model model, RedirectAttributes redirectAttributes) {
return change("custodian/password", principal, currentPassword, newPassword, confirmPassword,
model, redirectAttributes);
}

/**
* Shared change routine. {@code view} is the portal template, which (sans leading slash) also
* equals the request path, so the post/redirect/get target is {@code "redirect:/" + view}.
*/
private String change(String view, Principal principal, String currentPassword,
String newPassword, String confirmPassword,
Model model, RedirectAttributes redirectAttributes) {
RetailCustomerEntity account = retailCustomerService.findByUsername(principal.getName());
if (account == null) {
model.addAttribute("error", "Account not found.");
return view;
}
if (currentPassword == null
|| !customerPasswordEncoder.matches(currentPassword, account.getPassword())) {
model.addAttribute("error", "Your current password is incorrect.");
return view;
}
if (newPassword == null || newPassword.isBlank()) {
model.addAttribute("error", "The new password must not be blank.");
return view;
}
if (!newPassword.equals(confirmPassword)) {
model.addAttribute("error", "The new password and confirmation do not match.");
return view;
}

account.setPassword(customerPasswordEncoder.encode(newPassword));
retailCustomerService.save(account);
redirectAttributes.addFlashAttribute("message", "Your password has been changed.");
return "redirect:/" + view;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head}">
<title>Custodian Portal - Change Password</title>
</head>

<body>
<nav th:replace="~{fragments/layout :: custodianHeader}"></nav>

<div class="container">
<h2 class="mt-4">Change Password</h2>

<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<div th:if="${error}" class="alert alert-danger" th:text="${error}"></div>

<div class="card mt-3" style="max-width: 32rem;">
<div class="card-body">
<form th:action="@{/custodian/password}" method="post">
<div class="mb-3">
<label for="currentPassword" class="form-label">Current password</label>
<input type="password" id="currentPassword" name="currentPassword"
class="form-control" required autocomplete="current-password"/>
</div>
<div class="mb-3">
<label for="newPassword" class="form-label">New password</label>
<input type="password" id="newPassword" name="newPassword"
class="form-control" required autocomplete="new-password"/>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm new password</label>
<input type="password" id="confirmPassword" name="confirmPassword"
class="form-control" required autocomplete="new-password"/>
</div>
<button type="submit" class="btn btn-primary">Change password</button>
</form>
</div>
</div>

<hr class="my-5">
<footer th:replace="~{fragments/layout :: footer}"></footer>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head}">
<title>Customer Portal - Change Password</title>
</head>

<body>
<nav th:replace="~{fragments/layout :: customerHeader}"></nav>

<div class="container">
<h2 class="mt-4">Change Password</h2>

<div th:if="${message}" class="alert alert-success" th:text="${message}"></div>
<div th:if="${error}" class="alert alert-danger" th:text="${error}"></div>

<div class="card mt-3" style="max-width: 32rem;">
<div class="card-body">
<form th:action="@{/customer/password}" method="post">
<div class="mb-3">
<label for="currentPassword" class="form-label">Current password</label>
<input type="password" id="currentPassword" name="currentPassword"
class="form-control" required autocomplete="current-password"/>
</div>
<div class="mb-3">
<label for="newPassword" class="form-label">New password</label>
<input type="password" id="newPassword" name="newPassword"
class="form-control" required autocomplete="new-password"/>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm new password</label>
<input type="password" id="confirmPassword" name="confirmPassword"
class="form-control" required autocomplete="new-password"/>
</div>
<button type="submit" class="btn btn-primary">Change password</button>
</form>
</div>
</div>

<hr class="my-5">
<footer th:replace="~{fragments/layout :: footer}"></footer>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@
<li class="nav-item">
<a class="nav-link" th:href="@{/customer/authorizations}">My Authorizations</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/customer/password}">Change Password</a>
</li>
</ul>

<ul class="navbar-nav">
Expand Down Expand Up @@ -160,6 +163,7 @@
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="custodianUserDropdown">
<li><span class="dropdown-item-text text-muted small">Signed in as custodian</span></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" th:href="@{/custodian/password}">Change Password</a></li>
<li>
<form th:action="@{/logout}" method="post" class="m-0">
<button type="submit" class="dropdown-item">Logout</button>
Expand Down
Loading