From 3f938db257d2021269cbf05422d1384ecc37ecd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Fri, 27 Feb 2026 12:44:06 +0100 Subject: [PATCH] feat(back-end and front-end): back-office --- .gitea/workflows/cicd.yaml | 7 +- backend/build.gradle | 2 + .../config/SecurityConfig.java | 45 ++++ .../controller/AdminAuthController.java | 55 +++++ .../controller/AdminOrderController.java | 156 ++++++++++++++ .../dto/AdminLoginRequest.java | 17 ++ .../repository/OrderRepository.java | 4 +- .../AdminSessionAuthenticationFilter.java | 71 ++++++ .../security/AdminSessionService.java | 202 ++++++++++++++++++ .../resources/application-local.properties | 5 + .../src/main/resources/application.properties | 5 + .../controller/AdminAuthSecurityTest.java | 90 ++++++++ deploy/envs/dev.env | 3 + deploy/envs/int.env | 3 + deploy/envs/prod.env | 3 + docker-compose.deploy.yml | 3 + frontend/src/app/app.config.ts | 7 +- frontend/src/app/app.routes.ts | 4 + .../interceptors/admin-auth.interceptor.ts | 37 ++++ .../src/app/features/admin/admin.routes.ts | 14 ++ .../features/admin/guards/admin-auth.guard.ts | 41 ++++ .../pages/admin-dashboard.component.html | 67 ++++++ .../pages/admin-dashboard.component.scss | 119 +++++++++++ .../admin/pages/admin-dashboard.component.ts | 99 +++++++++ .../admin/pages/admin-login.component.html | 27 +++ .../admin/pages/admin-login.component.scss | 64 ++++++ .../admin/pages/admin-login.component.ts | 65 ++++++ .../admin/services/admin-auth.service.ts | 32 +++ .../admin/services/admin-orders.service.ts | 48 +++++ .../services/quote-estimator.service.ts | 22 -- frontend/src/environments/environment.prod.ts | 3 +- frontend/src/environments/environment.ts | 3 +- 32 files changed, 1293 insertions(+), 30 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/config/SecurityConfig.java create mode 100644 backend/src/main/java/com/printcalculator/controller/AdminAuthController.java create mode 100644 backend/src/main/java/com/printcalculator/controller/AdminOrderController.java create mode 100644 backend/src/main/java/com/printcalculator/dto/AdminLoginRequest.java create mode 100644 backend/src/main/java/com/printcalculator/security/AdminSessionAuthenticationFilter.java create mode 100644 backend/src/main/java/com/printcalculator/security/AdminSessionService.java create mode 100644 backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java create mode 100644 frontend/src/app/core/interceptors/admin-auth.interceptor.ts create mode 100644 frontend/src/app/features/admin/admin.routes.ts create mode 100644 frontend/src/app/features/admin/guards/admin-auth.guard.ts create mode 100644 frontend/src/app/features/admin/pages/admin-dashboard.component.html create mode 100644 frontend/src/app/features/admin/pages/admin-dashboard.component.scss create mode 100644 frontend/src/app/features/admin/pages/admin-dashboard.component.ts create mode 100644 frontend/src/app/features/admin/pages/admin-login.component.html create mode 100644 frontend/src/app/features/admin/pages/admin-login.component.scss create mode 100644 frontend/src/app/features/admin/pages/admin-login.component.ts create mode 100644 frontend/src/app/features/admin/services/admin-auth.service.ts create mode 100644 frontend/src/app/features/admin/services/admin-orders.service.ts diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index b1b875f..dc14714 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -163,9 +163,14 @@ jobs: printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \ "${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env + ADMIN_TTL="${{ secrets.ADMIN_SESSION_TTL_MINUTES }}" + ADMIN_TTL="${ADMIN_TTL:-480}" + printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \ + "${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env + # 5. Debug: print content (for debug purposes) echo "Preparing to send env file with variables:" - grep -v "PASSWORD" /tmp/full_env.env || true + grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true # 5. Send env to server ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ diff --git a/backend/build.gradle b/backend/build.gradle index ddadb78..627b2cc 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -24,6 +24,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'xyz.capybara:clamav-client:2.1.2' @@ -31,6 +32,7 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' compileOnly 'org.projectlombok:lombok' diff --git a/backend/src/main/java/com/printcalculator/config/SecurityConfig.java b/backend/src/main/java/com/printcalculator/config/SecurityConfig.java new file mode 100644 index 0000000..5f0c81c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/config/SecurityConfig.java @@ -0,0 +1,45 @@ +package com.printcalculator.config; + +import com.printcalculator.security.AdminSessionAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + AdminSessionAuthenticationFilter adminSessionAuthenticationFilter + ) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/api/admin/auth/login").permitAll() + .requestMatchers("/api/admin/**").authenticated() + .anyRequest().permitAll() + ) + .exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> { + response.setStatus(401); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}"); + })) + .addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/AdminAuthController.java b/backend/src/main/java/com/printcalculator/controller/AdminAuthController.java new file mode 100644 index 0000000..a489f56 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/AdminAuthController.java @@ -0,0 +1,55 @@ +package com.printcalculator.controller; + +import com.printcalculator.dto.AdminLoginRequest; +import com.printcalculator.security.AdminSessionService; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/auth") +public class AdminAuthController { + + private final AdminSessionService adminSessionService; + + public AdminAuthController(AdminSessionService adminSessionService) { + this.adminSessionService = adminSessionService; + } + + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody AdminLoginRequest request, + HttpServletResponse response + ) { + if (!adminSessionService.isPasswordValid(request.getPassword())) { + return ResponseEntity.status(401).body(Map.of("authenticated", false)); + } + + String token = adminSessionService.createSessionToken(); + response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLoginCookie(token).toString()); + + return ResponseEntity.ok(Map.of( + "authenticated", true, + "expiresInMinutes", adminSessionService.getSessionTtlMinutes() + )); + } + + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletResponse response) { + response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLogoutCookie().toString()); + return ResponseEntity.ok(Map.of("authenticated", false)); + } + + @GetMapping("/me") + public ResponseEntity> me() { + return ResponseEntity.ok(Map.of("authenticated", true)); + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/AdminOrderController.java new file mode 100644 index 0000000..f4ba54f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/AdminOrderController.java @@ -0,0 +1,156 @@ +package com.printcalculator.controller; + +import com.printcalculator.dto.AddressDto; +import com.printcalculator.dto.OrderDto; +import com.printcalculator.dto.OrderItemDto; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.service.PaymentService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +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.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@RestController +@RequestMapping("/api/admin/orders") +public class AdminOrderController { + + private final OrderRepository orderRepo; + private final OrderItemRepository orderItemRepo; + private final PaymentRepository paymentRepo; + private final PaymentService paymentService; + + public AdminOrderController( + OrderRepository orderRepo, + OrderItemRepository orderItemRepo, + PaymentRepository paymentRepo, + PaymentService paymentService + ) { + this.orderRepo = orderRepo; + this.orderItemRepo = orderItemRepo; + this.paymentRepo = paymentRepo; + this.paymentService = paymentService; + } + + @GetMapping + public ResponseEntity> listOrders() { + List response = orderRepo.findAllByOrderByCreatedAtDesc() + .stream() + .map(this::toOrderDto) + .toList(); + return ResponseEntity.ok(response); + } + + @GetMapping("/{orderId}") + public ResponseEntity getOrder(@PathVariable UUID orderId) { + return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); + } + + @PostMapping("/{orderId}/payments/confirm") + @Transactional + public ResponseEntity confirmPayment( + @PathVariable UUID orderId, + @RequestBody(required = false) Map payload + ) { + getOrderOrThrow(orderId); + String method = payload != null ? payload.get("method") : null; + paymentService.confirmPayment(orderId, method); + return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); + } + + private Order getOrderOrThrow(UUID orderId) { + return orderRepo.findById(orderId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found")); + } + + private OrderDto toOrderDto(Order order) { + List items = orderItemRepo.findByOrder_Id(order.getId()); + OrderDto dto = new OrderDto(); + dto.setId(order.getId()); + dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setStatus(order.getStatus()); + + paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> { + dto.setPaymentStatus(p.getStatus()); + dto.setPaymentMethod(p.getMethod()); + }); + + dto.setCustomerEmail(order.getCustomerEmail()); + dto.setCustomerPhone(order.getCustomerPhone()); + dto.setPreferredLanguage(order.getPreferredLanguage()); + dto.setBillingCustomerType(order.getBillingCustomerType()); + dto.setCurrency(order.getCurrency()); + dto.setSetupCostChf(order.getSetupCostChf()); + dto.setShippingCostChf(order.getShippingCostChf()); + dto.setDiscountChf(order.getDiscountChf()); + dto.setSubtotalChf(order.getSubtotalChf()); + dto.setTotalChf(order.getTotalChf()); + dto.setCreatedAt(order.getCreatedAt()); + dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); + + AddressDto billing = new AddressDto(); + billing.setFirstName(order.getBillingFirstName()); + billing.setLastName(order.getBillingLastName()); + billing.setCompanyName(order.getBillingCompanyName()); + billing.setContactPerson(order.getBillingContactPerson()); + billing.setAddressLine1(order.getBillingAddressLine1()); + billing.setAddressLine2(order.getBillingAddressLine2()); + billing.setZip(order.getBillingZip()); + billing.setCity(order.getBillingCity()); + billing.setCountryCode(order.getBillingCountryCode()); + dto.setBillingAddress(billing); + + if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) { + AddressDto shipping = new AddressDto(); + shipping.setFirstName(order.getShippingFirstName()); + shipping.setLastName(order.getShippingLastName()); + shipping.setCompanyName(order.getShippingCompanyName()); + shipping.setContactPerson(order.getShippingContactPerson()); + shipping.setAddressLine1(order.getShippingAddressLine1()); + shipping.setAddressLine2(order.getShippingAddressLine2()); + shipping.setZip(order.getShippingZip()); + shipping.setCity(order.getShippingCity()); + shipping.setCountryCode(order.getShippingCountryCode()); + dto.setShippingAddress(shipping); + } + + List itemDtos = items.stream().map(i -> { + OrderItemDto idto = new OrderItemDto(); + idto.setId(i.getId()); + idto.setOriginalFilename(i.getOriginalFilename()); + idto.setMaterialCode(i.getMaterialCode()); + idto.setColorCode(i.getColorCode()); + idto.setQuantity(i.getQuantity()); + idto.setPrintTimeSeconds(i.getPrintTimeSeconds()); + idto.setMaterialGrams(i.getMaterialGrams()); + idto.setUnitPriceChf(i.getUnitPriceChf()); + idto.setLineTotalChf(i.getLineTotalChf()); + return idto; + }).toList(); + dto.setItems(itemDtos); + + return dto; + } + + private String getDisplayOrderNumber(Order order) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminLoginRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminLoginRequest.java new file mode 100644 index 0000000..5a39f9d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminLoginRequest.java @@ -0,0 +1,17 @@ +package com.printcalculator.dto; + +import jakarta.validation.constraints.NotBlank; + +public class AdminLoginRequest { + + @NotBlank + private String password; + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/backend/src/main/java/com/printcalculator/repository/OrderRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderRepository.java index e8351d7..a4ca921 100644 --- a/backend/src/main/java/com/printcalculator/repository/OrderRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/OrderRepository.java @@ -3,7 +3,9 @@ package com.printcalculator.repository; import com.printcalculator.entity.Order; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.UUID; public interface OrderRepository extends JpaRepository { -} \ No newline at end of file + List findAllByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/com/printcalculator/security/AdminSessionAuthenticationFilter.java b/backend/src/main/java/com/printcalculator/security/AdminSessionAuthenticationFilter.java new file mode 100644 index 0000000..deac902 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/security/AdminSessionAuthenticationFilter.java @@ -0,0 +1,71 @@ +package com.printcalculator.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.Optional; + +@Component +public class AdminSessionAuthenticationFilter extends OncePerRequestFilter { + + private final AdminSessionService adminSessionService; + + public AdminSessionAuthenticationFilter(AdminSessionService adminSessionService) { + this.adminSessionService = adminSessionService; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = resolvePath(request); + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + if (!path.startsWith("/api/admin/")) { + return true; + } + return "/api/admin/auth/login".equals(path); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + Optional token = adminSessionService.extractTokenFromCookies(request); + Optional payload = token.flatMap(adminSessionService::validateSessionToken); + + if (payload.isEmpty()) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}"); + return; + } + + UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated( + "admin", + null, + Collections.emptyList() + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); + } + + private String resolvePath(HttpServletRequest request) { + String path = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) { + return path.substring(contextPath.length()); + } + return path; + } +} diff --git a/backend/src/main/java/com/printcalculator/security/AdminSessionService.java b/backend/src/main/java/com/printcalculator/security/AdminSessionService.java new file mode 100644 index 0000000..1ab99ef --- /dev/null +++ b/backend/src/main/java/com/printcalculator/security/AdminSessionService.java @@ -0,0 +1,202 @@ +package com.printcalculator.security; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +@Service +public class AdminSessionService { + + public static final String COOKIE_NAME = "admin_session"; + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + private final ObjectMapper objectMapper; + private final String adminPassword; + private final byte[] sessionSecret; + private final long sessionTtlMinutes; + + public AdminSessionService( + ObjectMapper objectMapper, + @Value("${admin.password}") String adminPassword, + @Value("${admin.session.secret}") String sessionSecret, + @Value("${admin.session.ttl-minutes}") long sessionTtlMinutes + ) { + this.objectMapper = objectMapper; + this.adminPassword = adminPassword; + this.sessionSecret = sessionSecret.getBytes(StandardCharsets.UTF_8); + this.sessionTtlMinutes = sessionTtlMinutes; + + validateConfiguration(adminPassword, sessionSecret, sessionTtlMinutes); + } + + public boolean isPasswordValid(String candidatePassword) { + if (candidatePassword == null) { + return false; + } + + return MessageDigest.isEqual( + adminPassword.getBytes(StandardCharsets.UTF_8), + candidatePassword.getBytes(StandardCharsets.UTF_8) + ); + } + + public String createSessionToken() { + Instant now = Instant.now(); + AdminSessionPayload payload = new AdminSessionPayload( + now.getEpochSecond(), + now.plus(Duration.ofMinutes(sessionTtlMinutes)).getEpochSecond(), + UUID.randomUUID().toString() + ); + + try { + String payloadJson = objectMapper.writeValueAsString(payload); + String encodedPayload = base64UrlEncode(payloadJson.getBytes(StandardCharsets.UTF_8)); + String signature = base64UrlEncode(sign(encodedPayload)); + return encodedPayload + "." + signature; + } catch (JsonProcessingException e) { + throw new IllegalStateException("Cannot create admin session token", e); + } + } + + public Optional validateSessionToken(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + + String[] parts = token.split("\\."); + if (parts.length != 2) { + return Optional.empty(); + } + + String encodedPayload = parts[0]; + String encodedSignature = parts[1]; + byte[] providedSignature; + try { + providedSignature = base64UrlDecode(encodedSignature); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + + byte[] expectedSignature = sign(encodedPayload); + if (!MessageDigest.isEqual(expectedSignature, providedSignature)) { + return Optional.empty(); + } + + try { + byte[] decodedPayload = base64UrlDecode(encodedPayload); + AdminSessionPayload payload = objectMapper.readValue(decodedPayload, AdminSessionPayload.class); + if (payload.exp <= Instant.now().getEpochSecond()) { + return Optional.empty(); + } + return Optional.of(payload); + } catch (IllegalArgumentException | IOException e) { + return Optional.empty(); + } + } + + public Optional extractTokenFromCookies(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + return Optional.empty(); + } + + for (Cookie cookie : cookies) { + if (COOKIE_NAME.equals(cookie.getName())) { + return Optional.ofNullable(cookie.getValue()); + } + } + + return Optional.empty(); + } + + public ResponseCookie buildLoginCookie(String token) { + return ResponseCookie.from(COOKIE_NAME, token) + .path("/") + .httpOnly(true) + .secure(true) + .sameSite("Lax") + .maxAge(Duration.ofMinutes(sessionTtlMinutes)) + .build(); + } + + public ResponseCookie buildLogoutCookie() { + return ResponseCookie.from(COOKIE_NAME, "") + .path("/") + .httpOnly(true) + .secure(true) + .sameSite("Lax") + .maxAge(Duration.ZERO) + .build(); + } + + public long getSessionTtlMinutes() { + return sessionTtlMinutes; + } + + private byte[] sign(String encodedPayload) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(sessionSecret, HMAC_ALGORITHM)); + return mac.doFinal(encodedPayload.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException("Cannot sign admin session token", e); + } + } + + private String base64UrlEncode(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } + + private byte[] base64UrlDecode(String data) { + return Base64.getUrlDecoder().decode(data); + } + + private void validateConfiguration(String password, String secret, long ttlMinutes) { + if (password == null || password.isBlank()) { + throw new IllegalStateException("ADMIN_PASSWORD must be configured and non-empty"); + } + if (secret == null || secret.isBlank()) { + throw new IllegalStateException("ADMIN_SESSION_SECRET must be configured and non-empty"); + } + if (secret.length() < 32) { + throw new IllegalStateException("ADMIN_SESSION_SECRET must be at least 32 characters long"); + } + if (ttlMinutes <= 0) { + throw new IllegalStateException("ADMIN_SESSION_TTL_MINUTES must be > 0"); + } + } + + public static class AdminSessionPayload { + @JsonProperty("iat") + public long iat; + @JsonProperty("exp") + public long exp; + @JsonProperty("nonce") + public String nonce; + + public AdminSessionPayload() { + } + + public AdminSessionPayload(long iat, long exp, String nonce) { + this.iat = iat; + this.exp = exp; + this.nonce = nonce; + } + } +} diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index 45c5593..568593c 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -1,2 +1,7 @@ app.mail.enabled=false app.mail.admin.enabled=false + +# Admin back-office local test credentials +admin.password=local-admin-password +admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789 +admin.session.ttl-minutes=480 diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index d74ad77..ded5b09 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -41,3 +41,8 @@ app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}} app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true} app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} + +# Admin back-office authentication +admin.password=${ADMIN_PASSWORD} +admin.session.secret=${ADMIN_SESSION_SECRET} +admin.session.ttl-minutes=${ADMIN_SESSION_TTL_MINUTES:480} diff --git a/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java new file mode 100644 index 0000000..5eeb84b --- /dev/null +++ b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java @@ -0,0 +1,90 @@ +package com.printcalculator.controller; + +import com.printcalculator.config.SecurityConfig; +import com.printcalculator.security.AdminSessionAuthenticationFilter; +import com.printcalculator.security.AdminSessionService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = AdminAuthController.class) +@Import({SecurityConfig.class, AdminSessionAuthenticationFilter.class, AdminSessionService.class}) +@TestPropertySource(properties = { + "admin.password=test-admin-password", + "admin.session.secret=0123456789abcdef0123456789abcdef", + "admin.session.ttl-minutes=60" +}) +class AdminAuthSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void loginOk_ShouldReturnCookie() throws Exception { + MvcResult result = mockMvc.perform(post("/api/admin/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"test-admin-password\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) + .andReturn(); + + String setCookie = result.getResponse().getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(setCookie); + assertTrue(setCookie.contains("admin_session=")); + assertTrue(setCookie.contains("HttpOnly")); + assertTrue(setCookie.contains("Secure")); + assertTrue(setCookie.contains("SameSite=Lax")); + } + + @Test + void loginKo_ShouldReturnUnauthorized() throws Exception { + mockMvc.perform(post("/api/admin/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"wrong-password\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.authenticated").value(false)); + } + + @Test + void adminAccessWithoutCookie_ShouldReturn401() throws Exception { + mockMvc.perform(get("/api/admin/auth/me")) + .andExpect(status().isUnauthorized()); + } + + @Test + void adminAccessWithValidCookie_ShouldReturn200() throws Exception { + MvcResult login = mockMvc.perform(post("/api/admin/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"test-admin-password\"}")) + .andExpect(status().isOk()) + .andReturn(); + + String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(setCookie); + + Cookie adminCookie = toCookie(setCookie); + mockMvc.perform(get("/api/admin/auth/me").cookie(adminCookie)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)); + } + + private Cookie toCookie(String setCookieHeader) { + String[] parts = setCookieHeader.split(";", 2); + String[] keyValue = parts[0].split("=", 2); + return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : ""); + } +} diff --git a/deploy/envs/dev.env b/deploy/envs/dev.env index eb400c1..8a82535 100644 --- a/deploy/envs/dev.env +++ b/deploy/envs/dev.env @@ -11,3 +11,6 @@ CLAMAV_HOST=192.168.1.147 CLAMAV_PORT=3310 CLAMAV_ENABLED=true APP_FRONTEND_BASE_URL=http://localhost:18082 +ADMIN_PASSWORD= +ADMIN_SESSION_SECRET= +ADMIN_SESSION_TTL_MINUTES=480 diff --git a/deploy/envs/int.env b/deploy/envs/int.env index d989460..c04a1bf 100644 --- a/deploy/envs/int.env +++ b/deploy/envs/int.env @@ -11,3 +11,6 @@ CLAMAV_HOST=192.168.1.147 CLAMAV_PORT=3310 CLAMAV_ENABLED=true APP_FRONTEND_BASE_URL=http://localhost:18081 +ADMIN_PASSWORD= +ADMIN_SESSION_SECRET= +ADMIN_SESSION_TTL_MINUTES=480 diff --git a/deploy/envs/prod.env b/deploy/envs/prod.env index ce5da60..87fe496 100644 --- a/deploy/envs/prod.env +++ b/deploy/envs/prod.env @@ -11,3 +11,6 @@ CLAMAV_HOST=192.168.1.147 CLAMAV_PORT=3310 CLAMAV_ENABLED=true APP_FRONTEND_BASE_URL=https://3d-fab.ch +ADMIN_PASSWORD= +ADMIN_SESSION_SECRET= +ADMIN_SESSION_TTL_MINUTES=480 diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 9f14474..1a141fe 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -23,6 +23,9 @@ services: - APP_MAIL_ADMIN_ENABLED=${APP_MAIL_ADMIN_ENABLED:-true} - APP_MAIL_ADMIN_ADDRESS=${APP_MAIL_ADMIN_ADDRESS:-info@3d-fab.ch} - APP_FRONTEND_BASE_URL=${APP_FRONTEND_BASE_URL:-http://localhost:4200} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} + - ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles restart: always diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 91f1b58..e0733e9 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,9 +1,10 @@ import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core'; import { provideRouter, withComponentInputBinding, withInMemoryScrolling, withViewTransitions } from '@angular/router'; import { routes } from './app.routes'; -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { provideTranslateHttpLoader, TranslateHttpLoader } from '@ngx-translate/http-loader'; +import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ @@ -16,7 +17,9 @@ export const appConfig: ApplicationConfig = { scrollPositionRestoration: 'top' }) ), - provideHttpClient(), + provideHttpClient( + withInterceptors([adminAuthInterceptor]) + ), provideTranslateHttpLoader({ prefix: './assets/i18n/', suffix: '.json' diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 484d579..d79b55f 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -37,6 +37,10 @@ const appChildRoutes: Routes = [ path: '', loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) }, + { + path: 'admin', + loadChildren: () => import('./features/admin/admin.routes').then(m => m.ADMIN_ROUTES) + }, { path: '**', redirectTo: '' diff --git a/frontend/src/app/core/interceptors/admin-auth.interceptor.ts b/frontend/src/app/core/interceptors/admin-auth.interceptor.ts new file mode 100644 index 0000000..156f6b3 --- /dev/null +++ b/frontend/src/app/core/interceptors/admin-auth.interceptor.ts @@ -0,0 +1,37 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { catchError, throwError } from 'rxjs'; + +const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); + +function resolveLangFromUrl(url: string): string { + const cleanUrl = (url || '').split('?')[0].split('#')[0]; + const segments = cleanUrl.split('/').filter(Boolean); + if (segments.length > 0 && SUPPORTED_LANGS.has(segments[0])) { + return segments[0]; + } + return 'it'; +} + +export const adminAuthInterceptor: HttpInterceptorFn = (req, next) => { + if (!req.url.includes('/api/admin/')) { + return next(req); + } + + const router = inject(Router); + const request = req.clone({ withCredentials: true }); + const isLoginRequest = request.url.includes('/api/admin/auth/login'); + + return next(request).pipe( + catchError((error: unknown) => { + if (!isLoginRequest && error instanceof HttpErrorResponse && error.status === 401) { + const lang = resolveLangFromUrl(router.url); + if (!router.url.includes('/admin/login')) { + void router.navigate(['/', lang, 'admin', 'login']); + } + } + return throwError(() => error); + }) + ); +}; diff --git a/frontend/src/app/features/admin/admin.routes.ts b/frontend/src/app/features/admin/admin.routes.ts new file mode 100644 index 0000000..d33a624 --- /dev/null +++ b/frontend/src/app/features/admin/admin.routes.ts @@ -0,0 +1,14 @@ +import { Routes } from '@angular/router'; +import { adminAuthGuard } from './guards/admin-auth.guard'; + +export const ADMIN_ROUTES: Routes = [ + { + path: 'login', + loadComponent: () => import('./pages/admin-login.component').then(m => m.AdminLoginComponent) + }, + { + path: '', + canActivate: [adminAuthGuard], + loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent) + } +]; diff --git a/frontend/src/app/features/admin/guards/admin-auth.guard.ts b/frontend/src/app/features/admin/guards/admin-auth.guard.ts new file mode 100644 index 0000000..f11046e --- /dev/null +++ b/frontend/src/app/features/admin/guards/admin-auth.guard.ts @@ -0,0 +1,41 @@ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { catchError, map, Observable, of } from 'rxjs'; +import { AdminAuthService } from '../services/admin-auth.service'; + +const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); + +function resolveLang(route: ActivatedRouteSnapshot): string { + for (const level of route.pathFromRoot) { + const candidate = level.paramMap.get('lang'); + if (candidate && SUPPORTED_LANGS.has(candidate)) { + return candidate; + } + } + return 'it'; +} + +export const adminAuthGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot +): Observable => { + const authService = inject(AdminAuthService); + const router = inject(Router); + const lang = resolveLang(route); + + return authService.me().pipe( + map((isAuthenticated) => { + if (isAuthenticated) { + return true; + } + return router.createUrlTree(['/', lang, 'admin', 'login'], { + queryParams: { redirect: state.url } + }); + }), + catchError(() => of( + router.createUrlTree(['/', lang, 'admin', 'login'], { + queryParams: { redirect: state.url } + }) + )) + ); +}; diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html new file mode 100644 index 0000000..9a89943 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -0,0 +1,67 @@ +
+
+
+

Back-office ordini

+

Gestione pagamenti e dettaglio ordini

+
+
+ + +
+
+ +

{{ errorMessage }}

+ +
+ + + + + + + + + + + + + + + + + + + + + +
OrdineEmailStatoPagamentoTotaleAzioni
{{ order.orderNumber }}{{ order.customerEmail }}{{ order.status }}{{ order.paymentStatus || 'PENDING' }}{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }} + + +
+
+ +
+

