Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@ public ResponseEntity<byte[]> 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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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";
}
}
66 changes: 66 additions & 0 deletions src/main/resources/static/script/filters.js
Original file line number Diff line number Diff line change
@@ -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');
});
})();
27 changes: 27 additions & 0 deletions src/main/resources/static/script/logout.js
Original file line number Diff line number Diff line change
@@ -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();
});
}
})();
51 changes: 51 additions & 0 deletions src/main/resources/static/style/history.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading