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

Data Custodian Dashboard

-
-
+
+
Retail Customers
-

Manage customer accounts and their usage points.

- Manage Customers +

Manage customer accounts and their usage points.

+ Manage Customers
-
-
+
+
OAuth Tokens
-

Monitor and manage OAuth access tokens.

- View Tokens +

Monitor and manage OAuth access tokens.

+ View Tokens
-
-
+
+
Data Upload
-

Upload customer usage data in bulk.

- Upload Data +

Upload customer usage data in bulk.

+ Upload Data
-
-
+
+
System Settings
-

Configure system-wide settings and preferences.

- Settings +

Configure system-wide settings and preferences.

+ Settings
@@ -133,8 +133,5 @@

0

- - - \ No newline at end of file diff --git a/openespi-datacustodian/src/main/resources/templates/custodian/oauth/tokens.html b/openespi-datacustodian/src/main/resources/templates/custodian/oauth/tokens.html new file mode 100644 index 000000000..9f81eaea4 --- /dev/null +++ b/openespi-datacustodian/src/main/resources/templates/custodian/oauth/tokens.html @@ -0,0 +1,54 @@ + + + + Custodian Portal - OAuth Tokens + + + + + +
+

OAuth Token Management

+

Read-only view of authorization grants held by the Data Custodian.

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
CustomerThird PartyScopeGrantStatusAccess Token
customerthird partyscopegrant + STATUS + ••••
No authorization grants found.
+
+
+ +
+
+
+ + diff --git a/openespi-datacustodian/src/main/resources/templates/custodian/retailcustomers/form.html b/openespi-datacustodian/src/main/resources/templates/custodian/retailcustomers/form.html new file mode 100644 index 000000000..844293aa9 --- /dev/null +++ b/openespi-datacustodian/src/main/resources/templates/custodian/retailcustomers/form.html @@ -0,0 +1,59 @@ + + + + Custodian Portal - Retail Customer + + + + + +
+

Retail Customer

+ +
+
+
+
+ + +
Username error
+
+
+ + +
First name error
+
+
+ + +
Last name error
+
+
+ + +
Leave blank to keep the current password.
+
Password error
+
+
+ + Cancel +
+
+
+
+ +
+
+
+ + diff --git a/openespi-datacustodian/src/main/resources/templates/custodian/retailcustomers/index.html b/openespi-datacustodian/src/main/resources/templates/custodian/retailcustomers/index.html new file mode 100644 index 000000000..14847142f --- /dev/null +++ b/openespi-datacustodian/src/main/resources/templates/custodian/retailcustomers/index.html @@ -0,0 +1,73 @@ + + + + Custodian Portal - Retail Customers + + + + + +
+
+

Retail Customers

+ + Add new customer + +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
UsernameFirst NameLast NameRoleEnabledActions
+ username + FirstLastROLE_USER + Yes + +
+ View + Edit +
+ +
+
+
No retail customers found.
+
+
+ +
+
+
+ + diff --git a/openespi-datacustodian/src/main/resources/templates/custodian/retailcustomers/show.html b/openespi-datacustodian/src/main/resources/templates/custodian/retailcustomers/show.html new file mode 100644 index 000000000..51503f579 --- /dev/null +++ b/openespi-datacustodian/src/main/resources/templates/custodian/retailcustomers/show.html @@ -0,0 +1,71 @@ + + + + Custodian Portal - Customer Detail + + + + + +
+
+

Customer Name

+
+ Edit +
+ +
+ + Back to list + +
+
+ +
+
Profile
+
+
+
Username
+
username
+ +
First Name
+
First
+ +
Last Name
+
Last
+ +
Email
+
email
+ +
Phone
+
phone
+ +
Role
+
+ ROLE_USER +
+ +
Enabled
+
+ Yes +
+
+
+
+ +
+ + Usage-point association from this screen is not yet wired (deferred — the association + write was not part of the resource-server migration). +
+ +
+
+
+ + diff --git a/openespi-datacustodian/src/main/resources/templates/custodian/settings.html b/openespi-datacustodian/src/main/resources/templates/custodian/settings.html new file mode 100644 index 000000000..5ca539e75 --- /dev/null +++ b/openespi-datacustodian/src/main/resources/templates/custodian/settings.html @@ -0,0 +1,46 @@ + + + + Custodian Portal - System Settings + + + + + +
+

System Settings

+

Read-only runtime configuration and system information.

+ +
+
Runtime Configuration
+
+ + + + + + + +
Keyvalue
+
+
+ +
+
Entity Counts
+
+ + + + + + + +
Key0
+
+
+ +
+
+
+ + diff --git a/openespi-datacustodian/src/main/resources/templates/custodian/upload.html b/openespi-datacustodian/src/main/resources/templates/custodian/upload.html new file mode 100644 index 000000000..2909e6224 --- /dev/null +++ b/openespi-datacustodian/src/main/resources/templates/custodian/upload.html @@ -0,0 +1,37 @@ + + + + Custodian Portal - Data Upload + + + + + +
+

Data Upload

+

Upload customer usage data as an ESPI Atom (XML) document.

+ +
+
error
+
+ +
+
+
+
+ + +
+ +
+
+
+ +
+
+
+ + diff --git a/openespi-datacustodian/src/main/resources/templates/customer/authorizations.html b/openespi-datacustodian/src/main/resources/templates/customer/authorizations.html new file mode 100644 index 000000000..d21660da4 --- /dev/null +++ b/openespi-datacustodian/src/main/resources/templates/customer/authorizations.html @@ -0,0 +1,64 @@ + + + + Customer Portal - My Authorizations + + + + + +
+

My Authorizations

+

+ Third parties you have granted access to your energy data. Revoke any you no longer want. +

+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
Third PartyShared Data (scope)StatusAction
Third Partyscope + STATUS + + +
+ +
+
+ You have not granted access to any third parties. +
+
+
+ +
+
+
+ + diff --git a/openespi-datacustodian/src/main/resources/templates/fragments/layout.html b/openespi-datacustodian/src/main/resources/templates/fragments/layout.html index ae5949ec2..2552fad5f 100644 --- a/openespi-datacustodian/src/main/resources/templates/fragments/layout.html +++ b/openespi-datacustodian/src/main/resources/templates/fragments/layout.html @@ -9,11 +9,14 @@ - + + + + - - + + @@ -22,35 +25,38 @@