dev #8
@@ -163,9 +163,14 @@ jobs:
|
|||||||
printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \
|
printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \
|
||||||
"${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env
|
"${{ 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)
|
# 5. Debug: print content (for debug purposes)
|
||||||
echo "Preparing to send env file with variables:"
|
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
|
# 5. Send env to server
|
||||||
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
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-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
implementation 'xyz.capybara:clamav-client:2.1.2'
|
implementation 'xyz.capybara:clamav-client:2.1.2'
|
||||||
@@ -31,6 +32,7 @@ dependencies {
|
|||||||
runtimeOnly 'org.postgresql:postgresql'
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testImplementation 'org.springframework.security:spring-security-test'
|
||||||
testRuntimeOnly 'com.h2database:h2'
|
testRuntimeOnly 'com.h2database:h2'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
compileOnly 'org.projectlombok:lombok'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Map<String, Object>> 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<Map<String, Object>> logout(HttpServletResponse response) {
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLogoutCookie().toString());
|
||||||
|
return ResponseEntity.ok(Map.of("authenticated", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<Map<String, Object>> me() {
|
||||||
|
return ResponseEntity.ok(Map.of("authenticated", true));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<OrderDto>> listOrders() {
|
||||||
|
List<OrderDto> response = orderRepo.findAllByOrderByCreatedAtDesc()
|
||||||
|
.stream()
|
||||||
|
.map(this::toOrderDto)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{orderId}")
|
||||||
|
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
|
||||||
|
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{orderId}/payments/confirm")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<OrderDto> confirmPayment(
|
||||||
|
@PathVariable UUID orderId,
|
||||||
|
@RequestBody(required = false) Map<String, String> 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<OrderItem> 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<OrderItemDto> 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package com.printcalculator.repository;
|
|||||||
import com.printcalculator.entity.Order;
|
import com.printcalculator.entity.Order;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface OrderRepository extends JpaRepository<Order, UUID> {
|
public interface OrderRepository extends JpaRepository<Order, UUID> {
|
||||||
}
|
List<Order> findAllByOrderByCreatedAtDesc();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<String> token = adminSessionService.extractTokenFromCookies(request);
|
||||||
|
Optional<AdminSessionService.AdminSessionPayload> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AdminSessionPayload> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
app.mail.enabled=false
|
app.mail.enabled=false
|
||||||
app.mail.admin.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
|
||||||
|
|||||||
@@ -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.enabled=${APP_MAIL_ADMIN_ENABLED:true}
|
||||||
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
|
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
|
||||||
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
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}
|
||||||
|
|||||||
@@ -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] : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,3 +11,6 @@ CLAMAV_HOST=192.168.1.147
|
|||||||
CLAMAV_PORT=3310
|
CLAMAV_PORT=3310
|
||||||
CLAMAV_ENABLED=true
|
CLAMAV_ENABLED=true
|
||||||
APP_FRONTEND_BASE_URL=http://localhost:18082
|
APP_FRONTEND_BASE_URL=http://localhost:18082
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
ADMIN_SESSION_SECRET=
|
||||||
|
ADMIN_SESSION_TTL_MINUTES=480
|
||||||
|
|||||||
@@ -11,3 +11,6 @@ CLAMAV_HOST=192.168.1.147
|
|||||||
CLAMAV_PORT=3310
|
CLAMAV_PORT=3310
|
||||||
CLAMAV_ENABLED=true
|
CLAMAV_ENABLED=true
|
||||||
APP_FRONTEND_BASE_URL=http://localhost:18081
|
APP_FRONTEND_BASE_URL=http://localhost:18081
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
ADMIN_SESSION_SECRET=
|
||||||
|
ADMIN_SESSION_TTL_MINUTES=480
|
||||||
|
|||||||
@@ -11,3 +11,6 @@ CLAMAV_HOST=192.168.1.147
|
|||||||
CLAMAV_PORT=3310
|
CLAMAV_PORT=3310
|
||||||
CLAMAV_ENABLED=true
|
CLAMAV_ENABLED=true
|
||||||
APP_FRONTEND_BASE_URL=https://3d-fab.ch
|
APP_FRONTEND_BASE_URL=https://3d-fab.ch
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
ADMIN_SESSION_SECRET=
|
||||||
|
ADMIN_SESSION_TTL_MINUTES=480
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ services:
|
|||||||
- APP_MAIL_ADMIN_ENABLED=${APP_MAIL_ADMIN_ENABLED:-true}
|
- APP_MAIL_ADMIN_ENABLED=${APP_MAIL_ADMIN_ENABLED:-true}
|
||||||
- APP_MAIL_ADMIN_ADDRESS=${APP_MAIL_ADMIN_ADDRESS:-info@3d-fab.ch}
|
- APP_MAIL_ADMIN_ADDRESS=${APP_MAIL_ADMIN_ADDRESS:-info@3d-fab.ch}
|
||||||
- APP_FRONTEND_BASE_URL=${APP_FRONTEND_BASE_URL:-http://localhost:4200}
|
- 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
|
- TEMP_DIR=/app/temp
|
||||||
- PROFILES_DIR=/app/profiles
|
- PROFILES_DIR=/app/profiles
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
|
import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
|
||||||
import { provideRouter, withComponentInputBinding, withInMemoryScrolling, withViewTransitions } from '@angular/router';
|
import { provideRouter, withComponentInputBinding, withInMemoryScrolling, withViewTransitions } from '@angular/router';
|
||||||
import { routes } from './app.routes';
|
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 { TranslateModule, TranslateLoader } from '@ngx-translate/core';
|
||||||
import { provideTranslateHttpLoader, TranslateHttpLoader } from '@ngx-translate/http-loader';
|
import { provideTranslateHttpLoader, TranslateHttpLoader } from '@ngx-translate/http-loader';
|
||||||
|
import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -16,7 +17,9 @@ export const appConfig: ApplicationConfig = {
|
|||||||
scrollPositionRestoration: 'top'
|
scrollPositionRestoration: 'top'
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
provideHttpClient(),
|
provideHttpClient(
|
||||||
|
withInterceptors([adminAuthInterceptor])
|
||||||
|
),
|
||||||
provideTranslateHttpLoader({
|
provideTranslateHttpLoader({
|
||||||
prefix: './assets/i18n/',
|
prefix: './assets/i18n/',
|
||||||
suffix: '.json'
|
suffix: '.json'
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ const appChildRoutes: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
|
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: '**',
|
path: '**',
|
||||||
redirectTo: ''
|
redirectTo: ''
|
||||||
|
|||||||
37
frontend/src/app/core/interceptors/admin-auth.interceptor.ts
Normal file
37
frontend/src/app/core/interceptors/admin-auth.interceptor.ts
Normal file
@@ -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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
14
frontend/src/app/features/admin/admin.routes.ts
Normal file
14
frontend/src/app/features/admin/admin.routes.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
];
|
||||||
41
frontend/src/app/features/admin/guards/admin-auth.guard.ts
Normal file
41
frontend/src/app/features/admin/guards/admin-auth.guard.ts
Normal file
@@ -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<boolean | UrlTree> => {
|
||||||
|
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 }
|
||||||
|
})
|
||||||
|
))
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<section class="admin-dashboard">
|
||||||
|
<header class="dashboard-header">
|
||||||
|
<div>
|
||||||
|
<h1>Back-office ordini</h1>
|
||||||
|
<p>Gestione pagamenti e dettaglio ordini</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" (click)="loadOrders()" [disabled]="loading">Aggiorna</button>
|
||||||
|
<button type="button" class="ghost" (click)="logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ordine</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th>Pagamento</th>
|
||||||
|
<th>Totale</th>
|
||||||
|
<th>Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let order of orders" [class.selected]="selectedOrder?.id === order.id">
|
||||||
|
<td>{{ order.orderNumber }}</td>
|
||||||
|
<td>{{ order.customerEmail }}</td>
|
||||||
|
<td>{{ order.status }}</td>
|
||||||
|
<td>{{ order.paymentStatus || 'PENDING' }}</td>
|
||||||
|
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button type="button" class="ghost" (click)="openDetails(order.id)">Dettaglio</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="confirmPayment(order.id)"
|
||||||
|
[disabled]="confirmingOrderId === order.id || order.paymentStatus === 'COMPLETED'"
|
||||||
|
>
|
||||||
|
{{ confirmingOrderId === order.id ? 'Invio...' : 'Conferma pagamento' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="details" *ngIf="selectedOrder">
|
||||||
|
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
|
||||||
|
<p *ngIf="detailLoading">Caricamento dettaglio...</p>
|
||||||
|
<p><strong>Cliente:</strong> {{ selectedOrder.customerEmail }}</p>
|
||||||
|
<p><strong>Pagamento:</strong> {{ selectedOrder.paymentStatus || 'PENDING' }}</p>
|
||||||
|
|
||||||
|
<div class="items">
|
||||||
|
<div class="item" *ngFor="let item of selectedOrder.items">
|
||||||
|
<p><strong>File:</strong> {{ item.originalFilename }}</p>
|
||||||
|
<p><strong>Qta:</strong> {{ item.quantity }}</p>
|
||||||
|
<p><strong>Prezzo riga:</strong> {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ng-template #loadingTpl>
|
||||||
|
<p>Caricamento ordini...</p>
|
||||||
|
</ng-template>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<section class="admin-login-page">
|
||||||
|
<div class="admin-login-card">
|
||||||
|
<h1>Back-office</h1>
|
||||||
|
<p>Inserisci la password condivisa.</p>
|
||||||
|
|
||||||
|
<form (ngSubmit)="submit()">
|
||||||
|
<label for="admin-password">Password</label>
|
||||||
|
<input
|
||||||
|
id="admin-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="password"
|
||||||
|
[disabled]="loading"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" [disabled]="loading || !password.trim()">
|
||||||
|
{{ loading ? 'Accesso...' : 'Accedi' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (errorMessage) {
|
||||||
|
<p class="error">{{ errorMessage }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
return this.http.post<AdminAuthResponse>(`${this.baseUrl}/login`, { password }, { withCredentials: true }).pipe(
|
||||||
|
map((response) => Boolean(response?.authenticated))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.baseUrl}/logout`, {}, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
me(): Observable<boolean> {
|
||||||
|
return this.http.get<AdminAuthResponse>(`${this.baseUrl}/me`, { withCredentials: true }).pipe(
|
||||||
|
map((response) => Boolean(response?.authenticated))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AdminOrder[]> {
|
||||||
|
return this.http.get<AdminOrder[]>(this.baseUrl, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrder(orderId: string): Observable<AdminOrder> {
|
||||||
|
return this.http.get<AdminOrder>(`${this.baseUrl}/${orderId}`, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmPayment(orderId: string): Observable<AdminOrder> {
|
||||||
|
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/payments/confirm`, {}, { withCredentials: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,8 +152,6 @@ export class QuoteEstimatorService {
|
|||||||
getOptions(): Observable<OptionsResponse> {
|
getOptions(): Observable<OptionsResponse> {
|
||||||
console.log('QuoteEstimatorService: Requesting options...');
|
console.log('QuoteEstimatorService: Requesting options...');
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
// @ts-ignore
|
|
||||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
|
||||||
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
|
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
|
||||||
tap({
|
tap({
|
||||||
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
|
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
|
||||||
@@ -166,43 +164,31 @@ export class QuoteEstimatorService {
|
|||||||
|
|
||||||
getQuoteSession(sessionId: string): Observable<any> {
|
getQuoteSession(sessionId: string): Observable<any> {
|
||||||
const headers: any = {};
|
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 });
|
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLineItem(lineItemId: string, changes: any): Observable<any> {
|
updateLineItem(lineItemId: string, changes: any): Observable<any> {
|
||||||
const headers: any = {};
|
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 });
|
return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
createOrder(sessionId: string, orderDetails: any): Observable<any> {
|
createOrder(sessionId: string, orderDetails: any): Observable<any> {
|
||||||
const headers: any = {};
|
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 });
|
return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrder(orderId: string): Observable<any> {
|
getOrder(orderId: string): Observable<any> {
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
// @ts-ignore
|
|
||||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
|
||||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
|
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
reportPayment(orderId: string, method: string): Observable<any> {
|
reportPayment(orderId: string, method: string): Observable<any> {
|
||||||
const headers: any = {};
|
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 });
|
return this.http.post(`${environment.apiUrl}/api/orders/${orderId}/payments/report`, { method }, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrderInvoice(orderId: string): Observable<Blob> {
|
getOrderInvoice(orderId: string): Observable<Blob> {
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
// @ts-ignore
|
|
||||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
|
||||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
|
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
|
||||||
headers,
|
headers,
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
@@ -211,8 +197,6 @@ export class QuoteEstimatorService {
|
|||||||
|
|
||||||
getOrderConfirmation(orderId: string): Observable<Blob> {
|
getOrderConfirmation(orderId: string): Observable<Blob> {
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
// @ts-ignore
|
|
||||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
|
||||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, {
|
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, {
|
||||||
headers,
|
headers,
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
@@ -221,8 +205,6 @@ export class QuoteEstimatorService {
|
|||||||
|
|
||||||
getTwintPayment(orderId: string): Observable<any> {
|
getTwintPayment(orderId: string): Observable<any> {
|
||||||
const headers: any = {};
|
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 });
|
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,8 +218,6 @@ export class QuoteEstimatorService {
|
|||||||
return new Observable(observer => {
|
return new Observable(observer => {
|
||||||
// 1. Create Session first
|
// 1. Create Session first
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
// @ts-ignore
|
|
||||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
|
||||||
|
|
||||||
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({
|
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({
|
||||||
next: (sessionRes) => {
|
next: (sessionRes) => {
|
||||||
@@ -347,8 +327,6 @@ export class QuoteEstimatorService {
|
|||||||
// Session File Retrieval
|
// Session File Retrieval
|
||||||
getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> {
|
getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> {
|
||||||
const headers: any = {};
|
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`, {
|
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`, {
|
||||||
headers,
|
headers,
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiUrl: '',
|
apiUrl: ''
|
||||||
basicAuth: ''
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
apiUrl: 'http://localhost:8000',
|
apiUrl: 'http://localhost:8000'
|
||||||
basicAuth: 'fab:0presura' // Format: 'username:password'
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user