diff --git a/build.gradle b/build.gradle index 2d57854..7d69af9 100644 --- a/build.gradle +++ b/build.gradle @@ -149,6 +149,9 @@ dependencies { testImplementation 'org.junit-pioneer:junit-pioneer:2.2.0' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' +implementation 'org.springframework.boot:spring-boot-starter-security' +implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' +implementation 'org.springframework.session:spring-session-jdbc' implementation 'org.flywaydb:flyway-core' testImplementation 'com.h2database:h2' testImplementation 'org.mockito:mockito-core:5.3.1' diff --git a/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentController.java b/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentController.java index 4ecb109..8faca44 100644 --- a/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentController.java +++ b/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentController.java @@ -73,10 +73,12 @@ public ResponseEntity exportPaymentsCsv( @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate startDate, @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate endDate) { adminAuthorizer.accept(adminKey); + var normalizedScope = (scope == null || scope.isBlank()) ? null : scope; var start = startDate != null ? startDate.atStartOfDay(UTC).toInstant() : Instant.EPOCH; var end = endDate != null ? endDate.plusDays(1).atStartOfDay(UTC).toInstant() : Instant.now(); - var csv = paymentService.buildPaymentsCsv(applicationName, scope, start, end); + var csv = paymentService.buildPaymentsCsv(applicationName, normalizedScope, start, end); + var filename = "payments_" + applicationName + "_" + currentTimeMillis() + ".csv"; return ResponseEntity.ok() diff --git a/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentViewController.java b/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentViewController.java new file mode 100644 index 0000000..be7a4e4 --- /dev/null +++ b/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentViewController.java @@ -0,0 +1,93 @@ +package school.hei.vola.endpoint.rest.controller; + +import static java.time.ZoneOffset.UTC; + +import java.time.Instant; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import school.hei.vola.repository.jpa.JApplicationRepository; +import school.hei.vola.service.PaymentService; + +@Controller +@RequiredArgsConstructor +public class PaymentViewController { + + private static final int PAGE_SIZE = 15; + + private final PaymentService paymentService; + private final JApplicationRepository jApplicationRepository; + + @Value("${ADMIN_API_KEY}") + private String adminKey; + + @GetMapping("/") + public String index() { + return "redirect:/payments"; + } + + @GetMapping("/payments") + public String paymentsPage( + @RequestParam(required = false) String applicationName, + @RequestParam(required = false) String scope, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(defaultValue = "0") int page, + Model model) { + model.addAttribute("applications", jApplicationRepository.findAll()); + + var effectiveApp = normalizeFilter(applicationName); + var effectiveScope = normalizeFilter(scope); + var parsedStartDate = parseDate(startDate); + var parsedEndDate = parseDate(endDate); + + var start = + parsedStartDate != null ? parsedStartDate.atStartOfDay(UTC).toInstant() : Instant.EPOCH; + var end = + parsedEndDate != null + ? parsedEndDate.plusDays(1).atStartOfDay(UTC).toInstant() + : Instant.now(); + + var totalAmount = + paymentService.sumAmountForSucceeded(effectiveApp, effectiveScope, start, end); + var pendingCount = paymentService.countPending(effectiveApp, effectiveScope, start, end); + var totalCount = paymentService.countFiltered(effectiveApp, effectiveScope, start, end); + + var paymentsPage = + paymentService.findFilteredPage( + effectiveApp, + effectiveScope, + start, + end, + PageRequest.of(page, PAGE_SIZE, Sort.by(Sort.Direction.DESC, "creationInstant"))); + + model.addAttribute("payments", paymentsPage.getContent()); + model.addAttribute("totalCollected", String.format("%,d Ar", totalAmount)); + model.addAttribute("pendingCount", pendingCount); + model.addAttribute("totalCount", totalCount); + model.addAttribute("currentPage", page); + model.addAttribute("totalPages", paymentsPage.getTotalPages()); + model.addAttribute("pageSize", PAGE_SIZE); + model.addAttribute("scopes", paymentService.findDistinctScopes(effectiveApp)); + model.addAttribute("selectedApplication", effectiveApp); + model.addAttribute("selectedScope", effectiveScope); + model.addAttribute("selectedStartDate", parsedStartDate); + model.addAttribute("selectedEndDate", parsedEndDate); + model.addAttribute("adminKey", adminKey); + return "payments"; + } + + private static String normalizeFilter(String value) { + return (value == null || value.isBlank() || "all".equals(value)) ? null : value; + } + + private static LocalDate parseDate(String dateStr) { + return (dateStr == null || dateStr.isBlank()) ? null : LocalDate.parse(dateStr); + } +} diff --git a/src/main/java/school/hei/vola/endpoint/rest/security/SecurityConf.java b/src/main/java/school/hei/vola/endpoint/rest/security/SecurityConf.java new file mode 100644 index 0000000..f443236 --- /dev/null +++ b/src/main/java/school/hei/vola/endpoint/rest/security/SecurityConf.java @@ -0,0 +1,62 @@ +package school.hei.vola.endpoint.rest.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.session.jdbc.config.annotation.web.http.EnableJdbcHttpSession; + +@Configuration +@EnableWebSecurity +@EnableJdbcHttpSession +public class SecurityConf { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests( + auth -> + auth.requestMatchers( + "/ping", + "/health/**", + "/error", + "/payment", + "/payments/search", + "/payments/export/csv", + "/orange/**", + "/css/**", + "/js/**", + "/script/**", + "/style/**", + "/images/**") + .permitAll() + .requestMatchers("/payments/**") + .authenticated() + .anyRequest() + .denyAll()) + .formLogin(login -> login.usernameParameter("email").defaultSuccessUrl("/payments", true)) + .logout(logout -> logout.logoutSuccessUrl("/")) + .csrf(csrf -> csrf.ignoringRequestMatchers("/payment", "/payments/search", "/orange/**")); + return http.build(); + } + + @Bean + public UserDetailsService users(Environment env, PasswordEncoder passwordEncoder) { + return new InMemoryUserDetailsManager( + User.builder() + .username(env.getRequiredProperty("ADMIN_EMAIL")) + .password(passwordEncoder.encode(env.getRequiredProperty("ADMIN_PASSWORD"))) + .build()); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/src/main/java/school/hei/vola/endpoint/rest/security/SessionConfig.java b/src/main/java/school/hei/vola/endpoint/rest/security/SessionConfig.java new file mode 100644 index 0000000..2b63b4a --- /dev/null +++ b/src/main/java/school/hei/vola/endpoint/rest/security/SessionConfig.java @@ -0,0 +1,42 @@ +package school.hei.vola.endpoint.rest.security; + +import jakarta.annotation.PostConstruct; +import java.sql.SQLException; +import javax.sql.DataSource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.init.DatabasePopulatorUtils; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; + +@Configuration +@Slf4j +public class SessionConfig { + + private final DataSource dataSource; + + public SessionConfig(DataSource dataSource) { + this.dataSource = dataSource; + } + + @PostConstruct + public void initSessionSchema() { + var populator = new ResourceDatabasePopulator(); + populator.addScript(new ClassPathResource(schemaPath())); + populator.setContinueOnError(true); + DatabasePopulatorUtils.execute(populator, dataSource); + log.info("Spring Session schema initialized using {}", schemaPath()); + } + + private String schemaPath() { + try (var conn = dataSource.getConnection()) { + var productName = conn.getMetaData().getDatabaseProductName(); + if ("H2".equalsIgnoreCase(productName)) { + return "org/springframework/session/jdbc/schema-h2.sql"; + } + } catch (SQLException e) { + log.warn("Could not detect database product name, falling back to PostgreSQL schema", e); + } + return "org/springframework/session/jdbc/schema-postgresql.sql"; + } +} diff --git a/src/main/resources/static/script/filters.js b/src/main/resources/static/script/filters.js new file mode 100644 index 0000000..a1c5f8b --- /dev/null +++ b/src/main/resources/static/script/filters.js @@ -0,0 +1,66 @@ +(function () { + const scopeInput = document.getElementById('scope'); + const scopeDropdown = document.getElementById('scope-dropdown'); + const scopeOptions = [...scopeDropdown.querySelectorAll('.scope-option')]; + const scopeClear = document.getElementById('scope-clear'); + + function setScopeClearVisible() { + scopeClear.classList.toggle('hidden', !scopeInput.value); + } + + function filterAndShowScope() { + const query = scopeInput.value.toLowerCase(); + const hasVisible = scopeOptions.some(function (opt) { + const matches = opt.textContent.toLowerCase().includes(query); + opt.classList.toggle('hidden', !matches); + return matches; + }); + scopeDropdown.classList.toggle('hidden', !hasVisible); + } + + scopeInput.addEventListener('focus', filterAndShowScope); + scopeDropdown.addEventListener('mousedown', function (e) { e.preventDefault(); }); + scopeInput.addEventListener('blur', function () { + scopeDropdown.classList.add('hidden'); + }); + scopeInput.addEventListener('input', function () { + filterAndShowScope(); + setScopeClearVisible(); + }); + scopeClear.addEventListener('click', function () { + scopeInput.value = ''; + scopeInput.form.submit(); + }); + scopeOptions.forEach(function (opt) { + opt.addEventListener('click', function () { + scopeInput.value = opt.dataset.value; + scopeDropdown.classList.add('hidden'); + setScopeClearVisible(); + scopeInput.form.submit(); + }); + }); + setScopeClearVisible(); + + const appHidden = document.getElementById('applicationName'); + const appDisplay = document.getElementById('app-display'); + const appTrigger = document.getElementById('app-trigger'); + const appDropdown = document.getElementById('app-dropdown'); + const appOptions = [...appDropdown.querySelectorAll('.app-option')]; + + appTrigger.addEventListener('click', function (e) { + e.stopPropagation(); + appDropdown.classList.toggle('hidden'); + }); + appOptions.forEach(function (opt) { + opt.addEventListener('click', function () { + const val = opt.dataset.value; + appHidden.value = val; + appDisplay.textContent = val === 'all' ? 'Toutes les applications' : val; + appDropdown.classList.add('hidden'); + appHidden.form.submit(); + }); + }); + document.addEventListener('click', function () { + appDropdown.classList.add('hidden'); + }); +})(); diff --git a/src/main/resources/static/script/logout.js b/src/main/resources/static/script/logout.js new file mode 100644 index 0000000..a583b52 --- /dev/null +++ b/src/main/resources/static/script/logout.js @@ -0,0 +1,27 @@ +(function () { + var logoutBtn = document.getElementById('logout-btn'); + var logoutDialog = document.getElementById('logout-dialog'); + var cancelBtn = document.getElementById('logout-cancel-btn'); + + function openDialog() { + if (logoutDialog) logoutDialog.showModal(); + } + + function closeDialog() { + if (logoutDialog) logoutDialog.close(); + } + + if (logoutBtn) { + logoutBtn.addEventListener('click', openDialog); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', closeDialog); + } + + if (logoutDialog) { + logoutDialog.addEventListener('click', function (e) { + if (e.target === logoutDialog) closeDialog(); + }); + } +})(); diff --git a/src/main/resources/static/style/history.css b/src/main/resources/static/style/history.css new file mode 100644 index 0000000..f9be3dd --- /dev/null +++ b/src/main/resources/static/style/history.css @@ -0,0 +1,51 @@ +:root { + --color-primary: #800000; + --color-primary-dark: #530c08; + --color-primary-light: #8a0a0a; + --color-accent: #D32F2F; + --color-accent-dark: #b71c1c; + --color-success: #22c55e; + --color-error: #ef4444; + --color-warning: #eab308; + --color-psp-bg: #DBEAFE; + --color-psp-text: #1E40AF; + --color-page-bg: #F4EFE3; + --color-input-bg: #F0EDE8; + --color-border: #D4C5B5; + --color-border-light: #E0D5CA; + --color-text-dark: #3C3C3C; + --dot-white: rgba(255, 255, 255, 0.12); + --dot-white-dim: rgba(255, 255, 255, 0.08); + --dot-size: 14px; +} + +.psp-badge { + background-color: var(--color-psp-bg); + color: var(--color-psp-text); +} + +.border-left-succeeded { + border-left: 3px solid var(--color-success); +} + +.border-left-failed { + border-left: 3px solid var(--color-error); +} + +.border-left-verifying { + border-left: 3px solid var(--color-warning); +} + +.stat-card { + background-image: + radial-gradient(var(--dot-white) 1px, transparent 1px), + linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%); + background-size: var(--dot-size) var(--dot-size), cover; +} + +.app-header { + background-image: + radial-gradient(var(--dot-white-dim) 1px, transparent 1px), + linear-gradient(120deg, var(--color-primary-light) 0%, var(--color-primary) 45%, var(--color-primary-dark) 100%); + background-size: var(--dot-size) var(--dot-size), cover; +} diff --git a/src/main/resources/templates/fragments/filter-form.html b/src/main/resources/templates/fragments/filter-form.html new file mode 100644 index 0000000..f18ab10 --- /dev/null +++ b/src/main/resources/templates/fragments/filter-form.html @@ -0,0 +1,80 @@ + + + +
+
+ +
+ + +
+
Toutes les applications
+ arrow_drop_down +
+ +
+ + +
+ +
+ + + arrow_drop_down +
+ +
+ + +
+ + +
+
+ + +
+ + +
+ + + refresh + Réinitialiser + +
+
+
+ + diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html new file mode 100644 index 0000000..1d18c6d --- /dev/null +++ b/src/main/resources/templates/fragments/header.html @@ -0,0 +1,61 @@ + + + +
+
+
+ + account_balance_wallet + +

Vola

+
+
+ + person + Admin + + +
+
+ + +
+
+
+ logout +
+

Confirmer la déconnexion

+

Voulez-vous vraiment vous déconnecter ?

+
+ +
+ + +
+ + + +
+
+ info +

Vous serez redirigé vers la page d'accueil après la déconnexion.

+
+
+
+
+
+ + diff --git a/src/main/resources/templates/fragments/pagination.html b/src/main/resources/templates/fragments/pagination.html new file mode 100644 index 0000000..50b9774 --- /dev/null +++ b/src/main/resources/templates/fragments/pagination.html @@ -0,0 +1,31 @@ + + + +
+ +
+ + diff --git a/src/main/resources/templates/fragments/stats-cards.html b/src/main/resources/templates/fragments/stats-cards.html new file mode 100644 index 0000000..5895d3c --- /dev/null +++ b/src/main/resources/templates/fragments/stats-cards.html @@ -0,0 +1,33 @@ + + + +
+
+
+
+ payments +

Total collecté

+
+

0 Ar

+

Au total

+
+
+
+ confirmation_number +

Nombre de paiements

+
+

0

+

Au total

+
+
+
+ av_timer +

En attente

+
+

0

+

Au total

+
+
+
+ + diff --git a/src/main/resources/templates/payments.html b/src/main/resources/templates/payments.html new file mode 100644 index 0000000..c2bec53 --- /dev/null +++ b/src/main/resources/templates/payments.html @@ -0,0 +1,127 @@ + + + + Vola - Historique + + + + + + + + +
+ +
+
+

+ Historique des paiements +

+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Date créationEmail payeurApplicationScopePSPMontantStatutDate vérificationRéférence OM
+ 2025-01-01 + 12:00:00 + + email@example.com + + App + + -- + + ORANGE_MONEY + + 0 Ar + + + En vérification + Succès + Échoué + + + + 2025-01-01 + 12:00:00 + + -- + + MP250917.1604.D33118 +
+
+ inbox + Aucun paiement trouvé +
+
+
+
+

+ swipe + Faites défiler horizontalement pour voir plus +

+
+
+ +
+
+ + + + diff --git a/src/test/java/school/hei/vola/conf/EnvConf.java b/src/test/java/school/hei/vola/conf/EnvConf.java index 7e53f3e..4ad7b91 100644 --- a/src/test/java/school/hei/vola/conf/EnvConf.java +++ b/src/test/java/school/hei/vola/conf/EnvConf.java @@ -12,5 +12,7 @@ void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.H2Dialect"); registry.add("orange.api.url", () -> apiUrl); registry.add("ADMIN_API_KEY", () -> "admin-api-key"); + registry.add("ADMIN_PASSWORD", () -> "test-password"); + registry.add("ADMIN_EMAIL", () -> "admin@cute.dev"); } } diff --git a/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentViewControllerIT.java b/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentViewControllerIT.java new file mode 100644 index 0000000..3fe3e8d --- /dev/null +++ b/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentViewControllerIT.java @@ -0,0 +1,112 @@ +package school.hei.vola.endpoint.rest.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Objects; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import school.hei.vola.conf.FacadeIT; + +class PaymentViewControllerIT extends FacadeIT { + + @Autowired private TestRestTemplate restTemplate; + + @Test + void unauthenticated_request_to_payments_shows_login_page() { + var response = restTemplate.getForEntity("/payments", String.class); + + assertEquals(200, response.getStatusCodeValue()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("Please sign in")); + } + + @Test + void authenticated_user_can_access_payments() { + var sessionCookie = fetchSessionCookie(); + var csrfToken = fetchCsrfToken(sessionCookie); + + var loginResponse = login(sessionCookie, csrfToken, "admin@cute.dev"); + assertEquals(302, loginResponse.getStatusCodeValue()); + assertEquals( + "/payments", Objects.requireNonNull(loginResponse.getHeaders().getLocation()).getPath()); + + var authCookie = extractSessionId(loginResponse); + var paymentsHeaders = new HttpHeaders(); + paymentsHeaders.add(HttpHeaders.COOKIE, authCookie); + var paymentsResponse = + restTemplate.exchange( + "/payments", HttpMethod.GET, new HttpEntity<>(paymentsHeaders), String.class); + + assertEquals(200, paymentsResponse.getStatusCodeValue()); + } + + @Test + void unknown_user_cannot_login() { + var sessionCookie = fetchSessionCookie(); + var csrfToken = fetchCsrfToken(sessionCookie); + + var loginResponse = login(sessionCookie, csrfToken, "nobody@user.guest"); + assertEquals(302, loginResponse.getStatusCodeValue()); + assertTrue( + Objects.requireNonNull(loginResponse.getHeaders().getLocation()) + .getPath() + .contains("/login")); + } + + @Test + void static_resources_are_accessible_without_authentication() { + var cssResponse = restTemplate.getForEntity("/style/history.css", String.class); + assertEquals(200, cssResponse.getStatusCodeValue()); + } + + private String fetchSessionCookie() { + var loginPage = restTemplate.getForEntity("/login", String.class); + var setCookie = loginPage.getHeaders().getFirst(HttpHeaders.SET_COOKIE); + assertNotNull(setCookie); + return extractSessionId(loginPage); + } + + private String fetchCsrfToken(String sessionCookie) { + var headers = new HttpHeaders(); + headers.add(HttpHeaders.COOKIE, sessionCookie); + var loginPage = + restTemplate.exchange("/login", HttpMethod.GET, new HttpEntity<>(headers), String.class); + var token = extractCsrfToken(loginPage.getBody()); + assertNotNull(token); + return token; + } + + private ResponseEntity login(String sessionCookie, String csrfToken, String email) { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.add(HttpHeaders.COOKIE, sessionCookie); + + var formData = new LinkedMultiValueMap(); + formData.add("email", email); + formData.add("password", "test-password"); + formData.add("_csrf", csrfToken); + + return restTemplate.postForEntity("/login", new HttpEntity<>(formData, headers), String.class); + } + + private static String extractSessionId(ResponseEntity response) { + var cookie = response.getHeaders().getFirst(HttpHeaders.SET_COOKIE); + return cookie != null ? cookie.split(";")[0] : null; + } + + private static String extractCsrfToken(String html) { + var pattern = Pattern.compile("name=\"_csrf\"[^>]*value=\"([^\"]+)\""); + var matcher = pattern.matcher(html); + return matcher.find() ? matcher.group(1) : null; + } +}