diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/RetailCustomerService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/RetailCustomerService.java
index eabcf0bd4..23c4b2b4d 100755
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/RetailCustomerService.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/RetailCustomerService.java
@@ -36,4 +36,6 @@ public interface RetailCustomerService {
RetailCustomerEntity findByUsername(String username);
+ void deleteById(Long retailCustomerId);
+
}
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/RetailCustomerServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/RetailCustomerServiceImpl.java
index 6f3986eb0..1912e0f25 100755
--- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/RetailCustomerServiceImpl.java
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/RetailCustomerServiceImpl.java
@@ -69,4 +69,9 @@ public RetailCustomerEntity findByUsername(String username) {
}
}
+ @Override
+ public void deleteById(Long retailCustomerId) {
+ retailCustomerRepository.deleteById(retailCustomerId);
+ }
+
}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/CustomerLoginSecurityConfiguration.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/CustomerLoginSecurityConfiguration.java
index f146f2045..5c6225279 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/CustomerLoginSecurityConfiguration.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/CustomerLoginSecurityConfiguration.java
@@ -26,6 +26,7 @@
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.config.Customizer;
@@ -88,10 +89,17 @@ public SecurityFilterChain customerLoginSecurityFilterChain(
provider.setPasswordEncoder(customerPasswordEncoder);
PathPatternRequestMatcher.Builder pp = PathPatternRequestMatcher.withDefaults();
+ // This session/form-login chain owns the human-facing UI surface. The public landing
+ // ("/", "/home") is included so the shared navbar "Home" link is session-aware and does not
+ // fall through to the stateless resource-server chain (which would 401 it). The ESPI API
+ // (/espi/**) stays on the resource-server chain.
RequestMatcher matcher = new OrRequestMatcher(
+ pp.matcher("/"),
+ pp.matcher("/home"),
pp.matcher("/login"),
pp.matcher("/logout"),
pp.matcher("/custodian/**"),
+ pp.matcher("/customer/**"),
pp.matcher("/oauth/authorize-screen/**"));
return http
@@ -103,7 +111,7 @@ public SecurityFilterChain customerLoginSecurityFilterChain(
// session-stored token. Right shape for vanilla form login.
.csrf(Customizer.withDefaults())
.authorizeHttpRequests(authz -> authz
- .requestMatchers(pp.matcher("/login")).permitAll()
+ .requestMatchers(pp.matcher("/"), pp.matcher("/home"), pp.matcher("/login")).permitAll()
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login")
@@ -136,12 +144,26 @@ public void onAuthenticationSuccess(HttpServletRequest request,
getRedirectStrategy().sendRedirect(request, response, returnTo);
}
else {
- getRedirectStrategy().sendRedirect(request, response, DEFAULT_SUCCESS_URL);
+ getRedirectStrategy().sendRedirect(request, response, landingFor(authentication));
}
}
};
}
+ /**
+ * Role-aware default landing. Only custodians/admins may see {@code /custodian/home}
+ * (it is {@code @PreAuthorize("hasRole('ROLE_CUSTODIAN')")}); a regular customer logging in
+ * would otherwise be redirected there and denied, so they land on the public home page.
+ */
+ private static String landingFor(Authentication authentication) {
+ boolean custodian = authentication.getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .anyMatch(a -> a.equals("ROLE_CUSTODIAN") || a.equals("ROLE_ADMIN"));
+ // Custodians land on the admin dashboard; retail customers on their self-service
+ // authorizations page (#173).
+ return custodian ? DEFAULT_SUCCESS_URL : "/customer/authorizations";
+ }
+
/**
* Accept only same-origin paths ({@code /foo}) or absolute URLs whose URI parses cleanly.
* Defense in depth against open-redirect — the AS-issued signed handoff in PR C3 will
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/SecurityConfiguration.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/SecurityConfiguration.java
index f8e8efc3f..b70a76fa3 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/SecurityConfiguration.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/SecurityConfiguration.java
@@ -21,9 +21,11 @@
import org.greenbuttonalliance.espi.common.scope.FunctionBlock;
import org.greenbuttonalliance.espi.common.scope.FunctionBlockCategory;
+import org.springframework.boot.security.autoconfigure.web.servlet.PathRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.authorization.AuthorityAuthorizationManager;
@@ -77,6 +79,19 @@ public class SecurityConfiguration {
@Value("${espi.authorization-server.client-secret:datacustodian-secret}")
private String clientSecret;
+ /**
+ * Exclude the portal's static web assets (CSS/JS/images/webjars/favicon) from the security
+ * filter chains entirely. These are public, non-sensitive files; routing them through the
+ * stateless OAuth2 resource-server chain otherwise 401s them and the admin/customer portal
+ * renders unstyled (#173). {@link PathRequest#toStaticResources()} covers Spring Boot's
+ * standard static locations.
+ */
+ @Bean
+ public WebSecurityCustomizer staticResourceSecurityCustomizer() {
+ return web -> web.ignoring().requestMatchers(
+ PathRequest.toStaticResources().atCommonLocations());
+ }
+
/**
* Main security filter chain for ESPI Resource Server endpoints.
*/
@@ -140,6 +155,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http,
.authorizeHttpRequests(authz -> authz
// Public endpoints
.requestMatchers(
+ "/error",
"/actuator/health",
"/actuator/info",
"/api-docs/**",
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/WebConfiguration.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/WebConfiguration.java
index 5857db742..07cfc2b13 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/WebConfiguration.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/WebConfiguration.java
@@ -167,7 +167,19 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCachePeriod(3600);
-
+
+ // @EnableWebMvc disables Spring Boot's default static-resource mappings, so the portal's
+ // CSS/JS/images must be registered explicitly; otherwise the templates' /css and /js links
+ // 404 and the portal renders unstyled (#173). These paths are also security-ignored via the
+ // WebSecurityCustomizer in SecurityConfiguration.
+ registry.addResourceHandler("/css/**")
+ .addResourceLocations("classpath:/static/css/")
+ .setCachePeriod(3600);
+
+ registry.addResourceHandler("/js/**")
+ .addResourceLocations("classpath:/static/js/")
+ .setCachePeriod(3600);
+
registry.addResourceHandler("/images/**")
.addResourceLocations("classpath:/static/images/")
.setCachePeriod(86400);
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/HomeController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/HomeController.java
index 9039c6bab..acb925ffd 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/HomeController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/HomeController.java
@@ -23,17 +23,18 @@
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
-// @Controller - COMMENTED OUT: UI not needed in resource server
-// @Component
+// Re-enabled (#173): the portal navbar links "Home" to "/"; without this the root 404s → /error →
+// resource-server chain → 401. Renders templates/home.html (public landing).
+@Controller
public class HomeController {
@GetMapping(Routes.ROOT)
public String index() {
- return "/home";
+ return "home";
}
@GetMapping(Routes.HOME)
public String home() {
- return "/home";
+ return "home";
}
}
\ No newline at end of file
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/CustodianHomeController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/CustodianHomeController.java
index 4ba8e8de7..c723db6f4 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/CustodianHomeController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/CustodianHomeController.java
@@ -19,20 +19,56 @@
package org.greenbuttonalliance.espi.datacustodian.web.custodian;
+import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity;
+import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository;
+import org.greenbuttonalliance.espi.common.service.AuthorizationService;
+import org.greenbuttonalliance.espi.common.service.RetailCustomerService;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-// The custodian login flow (C2a) redirects to /custodian/home on success
-// (CustomerLoginSecurityConfiguration DEFAULT_SUCCESS_URL), so this landing controller must be
-// enabled — otherwise the post-login redirect 404s and is re-dispatched to /error, which the
-// resource-server chain returns as 401. Template: templates/custodian/home.html.
+import java.util.List;
+
+/**
+ * Custodian portal landing page (admin dashboard).
+ *
+ *
The custodian login flow (C2a) redirects to {@code /custodian/home} on success
+ * (CustomerLoginSecurityConfiguration DEFAULT_SUCCESS_URL), so this landing controller must be
+ * enabled — otherwise the post-login redirect 404s and is re-dispatched to /error, which the
+ * resource-server chain returns as 401 (#171). This controller also answers the bare
+ * {@code /custodian} path so the navbar brand/Dashboard link resolves.
+ *
+ *
Read-only: it renders the dashboard and populates the overview stat tiles with simple entity
+ * counts. It performs no writes (CRUD remains deferred per #166). Template:
+ * templates/custodian/home.html.
+ */
@Controller
-@RequestMapping("/custodian/home")
+@PreAuthorize("hasRole('ROLE_CUSTODIAN')")
public class CustodianHomeController {
- @GetMapping
- public String index() {
- return "/custodian/home";
+ private final RetailCustomerService retailCustomerService;
+ private final AuthorizationService authorizationService;
+ private final UsagePointRepository usagePointRepository;
+
+ public CustodianHomeController(RetailCustomerService retailCustomerService,
+ AuthorizationService authorizationService,
+ UsagePointRepository usagePointRepository) {
+ this.retailCustomerService = retailCustomerService;
+ this.authorizationService = authorizationService;
+ this.usagePointRepository = usagePointRepository;
+ }
+
+ @GetMapping({"/custodian", "/custodian/home"})
+ public String index(Model model) {
+ List authorizations = authorizationService.findAll();
+ long activeTokens = authorizations.stream().filter(AuthorizationEntity::isActive).count();
+
+ model.addAttribute("totalCustomers", retailCustomerService.findAll().size());
+ model.addAttribute("activeTokens", activeTokens);
+ model.addAttribute("totalUsagePoints", usagePointRepository.count());
+ model.addAttribute("todayRequests", 0);
+
+ return "custodian/home";
}
-}
\ No newline at end of file
+}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/OAuthTokenController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/OAuthTokenController.java
new file mode 100644
index 000000000..c141d7450
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/OAuthTokenController.java
@@ -0,0 +1,100 @@
+/*
+ *
+ * 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.custodian;
+
+import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity;
+import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity;
+import org.greenbuttonalliance.espi.common.service.AuthorizationService;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.stereotype.Controller;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+
+import java.util.List;
+
+/**
+ * Custodian OAuth Token Management page (#173). Read-only view of the Authorization grants the Data
+ * Custodian holds. The legacy portal shipped this page as an empty placeholder; this renders a real
+ * table over {@link AuthorizationService#findAll()}.
+ *
+ *
Open-Session-In-View is disabled ({@code spring.jpa.open-in-view=false}), so the lazy
+ * {@code retailCustomer} relation cannot be touched from the template. The handler is
+ * {@link Transactional} and projects each entity into a fully-materialized {@link TokenView} record
+ * before returning, so the view renders only flat data.
+ */
+@Controller
+@PreAuthorize("hasRole('ROLE_CUSTODIAN')")
+public class OAuthTokenController {
+
+ private final AuthorizationService authorizationService;
+
+ public OAuthTokenController(AuthorizationService authorizationService) {
+ this.authorizationService = authorizationService;
+ }
+
+ @GetMapping("/custodian/oauth/tokens")
+ @Transactional(readOnly = true)
+ public String index(Model model) {
+ List tokens = authorizationService.findAll().stream()
+ .map(OAuthTokenController::toView)
+ .toList();
+ model.addAttribute("tokens", tokens);
+ return "custodian/oauth/tokens";
+ }
+
+ private static TokenView toView(AuthorizationEntity a) {
+ RetailCustomerEntity customer = a.getRetailCustomer();
+ String customerName = customer == null ? "—" : customer.getUsername();
+
+ String status;
+ if (a.isRevoked()) {
+ status = "REVOKED";
+ } else if (a.isExpired()) {
+ status = "EXPIRED";
+ } else if (a.isActive()) {
+ status = "ACTIVE";
+ } else {
+ status = "PENDING";
+ }
+
+ return new TokenView(
+ customerName,
+ a.getThirdParty(),
+ a.getScope(),
+ status,
+ a.getGrantType() == null ? "—" : a.getGrantType().toString(),
+ mask(a.getAccessToken()));
+ }
+
+ /** Show only the last 4 characters of a token so the page never leaks a usable credential. */
+ private static String mask(String token) {
+ if (token == null || token.isBlank()) {
+ return "—";
+ }
+ String trimmed = token.trim();
+ return trimmed.length() <= 4 ? "••••" : "••••" + trimmed.substring(trimmed.length() - 4);
+ }
+
+ /** Flat, fully-materialized projection safe to render with OSIV disabled. */
+ public record TokenView(String customer, String thirdParty, String scope, String status,
+ String grantType, String maskedToken) {
+ }
+}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/RetailCustomerController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/RetailCustomerController.java
index 2323ee995..f3061d0b1 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/RetailCustomerController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/RetailCustomerController.java
@@ -22,6 +22,8 @@
import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity;
import org.greenbuttonalliance.espi.common.service.RetailCustomerService;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -63,32 +65,102 @@ protected void initBinder(WebDataBinder binder) {
public String index(ModelMap model) {
model.put("customers", service.findAll());
- return "retailcustomers/index";
+ return "custodian/retailcustomers/index";
}
@GetMapping("/custodian/retailcustomers/form")
public String form(ModelMap model) {
model.put("retailCustomer", new RetailCustomerEntity());
+ model.put("formAction", "/custodian/retailcustomers/create");
+ model.put("editMode", false);
- return "retailcustomers/form";
+ return "custodian/retailcustomers/form";
}
@PostMapping("/custodian/retailcustomers/create")
public String create(
@ModelAttribute("retailCustomer") @Valid RetailCustomerEntity retailCustomer,
- BindingResult result) {
+ BindingResult result, ModelMap model) {
if (result.hasErrors()) {
- return "retailcustomers/form";
+ model.put("formAction", "/custodian/retailcustomers/create");
+ model.put("editMode", false);
+ return "custodian/retailcustomers/form";
}
+ // BCrypt-hash the raw password before persistence; the form submits cleartext
+ // over the (presumed-TLS) admin chain, the DB stores the bcrypt hash.
+ retailCustomer.setPassword(customerPasswordEncoder.encode(retailCustomer.getPassword()));
try {
- // BCrypt-hash the raw password before persistence; the form submits cleartext
- // over the (presumed-TLS) admin chain, the DB stores the bcrypt hash.
- retailCustomer.setPassword(customerPasswordEncoder.encode(retailCustomer.getPassword()));
service.save(retailCustomer);
+ }
+ catch (DataIntegrityViolationException e) {
+ // Most commonly a duplicate username (unique constraint). Surface it on the form
+ // instead of bubbling a 500.
+ result.rejectValue("username", "username.duplicate", "That username is already taken");
+ model.put("formAction", "/custodian/retailcustomers/create");
+ model.put("editMode", false);
+ return "custodian/retailcustomers/form";
+ }
+ return "redirect:/custodian/retailcustomers";
+ }
+
+ @GetMapping("/custodian/retailcustomers/{retailCustomerId}/edit")
+ public String edit(@PathVariable Long retailCustomerId, ModelMap model) {
+ RetailCustomerEntity retailCustomer = service.findById(retailCustomerId);
+ if (retailCustomer == null) {
+ return "redirect:/custodian/retailcustomers";
+ }
+ // Never round-trip the stored (hashed) password to the edit form.
+ retailCustomer.setPassword(null);
+ model.put("retailCustomer", retailCustomer);
+ model.put("formAction", "/custodian/retailcustomers/" + retailCustomerId + "/update");
+ model.put("editMode", true);
+ return "custodian/retailcustomers/form";
+ }
+
+ @PostMapping("/custodian/retailcustomers/{retailCustomerId}/update")
+ public String update(@PathVariable Long retailCustomerId,
+ @ModelAttribute("retailCustomer") RetailCustomerEntity form,
+ BindingResult result, ModelMap model) {
+ RetailCustomerEntity existing = service.findById(retailCustomerId);
+ if (existing == null) {
return "redirect:/custodian/retailcustomers";
}
- catch (Exception e) {
- return "retailcustomers/form";
+ // On edit, password is optional (blank = keep current); the other fields stay required.
+ // We validate manually rather than via the create-time @Valid validator.
+ rejectIfBlank(result, "username", form.getUsername(), "Username is required");
+ rejectIfBlank(result, "firstName", form.getFirstName(), "First name is required");
+ rejectIfBlank(result, "lastName", form.getLastName(), "Last name is required");
+ if (result.hasErrors()) {
+ model.put("formAction", "/custodian/retailcustomers/" + retailCustomerId + "/update");
+ model.put("editMode", true);
+ return "custodian/retailcustomers/form";
+ }
+ existing.setUsername(form.getUsername());
+ existing.setFirstName(form.getFirstName());
+ existing.setLastName(form.getLastName());
+ if (form.getPassword() != null && !form.getPassword().isBlank()) {
+ existing.setPassword(customerPasswordEncoder.encode(form.getPassword()));
+ }
+ service.save(existing);
+ return "redirect:/custodian/retailcustomers";
+ }
+
+ @PostMapping("/custodian/retailcustomers/{retailCustomerId}/delete")
+ public String delete(@PathVariable Long retailCustomerId, RedirectAttributes redirectAttributes) {
+ try {
+ service.deleteById(retailCustomerId);
+ }
+ catch (DataIntegrityViolationException e) {
+ // e.g. the customer still has authorizations/usage points referencing it.
+ redirectAttributes.addFlashAttribute("message",
+ "Cannot delete this customer because other records still reference it.");
+ }
+ return "redirect:/custodian/retailcustomers";
+ }
+
+ private static void rejectIfBlank(BindingResult result, String field, String value, String message) {
+ if (value == null || value.isBlank()) {
+ result.rejectValue(field, "field.required", message);
}
}
@@ -96,7 +168,7 @@ public String create(
public String show(@PathVariable Long retailCustomerId, ModelMap model) {
RetailCustomerEntity retailCustomer = service.findById(retailCustomerId);
model.put("retailCustomer", retailCustomer);
- return "/custodian/retailcustomers/show";
+ return "custodian/retailcustomers/show";
}
public static class RetailCustomerValidator implements Validator {
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/SettingsController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/SettingsController.java
new file mode 100644
index 000000000..ce25c78ef
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/SettingsController.java
@@ -0,0 +1,84 @@
+/*
+ *
+ * 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.custodian;
+
+import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository;
+import org.greenbuttonalliance.espi.common.service.RetailCustomerService;
+import org.springframework.boot.SpringBootVersion;
+import org.springframework.core.env.Environment;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Custodian System Settings page (#173). Deliberately read-only: it surfaces the
+ * runtime configuration an operator needs to see (active profiles, service endpoints, datasource,
+ * versions, entity counts). It exposes NO mutating actions — the legacy "reset / initialize
+ * sandbox" DB-management commands are intentionally not reproduced here, and CRUD writes remain
+ * deferred per #166.
+ */
+@Controller
+@PreAuthorize("hasRole('ROLE_CUSTODIAN')")
+public class SettingsController {
+
+ private final Environment environment;
+ private final RetailCustomerService retailCustomerService;
+ private final UsagePointRepository usagePointRepository;
+
+ public SettingsController(Environment environment,
+ RetailCustomerService retailCustomerService,
+ UsagePointRepository usagePointRepository) {
+ this.environment = environment;
+ this.retailCustomerService = retailCustomerService;
+ this.usagePointRepository = usagePointRepository;
+ }
+
+ @GetMapping("/custodian/settings")
+ public String index(Model model) {
+ Map info = new LinkedHashMap<>();
+
+ String[] profiles = environment.getActiveProfiles();
+ info.put("Active profiles", profiles.length == 0 ? "(default)" : String.join(", ", profiles));
+ info.put("Data Custodian base URL",
+ environment.getProperty("espi.datacustodian.base-url", "—"));
+ info.put("Authorization Server issuer",
+ environment.getProperty("espi.authorization-server.issuer-uri", "—"));
+ info.put("Token introspection endpoint",
+ environment.getProperty("espi.authorization-server.introspection-endpoint", "—"));
+ info.put("Datasource URL",
+ environment.getProperty("spring.datasource.url", "—"));
+ info.put("Open Session In View",
+ environment.getProperty("spring.jpa.open-in-view", "true"));
+ info.put("Java version", System.getProperty("java.version", "—"));
+ info.put("Spring Boot version", SpringBootVersion.getVersion());
+
+ Map counts = new LinkedHashMap<>();
+ counts.put("Retail customers", (long) retailCustomerService.findAll().size());
+ counts.put("Usage points", usagePointRepository.count());
+
+ model.addAttribute("info", info);
+ model.addAttribute("counts", counts);
+ return "custodian/settings";
+ }
+}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/UploadController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/UploadController.java
index 47adc9468..d7f5b3b64 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/UploadController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/UploadController.java
@@ -22,6 +22,7 @@
import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
@@ -29,13 +30,15 @@
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
-import org.xml.sax.SAXException;
import jakarta.xml.bind.JAXBException;
import java.io.IOException;
-// @Controller - COMMENTED OUT: UI not needed in resource server
-// @Component
+// Re-enabled (#173): the custodian portal needs the bulk-upload admin page. The actual import is
+// still stubbed (ImportService was never migrated); the POST handler reports that to the user
+// rather than silently doing nothing.
+@Controller
+@PreAuthorize("hasRole('ROLE_CUSTODIAN')")
@RequestMapping("/custodian/upload")
public class UploadController {
@@ -55,7 +58,7 @@ public UploadForm uploadForm() {
@GetMapping
public String upload() {
- return "/custodian/upload";
+ return "custodian/upload";
}
@PostMapping
@@ -66,13 +69,13 @@ public String uploadPost(@ModelAttribute UploadForm uploadForm,
// TODO: Implement ImportService
// importService.importData(uploadForm.getFile().getInputStream(), null);
result.addError(new ObjectError("uploadForm", "Import functionality not yet implemented"));
- return "/custodian/upload";
+ return "custodian/upload";
} catch (Exception e) {
result.addError(new ObjectError("uploadForm",
"Unable to process file"));
- return "/custodian/upload";
+ return "custodian/upload";
}
}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/customer/CustomerAuthorizationController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/customer/CustomerAuthorizationController.java
new file mode 100644
index 000000000..bf7a1af9e
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/customer/CustomerAuthorizationController.java
@@ -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.customer;
+
+import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity;
+import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity;
+import org.greenbuttonalliance.espi.common.service.AuthorizationService;
+import org.greenbuttonalliance.espi.common.service.RetailCustomerService;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.stereotype.Controller;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+
+import java.security.Principal;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Customer self-service portal (#173). A signed-in retail customer (ROLE_USER) sees the third-party
+ * authorizations granted against their data and can revoke any of them. This is the post-login
+ * landing for non-custodian users.
+ *
+ *
The authenticated principal carries only the username, so the customer is resolved via
+ * {@link RetailCustomerService#findByUsername(String)}. Open-Session-In-View is disabled, so the
+ * read handler is {@link Transactional} and projects entities into flat {@link AuthorizationView}
+ * records before the template renders. Revoke is authorization-checked: a customer may only revoke
+ * an authorization that belongs to them.
+ */
+@Controller
+@PreAuthorize("isAuthenticated()")
+public class CustomerAuthorizationController {
+
+ private final RetailCustomerService retailCustomerService;
+ private final AuthorizationService authorizationService;
+
+ public CustomerAuthorizationController(RetailCustomerService retailCustomerService,
+ AuthorizationService authorizationService) {
+ this.retailCustomerService = retailCustomerService;
+ this.authorizationService = authorizationService;
+ }
+
+ @GetMapping({"/customer", "/customer/home", "/customer/authorizations"})
+ @Transactional(readOnly = true)
+ public String authorizations(Principal principal, Model model) {
+ RetailCustomerEntity customer = retailCustomerService.findByUsername(principal.getName());
+ List authorizations = customer == null ? List.of()
+ : authorizationService.findAllByRetailCustomerId(customer.getId()).stream()
+ .map(CustomerAuthorizationController::toView)
+ .toList();
+ model.addAttribute("authorizations", authorizations);
+ return "customer/authorizations";
+ }
+
+ @PostMapping("/customer/authorizations/{authorizationId}/revoke")
+ @Transactional
+ public String revoke(@PathVariable UUID authorizationId, Principal principal,
+ RedirectAttributes redirectAttributes) {
+ RetailCustomerEntity customer = retailCustomerService.findByUsername(principal.getName());
+ AuthorizationEntity authorization = authorizationService.findById(authorizationId);
+
+ if (customer == null || authorization == null
+ || authorization.getRetailCustomer() == null
+ || !customer.getId().equals(authorization.getRetailCustomer().getId())) {
+ // Never let a customer act on an authorization that is not theirs.
+ redirectAttributes.addFlashAttribute("message", "Authorization not found.");
+ return "redirect:/customer/authorizations";
+ }
+
+ authorization.setStatus(AuthorizationEntity.STATUS_REVOKED);
+ authorizationService.save(authorization);
+ redirectAttributes.addFlashAttribute("message", "Access revoked.");
+ return "redirect:/customer/authorizations";
+ }
+
+ private static AuthorizationView toView(AuthorizationEntity a) {
+ String status;
+ if (a.isRevoked()) {
+ status = "REVOKED";
+ } else if (a.isExpired()) {
+ status = "EXPIRED";
+ } else if (a.isActive()) {
+ status = "ACTIVE";
+ } else {
+ status = "PENDING";
+ }
+ return new AuthorizationView(
+ a.getId() == null ? null : a.getId().toString(),
+ a.getThirdParty(),
+ a.getScope(),
+ status,
+ a.isRevoked() || a.isExpired());
+ }
+
+ /** Flat projection safe to render with OSIV disabled. {@code terminal} = cannot be revoked. */
+ public record AuthorizationView(String id, String thirdParty, String scope, String status,
+ boolean terminal) {
+ }
+}
diff --git a/openespi-datacustodian/src/main/resources/messages.properties b/openespi-datacustodian/src/main/resources/messages.properties
index 348bfc6a6..5dd83dbb5 100644
--- a/openespi-datacustodian/src/main/resources/messages.properties
+++ b/openespi-datacustodian/src/main/resources/messages.properties
@@ -11,6 +11,10 @@
# screen.* — Authorization Screen UI strings
# error.* — Error page strings
+# --- Form validation -------------------------------------------------------------------------
+# Generic "required field" message used by the custodian admin forms (RetailCustomerValidator).
+field.required=This field is required
+
# --- Function Block labels and descriptions -------------------------------------------------
# Labels in this file are the XSLT-verified mapping (GBA conformance scripts) from FB id to
# what the third party actually receives in the response. See issue #141 for details.
diff --git a/openespi-datacustodian/src/main/resources/templates/custodian/home.html b/openespi-datacustodian/src/main/resources/templates/custodian/home.html
index b631a095a..ad9252567 100644
--- a/openespi-datacustodian/src/main/resources/templates/custodian/home.html
+++ b/openespi-datacustodian/src/main/resources/templates/custodian/home.html
@@ -19,45 +19,45 @@