diff --git a/.gitignore b/.gitignore index 667aaef..bceaed0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target/ .mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ +.env ### STS ### .apt_generated diff --git a/pom.xml b/pom.xml index b1a7177..1d0c151 100644 --- a/pom.xml +++ b/pom.xml @@ -172,6 +172,11 @@ lombok ${lombok.version} + + org.hibernate.orm + hibernate-processor + ${hibernate.version} + diff --git a/src/main/java/org/example/visacasemanagementsystem/ApplicationViewController.java b/src/main/java/org/example/visacasemanagementsystem/ApplicationViewController.java index 4385fe6..a43c66a 100644 --- a/src/main/java/org/example/visacasemanagementsystem/ApplicationViewController.java +++ b/src/main/java/org/example/visacasemanagementsystem/ApplicationViewController.java @@ -1,31 +1,59 @@ package org.example.visacasemanagementsystem; +import jakarta.servlet.http.HttpServletResponse; import org.example.visacasemanagementsystem.user.security.UserPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import java.util.Objects; +/** + * Entry-point controller: maps the site root to the role-based router and + * hosts that router at /home. + */ @Controller public class ApplicationViewController { @GetMapping("/") public String index() { - return "redirect:/dashboard"; + return "redirect:/home"; } - @GetMapping("/dashboard") - public String dashboard(@AuthenticationPrincipal UserPrincipal principal) { + /** + * Role-based landing-page router. Each role has a distinct primary page: + * SYSADMIN -> /log/visa + * ADMIN -> /visa/cases + * USER -> /visa/my-applications. + */ + @GetMapping("/home") + public String home(@AuthenticationPrincipal UserPrincipal principal) { if (principal == null) { return "redirect:/user/login"; } - boolean isSysAdmin = principal.getAuthorities().stream() - .anyMatch(a -> Objects.equals(a.getAuthority(), "ROLE_SYSADMIN")); - boolean isAdmin = principal.getAuthorities().stream() - .anyMatch(a -> Objects.equals(a.getAuthority(), "ROLE_ADMIN")); - if (isSysAdmin) return "redirect:/dashboard/sysadmin"; - if (isAdmin) return "redirect:/dashboard/admin"; - return "redirect:/dashboard/applicant"; + boolean isSysAdmin = false; + boolean isAdmin = false; + for (var authority : principal.getAuthorities()) { + String role = authority.getAuthority(); + if (Objects.equals(role, "ROLE_SYSADMIN")) isSysAdmin = true; + else if (Objects.equals(role, "ROLE_ADMIN")) isAdmin = true; + } + + if (isSysAdmin) return "redirect:/log/visa"; + if (isAdmin) return "redirect:/visa/cases"; + return "redirect:/visa/my-applications"; + } + + /** + * Target of {@code SecurityConfig.exceptionHandling().accessDeniedPage(...)}. + */ + @GetMapping("/access-denied") + public String accessDenied(Model model, HttpServletResponse response) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + model.addAttribute("errorTitle", "⚠️Access Denied."); + model.addAttribute("errorMessage", + "You do not have permission to perform this action."); + return "error/error"; } } diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/controller/LogViewController.java b/src/main/java/org/example/visacasemanagementsystem/audit/controller/LogViewController.java new file mode 100644 index 0000000..c6fad38 --- /dev/null +++ b/src/main/java/org/example/visacasemanagementsystem/audit/controller/LogViewController.java @@ -0,0 +1,119 @@ +package org.example.visacasemanagementsystem.audit.controller; + +import org.example.visacasemanagementsystem.audit.UserEventType; +import org.example.visacasemanagementsystem.audit.VisaEventType; +import org.example.visacasemanagementsystem.audit.service.UserLogService; +import org.example.visacasemanagementsystem.audit.service.VisaLogService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; +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; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +/** + * SYSADMIN-only audit-log pages. + */ +@PreAuthorize("hasRole('SYSADMIN')") +@Controller +@RequestMapping("/log") +public class LogViewController { + + // Hard cap on page size for pagination. + private static final int MAX_PAGE_SIZE = 100; + private static final String DEFAULT_PAGE_SIZE_STR = "20"; + + private final VisaLogService visaLogService; + private final UserLogService userLogService; + + public LogViewController(VisaLogService visaLogService, UserLogService userLogService) { + this.visaLogService = visaLogService; + this.userLogService = userLogService; + } + + @GetMapping("/visa") + public String visaLog( + @RequestParam(value = "eventType", required = false) VisaEventType eventType, + @RequestParam(value = "from", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(value = "to", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = DEFAULT_PAGE_SIZE_STR) int size, + Model model) { + return renderLogPage(eventType, from, to, page, size, + visaLogService::findFiltered, + VisaEventType.values(), "Visa Log", "log/visa", model); + } + + @GetMapping("/user") + public String userLog( + @RequestParam(value = "eventType", required = false) UserEventType eventType, + @RequestParam(value = "from", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(value = "to", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = DEFAULT_PAGE_SIZE_STR) int size, + Model model) { + return renderLogPage(eventType, from, to, page, size, + userLogService::findFiltered, + UserEventType.values(), "User Log", "log/user", model); + } + + // ─── Helpers ────────────────────────────────────────────────────────── + + /** Four-arity fetch function: (eventType, from, to, pageable) → Page. */ + @FunctionalInterface + private interface LogFetcher, D> { + Page fetch(E eventType, LocalDateTime from, LocalDateTime to, Pageable pageable); + } + + /** + * Populates the model and returns the view name for any log page. + * Date-to-LocalDateTime conversion and pagination clamping are applied here + * so each handler only needs to supply its service reference and enum type. + */ + private , D> String renderLogPage( + E eventType, LocalDate from, LocalDate to, int page, int size, + LogFetcher fetchFn, + E[] allEventTypes, String pageTitle, String view, Model model) { + + Page logs = fetchFn.fetch( + eventType, + startOfDayOrNull(from), + endOfDayOrNull(to), + buildPageable(page, size)); + + model.addAttribute("logs", logs); + model.addAttribute("eventTypes", allEventTypes); + model.addAttribute("selectedEventType", eventType); + model.addAttribute("from", from); + model.addAttribute("to", to); + model.addAttribute("pageTitle", pageTitle); + return view; + } + + private static LocalDateTime startOfDayOrNull(LocalDate d) { + return d == null ? null : d.atStartOfDay(); + } + + private static LocalDateTime endOfDayOrNull(LocalDate d) { + return d == null ? null : d.atTime(LocalTime.MAX); + } + + private static PageRequest buildPageable(int page, int size) { + int safePage = Math.max(page, 0); + int safeSize = Math.clamp(size, 1, MAX_PAGE_SIZE); + return PageRequest.of(safePage, safeSize, Sort.by(Sort.Direction.DESC, "timeStamp")); + } +} diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/repository/UserLogRepository.java b/src/main/java/org/example/visacasemanagementsystem/audit/repository/UserLogRepository.java index 40a23b5..602c735 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/repository/UserLogRepository.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/repository/UserLogRepository.java @@ -2,8 +2,9 @@ import org.example.visacasemanagementsystem.audit.entity.UserLog; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; @Repository -public interface UserLogRepository extends JpaRepository { +public interface UserLogRepository extends JpaRepository, JpaSpecificationExecutor { } diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/repository/VisaLogRepository.java b/src/main/java/org/example/visacasemanagementsystem/audit/repository/VisaLogRepository.java index d6249d1..6a26ecc 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/repository/VisaLogRepository.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/repository/VisaLogRepository.java @@ -2,8 +2,9 @@ import org.example.visacasemanagementsystem.audit.entity.VisaLog; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; @Repository -public interface VisaLogRepository extends JpaRepository { +public interface VisaLogRepository extends JpaRepository, JpaSpecificationExecutor { } diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java index 98f1da2..7688604 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/UserLogService.java @@ -3,11 +3,16 @@ import org.example.visacasemanagementsystem.audit.UserEventType; import org.example.visacasemanagementsystem.audit.dto.UserLogDTO; import org.example.visacasemanagementsystem.audit.entity.UserLog; +import org.example.visacasemanagementsystem.audit.entity.UserLog_; import org.example.visacasemanagementsystem.audit.mapper.UserLogMapper; import org.example.visacasemanagementsystem.audit.repository.UserLogRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.util.List; @Service @@ -33,4 +38,29 @@ public List findAll() { .map(userLogMapper::toDTO) .toList(); } + + /** + * Used by the /log/user page. Mirror of VisaLogService#findFiltered. + * Only non-null filters are added to the query, avoiding Hibernate 6/7's + * inability to bind null enum-typed parameters in JPQL. + */ + @PreAuthorize("isAuthenticated()") + public Page findFiltered(UserEventType eventType, + LocalDateTime from, + LocalDateTime to, + Pageable pageable) { + // Start from the "no filter" Specification. Spring Data JPA 4 deprecated + // the where(null) idiom in favour of this explicit factory method. + Specification spec = Specification.unrestricted(); + if (eventType != null) { + spec = spec.and((root, query, cb) -> cb.equal(root.get(UserLog_.userEventType), eventType)); + } + if (from != null) { + spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get(UserLog_.timeStamp), from)); + } + if (to != null) { + spec = spec.and((root, query, cb) -> cb.lessThanOrEqualTo(root.get(UserLog_.timeStamp), to)); + } + return userLogRepository.findAll(spec, pageable).map(userLogMapper::toDTO); + } } diff --git a/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java b/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java index a7d72e2..15b94b4 100644 --- a/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java +++ b/src/main/java/org/example/visacasemanagementsystem/audit/service/VisaLogService.java @@ -3,11 +3,16 @@ import org.example.visacasemanagementsystem.audit.VisaEventType; import org.example.visacasemanagementsystem.audit.dto.VisaLogDTO; import org.example.visacasemanagementsystem.audit.entity.VisaLog; +import org.example.visacasemanagementsystem.audit.entity.VisaLog_; import org.example.visacasemanagementsystem.audit.mapper.VisaLogMapper; import org.example.visacasemanagementsystem.audit.repository.VisaLogRepository; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.util.List; @Service @@ -33,4 +38,28 @@ public List findAll() { .map(visaLogMapper::toDTO) .toList(); } + + /** + * Used by the /log/visa page. Any of the three filter values can be null — + * only non-null filters are added to the query, so Hibernate never has to + * bind a null enum parameter (which fails in Hibernate 6/7). + */ + public Page findFiltered(VisaEventType eventType, + LocalDateTime from, + LocalDateTime to, + Pageable pageable) { + // Start from the "no filter" Specification. Spring Data JPA 4 deprecated + // the where(null) idiom in favour of this explicit factory method. + Specification spec = Specification.unrestricted(); + if (eventType != null) { + spec = spec.and((root, query, cb) -> cb.equal(root.get(VisaLog_.visaEventType), eventType)); + } + if (from != null) { + spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get(VisaLog_.timeStamp), from)); + } + if (to != null) { + spec = spec.and((root, query, cb) -> cb.lessThanOrEqualTo(root.get(VisaLog_.timeStamp), to)); + } + return visaLogRepository.findAll(spec, pageable).map(visaLogMapper::toDTO); + } } diff --git a/src/main/java/org/example/visacasemanagementsystem/config/SecurityConfig.java b/src/main/java/org/example/visacasemanagementsystem/config/SecurityConfig.java index 8014411..bbf5ff5 100644 --- a/src/main/java/org/example/visacasemanagementsystem/config/SecurityConfig.java +++ b/src/main/java/org/example/visacasemanagementsystem/config/SecurityConfig.java @@ -7,6 +7,7 @@ import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.factory.PasswordEncoderFactories; @@ -15,7 +16,6 @@ import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.SecurityContextRepository; -import static org.springframework.security.config.Customizer.withDefaults; @Configuration @EnableWebSecurity @@ -32,29 +32,46 @@ public SecurityConfig(UserDetailsService userDetailsService) { public SecurityFilterChain securityFilterChain(HttpSecurity http, OauthSuccessHandler oauthSuccessHandler) throws Exception { http .authorizeHttpRequests(auth -> auth + // Public landing + static assets + anonymous auth forms .requestMatchers("/").permitAll() + .requestMatchers("/css/**").permitAll() .requestMatchers("/static/google-icon.svg").permitAll() .requestMatchers("/user/signup").permitAll() .requestMatchers("/user/login").permitAll() - .requestMatchers("/logout").permitAll() - .requestMatchers("/dashboard").authenticated() + + // Any authenticated user can hit these; role-specific protection + // lives on the individual @PreAuthorize annotations. + .requestMatchers(org.springframework.http.HttpMethod.POST, "/user/logout").authenticated() + .requestMatchers("/home").authenticated() .requestMatchers("/profile/**").authenticated() - .requestMatchers("/visas/**").authenticated() + .requestMatchers("/visa/cases").hasAnyRole("SYSADMIN", "ADMIN") + .requestMatchers("/visa/my-applications").hasRole("USER") + .requestMatchers("/visa/apply").hasRole("USER") + .requestMatchers("/visa/**").authenticated() .requestMatchers("/api/comments/**").authenticated() - .requestMatchers("/**/admin").hasRole("ADMIN") - .requestMatchers("/**/applicant").hasRole("USER") + + // Audit logs and user list are sysadmin-only. .requestMatchers("/profile/edit/{userId}/authorization").hasRole("SYSADMIN") + .requestMatchers("/log/**").hasRole("SYSADMIN") + .requestMatchers("/favicon.ico").permitAll() + .requestMatchers("/login/oauth2/**").permitAll() + .requestMatchers("/error").permitAll() + .requestMatchers("/access-denied").permitAll() .anyRequest().hasRole("SYSADMIN") ) + .exceptionHandling(ex -> ex.accessDeniedPage("/access-denied")) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) .formLogin(l -> l - .defaultSuccessUrl("/dashboard", true) + // /home is the single post-login router and redirects as follows: + // USER -> /visa/my-applications + // ADMIN -> /visa/cases, + // SYSADMIN -> /log/visa + .defaultSuccessUrl("/home", true) .loginPage("/user/login")) .oauth2Login(l -> l .loginPage("/user/login") .successHandler(oauthSuccessHandler)) - .logout(withDefaults()) //TODO: Custom logout page required - .httpBasic(withDefaults()); + .logout(AbstractHttpConfigurer::disable); return http.build(); } diff --git a/src/main/java/org/example/visacasemanagementsystem/exception/GlobalExceptionHandler.java b/src/main/java/org/example/visacasemanagementsystem/exception/GlobalExceptionHandler.java index 4e2bfba..73a55b1 100644 --- a/src/main/java/org/example/visacasemanagementsystem/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/example/visacasemanagementsystem/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import jakarta.persistence.EntityNotFoundException; import lombok.extern.slf4j.Slf4j; import org.example.visacasemanagementsystem.ApplicationViewController; +import org.example.visacasemanagementsystem.audit.controller.LogViewController; import org.example.visacasemanagementsystem.user.controller.UserViewController; import org.example.visacasemanagementsystem.visa.controller.VisaViewController; import org.springframework.http.HttpStatus; @@ -12,7 +13,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; -@ControllerAdvice(assignableTypes = {VisaViewController.class, UserViewController.class, ApplicationViewController.class}) +@ControllerAdvice(assignableTypes = {VisaViewController.class, UserViewController.class, ApplicationViewController.class, LogViewController.class}) @Slf4j public class GlobalExceptionHandler { @@ -23,9 +24,6 @@ public String handleUnauthorizedException(UnauthorizedException exception, Model model.addAttribute("errorMessage","You do not have permission to perform this action."); model.addAttribute("errorTitle", "⚠️Access Denied."); - // TODO: Fetch user role from SecurityContext and add correct dashboard URL to model - // model.addAttribute("dashboardUrl", determineDashboardUrl()); - return "error/error"; } @@ -46,9 +44,6 @@ public String handleNotFoundException(RuntimeException exception, Model model) { model.addAttribute("errorMessage", "The requested resource could not be found."); model.addAttribute("errorTitle", "⚠️Not Found."); - // TODO: Fetch user role from SecurityContext and add correct dashboard URL to model - // model.addAttribute("dashboardUrl", determineDashboardUrl()); - return "error/error"; } @@ -62,9 +57,6 @@ public String handleIllegalArgumentException(IllegalArgumentException exception, model.addAttribute("errorMessage", userMessage); model.addAttribute("errorTitle", "⚠️Bad Request."); - // TODO: Fetch user role from SecurityContext and add correct dashboard URL to model - // model.addAttribute("dashboardUrl", determineDashboardUrl()); - return "error/error"; } @@ -75,9 +67,6 @@ public String handleAllUncaughtErrors(Exception exception, Model model) { model.addAttribute("errorMessage","Something went wrong. Try again later."); model.addAttribute("errorTitle", "⚠️Internal Server Error."); - // TODO: Fetch user role from SecurityContext and add correct dashboard URL to model - // model.addAttribute("dashboardUrl", determineDashboardUrl()); - return "error/error"; } } diff --git a/src/main/java/org/example/visacasemanagementsystem/user/controller/UserViewController.java b/src/main/java/org/example/visacasemanagementsystem/user/controller/UserViewController.java index 81ecd46..8afd999 100644 --- a/src/main/java/org/example/visacasemanagementsystem/user/controller/UserViewController.java +++ b/src/main/java/org/example/visacasemanagementsystem/user/controller/UserViewController.java @@ -1,8 +1,9 @@ package org.example.visacasemanagementsystem.user.controller; import jakarta.persistence.EntityNotFoundException; -import org.example.visacasemanagementsystem.audit.service.UserLogService; -import org.example.visacasemanagementsystem.audit.service.VisaLogService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import org.example.visacasemanagementsystem.exception.UnauthorizedException; import org.example.visacasemanagementsystem.user.UserAuthorization; import org.example.visacasemanagementsystem.user.dto.CreateUserDTO; @@ -10,13 +11,14 @@ import org.example.visacasemanagementsystem.user.dto.UserDTO; import org.example.visacasemanagementsystem.user.security.UserPrincipal; import org.example.visacasemanagementsystem.user.service.UserService; -import org.example.visacasemanagementsystem.visa.dto.VisaDTO; -import org.example.visacasemanagementsystem.visa.service.VisaService; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -30,26 +32,23 @@ import java.util.List; import java.util.Objects; +/** + * User-facing controller: signup, login, profile view/edit, and the sysadmin user list. + */ @Controller public class UserViewController { - private final VisaService visaService; private final UserService userService; - private final VisaLogService visaLogService; - private final UserLogService userLogService; - - public UserViewController(VisaService visaService, - UserService userService, - VisaLogService visaLogService, - UserLogService userLogService) { - this.visaService = visaService; + + public UserViewController(UserService userService) { this.userService = userService; - this.visaLogService = visaLogService; - this.userLogService = userLogService; } // Signup form for new users, accessible to all @GetMapping("/user/signup") - public String userSignupForm(Model model) { + public String userSignupForm(@AuthenticationPrincipal UserPrincipal principal, Model model) { + if (principal != null) { + return "redirect:/home"; + } return "user/signup"; } @@ -75,7 +74,10 @@ public String createUser(@RequestParam String fullName, // Login page @GetMapping("/user/login") - public String userLoginForm(){ + public String userLoginForm(@AuthenticationPrincipal UserPrincipal principal) { + if (principal != null) { + return "redirect:/home"; + } return "user/login"; } @@ -89,8 +91,21 @@ public ResponseEntity icon() throws IOException { .body(resource); } - // Uneditable profile view from where the user themselves or a sysadmin can access the profile edit view through a - // button only available to them + // Sign-out endpoint. + @PostMapping("/user/logout") + public String logout(HttpServletRequest request, HttpServletResponse response) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + new SecurityContextLogoutHandler().logout(request, response, auth); + + // Invalidate the HTTP session + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + return "redirect:/user/login?logout"; + } + + // Uneditable profile view @GetMapping("/profile/view/{userId}") public String viewProfile(@AuthenticationPrincipal UserPrincipal principal, @PathVariable Long userId, @@ -171,15 +186,6 @@ public String updateAuthorization(@AuthenticationPrincipal UserPrincipal princip } } - // Adds the two model attributes the role dropdown on profile/edit needs - private void addAuthorizationFormAttributes(Model model, UserPrincipal principal, Long userId) { - boolean isOwnProfile = principal.getUserId().equals(userId); - boolean isSysAdmin = principal.getAuthorities().stream() - .anyMatch(a -> Objects.equals(a.getAuthority(), "ROLE_SYSADMIN")); - model.addAttribute("canChangeAuthorization", isSysAdmin && !isOwnProfile); - model.addAttribute("authorizations", UserAuthorization.values()); - } - // A list view of users only available to sysadmins @GetMapping("/user/list") public String userListView(@AuthenticationPrincipal UserPrincipal principal, @@ -190,34 +196,12 @@ public String userListView(@AuthenticationPrincipal UserPrincipal principal, return "user/list"; } - @GetMapping("/dashboard/applicant") - public String applicantDashboard(@AuthenticationPrincipal UserPrincipal principal, - Model model) { - List visas = visaService.findVisasByApplicantId(principal.getUserId()); - model.addAttribute("name", principal.getFullName()); - model.addAttribute("visas", visas); - return "dashboard/applicant"; - } - - @GetMapping("/dashboard/admin") - public String adminDashboard(@AuthenticationPrincipal UserPrincipal principal, - Model model) { - List assignedCases = visaService.findVisasByHandlerId(principal.getUserId()); - List unassignedCases = visaService.findVisaByStatus("SUBMITTED"); - model.addAttribute("name", principal.getFullName()); - model.addAttribute("assignedCases", assignedCases); - model.addAttribute("unassignedCases", unassignedCases); - return "dashboard/admin"; - } - - @GetMapping("/dashboard/sysadmin") - public String sysAdminDashboard(@AuthenticationPrincipal UserPrincipal principal, - Model model) { - List allUsers = userService.findAll(); - model.addAttribute("name", principal.getFullName()); - model.addAttribute("users", allUsers); - model.addAttribute("visaLogs", visaLogService.findAll()); - model.addAttribute("userLogs", userLogService.findAll()); - return "dashboard/sysadmin"; + // Adds the two model attributes the role dropdown on profile/edit needs + private void addAuthorizationFormAttributes(Model model, UserPrincipal principal, Long userId) { + boolean isOwnProfile = principal.getUserId().equals(userId); + boolean isSysAdmin = principal.getAuthorities().stream() + .anyMatch(a -> Objects.equals(a.getAuthority(), "ROLE_SYSADMIN")); + model.addAttribute("canChangeAuthorization", isSysAdmin && !isOwnProfile); + model.addAttribute("authorizations", UserAuthorization.values()); } } \ No newline at end of file diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java b/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java index 6eea585..4138438 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/controller/VisaViewController.java @@ -23,8 +23,11 @@ import java.util.List; +/** + * All visa-case endpoints. + */ @Controller -@RequestMapping("/visas") +@RequestMapping("/visa") public class VisaViewController { private final VisaService visaService; @@ -39,27 +42,41 @@ public VisaViewController(VisaService visaService, CommentService commentService this.fileService = fileService; } - @GetMapping("/dashboard") - public String showDashboard(@AuthenticationPrincipal UserPrincipal principal, Model model) { + // ─── USER: "My Applications" landing page ───────────────────────────── + @GetMapping("/my-applications") + public String showMyApplications(@AuthenticationPrincipal UserPrincipal principal, Model model) { UserDTO user = userService.findById(principal.getUserId()) .orElseThrow(() -> new EntityNotFoundException("User not found.")); - List visas; - // If the current user = ADMIN/SYSADMIN show everything - if ( user.userAuthorization() == UserAuthorization.ADMIN || - user.userAuthorization() == UserAuthorization.SYSADMIN) { - visas = visaService.findAll(); - } else { - // Normal user/applicant can only se their own visa applications - visas = visaService.findVisasByApplicant(principal.getUserId()); - } - // Send data to Thymeleaf + List visas = visaService.findVisasByApplicant(principal.getUserId()); + model.addAttribute("visas", visas); model.addAttribute("currentUser", user); - return "visa/dashboard"; + return "visa/my-applications"; } + // ─── ADMIN + SYSADMIN: "Visa Cases" three-list page ──────────────────── + @GetMapping("/cases") + public String showCases(@AuthenticationPrincipal UserPrincipal principal, Model model) { + UserDTO user = userService.findById(principal.getUserId()) + .orElseThrow(() -> new EntityNotFoundException("User not found.")); + + Long meId = principal.getUserId(); + + List openCases = visaService.findOpenCasesByHandler(meId); + List unassignedCases = visaService.findUnassignedCases(); + List handledCases = visaService.findHandledCasesByHandler(meId); + + model.addAttribute("currentUser", user); + model.addAttribute("openCases", openCases); + model.addAttribute("unassignedCases", unassignedCases); + model.addAttribute("handledCases", handledCases); + + return "visa/cases"; + } + + // ─── Apply / edit / details — unchanged semantics, renamed URLs ──────── @GetMapping("/apply") public String showApplyForm(@AuthenticationPrincipal UserPrincipal principal, Model model) { UserDTO user = userService.findById(principal.getUserId()) @@ -70,11 +87,7 @@ public String showApplyForm(@AuthenticationPrincipal UserPrincipal principal, Mo if (!model.containsAttribute("createVisaDTO")) { model.addAttribute("createVisaDTO", - new CreateVisaDTO(null, - "", - "", - null, - principal.getUserId())); + new CreateVisaDTO(null, "", "", null)); } return "visa/apply-form"; @@ -87,7 +100,7 @@ public String submitApplication( @RequestParam(value = "passportFile", required = false) MultipartFile passportFile, @AuthenticationPrincipal UserPrincipal principal, Model model) { - + if (bindingResult.hasErrors()) { prepareApplyModel(principal.getUserId(), model); return "visa/apply-form"; @@ -114,7 +127,8 @@ public String submitApplication( return "visa/apply-form"; } - return "redirect:/visas/dashboard"; + // Post-apply, send the applicant back to their own list. + return "redirect:/visa/my-applications"; } @GetMapping("/{id}/edit") @@ -200,7 +214,7 @@ public String processUpdate( return "visa/edit-form"; } - return "redirect:/visas/" + id; + return "redirect:/visa/" + id; } @PostMapping("/{id}/documents/delete") @@ -209,14 +223,14 @@ public String deleteVisaDocument(@PathVariable Long id, @AuthenticationPrincipal UserPrincipal principal) { visaService.removeVisaDocument(id, s3Key, principal.getUserId()); - return "redirect:/visas/" + id + "/edit"; + return "redirect:/visa/" + id + "/edit"; } @PostMapping("/{id}/approve") public String approveVisa(@PathVariable Long id, @AuthenticationPrincipal UserPrincipal principal) { visaService.approveVisa(id, principal.getUserId()); - return "redirect:/visas/" + id; + return "redirect:/visa/" + id; } @PostMapping("/{id}/request-info") @@ -226,7 +240,7 @@ public String requestMoreInformation(@PathVariable Long id, visaService.requestMoreInformation(id, principal.getUserId(), reason); - return "redirect:/visas/" + id; + return "redirect:/visa/" + id; } @PostMapping("/{id}/reject") @@ -234,13 +248,13 @@ public String rejectVisa(@PathVariable Long id, @AuthenticationPrincipal UserPrincipal principal, @RequestParam String reason) { visaService.rejectVisa(id, principal.getUserId(), reason); - return "redirect:/visas/" + id; + return "redirect:/visa/" + id; } @PostMapping("/{id}/assign") public String assignCaseToHandler(@PathVariable Long id, @AuthenticationPrincipal UserPrincipal principal) { visaService.assignHandler(id, principal.getUserId()); - return "redirect:/visas/" + id; + return "redirect:/visa/" + id; } @GetMapping("/{id}") @@ -264,6 +278,9 @@ public String viewDetails(@PathVariable Long id, @AuthenticationPrincipal UserPr model.addAttribute("comments", comments); model.addAttribute("currentUser", user); + model.addAttribute("backUrl", + user.userAuthorization() == UserAuthorization.USER ? "/visa/my-applications" : "/visa/cases"); + return "visa/details"; } @@ -275,4 +292,4 @@ public void prepareApplyModel(Long currentUserId, Model model) { model.addAttribute("currentUser", user); model.addAttribute("visaTypes", VisaType.values()); } -} +} \ No newline at end of file diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/dto/CreateVisaDTO.java b/src/main/java/org/example/visacasemanagementsystem/visa/dto/CreateVisaDTO.java index c7fd256..af191b5 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/dto/CreateVisaDTO.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/dto/CreateVisaDTO.java @@ -7,11 +7,13 @@ import java.time.LocalDate; +/** + * Inbound payload for /visa/apply. + */ public record CreateVisaDTO( @NotNull(message = "Visa type must be specified") VisaType visaType, @NotBlank(message = "Nationality must be specified") String nationality, @NotBlank(message = "Passport number must be provided") String passportNumber, - @NotNull(message = "Travel date must be provided") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate travelDate, - Long applicantId + @NotNull(message = "Travel date must be provided") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate travelDate ) { } diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/repository/VisaRepository.java b/src/main/java/org/example/visacasemanagementsystem/visa/repository/VisaRepository.java index 9e3037b..35fbdd5 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/repository/VisaRepository.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/repository/VisaRepository.java @@ -30,4 +30,10 @@ public interface VisaRepository extends JpaRepository { List findVisasByHandlerId(Long handlerId, Sort sort); + // Used by /visa/cases "Open Cases" list (ASSIGNED + INCOMPLETE held by me). + List findByHandler_IdAndVisaStatusIn(Long handlerId, List statuses, Sort sort); + + // Used by /visa/cases "Unassigned Cases" list (SUBMITTED without a handler). + List findByVisaStatusAndHandlerIsNull(VisaStatus visaStatus, Sort sort); + } diff --git a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java index 94c5670..25f2cca 100644 --- a/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java +++ b/src/main/java/org/example/visacasemanagementsystem/visa/service/VisaService.java @@ -37,6 +37,13 @@ public class VisaService { private static final String NOT_FOUND_MESSAGE = "Visa not found."; + // Status groupings used by the admin "Visa Cases" page. Held as constants + // so the lists are allocated once per JVM rather than once per request. + private static final List OPEN_STATUSES = + List.of(VisaStatus.ASSIGNED, VisaStatus.INCOMPLETE); + private static final List HANDLED_STATUSES = + List.of(VisaStatus.GRANTED, VisaStatus.REJECTED); + private final VisaRepository visaRepository; private final UserRepository userRepository; private final VisaMapper visaMapper; @@ -469,4 +476,44 @@ public List findVisasByHandlerId(Long handlerId) { .map(visaMapper::toDTO) .toList(); } + + // --- Queries for /visa/cases (admin + sysadmin landing page) --- + + /** + * Open Cases: everything the current handler is actively working on. + */ + public List findOpenCasesByHandler(Long handlerId) { + return visaRepository.findByHandler_IdAndVisaStatusIn( + handlerId, + OPEN_STATUSES, + Sort.by("updatedAt").descending()) + .stream() + .map(visaMapper::toDTO) + .toList(); + } + + /** + * Unassigned Cases: freshly-submitted applications nobody is handling yet. + */ + public List findUnassignedCases() { + return visaRepository.findByVisaStatusAndHandlerIsNull( + VisaStatus.SUBMITTED, + Sort.by("updatedAt").descending()) + .stream() + .map(visaMapper::toDTO) + .toList(); + } + + /** + * Handled Cases: contains cases which have been either GRANTED or REJECTED. + */ + public List findHandledCasesByHandler(Long handlerId) { + return visaRepository.findByHandler_IdAndVisaStatusIn( + handlerId, + HANDLED_STATUSES, + Sort.by("updatedAt").descending()) + .stream() + .map(visaMapper::toDTO) + .toList(); + } } diff --git a/src/main/resources/static/css/components.css b/src/main/resources/static/css/components.css new file mode 100644 index 0000000..1034d85 --- /dev/null +++ b/src/main/resources/static/css/components.css @@ -0,0 +1,519 @@ +/* -------------------------------------------------------------------------- + * components.css + * Reusable component classes built on top of tokens.css. + * + * Keep this file focused on patterns that appear in two or more templates. + * Page-unique styling stays inline in the template that needs it. + * + * Class names are preserved from the original inline styles so no template + * needs to be re-tagged: .container, .btn-submit, .status-badge, etc. + * -------------------------------------------------------------------------- */ + +/* ========================================================================== + * Base + * ========================================================================== */ +body { + font-family: var(--font-system); + color: var(--color-text-body); + line-height: 1.6; + background: var(--color-bg); + margin: 0; + padding: 0; +} + +::placeholder { color: var(--color-placeholder); } + +h1 { font-size: 24px; font-weight: 500; color: var(--color-text-primary); margin: 0; } +h2 { font-size: 18px; font-weight: 500; color: var(--color-text-primary); margin: 0; } + +/* ========================================================================== + * Site navigation (was fragments/header.html :: nav-style) + * The fragment itself still renders the