diff --git a/.github/workflows/buildtest.yaml b/.github/workflows/buildtest.yaml
index 1ef3db8..e29f5af 100644
--- a/.github/workflows/buildtest.yaml
+++ b/.github/workflows/buildtest.yaml
@@ -1,12 +1,11 @@
name: Build and Test Admin Service
on:
- push:
- branches:
- - '**'
pull_request:
branches:
- - '**'
+ - main
+ - dev
+ - devOps
jobs:
build-test:
diff --git a/admin-service/pom.xml b/admin-service/pom.xml
index eaa958c..60ba627 100644
--- a/admin-service/pom.xml
+++ b/admin-service/pom.xml
@@ -46,6 +46,10 @@
org.springframework.boot
spring-boot-starter-security
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-resource-server
+
org.springframework.boot
spring-boot-starter-validation
@@ -54,6 +58,29 @@
org.springframework.boot
spring-boot-starter-web
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
org.springframework.boot
diff --git a/admin-service/src/main/java/com/techtorque/admin_service/config/JwtAuthenticationFilter.java b/admin-service/src/main/java/com/techtorque/admin_service/config/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..592e152
--- /dev/null
+++ b/admin-service/src/main/java/com/techtorque/admin_service/config/JwtAuthenticationFilter.java
@@ -0,0 +1,82 @@
+package com.techtorque.admin_service.config;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import javax.crypto.SecretKey;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Component
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ @Value("${jwt.secret:mysecretkey}")
+ private String jwtSecret;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+
+ String authHeader = request.getHeader("Authorization");
+
+ if (authHeader != null && authHeader.startsWith("Bearer ")) {
+ String token = authHeader.substring(7);
+
+ try {
+ SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
+
+ Claims claims = Jwts.parser()
+ .verifyWith(key)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+
+ String username = claims.getSubject();
+ @SuppressWarnings("unchecked")
+ List roles = (List) claims.get("roles");
+
+ if (username != null && roles != null) {
+ List authorities = roles.stream()
+ .map(role -> {
+ String roleUpper = role.trim().toUpperCase();
+ // Treat SUPER_ADMIN as ADMIN for authorization purposes
+ if ("SUPER_ADMIN".equals(roleUpper)) {
+ // Add both SUPER_ADMIN and ADMIN roles
+ return Arrays.asList(
+ new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"),
+ new SimpleGrantedAuthority("ROLE_ADMIN")
+ );
+ }
+ return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + roleUpper));
+ })
+ .flatMap(List::stream)
+ .collect(Collectors.toList());
+
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(username, null, authorities);
+
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+ } catch (Exception e) {
+ logger.warn("JWT token validation failed: " + e.getMessage());
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/admin-service/src/main/java/com/techtorque/admin_service/config/SecurityConfig.java b/admin-service/src/main/java/com/techtorque/admin_service/config/SecurityConfig.java
index 7339bc9..c3c3706 100644
--- a/admin-service/src/main/java/com/techtorque/admin_service/config/SecurityConfig.java
+++ b/admin-service/src/main/java/com/techtorque/admin_service/config/SecurityConfig.java
@@ -1,5 +1,6 @@
package com.techtorque.admin_service.config;
+import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@@ -12,8 +13,11 @@
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
+@RequiredArgsConstructor
public class SecurityConfig {
+ private final JwtAuthenticationFilter jwtAuthenticationFilter;
+
// A more comprehensive whitelist for Swagger/OpenAPI, based on the auth-service config.
private static final String[] SWAGGER_WHITELIST = {
"/v3/api-docs/**",
@@ -24,6 +28,11 @@ public class SecurityConfig {
"/api-docs/**"
};
+ // Public endpoints accessible by all authenticated users (including CUSTOMER role)
+ private static final String[] PUBLIC_ENDPOINTS = {
+ "/public/**"
+ };
+
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
@@ -42,12 +51,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// Permit all requests to the Swagger UI and API docs paths
.requestMatchers(SWAGGER_WHITELIST).permitAll()
+ // Allow authenticated users to access public endpoints
+ .requestMatchers(PUBLIC_ENDPOINTS).authenticated()
+
// All other requests must be authenticated
.anyRequest().authenticated()
)
- // Add our custom filter to read headers from the Gateway
- .addFilterBefore(new GatewayHeaderFilter(), UsernamePasswordAuthenticationFilter.class);
+ // Add JWT filter first (for direct service-to-service calls with JWT)
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
+
+ // Add our custom filter to read headers from the Gateway (for gateway-routed calls)
+ .addFilterAfter(new GatewayHeaderFilter(), JwtAuthenticationFilter.class);
return http.build();
}
diff --git a/admin-service/src/main/java/com/techtorque/admin_service/config/WebClientConfig.java b/admin-service/src/main/java/com/techtorque/admin_service/config/WebClientConfig.java
index 531fc62..d1726fa 100644
--- a/admin-service/src/main/java/com/techtorque/admin_service/config/WebClientConfig.java
+++ b/admin-service/src/main/java/com/techtorque/admin_service/config/WebClientConfig.java
@@ -5,7 +5,12 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
+import reactor.core.publisher.Mono;
+import jakarta.servlet.http.HttpServletRequest;
/**
* Configuration for WebClient to communicate with other microservices
@@ -31,12 +36,35 @@ public class WebClientConfig {
@Value("${services.vehicle.url:http://localhost:8082}")
private String vehicleServiceUrl;
+ /**
+ * Exchange filter function to propagate JWT token from incoming request to outgoing WebClient calls
+ */
+ private ExchangeFilterFunction jwtTokenPropagationFilter() {
+ return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
+ ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ if (attributes != null) {
+ HttpServletRequest request = attributes.getRequest();
+ String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (authHeader != null && authHeader.startsWith("Bearer ")) {
+ return Mono.just(
+ org.springframework.web.reactive.function.client.ClientRequest
+ .from(clientRequest)
+ .header(HttpHeaders.AUTHORIZATION, authHeader)
+ .build()
+ );
+ }
+ }
+ return Mono.just(clientRequest);
+ });
+ }
+
@Bean(name = "authServiceWebClient")
public WebClient authServiceWebClient(WebClient.Builder webClientBuilder) {
return webClientBuilder
.baseUrl(authServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .filter(jwtTokenPropagationFilter())
.build();
}
@@ -46,6 +74,7 @@ public WebClient paymentServiceWebClient(WebClient.Builder webClientBuilder) {
.baseUrl(paymentServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .filter(jwtTokenPropagationFilter())
.build();
}
@@ -55,6 +84,7 @@ public WebClient appointmentServiceWebClient(WebClient.Builder webClientBuilder)
.baseUrl(appointmentServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .filter(jwtTokenPropagationFilter())
.build();
}
@@ -64,6 +94,7 @@ public WebClient projectServiceWebClient(WebClient.Builder webClientBuilder) {
.baseUrl(projectServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .filter(jwtTokenPropagationFilter())
.build();
}
@@ -73,6 +104,7 @@ public WebClient timeLoggingServiceWebClient(WebClient.Builder webClientBuilder)
.baseUrl(timeLoggingServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .filter(jwtTokenPropagationFilter())
.build();
}
@@ -82,6 +114,7 @@ public WebClient vehicleServiceWebClient(WebClient.Builder webClientBuilder) {
.baseUrl(vehicleServiceUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .filter(jwtTokenPropagationFilter())
.build();
}
}
diff --git a/admin-service/src/main/java/com/techtorque/admin_service/controller/PublicServiceTypeController.java b/admin-service/src/main/java/com/techtorque/admin_service/controller/PublicServiceTypeController.java
new file mode 100644
index 0000000..2f0796f
--- /dev/null
+++ b/admin-service/src/main/java/com/techtorque/admin_service/controller/PublicServiceTypeController.java
@@ -0,0 +1,43 @@
+package com.techtorque.admin_service.controller;
+
+import com.techtorque.admin_service.dto.response.ServiceTypeResponse;
+import com.techtorque.admin_service.service.AdminServiceConfigService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * Public controller for service types - accessible by all authenticated users and other microservices
+ * This allows customers and other services to read active service types without admin privileges
+ */
+@RestController
+@RequestMapping("/public/service-types")
+@RequiredArgsConstructor
+@Slf4j
+@Tag(name = "Public Service Types", description = "Public API for accessing service types")
+public class PublicServiceTypeController {
+
+ private final AdminServiceConfigService adminServiceConfigService;
+
+ @GetMapping
+ @Operation(summary = "Get all active service types", description = "Retrieve all active service types available to customers")
+ public ResponseEntity> getActiveServiceTypes() {
+ log.info("Public request: Fetching all active service types");
+ List serviceTypes = adminServiceConfigService.getAllServiceTypes(true); // activeOnly = true
+ log.info("Retrieved {} active service types for public access", serviceTypes.size());
+ return ResponseEntity.ok(serviceTypes);
+ }
+
+ @GetMapping("/{id}")
+ @Operation(summary = "Get service type by ID", description = "Retrieve a specific service type by its ID")
+ public ResponseEntity getServiceTypeById(@PathVariable String id) {
+ log.info("Public request: Fetching service type with ID: {}", id);
+ ServiceTypeResponse serviceType = adminServiceConfigService.getServiceTypeById(id);
+ return ResponseEntity.ok(serviceType);
+ }
+}
diff --git a/admin-service/src/main/java/com/techtorque/admin_service/dto/request/UpdateUserRequest.java b/admin-service/src/main/java/com/techtorque/admin_service/dto/request/UpdateUserRequest.java
index dd2584e..1f42e91 100644
--- a/admin-service/src/main/java/com/techtorque/admin_service/dto/request/UpdateUserRequest.java
+++ b/admin-service/src/main/java/com/techtorque/admin_service/dto/request/UpdateUserRequest.java
@@ -24,11 +24,24 @@ public class UpdateUserRequest {
@Pattern(regexp = "ADMIN|EMPLOYEE|CUSTOMER", message = "Role must be ADMIN, EMPLOYEE, or CUSTOMER")
private String role;
+ private List roles;
+
private Boolean active;
+
+ // Alternative field name for activation status (frontend compatibility)
+ private Boolean enabled;
@Size(max = 20, message = "Maximum 20 permissions allowed")
private List permissions;
@Size(max = 100)
private String department;
+
+ /**
+ * Get the activation status, checking both 'active' and 'enabled' fields
+ */
+ public Boolean getActivationStatus() {
+ // Prioritize 'enabled' if set, otherwise use 'active'
+ return enabled != null ? enabled : active;
+ }
}
\ No newline at end of file
diff --git a/admin-service/src/main/java/com/techtorque/admin_service/dto/response/ServiceTypeResponse.java b/admin-service/src/main/java/com/techtorque/admin_service/dto/response/ServiceTypeResponse.java
index 16bf72f..5c00f2c 100644
--- a/admin-service/src/main/java/com/techtorque/admin_service/dto/response/ServiceTypeResponse.java
+++ b/admin-service/src/main/java/com/techtorque/admin_service/dto/response/ServiceTypeResponse.java
@@ -20,8 +20,8 @@ public class ServiceTypeResponse {
private String name;
private String description;
private String category;
- private BigDecimal price;
- private Integer durationMinutes;
+ private BigDecimal basePriceLKR; // Changed from 'price' to match frontend
+ private Integer estimatedDurationMinutes; // Changed from 'durationMinutes' to match frontend
private Boolean active;
private Boolean requiresApproval;
private Integer dailyCapacity;
diff --git a/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminServiceConfigServiceImpl.java b/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminServiceConfigServiceImpl.java
index 48a96f5..894b10b 100644
--- a/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminServiceConfigServiceImpl.java
+++ b/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminServiceConfigServiceImpl.java
@@ -121,9 +121,8 @@ public void deleteServiceType(String id, String deletedBy) {
ServiceType serviceType = serviceTypeRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Service type not found: " + id));
- // Soft delete
- serviceType.setActive(false);
- serviceTypeRepository.save(serviceType);
+ // Hard delete - actually remove from database
+ serviceTypeRepository.delete(serviceType);
log.info("Service type deleted successfully: {}", id);
}
@@ -134,8 +133,8 @@ private ServiceTypeResponse convertToResponse(ServiceType serviceType) {
.name(serviceType.getName())
.description(serviceType.getDescription())
.category(serviceType.getCategory())
- .price(serviceType.getPrice())
- .durationMinutes(serviceType.getDefaultDurationMinutes())
+ .basePriceLKR(serviceType.getPrice())
+ .estimatedDurationMinutes(serviceType.getDefaultDurationMinutes())
.active(serviceType.getActive())
.requiresApproval(serviceType.getRequiresApproval())
.dailyCapacity(serviceType.getDailyCapacity())
diff --git a/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminUserServiceImpl.java b/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminUserServiceImpl.java
index 1090b0b..23819d1 100644
--- a/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminUserServiceImpl.java
+++ b/admin-service/src/main/java/com/techtorque/admin_service/service/impl/AdminUserServiceImpl.java
@@ -75,23 +75,21 @@ public List getAllUsers(String role, Boolean active, int page, int
@Override
public UserResponse getUserById(String userId) {
- log.info("Fetching user: {} from auth service", userId);
+ log.info("Fetching user by ID: {} from auth service", userId);
try {
- UserResponse user = authServiceWebClient.get()
- .uri("/users/" + userId)
- .retrieve()
- .bodyToMono(UserResponse.class)
- .block();
-
- if (user == null) {
- throw new RuntimeException("User not found: " + userId);
- }
+ // Auth service endpoints use username, not userId
+ // We need to first get all users and find the one with matching ID
+ List allUsers = getAllUsers(null, null, 0, 1000);
- // Convert id to userId if needed
- if (user.getUserId() == null && user.getId() != null) {
- user.setUserId(String.valueOf(user.getId()));
- }
+ UserResponse user = allUsers.stream()
+ .filter(u -> {
+ String userIdStr = u.getUserId() != null ? u.getUserId() : String.valueOf(u.getId());
+ return userIdStr.equals(userId);
+ })
+ .findFirst()
+ .orElseThrow(() -> new RuntimeException("User not found with ID: " + userId));
+ log.info("Found user: {} with username: {}", userId, user.getUsername());
return user;
} catch (Exception e) {
log.error("Error fetching user: {}", userId, e);
@@ -139,14 +137,105 @@ public UserResponse createAdmin(CreateEmployeeRequest request) {
public UserResponse updateUser(String userId, UpdateUserRequest request) {
log.info("Updating user: {} via auth service", userId);
try {
- UserResponse response = authServiceWebClient.put()
- .uri("/users/" + userId)
- .bodyValue(request)
- .retrieve()
- .bodyToMono(UserResponse.class)
- .block();
-
- return response;
+ // First, get the current user details to obtain the username
+ UserResponse currentUser = getUserById(userId);
+ String username = currentUser.getUsername();
+
+ // Handle role updates separately via the roles endpoint
+ if (request.getRoles() != null || request.getRole() != null) {
+ // Get current roles
+ List currentRoles = currentUser.getRoles() != null ? currentUser.getRoles() : new java.util.ArrayList<>();
+ List newRoles = new java.util.ArrayList<>();
+
+ // Build the new role list - prioritize roles array over single role
+ if (request.getRoles() != null && !request.getRoles().isEmpty()) {
+ newRoles.addAll(request.getRoles());
+ } else if (request.getRole() != null) {
+ newRoles.add(request.getRole());
+ }
+
+ // Always preserve CUSTOMER role if it exists
+ if (currentRoles.contains("CUSTOMER")) {
+ if (!newRoles.contains("CUSTOMER")) {
+ newRoles.add("CUSTOMER");
+ }
+ }
+
+ // Always preserve SUPER_ADMIN role if it exists (cannot be removed via this endpoint)
+ if (currentRoles.contains("SUPER_ADMIN")) {
+ if (!newRoles.contains("SUPER_ADMIN")) {
+ newRoles.add("SUPER_ADMIN");
+ }
+ }
+
+ // Determine which roles to add and which to remove
+ List rolesToAdd = new java.util.ArrayList<>();
+ List rolesToRemove = new java.util.ArrayList<>();
+
+ // Find roles to add
+ for (String role : newRoles) {
+ if (!currentRoles.contains(role)) {
+ rolesToAdd.add(role);
+ }
+ }
+
+ // Find roles to remove (only remove EMPLOYEE and ADMIN, never CUSTOMER or SUPER_ADMIN)
+ for (String role : currentRoles) {
+ if (!newRoles.contains(role) && (role.equals("EMPLOYEE") || role.equals("ADMIN"))) {
+ rolesToRemove.add(role);
+ }
+ }
+
+ // Apply role changes
+ for (String roleToAdd : rolesToAdd) {
+ log.info("Assigning role {} to user {}", roleToAdd, username);
+ java.util.Map roleRequest = new java.util.HashMap<>();
+ roleRequest.put("roleName", roleToAdd);
+ roleRequest.put("action", "ASSIGN");
+
+ authServiceWebClient.post()
+ .uri("/users/" + username + "/roles")
+ .bodyValue(roleRequest)
+ .retrieve()
+ .bodyToMono(Void.class)
+ .block();
+ }
+
+ for (String roleToRemove : rolesToRemove) {
+ log.info("Revoking role {} from user {}", roleToRemove, username);
+ java.util.Map roleRequest = new java.util.HashMap<>();
+ roleRequest.put("roleName", roleToRemove);
+ roleRequest.put("action", "REVOKE");
+
+ authServiceWebClient.post()
+ .uri("/users/" + username + "/roles")
+ .bodyValue(roleRequest)
+ .retrieve()
+ .bodyToMono(Void.class)
+ .block();
+ }
+ }
+
+ // Handle other updates (active status, department, etc.)
+ Boolean activationStatus = request.getActivationStatus();
+ if (activationStatus != null || request.getDepartment() != null) {
+ java.util.Map updateRequest = new java.util.HashMap<>();
+ if (activationStatus != null) {
+ updateRequest.put("enabled", activationStatus);
+ }
+
+ if (updateRequest.size() > 0) {
+ authServiceWebClient.put()
+ .uri("/users/" + username)
+ .bodyValue(updateRequest)
+ .retrieve()
+ .bodyToMono(Void.class)
+ .block();
+ }
+ }
+
+ // Return the updated user
+ return getUserById(userId);
} catch (Exception e) {
log.error("Error updating user: {}", userId, e);
throw new RuntimeException("Failed to update user: " + e.getMessage());
diff --git a/admin-service/src/main/resources/application.properties b/admin-service/src/main/resources/application.properties
index a26b0dc..58c025c 100644
--- a/admin-service/src/main/resources/application.properties
+++ b/admin-service/src/main/resources/application.properties
@@ -17,6 +17,9 @@ spring.jpa.properties.hibernate.format_sql=true
# Development/Production Profile
spring.profiles.active=${SPRING_PROFILE:dev}
+# JWT Configuration (must match Auth Service secret)
+jwt.secret=${JWT_SECRET:YourSuperSecretKeyForJWTGoesHereAndItMustBeVeryLongForSecurityPurposes}
+
# OpenAPI access URL
# http://localhost:8087/swagger-ui/index.html