Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9a386c4
build: add thymeleaf for local admin UI dev, temporary until Poja bot…
Mathieu-bot Jul 27, 2026
80b796a
feat: add admin dashboard templates and static assets
Mathieu-bot Jul 27, 2026
7ab697f
feat: add PaymentViewController and DateParser for admin dashboard
Mathieu-bot Jul 27, 2026
3277dff
test: add PaymentViewControllerIT
Mathieu-bot Jul 27, 2026
27a96ed
fix: add missing history.css and favicon
Mathieu-bot Jul 27, 2026
3b7ba1e
fix: fix test
SalomiaZK Jul 28, 2026
7490565
feat: add CSV export for payments
TsioryJonathan Jul 29, 2026
6718fa0
feat: wall off /payments behind a shared password
Mathieu-bot Jul 30, 2026
7d75492
refactor: inline DateParser into PaymentViewController
Mathieu-bot Jul 30, 2026
54fab77
feat: add CSV export and logout to admin UI
Mathieu-bot Jul 30, 2026
3cb3e46
test: switch from unit mocks to real HTTP integration
Mathieu-bot Jul 30, 2026
b92d5f6
chore: clean up filters.js and remove unused favicon
Mathieu-bot Jul 30, 2026
27d3406
fix normalize empty scope param in export endpoint
Mathieu-bot Jul 30, 2026
53684a6
Merge branch 'preprod' into feat/admin-ui
Mathieu-bot Jul 30, 2026
88b8575
chore: remove thymeleaf dep from build.gradle (managed by Poja console)
Mathieu-bot Jul 30, 2026
de9b0b2
Merge branch 'preprod' of github.com:hei-school/vola into feat/admin-ui
TsioryJonathan Jul 30, 2026
d8e7031
chore: re-add spring deps (thymeleaf, security, session-jdbc)
Mathieu-bot Jul 30, 2026
ae6091e
feat: add admin payment dashboard
TsioryJonathan Jul 30, 2026
d9575d7
refactor: use SPRING_SESSION_* env vars instead of SessionConfig
Mathieu-bot Jul 30, 2026
58adb67
fix: constructor injection and configurable page size
Mathieu-bot Jul 30, 2026
b9ab302
feat: add ApiKeyAuthenticationFilter and protect API endpoints with s…
Mathieu-bot Jul 30, 2026
5812134
refactor: use SimpleGrantedAuthority instead of lambda
Mathieu-bot Jul 30, 2026
7e9c5e7
fix: restrict admin dashboard to ROLE_ADMIN and stop exposing adminKe…
Mathieu-bot Aug 3, 2026
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 @@ -67,16 +67,20 @@ public Payment getPayment(

@GetMapping("/payments/export/csv")
public ResponseEntity<byte[]> exportPaymentsCsv(
@RequestParam String adminKey,
@RequestParam(required = false) String adminKey,
@RequestParam String applicationName,
@RequestParam(required = false) String scope,
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate startDate,
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate endDate) {
adminAuthorizer.accept(adminKey);
if (adminKey != null && !adminKey.isBlank()) {
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,91 @@
package school.hei.vola.endpoint.rest.controller;

import static java.time.ZoneOffset.UTC;

import java.time.Instant;
import java.time.LocalDate;
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
public class PaymentViewController {

private final PaymentService paymentService;
private final JApplicationRepository jApplicationRepository;

public PaymentViewController(
PaymentService paymentService, JApplicationRepository jApplicationRepository) {
this.paymentService = paymentService;
this.jApplicationRepository = jApplicationRepository;
}

@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,
@RequestParam(defaultValue = "15") int size,
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, 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", size);
model.addAttribute("scopes", paymentService.findDistinctScopes(effectiveApp));
model.addAttribute("selectedApplication", effectiveApp);
model.addAttribute("selectedScope", effectiveScope);
model.addAttribute("selectedStartDate", parsedStartDate);
model.addAttribute("selectedEndDate", parsedEndDate);
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,54 @@
package school.hei.vola.endpoint.rest.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

public class ApiKeyAuthenticationFilter extends OncePerRequestFilter {

private final ApplicationAuthorizer applicationAuthorizer;
private final AdminAuthorizer adminAuthorizer;

public ApiKeyAuthenticationFilter(
ApplicationAuthorizer applicationAuthorizer, AdminAuthorizer adminAuthorizer) {
this.applicationAuthorizer = applicationAuthorizer;
this.adminAuthorizer = adminAuthorizer;
}

@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
var apiKey = request.getParameter("apiKey");
var adminKey = request.getParameter("adminKey");

try {
if (apiKey != null && !apiKey.isBlank()) {
applicationAuthorizer.accept(apiKey);
SecurityContextHolder.getContext()
.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
apiKey, null, List.of(new SimpleGrantedAuthority("ROLE_APP"))));
} else if (adminKey != null && !adminKey.isBlank()) {
adminAuthorizer.accept(adminKey);
SecurityContextHolder.getContext()
.setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
adminKey, null, List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))));
}
} catch (UnauthorizedException e) {
SecurityContextHolder.clearContext();
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid key");
return;
}

chain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConf {

@Bean
public SecurityFilterChain filterChain(
HttpSecurity http, ApiKeyAuthenticationFilter apiKeyAuthenticationFilter) throws Exception {
http.addFilterBefore(apiKeyAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(
auth ->
auth.requestMatchers(
"/ping",
"/health/**",
"/error",
"/orange/sync",
"/css/**",
"/js/**",
"/script/**",
"/style/**",
"/images/**")
.permitAll()
Comment thread
TsioryJonathan marked this conversation as resolved.
.requestMatchers("/payment", "/payments/search", "/orange/transactions/import")
.hasRole("APP")
.requestMatchers("/payments/**")
.hasRole("ADMIN")
.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 ApiKeyAuthenticationFilter apiKeyAuthenticationFilter(
ApplicationAuthorizer applicationAuthorizer, AdminAuthorizer adminAuthorizer) {
return new ApiKeyAuthenticationFilter(applicationAuthorizer, adminAuthorizer);
}

@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")))
.roles("ADMIN")
.build());
}

@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
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