Dettaglio ordine {{ selectedOrder.orderNumber }}

+

Caricamento dettaglio...

+

Cliente: {{ selectedOrder.customerEmail }}

+

Pagamento: {{ selectedOrder.paymentStatus || 'PENDING' }}

+ +
+
+

File: {{ item.originalFilename }}

+

Qta: {{ item.quantity }}

+

Prezzo riga: {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }}

+
+
+
+
+ + +

Caricamento ordini...

+
diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss new file mode 100644 index 0000000..f5eb982 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss @@ -0,0 +1,119 @@ +.admin-dashboard { + padding: 1rem; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; +} + +.dashboard-header h1 { + margin: 0; + font-size: 1.6rem; +} + +.dashboard-header p { + margin: 0.35rem 0 0; + color: #4b5a70; +} + +.header-actions { + display: flex; + gap: 0.5rem; +} + +button { + border: 0; + border-radius: 10px; + background: #0f3f6f; + color: #fff; + padding: 0.55rem 0.8rem; + font-weight: 600; + cursor: pointer; +} + +button.ghost { + background: #eef2f8; + color: #163a5f; +} + +button:disabled { + opacity: 0.65; + cursor: default; +} + +.table-wrap { + overflow: auto; + border: 1px solid #d8e0ec; + border-radius: 12px; + background: #fff; +} + +table { + width: 100%; + border-collapse: collapse; +} + +thead { + background: #f3f6fa; +} + +th, +td { + text-align: left; + padding: 0.75rem; + border-bottom: 1px solid #e5ebf4; + vertical-align: top; +} + +td.actions { + display: flex; + gap: 0.5rem; + min-width: 210px; +} + +tr.selected { + background: #f4f9ff; +} + +.details { + margin-top: 1rem; + background: #fff; + border: 1px solid #d8e0ec; + border-radius: 12px; + padding: 1rem; +} + +.details h2 { + margin-top: 0; +} + +.items { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); +} + +.item { + border: 1px solid #e5ebf4; + border-radius: 10px; + padding: 0.65rem; +} + +.item p { + margin: 0.2rem 0; +} + +.error { + color: #b4232c; + margin-bottom: 0.9rem; +} + +@media (max-width: 820px) { + .dashboard-header { + flex-direction: column; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts new file mode 100644 index 0000000..a73ea5a --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts @@ -0,0 +1,99 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AdminAuthService } from '../services/admin-auth.service'; +import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service'; + +const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); + +@Component({ + selector: 'app-admin-dashboard', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-dashboard.component.html', + styleUrl: './admin-dashboard.component.scss' +}) +export class AdminDashboardComponent implements OnInit { + private readonly adminOrdersService = inject(AdminOrdersService); + private readonly adminAuthService = inject(AdminAuthService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + orders: AdminOrder[] = []; + selectedOrder: AdminOrder | null = null; + loading = false; + detailLoading = false; + confirmingOrderId: string | null = null; + errorMessage: string | null = null; + + ngOnInit(): void { + this.loadOrders(); + } + + loadOrders(): void { + this.loading = true; + this.errorMessage = null; + this.adminOrdersService.listOrders().subscribe({ + next: (orders) => { + this.orders = orders; + this.loading = false; + }, + error: () => { + this.loading = false; + this.errorMessage = 'Impossibile caricare gli ordini.'; + } + }); + } + + openDetails(orderId: string): void { + this.detailLoading = true; + this.adminOrdersService.getOrder(orderId).subscribe({ + next: (order) => { + this.selectedOrder = order; + this.detailLoading = false; + }, + error: () => { + this.detailLoading = false; + this.errorMessage = 'Impossibile caricare il dettaglio ordine.'; + } + }); + } + + confirmPayment(orderId: string): void { + this.confirmingOrderId = orderId; + this.adminOrdersService.confirmPayment(orderId).subscribe({ + next: (updatedOrder) => { + this.confirmingOrderId = null; + this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order); + if (this.selectedOrder?.id === updatedOrder.id) { + this.selectedOrder = updatedOrder; + } + }, + error: () => { + this.confirmingOrderId = null; + this.errorMessage = 'Conferma pagamento non riuscita.'; + } + }); + } + + logout(): void { + this.adminAuthService.logout().subscribe({ + next: () => { + void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']); + }, + error: () => { + void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']); + } + }); + } + + private resolveLang(): string { + for (const level of this.route.pathFromRoot) { + const lang = level.snapshot.paramMap.get('lang'); + if (lang && SUPPORTED_LANGS.has(lang)) { + return lang; + } + } + return 'it'; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-login.component.html b/frontend/src/app/features/admin/pages/admin-login.component.html new file mode 100644 index 0000000..77674e1 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-login.component.html @@ -0,0 +1,27 @@ + diff --git a/frontend/src/app/features/admin/pages/admin-login.component.scss b/frontend/src/app/features/admin/pages/admin-login.component.scss new file mode 100644 index 0000000..22e8546 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-login.component.scss @@ -0,0 +1,64 @@ +.admin-login-page { + display: flex; + justify-content: center; + align-items: center; + min-height: 70vh; + padding: 2rem 1rem; +} + +.admin-login-card { + width: 100%; + max-width: 420px; + background: #fff; + border: 1px solid #d6dde8; + border-radius: 14px; + padding: 1.5rem; + box-shadow: 0 20px 45px rgba(15, 23, 42, 0.08); +} + +h1 { + margin: 0; + font-size: 1.6rem; +} + +p { + margin: 0.5rem 0 1.25rem; + color: #46546a; +} + +form { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +label { + font-weight: 600; +} + +input { + border: 1px solid #c3cedd; + border-radius: 10px; + padding: 0.75rem; + font-size: 1rem; +} + +button { + border: 0; + border-radius: 10px; + background: #0f3f6f; + color: #fff; + padding: 0.75rem 0.9rem; + font-weight: 600; + cursor: pointer; +} + +button:disabled { + opacity: 0.65; + cursor: default; +} + +.error { + margin-top: 1rem; + color: #b0182a; +} diff --git a/frontend/src/app/features/admin/pages/admin-login.component.ts b/frontend/src/app/features/admin/pages/admin-login.component.ts new file mode 100644 index 0000000..ac070a1 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-login.component.ts @@ -0,0 +1,65 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AdminAuthService } from '../services/admin-auth.service'; + +const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); + +@Component({ + selector: 'app-admin-login', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-login.component.html', + styleUrl: './admin-login.component.scss' +}) +export class AdminLoginComponent { + private readonly authService = inject(AdminAuthService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + + password = ''; + loading = false; + errorMessage: string | null = null; + + submit(): void { + if (!this.password.trim() || this.loading) { + return; + } + + this.loading = true; + this.errorMessage = null; + + this.authService.login(this.password).subscribe({ + next: (isAuthenticated) => { + this.loading = false; + if (!isAuthenticated) { + this.errorMessage = 'Password non valida.'; + return; + } + + const redirect = this.route.snapshot.queryParamMap.get('redirect'); + if (redirect && redirect.startsWith('/')) { + void this.router.navigateByUrl(redirect); + return; + } + + void this.router.navigate(['/', this.resolveLang(), 'admin']); + }, + error: () => { + this.loading = false; + this.errorMessage = 'Password non valida.'; + } + }); + } + + private resolveLang(): string { + for (const level of this.route.pathFromRoot) { + const lang = level.snapshot.paramMap.get('lang'); + if (lang && SUPPORTED_LANGS.has(lang)) { + return lang; + } + } + return 'it'; + } +} diff --git a/frontend/src/app/features/admin/services/admin-auth.service.ts b/frontend/src/app/features/admin/services/admin-auth.service.ts new file mode 100644 index 0000000..30779f8 --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-auth.service.ts @@ -0,0 +1,32 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { map, Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; + +interface AdminAuthResponse { + authenticated: boolean; +} + +@Injectable({ + providedIn: 'root' +}) +export class AdminAuthService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/api/admin/auth`; + + login(password: string): Observable { + return this.http.post(`${this.baseUrl}/login`, { password }, { withCredentials: true }).pipe( + map((response) => Boolean(response?.authenticated)) + ); + } + + logout(): Observable { + return this.http.post(`${this.baseUrl}/logout`, {}, { withCredentials: true }); + } + + me(): Observable { + return this.http.get(`${this.baseUrl}/me`, { withCredentials: true }).pipe( + map((response) => Boolean(response?.authenticated)) + ); + } +} diff --git a/frontend/src/app/features/admin/services/admin-orders.service.ts b/frontend/src/app/features/admin/services/admin-orders.service.ts new file mode 100644 index 0000000..e9d163d --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-orders.service.ts @@ -0,0 +1,48 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; + +export interface AdminOrderItem { + id: string; + originalFilename: string; + materialCode: string; + colorCode: string; + quantity: number; + printTimeSeconds: number; + materialGrams: number; + unitPriceChf: number; + lineTotalChf: number; +} + +export interface AdminOrder { + id: string; + orderNumber: string; + status: string; + paymentStatus?: string; + paymentMethod?: string; + customerEmail: string; + totalChf: number; + createdAt: string; + items: AdminOrderItem[]; +} + +@Injectable({ + providedIn: 'root' +}) +export class AdminOrdersService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/api/admin/orders`; + + listOrders(): Observable { + return this.http.get(this.baseUrl, { withCredentials: true }); + } + + getOrder(orderId: string): Observable { + return this.http.get(`${this.baseUrl}/${orderId}`, { withCredentials: true }); + } + + confirmPayment(orderId: string): Observable { + return this.http.post(`${this.baseUrl}/${orderId}/payments/confirm`, {}, { withCredentials: true }); + } +} diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 975c035..bb86e5c 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -152,8 +152,6 @@ export class QuoteEstimatorService { getOptions(): Observable { console.log('QuoteEstimatorService: Requesting options...'); const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.get(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe( tap({ next: (res) => console.log('QuoteEstimatorService: Options loaded', res), @@ -166,43 +164,31 @@ export class QuoteEstimatorService { getQuoteSession(sessionId: string): Observable { const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }); } updateLineItem(lineItemId: string, changes: any): Observable { const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers }); } createOrder(sessionId: string, orderDetails: any): Observable { const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers }); } getOrder(orderId: string): Observable { const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers }); } reportPayment(orderId: string, method: string): Observable { const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.post(`${environment.apiUrl}/api/orders/${orderId}/payments/report`, { method }, { headers }); } getOrderInvoice(orderId: string): Observable { const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, { headers, responseType: 'blob' @@ -211,8 +197,6 @@ export class QuoteEstimatorService { getOrderConfirmation(orderId: string): Observable { const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, { headers, responseType: 'blob' @@ -221,8 +205,6 @@ export class QuoteEstimatorService { getTwintPayment(orderId: string): Observable { const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers }); } @@ -236,8 +218,6 @@ export class QuoteEstimatorService { return new Observable(observer => { // 1. Create Session first const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); this.http.post(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({ next: (sessionRes) => { @@ -347,8 +327,6 @@ export class QuoteEstimatorService { // Session File Retrieval getLineItemContent(sessionId: string, lineItemId: string): Observable { const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`, { headers, responseType: 'blob' diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index e1801b7..e76a9fe 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -1,5 +1,4 @@ export const environment = { production: true, - apiUrl: '', - basicAuth: '' + apiUrl: '' }; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 48dc8e5..837417a 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -1,5 +1,4 @@ export const environment = { production: false, - apiUrl: 'http://localhost:8000', - basicAuth: 'fab:0presura' // Format: 'username:password' + apiUrl: 'http://localhost:8000' };