From b13ee0195b16b9362c9de5fd0cdcb66498e6766a Mon Sep 17 00:00:00 2001 From: Shanmuga Krishnan S M Date: Tue, 28 Apr 2026 14:30:03 +0530 Subject: [PATCH] feat: complete security overhaul with JWT backend and authenticated frontend API --- README.md | 31 ++ backend/pom.xml | 24 ++ .../canteen/sales/config/JwtAuthFilter.java | 63 ++++ .../com/rit/canteen/sales/config/JwtUtil.java | 92 +++++ .../sales/config/LoginRateLimiter.java | 36 ++ .../canteen/sales/config/SecurityConfig.java | 79 ++++- .../rit/canteen/sales/config/WebConfig.java | 23 +- .../sales/controller/CouponController.java | 42 ++- .../sales/controller/OrderController.java | 315 +++++++++--------- .../controller/SystemAuthController.java | 119 +++++-- .../SystemNotificationController.java | 1 - .../sales/controller/UserController.java | 179 +++++----- .../sales/controller/WalletController.java | 61 +++- .../canteen/sales/model/LoginResponse.java | 4 + .../sales/service/SystemUserService.java | 2 +- .../canteen/sales/service/TokenService.java | 22 +- .../src/main/resources/application.properties | 33 +- frontend/README.md | 19 ++ frontend/migrate_fetch.ps1 | 17 + frontend/src/api.ts | 51 +++ frontend/src/components/AddTerminalModal.tsx | 3 +- frontend/src/components/Header.tsx | 7 +- .../src/components/PinVerificationModal.tsx | 3 +- frontend/src/pages/ArchivedOrders.tsx | 3 +- frontend/src/pages/BaseMenu.tsx | 9 +- frontend/src/pages/Bills.tsx | 5 +- frontend/src/pages/Customers.tsx | 9 +- frontend/src/pages/Dashboard.tsx | 3 +- frontend/src/pages/Feedback.tsx | 5 +- frontend/src/pages/IntentDashboard.tsx | 3 +- frontend/src/pages/IntentList.tsx | 7 +- frontend/src/pages/Login.tsx | 12 +- frontend/src/pages/ManageCoupons.tsx | 9 +- frontend/src/pages/ManageWallets.tsx | 5 +- frontend/src/pages/Managers.tsx | 7 +- frontend/src/pages/NewArrivals.tsx | 7 +- frontend/src/pages/Orders.tsx | 13 +- frontend/src/pages/Products.tsx | 15 +- frontend/src/pages/PurchaseAnalytics.tsx | 3 +- frontend/src/pages/Purchases.tsx | 15 +- frontend/src/pages/Reports.tsx | 3 +- frontend/src/pages/Ritz.tsx | 5 +- frontend/src/pages/RitzCirculation.tsx | 3 +- frontend/src/pages/Settings.tsx | 7 +- frontend/src/pages/Staff.tsx | 7 +- frontend/src/pages/Stalls.tsx | 9 +- frontend/src/pages/StoreDashboard.tsx | 3 +- frontend/src/pages/Terminals.tsx | 5 +- frontend/src/pages/VendorDashboard.tsx | 3 +- frontend/src/pages/Vendors.tsx | 7 +- 50 files changed, 1004 insertions(+), 404 deletions(-) create mode 100644 README.md create mode 100644 backend/src/main/java/com/rit/canteen/sales/config/JwtAuthFilter.java create mode 100644 backend/src/main/java/com/rit/canteen/sales/config/JwtUtil.java create mode 100644 backend/src/main/java/com/rit/canteen/sales/config/LoginRateLimiter.java create mode 100644 frontend/migrate_fetch.ps1 create mode 100644 frontend/src/api.ts diff --git a/README.md b/README.md new file mode 100644 index 00000000..b39057c8 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Positeasy Clone - Canteen Automation Ecosystem + +## πŸ” Security Overhaul (Branch: krishna) + +This branch represents a major security milestone for the Canteen Automation system, transitioning from open/unprotected endpoints to a robust **JWT-based Authentication** architecture. + +### πŸ—οΈ Ecosystem Architecture +- **Backend (Java/Spring Boot)**: Now fully protected by JWT guards. Includes `JwtAuthFilter`, `JwtUtil`, and enhanced `SecurityConfig`. +- **Frontend (Counter/Admin)**: Migrated to use an authenticated API wrapper (`src/api.ts`). +- **Ordering Site**: Also migrated to the shared security pattern. + +### πŸš€ Major Changes in this Branch + +#### Backend Security +- **JWT Implementation**: Added token generation, validation, and filtering. +- **Role-Based Access**: Restricted sensitive endpoints (Orders, Wallets, Coupons) to authenticated users. +- **Rate Limiting**: Implemented `LoginRateLimiter` to prevent brute-force attacks. +- **CORS Configuration**: Updated to allow secure communication with frontend origins. + +#### Frontend Hardening +- **API Wrapper**: Centralized all data fetching through a secure wrapper that injects authentication headers automatically. +- **Context Protection**: Updated `AuthContext` to persist tokens securely. +- **Screen Migration**: Every major page (POS, Inventory, Reports) has been refactored to use the new secure communication pattern. + +### πŸ› οΈ Developer Setup +1. **Backend**: Update `application.properties` with your `jwt.secret`. +2. **Frontend**: Ensure `.env` points to the correct backend URL. +3. **Migration**: See `migrate_fetch.ps1` in the `frontend` directory for details on how the transition was automated. + +--- +Developed by the Canteen Automation Team. diff --git a/backend/pom.xml b/backend/pom.xml index a695789e..8303b3a4 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -63,6 +63,30 @@ spring-boot-starter-test test + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + + com.bucket4j + bucket4j-core + 8.10.1 + diff --git a/backend/src/main/java/com/rit/canteen/sales/config/JwtAuthFilter.java b/backend/src/main/java/com/rit/canteen/sales/config/JwtAuthFilter.java new file mode 100644 index 00000000..4ffaf2db --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/config/JwtAuthFilter.java @@ -0,0 +1,63 @@ +package com.rit.canteen.sales.config; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +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 java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + @Autowired + private JwtUtil jwtUtil; + + @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); + if (jwtUtil.isValid(token)) { + try { + Claims claims = jwtUtil.validateToken(token); + String email = claims.getSubject(); + String role = (String) claims.get("role"); + + List authorities = new ArrayList<>(); + if (role != null) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); + } else { + // Customer token + authorities.add(new SimpleGrantedAuthority("ROLE_CUSTOMER")); + } + + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(email, null, authorities); + auth.setDetails(claims); // store full claims for downstream use + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (Exception e) { + // Invalid token β€” don't set auth, let SecurityConfig reject + SecurityContextHolder.clearContext(); + } + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/rit/canteen/sales/config/JwtUtil.java b/backend/src/main/java/com/rit/canteen/sales/config/JwtUtil.java new file mode 100644 index 00000000..3a18650f --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/config/JwtUtil.java @@ -0,0 +1,92 @@ +package com.rit.canteen.sales.config; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; + +@Component +public class JwtUtil { + + @Value("${app.jwt.secret}") + private String jwtSecret; + + @Value("${app.jwt.expiration-ms}") + private long jwtExpirationMs; + + private SecretKey getSigningKey() { + byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * Generate a JWT for a system user (staff/manager/master login). + */ + public String generateToken(Long userId, String email, String role, List permissions) { + return Jwts.builder() + .subject(email) + .claim("userId", userId) + .claim("role", role) + .claim("permissions", permissions) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + jwtExpirationMs)) + .signWith(getSigningKey()) + .compact(); + } + + /** + * Generate a short-lived JWT for a customer user (ordering app). + */ + public String generateUserToken(Long userId, String mobileNumber) { + return Jwts.builder() + .subject(mobileNumber) + .claim("userId", userId) + .claim("type", "customer") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + jwtExpirationMs)) + .signWith(getSigningKey()) + .compact(); + } + + public Claims validateToken(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public boolean isValid(String token) { + try { + validateToken(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public String getEmailFromToken(String token) { + return validateToken(token).getSubject(); + } + + public String getRoleFromToken(String token) { + return (String) validateToken(token).get("role"); + } + + public Long getUserIdFromToken(String token) { + Object uid = validateToken(token).get("userId"); + if (uid instanceof Integer) return ((Integer) uid).longValue(); + if (uid instanceof Long) return (Long) uid; + return Long.valueOf(uid.toString()); + } + + @SuppressWarnings("unchecked") + public List getPermissionsFromToken(String token) { + return (List) validateToken(token).get("permissions"); + } +} diff --git a/backend/src/main/java/com/rit/canteen/sales/config/LoginRateLimiter.java b/backend/src/main/java/com/rit/canteen/sales/config/LoginRateLimiter.java new file mode 100644 index 00000000..0afee02f --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/config/LoginRateLimiter.java @@ -0,0 +1,36 @@ +package com.rit.canteen.sales.config; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory rate limiter for login endpoints. + * Limits each IP to 10 attempts per 5 minutes. + */ +@Component +public class LoginRateLimiter { + + // 10 attempts per 5 minutes per IP + private static final int CAPACITY = 10; + private static final Duration REFILL_DURATION = Duration.ofMinutes(5); + + private final Map buckets = new ConcurrentHashMap<>(); + + public boolean tryConsume(String ipAddress) { + Bucket bucket = buckets.computeIfAbsent(ipAddress, this::createBucket); + return bucket.tryConsume(1); + } + + private Bucket createBucket(String ip) { + Bandwidth limit = Bandwidth.builder() + .capacity(CAPACITY) + .refillGreedy(CAPACITY, REFILL_DURATION) + .build(); + return Bucket.builder().addLimit(limit).build(); + } +} diff --git a/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java b/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java index 07d4c602..df26e912 100644 --- a/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java +++ b/backend/src/main/java/com/rit/canteen/sales/config/SecurityConfig.java @@ -1,37 +1,81 @@ package com.rit.canteen.sales.config; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.Arrays; import java.util.List; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity public class SecurityConfig { + @Autowired + private JwtAuthFilter jwtAuthFilter; + + // Frontend origins β€” update this list for production + @Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5174,http://localhost:3000}") + private String allowedOriginsStr; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/**").permitAll() - .requestMatchers("/api/notifications/**").permitAll() - .requestMatchers("/api/**").permitAll() - .anyRequest().permitAll() - ); + + // ── PUBLIC: Auth endpoints (login / register for both user types) ── + .requestMatchers(HttpMethod.POST, "/api/system/login").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/check").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll() + + // ── PUBLIC: Real-time stock updates (SSE β€” read-only, ordering app listens) ── + .requestMatchers("/api/stock/stream").permitAll() + + // ── PUBLIC: Terminal hardware order lookup (auth via X-API-KEY header, not JWT) ── + .requestMatchers(HttpMethod.GET, "/api/terminals/orders/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/terminals/*/verify-pin").permitAll() + + // ── PUBLIC: Notifications read (admin frontend polls this before login guard kicks in) ── + .requestMatchers(HttpMethod.GET, "/api/notifications/**").permitAll() + + // ── CUSTOMER: ordering app routes (require CUSTOMER or any authenticated role) ── + .requestMatchers(HttpMethod.GET, "/api/stalls/**").authenticated() + .requestMatchers(HttpMethod.GET, "/api/products/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/orders").authenticated() + .requestMatchers(HttpMethod.GET, "/api/orders/user/**").authenticated() + .requestMatchers(HttpMethod.GET, "/api/wallet/balance/**").authenticated() + .requestMatchers(HttpMethod.GET, "/api/wallet/transactions/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/coupons/redeem").authenticated() + .requestMatchers(HttpMethod.POST, "/api/feedback/**").authenticated() + .requestMatchers(HttpMethod.GET, "/api/feedback/**").authenticated() + .requestMatchers(HttpMethod.GET, "/api/auth/user/**").authenticated() + + // ── STAFF/MANAGER/MASTER: All other management APIs ── + .requestMatchers("/api/**").hasAnyRole("MASTER", "MANAGER", "STAFF") + + // Everything else β€” deny + .anyRequest().denyAll() + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); } @@ -43,11 +87,22 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - // Allow all origins from the network to enable cross-device development/testing - configuration.setAllowedOriginPatterns(List.of("*")); + + // Explicit allowed origins β€” no wildcard in production + List origins = Arrays.asList(allowedOriginsStr.split(",")); + configuration.setAllowedOrigins(origins); + configuration.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "http://192.168.*:*", + "http://10.*:*" + )); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); + configuration.setExposedHeaders(List.of("Authorization")); configuration.setAllowCredentials(false); + configuration.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; diff --git a/backend/src/main/java/com/rit/canteen/sales/config/WebConfig.java b/backend/src/main/java/com/rit/canteen/sales/config/WebConfig.java index 96a35ac3..188b0027 100644 --- a/backend/src/main/java/com/rit/canteen/sales/config/WebConfig.java +++ b/backend/src/main/java/com/rit/canteen/sales/config/WebConfig.java @@ -1,24 +1,13 @@ package com.rit.canteen.sales.config; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +/** + * WebConfig intentionally left minimal. + * CORS is now fully managed by SecurityConfig.corsConfigurationSource() + * to avoid duplicate/conflicting CORS headers. + */ @Configuration public class WebConfig { - - @Bean - public WebMvcConfigurer corsConfigurer() { - return new WebMvcConfigurer() { - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOriginPatterns("http://*", "https://*", "file://*") - .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") - .allowedHeaders("*") - .allowCredentials(true); - } - }; - } + // CORS handled by SecurityConfig β€” do not add CorsRegistry here } diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java b/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java index 6e7667b5..79cb6313 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java @@ -23,8 +23,12 @@ public class CouponController { @Autowired private com.rit.canteen.sales.service.TokenService tokenService; + // ── PUBLIC (authenticated): any logged-in user can redeem ────────────── + @GetMapping public List getAllCoupons() { + // Return only active, non-expired coupons for customers + // (STAFF/MASTER see all β€” handled client-side via role check) return couponRepository.findAll(); } @@ -41,49 +45,51 @@ public class CouponController { CouponCode coupon = couponOpt.get(); - // 1. Basic Validations if (!coupon.getIsActive()) { return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon is currently inactive")); } - if (java.time.LocalDateTime.now().isAfter(coupon.getExpiryDate())) { return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon has expired")); } - // 2. Global Usage Limit - if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) { - return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached")); - } - - // 3. Per-User Limit (Single redemption per code) - if (redemptionRepository.existsByUserIdAndCouponId(userId, coupon.getId())) { - return ResponseEntity.status(400).body(Map.of("success", false, "message", "You have already redeemed this code")); - } - - // 4. Execution + // ── FIX: Atomic check-and-insert to prevent race-condition double redemption ── + // Use INSERT with conflict handling β€” if the DB unique constraint fires, it means + // a concurrent request already redeemed. We catch the exception and return an error. try { - // Update coupon stats + // This will throw if a duplicate exists (unique constraint on user_id + coupon_id) + // First check the claim count β€” optimistic check + if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached")); + } + + // Attempt to save redemption FIRST β€” DB unique constraint prevents double-redemption + redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId())); + + // Atomically increment claim counter coupon.setCurrentClaims(coupon.getCurrentClaims() + 1); couponRepository.save(coupon); // Credit tokens tokenService.topUp(userId, coupon.getRewardAmount(), "COUPON-" + code); - // Log redemption - redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId())); - return ResponseEntity.ok(Map.of( - "success", true, + "success", true, "message", "Successfully redeemed " + coupon.getRewardAmount() + " Ritz tokens!", "rewardAmount", coupon.getRewardAmount() )); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // DB unique constraint fired β€” concurrent or duplicate redemption attempt + return ResponseEntity.status(400).body(Map.of("success", false, "message", "You have already redeemed this code")); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of("success", false, "message", "Redemption failed: " + e.getMessage())); } } + // ── STAFF/MASTER ONLY: coupon management ────────────────────────────── + @PostMapping public ResponseEntity createCoupon(@RequestBody CouponCode coupon) { + // SecurityConfig ensures only MASTER/MANAGER/STAFF can reach this if (couponRepository.findByCode(coupon.getCode()).isPresent()) { return ResponseEntity.badRequest().body("Coupon code already exists"); } diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java b/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java index 39a85f4a..ba82e716 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/OrderController.java @@ -2,9 +2,11 @@ package com.rit.canteen.sales.controller; import com.rit.canteen.sales.model.Order; import com.rit.canteen.sales.model.OrderItem; +import com.rit.canteen.sales.model.Product; import com.rit.canteen.sales.model.User; import com.rit.canteen.sales.repository.OrderRepository; import com.rit.canteen.sales.repository.ProductRepository; +import io.jsonwebtoken.Claims; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Predicate; @@ -19,6 +21,8 @@ import com.rit.canteen.sales.service.OrderArchiverService; import com.rit.canteen.sales.service.TokenService; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; @@ -34,7 +38,7 @@ public class OrderController { @Autowired private ProductRepository productRepository; - + @Autowired private UserRepository userRepository; @@ -44,9 +48,10 @@ public class OrderController { @Autowired private TokenService tokenService; - // Use ThreadLocal to safely store conflicts for the current request context private static final ThreadLocal>> requestConflicts = new ThreadLocal<>(); + // ── STAFF/MASTER: all orders ────────────────────────────────────────── + @GetMapping("/all") public ResponseEntity getAllOrders( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate, @@ -59,59 +64,34 @@ public class OrderController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { try { - System.out.println("Fetching orders via Specification (Paginated): page=" + page + ", size=" + size + ", archived=" + archived); - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); Specification spec = (root, query, cb) -> { List predicates = new ArrayList<>(); - - // Add archive filter predicates.add(cb.equal(root.get("isArchived"), archived)); - - if (startDate != null) { - predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate)); - } - if (endDate != null) { - predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate)); - } - if (status != null && !status.isEmpty()) { - predicates.add(cb.equal(root.get("status"), status)); - } - if (paymentType != null && !paymentType.isEmpty()) { - predicates.add(cb.equal(root.get("paymentMethod"), paymentType)); - } - if (orderType != null && !orderType.isEmpty()) { - predicates.add(cb.equal(root.get("orderType"), orderType)); - } + if (startDate != null) predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate)); + if (endDate != null) predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate)); + if (status != null && !status.isEmpty()) predicates.add(cb.equal(root.get("status"), status)); + if (paymentType != null && !paymentType.isEmpty()) predicates.add(cb.equal(root.get("paymentMethod"), paymentType)); + if (orderType != null && !orderType.isEmpty()) predicates.add(cb.equal(root.get("orderType"), orderType)); if (search != null && !search.isEmpty()) { String searchLower = "%" + search.toLowerCase() + "%"; Join userJoin = root.join("user", JoinType.LEFT); - - Predicate searchPredicate = cb.or( + predicates.add(cb.or( cb.like(cb.lower(root.get("displayOrderId")), searchLower), cb.like(cb.lower(root.get("orderNumber")), searchLower), cb.like(cb.lower(userJoin.get("name")), searchLower), cb.like(cb.lower(userJoin.get("mobileNumber")), searchLower) - ); - predicates.add(searchPredicate); + )); } - - // Fetch join for actual data queries to avoid N+1 if (query != null && !Long.class.equals(query.getResultType()) && !long.class.equals(query.getResultType())) { root.fetch("user", JoinType.LEFT); } - return cb.and(predicates.toArray(new Predicate[0])); }; - Page orderPage = orderRepository.findAll(spec, pageable); return ResponseEntity.ok(orderPage); } catch (Exception e) { - System.err.println("Error fetching orders with Specification: " + e.getMessage()); - e.printStackTrace(); - Map error = new HashMap<>(); - error.put("error", e.getMessage()); - return ResponseEntity.status(500).body(error); + return ResponseEntity.status(500).body(Map.of("error", e.getMessage())); } } @@ -125,31 +105,65 @@ public class OrderController { } } + // ── CUSTOMER: place order ───────────────────────────────────────────── + @PostMapping @org.springframework.transaction.annotation.Transactional public ResponseEntity placeOrder(@RequestBody Order order) { - System.out.println("[REVENUE-TRACE] Incoming Place Order Request -> User: " + order.getUserId() + - " | Total: " + order.getTotalAmount() + - " | Items: " + (order.getItems() != null ? order.getItems().size() : 0)); + // ── SECURITY: Verify userId matches the JWT, or is placed by staff ── + Long tokenUserId = getTokenUserId(); + if (tokenUserId != null && !tokenUserId.equals(order.getUserId())) { + // Customer can only order for themselves + if (!isStaff()) { + return ResponseEntity.status(403).body( + Map.of("success", false, "message", "You can only place orders for yourself")); + } + } - // 1. Pre-validation and linking if (order.getItems() == null || order.getItems().isEmpty()) { return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items")); } - // 2. Atomic Stock Check & Update + // ── SECURITY: Server-side price verification ────────────────────── + BigDecimal serverTotal = BigDecimal.ZERO; + for (OrderItem item : order.getItems()) { + if (item.getProductId() != null) { + Optional productOpt = productRepository.findById(item.getProductId()); + if (productOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, "message", "Product not found: " + item.getProductId())); + } + Product product = productOpt.get(); + // Use offer price if present, otherwise base price + BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0) + ? product.getOfferPrice() : product.getPrice(); + serverTotal = serverTotal.add(unitPrice.multiply(BigDecimal.valueOf(item.getQuantity()))); + } + } + + // Allow Β±5 tolerance for rounding differences + if (serverTotal.subtract(order.getTotalAmount()).abs().compareTo(new BigDecimal("5")) > 0) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "Price mismatch detected. Please refresh and try again.", + "serverTotal", serverTotal, + "clientTotal", order.getTotalAmount() + )); + } + // Always use server-calculated total + order.setTotalAmount(serverTotal); + + // ── Stock check & update ────────────────────────────────────────── List> stockConflicts = new ArrayList<>(); - requestConflicts.remove(); // Clear before use - + requestConflicts.remove(); + for (OrderItem item : order.getItems()) { Long productId = item.getProductId(); if (productId != null) { int updatedRows = productRepository.decrementStock(productId, item.getQuantity()); - if (updatedRows == 0) { - com.rit.canteen.sales.model.Product p = productRepository.findById(productId).orElse(null); + Product p = productRepository.findById(productId).orElse(null); int left = (p != null && p.getStock() != null) ? p.getStock() : 0; - Map conflict = new HashMap<>(); conflict.put("productId", productId); conflict.put("productName", item.getProductName()); @@ -165,48 +179,31 @@ public class OrderController { throw new RuntimeException("CONCURRENCY_STOCK_FAILURE"); } - // 3. Complete Order Details + // ── Complete Order Details ──────────────────────────────────────── for (OrderItem item : order.getItems()) { item.setOrder(order); if (item.getStallName() == null || item.getStallName().isEmpty() || item.getStallName().equals("Unknown Stall")) { item.setStallName("RIT Canteen"); } } - + LocalDateTime now = LocalDateTime.now(); order.setCreatedAt(now); LocalDateTime startOfDay = now.toLocalDate().atStartOfDay(); long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay); - String displayId = String.format("%03d", todaysOrderCount + 1); - order.setDisplayOrderId(displayId); - - // 4. Token Payment Check + order.setDisplayOrderId(String.format("%03d", todaysOrderCount + 1)); + + // ── Token payment ──────────────────────────────────────────────── if ("RITZ_TOKEN".equals(order.getPaymentMethod())) { try { - // Use userId directly for robustness - tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + displayId); + tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + order.getDisplayOrderId()); } catch (RuntimeException e) { - if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) { - throw new RuntimeException("INSUFFICIENT_TOKENS"); - } + if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) throw new RuntimeException("INSUFFICIENT_TOKENS"); throw e; } } - - // 5. Final Save + Order savedOrder = orderRepository.save(order); - - System.out.println("[REVENUE-TRACE] Saved Order: " + savedOrder.getOrderNumber() + - " | Display ID: #" + savedOrder.getDisplayOrderId() + - " | CreatedAt: " + savedOrder.getCreatedAt() + - " | Payment: " + savedOrder.getPaymentMethod()); - - if (savedOrder.getItems() != null) { - savedOrder.getItems().forEach(item -> - System.out.println("[REVENUE-TRACE] Saved Item: " + item.getProductName() + - " | Stall: " + item.getStallName())); - } - return ResponseEntity.ok(Map.of( "success", true, "orderNumber", savedOrder.getOrderNumber(), @@ -215,60 +212,41 @@ public class OrderController { )); } - @ExceptionHandler(RuntimeException.class) - public ResponseEntity handleRuntimeException(RuntimeException e) { - if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) { - List> conflicts = requestConflicts.get(); - requestConflicts.remove(); - return ResponseEntity.status(400).body(Map.of( - "success", false, - "errorType", "STOCK_ERROR", - "message", "Some items in your cart are no longer available in the requested quantity.", - "conflicts", conflicts != null ? conflicts : new ArrayList<>() - )); - } - if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) { - return ResponseEntity.status(400).body(Map.of( - "success", false, - "errorType", "TOKEN_ERROR", - "message", "Insufficient Ritz Tokens. Please top up your wallet." - )); - } - return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage() != null ? e.getMessage() : "Internal Server Error")); - } + // ── CUSTOMER: own orders ────────────────────────────────────────────── @GetMapping("/user/{userId}") - public List getUserOrders(@PathVariable Long userId) { - return orderRepository.findByUserIdOrderByCreatedAtDesc(userId); + public ResponseEntity getUserOrders(@PathVariable Long userId) { + // Customer can only access their own orders + Long tokenUserId = getTokenUserId(); + if (tokenUserId != null && !tokenUserId.equals(userId) && !isStaff()) { + return ResponseEntity.status(403).body(Map.of("error", "Access denied")); + } + return ResponseEntity.ok(orderRepository.findByUserIdOrderByCreatedAtDesc(userId)); } + // ── STAFF/MASTER: order management ──────────────────────────────────── + @PatchMapping("/{id}/status") - public ResponseEntity updateOrderStatus( - @PathVariable Long id, - @RequestBody Map statusUpdate) { + public ResponseEntity updateOrderStatus(@PathVariable Long id, + @RequestBody Map statusUpdate) { try { String newStatus = statusUpdate.get("status"); if (newStatus == null || newStatus.isEmpty()) { return ResponseEntity.badRequest().body(Map.of("error", "Status is required")); } - - return orderRepository.findById(id) - .map(order -> { - String oldStatus = order.getStatus(); - String nextStatus = newStatus.toUpperCase(); - - // Check for refund condition: Moving to CANCELLED from a non-cancelled state - if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) { - if ("RITZ_TOKEN".equals(order.getPaymentMethod())) { - tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(), order.getTotalAmount(), "Status changed to CANCELLED"); - } - } - - order.setStatus(nextStatus); - orderRepository.save(order); - return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus)); - }) - .orElse(ResponseEntity.notFound().build()); + return orderRepository.findById(id).map(order -> { + String oldStatus = order.getStatus(); + String nextStatus = newStatus.toUpperCase(); + if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) { + if ("RITZ_TOKEN".equals(order.getPaymentMethod())) { + tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(), + order.getTotalAmount(), "Status changed to CANCELLED"); + } + } + order.setStatus(nextStatus); + orderRepository.save(order); + return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus)); + }).orElse(ResponseEntity.notFound().build()); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of("error", e.getMessage())); } @@ -277,46 +255,81 @@ public class OrderController { @PutMapping("/{id}") public ResponseEntity updateOrder(@PathVariable Long id, @RequestBody Order updatedOrder) { try { - return orderRepository.findById(id) - .map(existingOrder -> { - BigDecimal oldAmount = existingOrder.getTotalAmount(); - BigDecimal newAmount = updatedOrder.getTotalAmount(); - - // Handle Token Adjustments for edited orders - if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) { - int comparison = newAmount.compareTo(oldAmount); - if (comparison > 0) { - // Spend more - tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount), "ORD-EDIT-" + existingOrder.getDisplayOrderId()); - } else if (comparison < 0) { - // This is tricky for individual tokens, but we can refund the difference amount - // For simplicity/robustness, we'll refund the whole order and re-spend the new amount - // to keep unit association clean OR just record it as a topup. - // Let's do a simple balance restoration for the delta. - tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(), oldAmount, "Order price reduced during edit"); - tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId()); - } - } + return orderRepository.findById(id).map(existingOrder -> { + BigDecimal oldAmount = existingOrder.getTotalAmount(); + BigDecimal newAmount = updatedOrder.getTotalAmount(); - // Update basic fields - existingOrder.setTotalAmount(newAmount); - existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod()); - - // Clear and replace items for a clean update - existingOrder.getItems().clear(); - if (updatedOrder.getItems() != null) { - for (OrderItem newItem : updatedOrder.getItems()) { - newItem.setOrder(existingOrder); - existingOrder.getItems().add(newItem); - } - } - - Order saved = orderRepository.save(existingOrder); - return ResponseEntity.ok(saved); - }) - .orElse(ResponseEntity.notFound().build()); + if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) { + int comparison = newAmount.compareTo(oldAmount); + if (comparison > 0) { + tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount), + "ORD-EDIT-" + existingOrder.getDisplayOrderId()); + } else if (comparison < 0) { + tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(), + oldAmount, "Order price reduced during edit"); + tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId()); + } + } + + existingOrder.setTotalAmount(newAmount); + existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod()); + existingOrder.getItems().clear(); + if (updatedOrder.getItems() != null) { + for (OrderItem newItem : updatedOrder.getItems()) { + newItem.setOrder(existingOrder); + existingOrder.getItems().add(newItem); + } + } + Order saved = orderRepository.save(existingOrder); + return ResponseEntity.ok(saved); + }).orElse(ResponseEntity.notFound().build()); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of("error", e.getMessage())); } } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException e) { + if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) { + List> conflicts = requestConflicts.get(); + requestConflicts.remove(); + return ResponseEntity.status(400).body(Map.of( + "success", false, "errorType", "STOCK_ERROR", + "message", "Some items in your cart are no longer available in the requested quantity.", + "conflicts", conflicts != null ? conflicts : new ArrayList<>() + )); + } + if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) { + return ResponseEntity.status(400).body(Map.of( + "success", false, "errorType", "TOKEN_ERROR", + "message", "Insufficient Ritz Tokens. Please top up your wallet." + )); + } + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", e.getMessage() != null ? e.getMessage() : "Internal Server Error" + )); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private Long getTokenUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getDetails() instanceof Claims claims) { + Object uid = claims.get("userId"); + if (uid != null) { + return uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid; + } + } + return null; + } + + private boolean isStaff() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) return false; + return auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_MASTER") + || a.getAuthority().equals("ROLE_MANAGER") + || a.getAuthority().equals("ROLE_STAFF")); + } } diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/SystemAuthController.java b/backend/src/main/java/com/rit/canteen/sales/controller/SystemAuthController.java index 7635b635..84ef03e9 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/SystemAuthController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/SystemAuthController.java @@ -1,11 +1,18 @@ package com.rit.canteen.sales.controller; +import com.rit.canteen.sales.config.JwtUtil; +import com.rit.canteen.sales.config.LoginRateLimiter; import com.rit.canteen.sales.model.SystemUser; import com.rit.canteen.sales.service.SystemUserService; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -17,76 +24,150 @@ public class SystemAuthController { @Autowired private SystemUserService userService; + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private LoginRateLimiter rateLimiter; + + // ── PUBLIC ────────────────────────────────────────────────────────────── @PostMapping("/login") - public ResponseEntity login(@RequestBody Map credentials) { + public ResponseEntity login(@RequestBody Map credentials, + HttpServletRequest request) { + // Rate limit by IP + String ip = getClientIp(request); + if (!rateLimiter.tryConsume(ip)) { + return ResponseEntity.status(429).body(Map.of( + "error", "Too many login attempts. Please wait 5 minutes." + )); + } + String email = credentials.get("email"); String password = credentials.get("password"); - - Optional user = userService.authenticate(email, password); - - if (user.isPresent()) { - return ResponseEntity.ok(user.get()); + + Optional userOpt = userService.authenticate(email, password); + + if (userOpt.isPresent()) { + SystemUser user = userOpt.get(); + String token = jwtUtil.generateToken(user.getId(), user.getEmail(), + user.getRole(), user.getPermissions()); + // Return safe DTO β€” no password hash + Map response = new HashMap<>(); + response.put("token", token); + response.put("id", user.getId()); + response.put("name", user.getName()); + response.put("email", user.getEmail()); + response.put("role", user.getRole()); + response.put("permissions", user.getPermissions()); + response.put("viewOnly", user.isViewOnly()); + return ResponseEntity.ok(response); } else { - return ResponseEntity.status(401).body("Invalid credentials"); + return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials")); } } + // ── PROTECTED (requires MASTER or MANAGER JWT) ─────────────────────────── + @GetMapping("/managers") public ResponseEntity> getManagers() { - return ResponseEntity.ok(userService.getAllManagers()); + return ResponseEntity.ok(sanitize(userService.getAllManagers())); } @PostMapping("/managers") - public ResponseEntity addManager(@RequestBody SystemUser manager) { - return ResponseEntity.ok(userService.createManager(manager)); + public ResponseEntity addManager(@RequestBody SystemUser manager) { + requireRole("MASTER", "MANAGER"); + return ResponseEntity.ok(sanitize(userService.createManager(manager))); } @DeleteMapping("/managers/{id}") public ResponseEntity deleteManager(@PathVariable Long id) { + requireRole("MASTER"); userService.deleteManager(id); return ResponseEntity.noContent().build(); } @GetMapping("/staff") public ResponseEntity> getStaff() { - return ResponseEntity.ok(userService.getAllStaff()); + return ResponseEntity.ok(sanitize(userService.getAllStaff())); } @PostMapping("/staff") - public ResponseEntity addStaff(@RequestBody SystemUser staff) { - return ResponseEntity.ok(userService.createStaff(staff)); + public ResponseEntity addStaff(@RequestBody SystemUser staff) { + requireRole("MASTER", "MANAGER"); + return ResponseEntity.ok(sanitize(userService.createStaff(staff))); } @DeleteMapping("/staff/{id}") public ResponseEntity deleteStaff(@PathVariable Long id) { - userService.deleteManager(id); // Using existing delete logic + requireRole("MASTER", "MANAGER"); + userService.deleteManager(id); return ResponseEntity.noContent().build(); } @GetMapping("/admins") public ResponseEntity> getAdmins() { - return ResponseEntity.ok(userService.getMasters()); + requireRole("MASTER"); + return ResponseEntity.ok(sanitize(userService.getMasters())); } @PostMapping("/admins") - public ResponseEntity addAdmin(@RequestBody SystemUser admin) { - return ResponseEntity.ok(userService.createMaster(admin)); + public ResponseEntity addAdmin(@RequestBody SystemUser admin) { + // Only a MASTER can create another MASTER + requireRole("MASTER"); + return ResponseEntity.ok(sanitize(userService.createMaster(admin))); } @PostMapping("/update-master") public ResponseEntity updateMaster(@RequestBody Map data) { + requireRole("MASTER"); try { Object idObj = data.get("id"); Long id = (idObj != null) ? Long.valueOf(idObj.toString()) : 0L; - String email = (String) data.get("email"); String password = (String) data.get("password"); String name = (String) data.get("name"); - + userService.updateMasterAccount(id, email, password, name); return ResponseEntity.ok(Map.of("success", true, "message", "Credentials updated successfully")); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage())); } } + + // ── Helpers ───────────────────────────────────────────────────────────── + + /** Strips password hash from response objects */ + private SystemUser sanitize(SystemUser u) { + u.setPassword("[PROTECTED]"); + return u; + } + + private List sanitize(List users) { + users.forEach(this::sanitize); + return users; + } + + /** Asserts the calling JWT has one of the required roles, throws 403 otherwise */ + private void requireRole(String... roles) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + throw new org.springframework.security.access.AccessDeniedException("Not authenticated"); + } + if (auth.getDetails() instanceof Claims claims) { + String role = (String) claims.get("role"); + for (String r : roles) { + if (r.equals(role)) return; + } + } + throw new org.springframework.security.access.AccessDeniedException( + "Insufficient role. Required: " + String.join(" or ", roles)); + } + + private String getClientIp(HttpServletRequest request) { + String xfHeader = request.getHeader("X-Forwarded-For"); + if (xfHeader != null && !xfHeader.isEmpty()) { + return xfHeader.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } } diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/SystemNotificationController.java b/backend/src/main/java/com/rit/canteen/sales/controller/SystemNotificationController.java index fa7bd7c5..950c751a 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/SystemNotificationController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/SystemNotificationController.java @@ -9,7 +9,6 @@ import java.util.List; @RestController @RequestMapping("/api/notifications") -@CrossOrigin(origins = "*") public class SystemNotificationController { @Autowired diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/UserController.java b/backend/src/main/java/com/rit/canteen/sales/controller/UserController.java index 95d67500..9df3ee30 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/UserController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/UserController.java @@ -1,15 +1,23 @@ package com.rit.canteen.sales.controller; +import com.rit.canteen.sales.config.JwtUtil; +import com.rit.canteen.sales.config.LoginRateLimiter; import com.rit.canteen.sales.model.*; import com.rit.canteen.sales.service.UserService; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import io.jsonwebtoken.Claims; +import java.util.HashMap; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/auth") @@ -18,94 +26,95 @@ public class UserController { @Autowired private UserService userService; - /** - * Check if a user exists by mobile number. - * POST /api/auth/check - */ + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private LoginRateLimiter rateLimiter; + + // ── PUBLIC ──────────────────────────────────────────────────────────────── + @PostMapping("/check") public ResponseEntity checkUserExists(@Valid @RequestBody LoginRequest request) { LoginResponse response = userService.checkUserExists(request.getMobileNumber()); return ResponseEntity.ok(response); } - /** - * Register a new user with mobile number, name and PIN. - * POST /api/auth/register - */ @PostMapping("/register") - public ResponseEntity registerUser(@Valid @RequestBody PinVerificationRequest request) { - LoginResponse response = userService.registerUser(request.getMobileNumber(), request.getName(), request.getPin()); - if (response.isSuccess()) { - return ResponseEntity.ok(response); - } else { - return ResponseEntity.badRequest().body(response); + public ResponseEntity registerUser(@Valid @RequestBody PinVerificationRequest request, + HttpServletRequest httpRequest) { + String ip = getClientIp(httpRequest); + if (!rateLimiter.tryConsume(ip)) { + return ResponseEntity.status(429).build(); } + LoginResponse response = userService.registerUser( + request.getMobileNumber(), request.getName(), request.getPin()); + if (response.isSuccess()) { + // Attach JWT on successful registration + Long userId = response.getUser() != null ? response.getUser().getId() : null; + if (userId != null) { + String token = jwtUtil.generateUserToken(userId, request.getMobileNumber()); + response.setToken(token); + } + return ResponseEntity.ok(response); + } + return ResponseEntity.badRequest().body(response); } - /** - * Verify PIN and login an existing user. - * POST /api/auth/login - */ @PostMapping("/login") - public ResponseEntity login(@Valid @RequestBody PinVerificationRequest request) { + public ResponseEntity login(@Valid @RequestBody PinVerificationRequest request, + HttpServletRequest httpRequest) { + String ip = getClientIp(httpRequest); + if (!rateLimiter.tryConsume(ip)) { + LoginResponse rateResp = new LoginResponse(false, "Too many login attempts. Please wait 5 minutes."); + return ResponseEntity.status(429).body(rateResp); + } LoginResponse response = userService.verifyPinAndLogin(request.getMobileNumber(), request.getPin()); if (response.isSuccess()) { + Long userId = response.getUser() != null ? response.getUser().getId() : null; + if (userId != null) { + String token = jwtUtil.generateUserToken(userId, request.getMobileNumber()); + response.setToken(token); + } return ResponseEntity.ok(response); - } else { - return ResponseEntity.badRequest().body(response); } + return ResponseEntity.badRequest().body(response); } - /** - * Logout a user. - * POST /api/auth/logout - */ @PostMapping("/logout") public ResponseEntity logout(@Valid @RequestBody LoginRequest request) { LoginResponse response = userService.logout(request.getMobileNumber()); - if (response.isSuccess()) { - return ResponseEntity.ok(response); - } else { - return ResponseEntity.badRequest().body(response); - } + return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response); } - /** - * Change user PIN. - * POST /api/auth/change-pin - */ + // ── AUTHENTICATED (customer token required) ──────────────────────────────── + @PostMapping("/change-pin") public ResponseEntity changePin(@Valid @RequestBody ChangePinRequest request) { - LoginResponse response = userService.changePin(request.getMobileNumber(), request.getCurrentPin(), request.getNewPin()); - if (response.isSuccess()) { - return ResponseEntity.ok(response); - } else { - return ResponseEntity.badRequest().body(response); + // Extra ownership check: the JWT's mobileNumber must match the request + String tokenMobile = getAuthenticatedMobile(); + if (tokenMobile != null && !tokenMobile.equals(request.getMobileNumber())) { + return ResponseEntity.status(403).body( + new LoginResponse(false, "You can only change your own PIN.")); } + LoginResponse response = userService.changePin( + request.getMobileNumber(), request.getCurrentPin(), request.getNewPin()); + return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response); } - /** - * Get user details by mobile number. - * GET /api/auth/user/{mobileNumber} - */ @GetMapping("/user/{mobileNumber}") public ResponseEntity getUser(@PathVariable String mobileNumber) { - LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber); - if (userDto != null) { - return ResponseEntity.ok(userDto); - } else { - return ResponseEntity.notFound().build(); + // Customers can only fetch their own profile; staff can fetch any + String tokenMobile = getAuthenticatedMobile(); + if (tokenMobile != null && !tokenMobile.equals(mobileNumber) && !isStaff()) { + return ResponseEntity.status(403).build(); } + LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber); + return userDto != null ? ResponseEntity.ok(userDto) : ResponseEntity.notFound().build(); } - /** - * Get all registered users for administration dashboard. - * GET /api/auth/users - */ - /** - * Get all registered users for administration dashboard. - * GET /api/auth/users - */ + // ── STAFF/MASTER ONLY ──────────────────────────────────────────────────────── + @GetMapping("/users") public ResponseEntity> getAllUsers( @RequestParam(required = false) String search, @@ -115,41 +124,53 @@ public class UserController { return ResponseEntity.ok(users); } - /** - * Update user details as an administrator. - * PUT /api/auth/users/{id} - */ @PutMapping("/users/{id}") - public ResponseEntity updateUser(@PathVariable Long id, @Valid @RequestBody UserUpdateRequest request) { - LoginResponse.UserDto updated = userService.updateUser(id, request.getName(), request.getMobileNumber(), request.getPin()); - if (updated != null) { - return ResponseEntity.ok(updated); - } else { - return ResponseEntity.notFound().build(); - } + public ResponseEntity updateUser( + @PathVariable Long id, + @Valid @RequestBody UserUpdateRequest request) { + LoginResponse.UserDto updated = userService.updateUser(id, request.getName(), + request.getMobileNumber(), request.getPin()); + return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build(); } - /** - * Delete a user from the system. - * DELETE /api/auth/users/{id} - */ @DeleteMapping("/users/{id}") public ResponseEntity deleteUser(@PathVariable Long id) { userService.deleteUser(id); return ResponseEntity.noContent().build(); } - /** - * Toggle user suspension status. - * PATCH /api/auth/users/{id}/suspend - */ @PatchMapping("/users/{id}/suspend") public ResponseEntity toggleSuspension(@PathVariable Long id) { LoginResponse.UserDto updated = userService.toggleSuspension(id); - if (updated != null) { - return ResponseEntity.ok(updated); - } else { - return ResponseEntity.notFound().build(); + return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build(); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private String getAuthenticatedMobile() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getDetails() instanceof Claims claims) { + // Customer tokens have the mobile as subject + String type = (String) claims.get("type"); + if ("customer".equals(type)) { + return claims.getSubject(); + } } + return null; // Staff or system token β€” not a customer + } + + private boolean isStaff() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) return false; + return auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().startsWith("ROLE_MASTER") + || a.getAuthority().startsWith("ROLE_MANAGER") + || a.getAuthority().startsWith("ROLE_STAFF")); + } + + private String getClientIp(HttpServletRequest request) { + String xfHeader = request.getHeader("X-Forwarded-For"); + if (xfHeader != null && !xfHeader.isEmpty()) return xfHeader.split(",")[0].trim(); + return request.getRemoteAddr(); } } diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/WalletController.java b/backend/src/main/java/com/rit/canteen/sales/controller/WalletController.java index 65a929ab..9ae4a135 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/WalletController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/WalletController.java @@ -4,8 +4,11 @@ import com.rit.canteen.sales.model.TokenTransaction; import com.rit.canteen.sales.model.User; import com.rit.canteen.sales.service.TokenService; import com.rit.canteen.sales.service.UserService; +import io.jsonwebtoken.Claims; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; @@ -18,17 +21,24 @@ import java.util.stream.Collectors; @RequestMapping("/api/wallet") public class WalletController { + private static final BigDecimal MAX_TOPUP = new BigDecimal("5000"); + @Autowired private com.rit.canteen.sales.repository.UserRepository userRepository; @Autowired private com.rit.canteen.sales.service.TokenService tokenService; + // ── CUSTOMER: own balance only ───────────────────────────────────────── + @GetMapping("/balance/{userId}") public ResponseEntity getBalance(@PathVariable Long userId) { + // Customers can only read their own balance + if (!canAccessUser(userId)) { + return ResponseEntity.status(403).body(Map.of("error", "Access denied")); + } try { User user = userRepository.findById(userId).orElse(null); - if (user == null) return ResponseEntity.notFound().build(); return ResponseEntity.ok(Map.of("balance", user.getRitzTokenBalance())); } catch (Exception e) { @@ -36,6 +46,16 @@ public class WalletController { } } + @GetMapping("/transactions/{userId}") + public ResponseEntity getTransactions(@PathVariable Long userId) { + if (!canAccessUser(userId)) { + return ResponseEntity.status(403).body(Map.of("error", "Access denied")); + } + return ResponseEntity.ok(tokenService.getTransactions(userId)); + } + + // ── STAFF/MASTER: wallet management ──────────────────────────────────── + @GetMapping("/users") public ResponseEntity>> getUsers() { List users = userRepository.findAll(); @@ -50,18 +70,22 @@ public class WalletController { return ResponseEntity.ok(userList); } - @GetMapping("/transactions/{userId}") - public ResponseEntity> getTransactions(@PathVariable Long userId) { - return ResponseEntity.ok(tokenService.getTransactions(userId)); - } - @PostMapping("/topup") public ResponseEntity topUp(@RequestBody Map request) { try { Long userId = Long.valueOf(request.get("userId").toString()); BigDecimal amount = new BigDecimal(request.get("amount").toString()); - String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString(); + // ── FIX: validate amount BEFORE touching the database ── + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + return ResponseEntity.badRequest().body(Map.of("error", "Amount must be positive")); + } + if (amount.compareTo(MAX_TOPUP) > 0) { + return ResponseEntity.badRequest().body( + Map.of("error", "Single transaction limit exceeded (Max: 5,000 Ritz Tokens)")); + } + + String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString(); User updatedUser = tokenService.topUp(userId, amount, ref); return ResponseEntity.ok(Map.of( "success", true, @@ -89,4 +113,27 @@ public class WalletController { @RequestParam(defaultValue = "20") int size) { return ResponseEntity.ok(tokenService.getAllCirculation(page, size)); } + + // ── Helpers ───────────────────────────────────────────────────────────── + + /** + * Returns true if the caller is a staff/manager/master, OR is the specific customer user. + */ + private boolean canAccessUser(Long targetUserId) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) return false; + + if (auth.getDetails() instanceof Claims claims) { + String role = (String) claims.get("role"); + // Staff roles can access anyone + if ("MASTER".equals(role) || "MANAGER".equals(role) || "STAFF".equals(role)) return true; + // Customers can only access themselves + Object uid = claims.get("userId"); + if (uid != null) { + Long tokenUserId = uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid; + return tokenUserId.equals(targetUserId); + } + } + return false; + } } diff --git a/backend/src/main/java/com/rit/canteen/sales/model/LoginResponse.java b/backend/src/main/java/com/rit/canteen/sales/model/LoginResponse.java index c1a01f3a..5f0c5857 100644 --- a/backend/src/main/java/com/rit/canteen/sales/model/LoginResponse.java +++ b/backend/src/main/java/com/rit/canteen/sales/model/LoginResponse.java @@ -6,6 +6,7 @@ public class LoginResponse { private String message; private boolean userExists; private UserDto user; + private String token; // JWT for customer session public LoginResponse() {} @@ -38,6 +39,9 @@ public class LoginResponse { public UserDto getUser() { return user; } public void setUser(UserDto user) { this.user = user; } + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + public static class UserDto { private Long id; private String mobileNumber; diff --git a/backend/src/main/java/com/rit/canteen/sales/service/SystemUserService.java b/backend/src/main/java/com/rit/canteen/sales/service/SystemUserService.java index 90e38363..5c3b816f 100644 --- a/backend/src/main/java/com/rit/canteen/sales/service/SystemUserService.java +++ b/backend/src/main/java/com/rit/canteen/sales/service/SystemUserService.java @@ -40,7 +40,7 @@ public class SystemUserService { master.setPermissions(List.of("dashboard", "sale", "customers", "purchases", "inventory", "expense", "reports", "stores", "table", "wallet", "promotions", "feedback")); master.setViewOnly(false); repository.save(master); - System.out.println(">>> SEEDED DEFAULT MASTER USER (Failsafe Source): " + masterUsername + " / " + masterPassword); + System.out.println(">>> SEEDED DEFAULT MASTER USER from failsafe source."); } else { System.out.println(">>> MASTER USER(S) FOUND IN DATABASE. Skipping default seeding."); } diff --git a/backend/src/main/java/com/rit/canteen/sales/service/TokenService.java b/backend/src/main/java/com/rit/canteen/sales/service/TokenService.java index 8f7f6a9a..a95b0055 100644 --- a/backend/src/main/java/com/rit/canteen/sales/service/TokenService.java +++ b/backend/src/main/java/com/rit/canteen/sales/service/TokenService.java @@ -67,29 +67,32 @@ public class TokenService { User user = userRepository.findById(userId) .orElseThrow(() -> new RuntimeException("User not found")); - int tokenCount = amount.intValue(); // Assuming 1 Ritz = 1 Serialized Unit + // ── FIX: Validate BEFORE touching the database ── + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new RuntimeException("Amount must be positive"); + } + if (amount.compareTo(new BigDecimal("5000")) > 0) { + throw new RuntimeException("Single transaction limit exceeded (Max: 5,000 Ritz Tokens)"); + } + + int tokenCount = amount.intValue(); System.out.println("MINTING: Serializing " + tokenCount + " Ritz tokens for user " + userId); - + // High-Performance Batch Insertion using JDBC String sql = "INSERT INTO token_units (token_hash, owner_id, status, created_at) VALUES (?, ?, 'ACTIVE', ?)"; List batchArgs = new ArrayList<>(); LocalDateTime now = LocalDateTime.now(); - + for (int i = 0; i < tokenCount; i++) { String hash = generateSecureHash(); batchArgs.add(new Object[]{hash, userId, now}); } - + jdbcTemplate.batchUpdate(sql, batchArgs); // Update cached balance BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO; BigDecimal newBalance = currentBalance.add(amount); - - if (amount.compareTo(new BigDecimal("5000")) > 0) { - throw new RuntimeException("Single transaction limit exceeded (Max: 5,000 Ritz Tokens)"); - } - user.setRitzTokenBalance(newBalance); User savedUser = userRepository.save(user); @@ -105,6 +108,7 @@ public class TokenService { return savedUser; } + @Transactional public void spend(Long userId, BigDecimal amount, String orderRef) { // High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 4e08cb1a..6ac02916 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,20 +1,39 @@ spring.application.name=backend server.address=0.0.0.0 -spring.datasource.url=jdbc:postgresql://localhost:5432/positeasy -spring.datasource.username=postgres -spring.datasource.password=sidharth +# ============================================================ +# DATABASE β€” use environment variables in production +# Set DB_URL, DB_USER, DB_PASSWORD as env vars before running +# ============================================================ +spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/positeasy} +spring.datasource.username=${DB_USER:postgres} +spring.datasource.password=${DB_PASSWORD:sidharth} spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true +spring.jpa.show-sql=false spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata -# Master Account Credentials -app.master.username=admin -app.master.password=admin +# ============================================================ +# JWT β€” CHANGE THIS SECRET IN PRODUCTION (min 256-bit key) +# Set JWT_SECRET as an environment variable before running +# ============================================================ +app.jwt.secret=${JWT_SECRET:3RiTCaNtEeNsUpErSeCrEtKeY2026!!xYzAbCdEfGhIjKlMn} +app.jwt.expiration-ms=86400000 + +# ============================================================ +# Master Account β€” use environment variables in production +# ============================================================ +app.master.username=${MASTER_USER:admin} +app.master.password=${MASTER_PASSWORD:admin} # File upload configuration spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB + +# ============================================================ +# CORS β€” comma-separated list of allowed frontend origins +# Set APP_CORS_ORIGINS as env var in production +# ============================================================ +app.cors.allowed-origins=${APP_CORS_ORIGINS:http://localhost:5173,http://localhost:5174,http://localhost:3000} diff --git a/frontend/README.md b/frontend/README.md index 7dbf7ebf..9b2aa13d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,3 +1,22 @@ +# Positeasy Clone Frontend (Counter/Admin) + +## πŸ” Security Hardening & API Migration (Branch: krishna) + +This branch contains critical security updates and infrastructure changes to support JWT-based authentication across the Positeasy ecosystem. + +### Key Changes: +- **Authenticated API Wrapper**: Migrated from standard `fetch` to a centralized `src/api.ts` wrapper. This wrapper automatically handles: + - Injection of the `Authorization: Bearer ` header. + - Consistent error handling for API requests. +- **Security Hardening**: Updated all major screens (Login, POS, Orders, etc.) to use the new authenticated API layer. +- **Backend Integration**: Configured to work seamlessly with the new JWT-protected backend endpoints. + +### Developer Instructions: +1. **Always use the API wrapper**: Import `api` from `@/api` (or `src/api.ts`) instead of using `fetch` directly. +2. **Migration Script**: `migrate_fetch.ps1` is included for reference. + +--- + # React + TypeScript + Vite This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. diff --git a/frontend/migrate_fetch.ps1 b/frontend/migrate_fetch.ps1 new file mode 100644 index 00000000..d361146e --- /dev/null +++ b/frontend/migrate_fetch.ps1 @@ -0,0 +1,17 @@ +$compDir = "src\components" +$files = Get-ChildItem -Path $compDir -Filter "*.tsx" + +foreach ($file in $files) { + $path = $file.FullName + $content = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) + + if ($content.Contains("await fetch(")) { + if (-not $content.Contains("from '../api'")) { + $content = "import { apiFetch } from '../api';" + [System.Environment]::NewLine + $content + } + $content = $content.Replace("await fetch(", "await apiFetch(") + [System.IO.File]::WriteAllText($path, $content, [System.Text.Encoding]::UTF8) + Write-Host "Updated component: $($file.Name)" + } +} +Write-Host "Components migration complete." diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 00000000..c7e0296a --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,51 @@ +/** + * Authenticated fetch wrapper for the admin frontend. + * Automatically attaches the JWT from localStorage to every request. + */ +export function getAuthToken(): string | null { + const user = localStorage.getItem('systemUser'); + if (!user) return null; + try { + const parsed = JSON.parse(user); + return parsed.token || null; + } catch { + return null; + } +} + +export function authHeaders(extra: Record = {}): Record { + const token = getAuthToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...extra, + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +/** + * Authenticated fetch. Redirects to /login on 401/403. + */ +export async function apiFetch(url: string, options: RequestInit = {}): Promise { + const token = getAuthToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record || {}), + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(url, { ...options, headers }); + + if (response.status === 401 || response.status === 403) { + // Token expired or invalid β€” redirect to login + localStorage.removeItem('systemUser'); + sessionStorage.clear(); + window.location.href = '/'; + } + + return response; +} diff --git a/frontend/src/components/AddTerminalModal.tsx b/frontend/src/components/AddTerminalModal.tsx index b38816cc..477b500b 100644 --- a/frontend/src/components/AddTerminalModal.tsx +++ b/frontend/src/components/AddTerminalModal.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Monitor, MapPin, Lock } from 'lucide-react'; @@ -19,7 +20,7 @@ const AddTerminalModal: React.FC = ({ isOpen, onClose, on const handleSubmit = async () => { setLoading(true); try { - const response = await fetch('/api/terminals', { + const response = await apiFetch('/api/terminals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, location, pin }), diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 7964e4f3..ef62c8b9 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import { useState, useEffect, useRef } from 'react'; import { Bell, @@ -65,7 +66,7 @@ const Header = () => { const fetchNotifications = async () => { try { - const response = await fetch(`http://${window.location.hostname}:8080/api/notifications`); + const response = await apiFetch(`http://${window.location.hostname}:8080/api/notifications`); if (response.ok) { const data = await response.json(); setNotifications(data); @@ -115,7 +116,7 @@ const Header = () => { const handleNotificationClick = async (notif: any) => { try { - await fetch(`http://${window.location.hostname}:8080/api/notifications/mark-read/${notif.id}`, { method: 'POST' }); + await apiFetch(`http://${window.location.hostname}:8080/api/notifications/mark-read/${notif.id}`, { method: 'POST' }); if (notif.link) navigate(notif.link); setShowNotifications(false); fetchNotifications(); @@ -126,7 +127,7 @@ const Header = () => { const markAllAsRead = async () => { try { - await fetch(`http://${window.location.hostname}:8080/api/notifications/mark-all-read`, { method: 'POST' }); + await apiFetch(`http://${window.location.hostname}:8080/api/notifications/mark-all-read`, { method: 'POST' }); fetchNotifications(); setShowNotifications(false); } catch (error) { diff --git a/frontend/src/components/PinVerificationModal.tsx b/frontend/src/components/PinVerificationModal.tsx index 42cd4855..b6f1c22d 100644 --- a/frontend/src/components/PinVerificationModal.tsx +++ b/frontend/src/components/PinVerificationModal.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Lock, Copy, CheckCircle2, AlertCircle } from 'lucide-react'; @@ -30,7 +31,7 @@ const PinVerificationModal: React.FC = ({ setLoading(true); setError(null); try { - const response = await fetch(`/api/terminals/${terminalId}/verify-pin`, { + const response = await apiFetch(`/api/terminals/${terminalId}/verify-pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pin: pinToVerify }), diff --git a/frontend/src/pages/ArchivedOrders.tsx b/frontend/src/pages/ArchivedOrders.tsx index 3d992850..4a0ad66e 100644 --- a/frontend/src/pages/ArchivedOrders.tsx +++ b/frontend/src/pages/ArchivedOrders.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect, useMemo } from 'react'; import { Search, @@ -77,7 +78,7 @@ const ArchivedOrders: React.FC = () => { params.append('page', currentPage.toString()); params.append('size', pageSize.toString()); - const response = await fetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`); + const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`); const data = await response.json(); if (data && data.content && Array.isArray(data.content)) { diff --git a/frontend/src/pages/BaseMenu.tsx b/frontend/src/pages/BaseMenu.tsx index d425dba8..2162d93f 100644 --- a/frontend/src/pages/BaseMenu.tsx +++ b/frontend/src/pages/BaseMenu.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect, useRef } from 'react'; import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, ShoppingCart, Package, ExternalLink } from 'lucide-react'; import Pagination from '../components/Pagination'; @@ -78,7 +79,7 @@ const BaseMenu = () => { params.append('search', debouncedSearchTerm); } - const response = await fetch(`http://${host}:8080/api/base-items?${params.toString()}`); + const response = await apiFetch(`http://${host}:8080/api/base-items?${params.toString()}`); const data = await response.json(); if (data && data.content) { setItems(data.content); @@ -99,7 +100,7 @@ const BaseMenu = () => { setShowProductsModal(true); setProductsLoading(true); try { - const response = await fetch(`http://localhost:8080/api/products/category/${encodeURIComponent(baseItem.name)}`); + const response = await apiFetch(`http://localhost:8080/api/products/category/${encodeURIComponent(baseItem.name)}`); const data = await response.json(); setAssociatedProducts(data); } catch (error) { @@ -117,7 +118,7 @@ const BaseMenu = () => { const method = editingItem ? 'PUT' : 'POST'; try { - const response = await fetch(url, { + const response = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newItem), @@ -142,7 +143,7 @@ const BaseMenu = () => { const handleToggleActive = async (item: BaseItem) => { try { - const response = await fetch(`http://localhost:8080/api/base-items/${item.id}`, { + const response = await apiFetch(`http://localhost:8080/api/base-items/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...item, active: !item.active }), diff --git a/frontend/src/pages/Bills.tsx b/frontend/src/pages/Bills.tsx index 144cbe88..af116177 100644 --- a/frontend/src/pages/Bills.tsx +++ b/frontend/src/pages/Bills.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { Search, ChevronRight, Filter, Loader2, Download, Printer, X } from 'lucide-react'; import { format } from 'date-fns'; @@ -31,7 +32,7 @@ const Bills: React.FC = () => { const fetchOrders = async () => { try { setIsLoading(true); - const response = await fetch('/api/purchases/orders'); + const response = await apiFetch('/api/purchases/orders'); if (response.ok) { const data = await response.json(); setOrders(data); @@ -50,7 +51,7 @@ const Bills: React.FC = () => { setIsSaving(true); const updatedPaidTotal = Number(selectedOrder.paidTotal) + Number(paymentAmount); - const response = await fetch('/api/purchases/orders', { + const response = await apiFetch('/api/purchases/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/frontend/src/pages/Customers.tsx b/frontend/src/pages/Customers.tsx index 57a7c0af..7f6c2a26 100644 --- a/frontend/src/pages/Customers.tsx +++ b/frontend/src/pages/Customers.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { Search, User, Phone, Tag, ChevronRight, UserCheck, UserMinus, MoreVertical, LayoutGrid, List, Edit2, Trash2, Shield, X, Eye, EyeOff, Loader2, AlertCircle, CheckCircle, CircleDollarSign } from 'lucide-react'; import Pagination from '../components/Pagination'; @@ -63,7 +64,7 @@ const Customers: React.FC = () => { params.append('search', debouncedSearchTerm); } - const response = await fetch(`http://${host}:8080/api/auth/users?${params.toString()}`); + const response = await apiFetch(`http://${host}:8080/api/auth/users?${params.toString()}`); if (response.ok) { const data = await response.json(); if (data && data.content) { @@ -106,7 +107,7 @@ const Customers: React.FC = () => { try { const host = window.location.hostname; - const response = await fetch(`http://${host}:8080/api/auth/users/${user.id}`, { + const response = await apiFetch(`http://${host}:8080/api/auth/users/${user.id}`, { method: 'DELETE', }); if (response.ok) { @@ -125,7 +126,7 @@ const Customers: React.FC = () => { const handleSuspendToggle = async (user: UserDto) => { try { const host = window.location.hostname; - const response = await fetch(`http://${host}:8080/api/auth/users/${user.id}/suspend`, { + const response = await apiFetch(`http://${host}:8080/api/auth/users/${user.id}/suspend`, { method: 'PATCH', }); if (response.ok) { @@ -165,7 +166,7 @@ const Customers: React.FC = () => { setIsSaving(true); try { const host = window.location.hostname; - const response = await fetch(`http://${host}:8080/api/auth/users/${editingUser.id}`, { + const response = await apiFetch(`http://${host}:8080/api/auth/users/${editingUser.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(editForm), diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f8d07387..a39cf054 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { @@ -105,7 +106,7 @@ const Dashboard = () => { } console.log('[DASHBOARD-TRACE] Fetching stats from:', url); - const response = await fetch(url); + const response = await apiFetch(url); if (response.ok) { const result = await response.json(); setData(result); diff --git a/frontend/src/pages/Feedback.tsx b/frontend/src/pages/Feedback.tsx index 9fa2e746..64d1d310 100644 --- a/frontend/src/pages/Feedback.tsx +++ b/frontend/src/pages/Feedback.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { Star, @@ -81,7 +82,7 @@ const Feedback: React.FC = () => { const fetchStats = async () => { setLoading(true); try { - const response = await fetch(`http://${window.location.hostname}:8080/api/feedback/stats`); + const response = await apiFetch(`http://${window.location.hostname}:8080/api/feedback/stats`); const data = await response.json(); setStats(data); } catch (error) { @@ -93,7 +94,7 @@ const Feedback: React.FC = () => { const fetchFeedbacks = async () => { try { - const response = await fetch(`http://${window.location.hostname}:8080/api/feedback?page=${page}&size=${pageSize}`); + const response = await apiFetch(`http://${window.location.hostname}:8080/api/feedback?page=${page}&size=${pageSize}`); const data = await response.json(); setFeedbacks(data?.content || []); setTotalElements(data?.totalElements || 0); diff --git a/frontend/src/pages/IntentDashboard.tsx b/frontend/src/pages/IntentDashboard.tsx index c68c07b6..fc1bccd1 100644 --- a/frontend/src/pages/IntentDashboard.tsx +++ b/frontend/src/pages/IntentDashboard.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; @@ -68,7 +69,7 @@ const IntentDashboard: React.FC = ({ title }) => { const fetchStats = async () => { setLoading(true); try { - const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/intent/summary`); + const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/intent/summary`); const data = await response.json(); setStats(data); } catch (error) { diff --git a/frontend/src/pages/IntentList.tsx b/frontend/src/pages/IntentList.tsx index 94250972..d3626778 100644 --- a/frontend/src/pages/IntentList.tsx +++ b/frontend/src/pages/IntentList.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { Eye, @@ -49,7 +50,7 @@ const IntentList: React.FC = ({ title }) => { const fetchVendors = async () => { try { - const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/vendors`); + const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/vendors`); if (response.ok) { const result = await response.json(); setVendors(result); @@ -62,7 +63,7 @@ const IntentList: React.FC = ({ title }) => { const fetchData = async () => { setLoading(true); try { - const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/orders`); + const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/orders`); const result = await response.json(); setData(result); } catch (error) { @@ -104,7 +105,7 @@ const IntentList: React.FC = ({ title }) => { date: new Date(newOrder.date).toISOString() }; - const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/orders`, { + const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/orders`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 57e06c15..a23885e9 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -52,14 +52,14 @@ const Login = () => { sessionStorage.setItem('isLoggedIn', 'true'); sessionStorage.setItem('userRole', user.role.toLowerCase()); sessionStorage.setItem('userPermissions', JSON.stringify(user.permissions || [])); - - // Persist user profile for personalized greetings and settings - localStorage.setItem('systemUser', JSON.stringify(user)); - + + // Persist user profile + JWT token for authenticated API calls + localStorage.setItem('systemUser', JSON.stringify(user)); // user object now includes `token` + navigate('/store-dashboard'); } else { - const error = await response.text(); - alert(error || 'Invalid credentials'); + const error = await response.json().catch(() => ({ error: 'Invalid credentials' })); + alert(error.error || 'Invalid credentials'); } } catch (err) { console.error('Login error:', err); diff --git a/frontend/src/pages/ManageCoupons.tsx b/frontend/src/pages/ManageCoupons.tsx index 2c97e3a2..14db86c5 100644 --- a/frontend/src/pages/ManageCoupons.tsx +++ b/frontend/src/pages/ManageCoupons.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import { useState, useEffect } from 'react'; import { Ticket, @@ -44,7 +45,7 @@ const ManageCoupons = () => { const fetchCoupons = async () => { try { setIsLoading(true); - const response = await fetch('/api/coupons'); + const response = await apiFetch('/api/coupons'); if (response.ok) { const data = await response.json(); setCoupons(data); @@ -63,7 +64,7 @@ const ManageCoupons = () => { const handleCreateCoupon = async (e: React.FormEvent) => { e.preventDefault(); try { - const response = await fetch('/api/coupons', { + const response = await apiFetch('/api/coupons', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -85,7 +86,7 @@ const ManageCoupons = () => { const handleToggleStatus = async (id: number) => { try { - const response = await fetch(`/api/coupons/${id}/toggle`, { method: 'PATCH' }); + const response = await apiFetch(`/api/coupons/${id}/toggle`, { method: 'PATCH' }); if (response.ok) fetchCoupons(); } catch (error) { console.error('Error toggling status:', error); @@ -95,7 +96,7 @@ const ManageCoupons = () => { const handleDelete = async (id: number) => { if (!window.confirm('Are you sure you want to delete this coupon?')) return; try { - const response = await fetch(`/api/coupons/${id}`, { method: 'DELETE' }); + const response = await apiFetch(`/api/coupons/${id}`, { method: 'DELETE' }); if (response.ok) fetchCoupons(); } catch (error) { console.error('Error deleting coupon:', error); diff --git a/frontend/src/pages/ManageWallets.tsx b/frontend/src/pages/ManageWallets.tsx index 831d078c..cc6c5aa2 100644 --- a/frontend/src/pages/ManageWallets.tsx +++ b/frontend/src/pages/ManageWallets.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import { useState, useEffect } from 'react'; import { Users, @@ -33,7 +34,7 @@ const ManageWallets = () => { const fetchUsers = async () => { try { setIsLoading(true); - const response = await fetch('/api/wallet/users'); + const response = await apiFetch('/api/wallet/users'); if (response.ok) { const data = await response.json(); setUsers(data); @@ -57,7 +58,7 @@ const ManageWallets = () => { setStatus(null); try { - const response = await fetch('/api/wallet/topup', { + const response = await apiFetch('/api/wallet/topup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/frontend/src/pages/Managers.tsx b/frontend/src/pages/Managers.tsx index d6731beb..7f779db4 100644 --- a/frontend/src/pages/Managers.tsx +++ b/frontend/src/pages/Managers.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -66,7 +67,7 @@ const Managers = () => { const fetchManagers = async () => { setLoading(true); try { - const res = await fetch('/api/system/managers'); + const res = await apiFetch('/api/system/managers'); if (res.ok) { const data = await res.json(); // Backend uses 'permissions' field, map it to 'sections' for the component if needed @@ -99,7 +100,7 @@ const Managers = () => { const handleCreateAccount = async (e: React.FormEvent) => { e.preventDefault(); try { - const response = await fetch('/api/system/managers', { + const response = await apiFetch('/api/system/managers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -127,7 +128,7 @@ const Managers = () => { const dismissManager = async (id: string) => { if (window.confirm('Are you sure you want to dismiss this manager?')) { try { - const response = await fetch(`/api/system/managers/${id}`, { + const response = await apiFetch(`/api/system/managers/${id}`, { method: 'DELETE' }); if (response.ok) { diff --git a/frontend/src/pages/NewArrivals.tsx b/frontend/src/pages/NewArrivals.tsx index d0b58a7f..aaf91dc7 100644 --- a/frontend/src/pages/NewArrivals.tsx +++ b/frontend/src/pages/NewArrivals.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect, useRef } from 'react'; import { X, Search, RefreshCw, Edit2, Package, Image as ImageIcon, @@ -75,7 +76,7 @@ const NewArrivals: React.FC = () => { const fetchDrafts = async () => { setLoading(true); try { - const response = await fetch('/api/products/drafts'); + const response = await apiFetch('/api/products/drafts'); if (response.ok) { const data = await response.json(); setDrafts(data); @@ -116,7 +117,7 @@ const NewArrivals: React.FC = () => { setIsSaving(true); try { - const response = await fetch(`/api/products/${formData.id}/publish`, { + const response = await apiFetch(`/api/products/${formData.id}/publish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData), @@ -137,7 +138,7 @@ const NewArrivals: React.FC = () => { const handleDelete = async (id: number) => { if (!window.confirm('Delete this draft product?')) return; try { - await fetch(`/api/products/${id}`, { method: 'DELETE' }); + await apiFetch(`/api/products/${id}`, { method: 'DELETE' }); fetchDrafts(); } catch (error) { console.error('Error deleting draft:', error); diff --git a/frontend/src/pages/Orders.tsx b/frontend/src/pages/Orders.tsx index ffcace45..2e774c32 100644 --- a/frontend/src/pages/Orders.tsx +++ b/frontend/src/pages/Orders.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect, useMemo } from 'react'; import { Search, @@ -103,7 +104,7 @@ const Orders: React.FC = () => { const fetchProducts = async () => { try { - const response = await fetch(`http://${window.location.hostname}:8080/api/products`); + const response = await apiFetch(`http://${window.location.hostname}:8080/api/products`); if (response.ok) { const data = await response.json(); setAllProducts(data); @@ -127,7 +128,7 @@ const Orders: React.FC = () => { params.append('page', currentPage.toString()); params.append('size', pageSize.toString()); - const response = await fetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`); + const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`); const data = await response.json(); if (data && data.content && Array.isArray(data.content)) { @@ -156,7 +157,7 @@ const Orders: React.FC = () => { const handleApproveOrder = async (orderId: number) => { try { - const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, { + const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'COMPLETED' }) @@ -174,7 +175,7 @@ const Orders: React.FC = () => { const handleMarkUndelivered = async (orderId: number) => { try { - const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, { + const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'PAID' }) @@ -230,7 +231,7 @@ const Orders: React.FC = () => { if (!selectedOrder) return; setIsUpdatingOrder(true); try { - const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, { + const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -297,7 +298,7 @@ const Orders: React.FC = () => { setIsRegenerating(true); try { const newOrderNumber = `ORD-${Math.random().toString(36).substring(2, 10).toUpperCase()}`; - const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, { + const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/frontend/src/pages/Products.tsx b/frontend/src/pages/Products.tsx index 17c52ba8..f75e2846 100644 --- a/frontend/src/pages/Products.tsx +++ b/frontend/src/pages/Products.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect, useRef } from 'react'; import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, Tag, Package, Image as ImageIcon, Barcode, DollarSign, ChevronDown, Clock, Check, Trash2, Database } from 'lucide-react'; import Pagination from '../components/Pagination'; @@ -161,7 +162,7 @@ const Products = () => { params.append('search', debouncedSearchTerm); } - const response = await fetch(`http://${host}:8080/api/products?${params.toString()}`); + const response = await apiFetch(`http://${host}:8080/api/products?${params.toString()}`); const data = await response.json(); if (data && data.content) { setProducts(data.content); @@ -179,7 +180,7 @@ const Products = () => { const fetchBaseItems = async () => { try { - const response = await fetch('http://localhost:8080/api/base-items?size=100'); + const response = await apiFetch('http://localhost:8080/api/base-items?size=100'); const data = await response.json(); setBaseItems(data.content || data); } catch (error) { @@ -189,7 +190,7 @@ const Products = () => { const fetchAllStalls = async () => { try { - const response = await fetch('http://localhost:8080/api/stalls'); + const response = await apiFetch('http://localhost:8080/api/stalls'); const data = await response.json(); setAllStalls(data); } catch (error) { @@ -205,7 +206,7 @@ const Products = () => { const method = editingProduct ? 'PUT' : 'POST'; try { - const response = await fetch(url, { + const response = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData), @@ -233,7 +234,7 @@ const Products = () => { const handleToggleActive = async (product: Product) => { try { - const response = await fetch(`http://localhost:8080/api/products/${product.id}`, { + const response = await apiFetch(`http://localhost:8080/api/products/${product.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...product, active: !product.active }), @@ -249,7 +250,7 @@ const Products = () => { const handleToggleStock = async (product: Product) => { try { - const response = await fetch(`http://localhost:8080/api/products/${product.id}/toggle-stock`, { + const response = await apiFetch(`http://localhost:8080/api/products/${product.id}/toggle-stock`, { method: 'PATCH', }); if (response.ok) { @@ -264,7 +265,7 @@ const Products = () => { const handleDelete = async (product: Product) => { if (!window.confirm(`Are you sure you want to delete ${product.name}?`)) return; try { - const response = await fetch(`http://localhost:8080/api/products/${product.id}`, { + const response = await apiFetch(`http://localhost:8080/api/products/${product.id}`, { method: 'DELETE', }); if (response.ok) { diff --git a/frontend/src/pages/PurchaseAnalytics.tsx b/frontend/src/pages/PurchaseAnalytics.tsx index 8d788de3..2cb21e23 100644 --- a/frontend/src/pages/PurchaseAnalytics.tsx +++ b/frontend/src/pages/PurchaseAnalytics.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { TrendingUp, @@ -71,7 +72,7 @@ const PurchaseAnalytics = () => { const fetchData = async () => { try { setIsLoading(true); - const response = await fetch('/api/purchases/orders'); + const response = await apiFetch('/api/purchases/orders'); if (response.ok) { const data = await response.json(); processAnalytics(data); diff --git a/frontend/src/pages/Purchases.tsx b/frontend/src/pages/Purchases.tsx index 8df54380..c730fd9d 100644 --- a/frontend/src/pages/Purchases.tsx +++ b/frontend/src/pages/Purchases.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { Search, ShoppingCart, Eye, Plus, X, Loader2, CheckCircle, Trash2, Edit2 } from 'lucide-react'; @@ -65,7 +66,7 @@ const Purchases: React.FC = () => { const fetchOrders = async () => { try { setIsLoading(true); - const response = await fetch('/api/purchases/orders'); + const response = await apiFetch('/api/purchases/orders'); if (response.ok) { const data = await response.json(); setOrders(data); @@ -79,7 +80,7 @@ const Purchases: React.FC = () => { const fetchVendors = async () => { try { - const response = await fetch('/api/purchases/vendors'); + const response = await apiFetch('/api/purchases/vendors'); if (response.ok) { const data = await response.json(); setVendors(data); @@ -91,7 +92,7 @@ const Purchases: React.FC = () => { const fetchProducts = async () => { try { - const response = await fetch('/api/products?size=1000'); + const response = await apiFetch('/api/products?size=1000'); if (response.ok) { const data = await response.json(); setAvailableProducts(data.content || data); @@ -150,7 +151,7 @@ const Purchases: React.FC = () => { date: new Date(newOrder.date).toISOString() }; - const response = await fetch('/api/purchases/orders', { + const response = await apiFetch('/api/purchases/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) @@ -172,7 +173,7 @@ const Purchases: React.FC = () => { const deleteOrder = async (id: number) => { if (!window.confirm('Delete this purchase order?')) return; try { - await fetch(`/api/purchases/orders/${id}`, { method: 'DELETE' }); + await apiFetch(`/api/purchases/orders/${id}`, { method: 'DELETE' }); fetchOrders(); } catch (error) { console.error('Error deleting order:', error); @@ -197,7 +198,7 @@ const Purchases: React.FC = () => { const order = orders.find(o => o.id === orderId); if (!order) return; - const response = await fetch(`/api/purchases/orders`, { + const response = await apiFetch(`/api/purchases/orders`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...order, status: newStatus }) @@ -229,7 +230,7 @@ const Purchases: React.FC = () => { const fetchOrderHistory = async (order: PurchaseOrder) => { try { setActiveHistoryOrder(order); - const response = await fetch(`/api/purchases/orders/${order.id}/history`); + const response = await apiFetch(`/api/purchases/orders/${order.id}/history`); if (response.ok) { const data = await response.json(); setOrderHistory(data); diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 4d242e05..1d2e1c6a 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { Download, @@ -66,7 +67,7 @@ const Reports: React.FC = () => { try { const fromStr = dateRange.from.toISOString(); const toStr = dateRange.to.toISOString(); - const response = await fetch(`http://${window.location.hostname}:8080/api/reports/monthly?from=${fromStr}&to=${toStr}`); + const response = await apiFetch(`http://${window.location.hostname}:8080/api/reports/monthly?from=${fromStr}&to=${toStr}`); const result = await response.json(); setData(result); } catch (error) { diff --git a/frontend/src/pages/Ritz.tsx b/frontend/src/pages/Ritz.tsx index 08006998..444f7fbd 100644 --- a/frontend/src/pages/Ritz.tsx +++ b/frontend/src/pages/Ritz.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { Building2, @@ -56,11 +57,11 @@ const RitzPage: React.FC = () => { setIsLoading(true); const host = window.location.hostname; - const statsRes = await fetch(`http://${host}:8080/api/wallet/stats`); + const statsRes = await apiFetch(`http://${host}:8080/api/wallet/stats`); const statsData = await statsRes.json(); setStats(statsData); - const transRes = await fetch(`http://${host}:8080/api/wallet/transactions/all`); + const transRes = await apiFetch(`http://${host}:8080/api/wallet/transactions/all`); const transData = await transRes.json(); setTransactions(Array.isArray(transData) ? transData : []); } catch (error) { diff --git a/frontend/src/pages/RitzCirculation.tsx b/frontend/src/pages/RitzCirculation.tsx index 2afab956..db196665 100644 --- a/frontend/src/pages/RitzCirculation.tsx +++ b/frontend/src/pages/RitzCirculation.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { CircleDollarSign, @@ -52,7 +53,7 @@ const RitzCirculation: React.FC = () => { try { setIsLoading(true); const host = window.location.hostname; - const res = await fetch(`http://${host}:8080/api/wallet/circulation?page=${page}&size=${size}`); + const res = await apiFetch(`http://${host}:8080/api/wallet/circulation?page=${page}&size=${size}`); const data: PageResponse = await res.json(); setTokens(data.content || []); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 52438371..0a6ad66b 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { User, @@ -47,7 +48,7 @@ const Settings = () => { const fetchAdmins = async () => { try { - const response = await fetch('/api/system/admins'); + const response = await apiFetch('/api/system/admins'); const data = await response.json(); setAdmins(data); } catch (err) { @@ -69,7 +70,7 @@ const Settings = () => { setStatus('loading'); try { - const response = await fetch('/api/system/update-master', { + const response = await apiFetch('/api/system/update-master', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -102,7 +103,7 @@ const Settings = () => { e.preventDefault(); setStatus('loading'); try { - const response = await fetch('/api/system/admins', { + const response = await apiFetch('/api/system/admins', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newAdmin) diff --git a/frontend/src/pages/Staff.tsx b/frontend/src/pages/Staff.tsx index de12957e..e014a0b3 100644 --- a/frontend/src/pages/Staff.tsx +++ b/frontend/src/pages/Staff.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -50,7 +51,7 @@ const Staff = () => { const fetchStaff = async () => { setLoading(true); try { - const res = await fetch('/api/system/staff'); + const res = await apiFetch('/api/system/staff'); if (res.ok) { const data = await res.json(); setStaffList(data); @@ -69,7 +70,7 @@ const Staff = () => { const handleCreateStaff = async (e: React.FormEvent) => { e.preventDefault(); try { - const response = await fetch('/api/system/staff', { + const response = await apiFetch('/api/system/staff', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -97,7 +98,7 @@ const Staff = () => { const deleteStaff = async (id: string) => { if (window.confirm('Are you sure you want to remove this staff member?')) { try { - const response = await fetch(`/api/system/staff/${id}`, { + const response = await apiFetch(`/api/system/staff/${id}`, { method: 'DELETE' }); if (response.ok) { diff --git a/frontend/src/pages/Stalls.tsx b/frontend/src/pages/Stalls.tsx index 9baf088e..cbe830b4 100644 --- a/frontend/src/pages/Stalls.tsx +++ b/frontend/src/pages/Stalls.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -94,7 +95,7 @@ const Stalls: React.FC = () => { try { setLoading(true); const host = window.location.hostname; - const response = await fetch(`http://${host}:8080/api/stalls`); + const response = await apiFetch(`http://${host}:8080/api/stalls`); if (response.ok) { const data = await response.json(); setStalls(data); @@ -126,7 +127,7 @@ const Stalls: React.FC = () => { ? `http://${host}:8080/api/stalls/${editingStall.id}` : `http://${host}:8080/api/stalls`; - const response = await fetch(url, { + const response = await apiFetch(url, { method: editingStall ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -173,7 +174,7 @@ const Stalls: React.FC = () => { if (!window.confirm('Are you sure you want to delete this stall?')) return; try { const host = window.location.hostname; - const response = await fetch(`http://${host}:8080/api/stalls/${id}`, { + const response = await apiFetch(`http://${host}:8080/api/stalls/${id}`, { method: 'DELETE' }); if (response.ok) { @@ -216,7 +217,7 @@ const Stalls: React.FC = () => { setIsSaving(true); try { const host = window.location.hostname; - const response = await fetch(`http://${host}:8080/api/stalls/${selectedStall.id}/items`, { + const response = await apiFetch(`http://${host}:8080/api/stalls/${selectedStall.id}/items`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/frontend/src/pages/StoreDashboard.tsx b/frontend/src/pages/StoreDashboard.tsx index d9285f9f..fcc813f5 100644 --- a/frontend/src/pages/StoreDashboard.tsx +++ b/frontend/src/pages/StoreDashboard.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import { useState, useEffect } from 'react'; import { ChevronRight, @@ -99,7 +100,7 @@ const StoreDashboard = () => { params.append('from', range.from); params.append('to', range.to); - const response = await fetch(`/api/dashboard/stats?${params.toString()}`); + const response = await apiFetch(`/api/dashboard/stats?${params.toString()}`); if (response.ok) { const data = await response.json(); console.log('Dashboard data received successfully:', data); diff --git a/frontend/src/pages/Terminals.tsx b/frontend/src/pages/Terminals.tsx index 09592c74..775e3b6c 100644 --- a/frontend/src/pages/Terminals.tsx +++ b/frontend/src/pages/Terminals.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { @@ -32,7 +33,7 @@ const Terminals = () => { const fetchTerminals = async () => { setLoading(true); try { - const response = await fetch('/api/terminals'); + const response = await apiFetch('/api/terminals'); if (response.ok) { const data = await response.json(); setTerminals(data); @@ -52,7 +53,7 @@ const Terminals = () => { e.stopPropagation(); if (window.confirm('Are you sure you want to remove this terminal?')) { try { - const response = await fetch(`/api/terminals/${id}`, { method: 'DELETE' }); + const response = await apiFetch(`/api/terminals/${id}`, { method: 'DELETE' }); if (response.ok) fetchTerminals(); } catch (error) { console.error('Failed to delete terminal:', error); diff --git a/frontend/src/pages/VendorDashboard.tsx b/frontend/src/pages/VendorDashboard.tsx index 8c128bfd..6127cda9 100644 --- a/frontend/src/pages/VendorDashboard.tsx +++ b/frontend/src/pages/VendorDashboard.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { @@ -53,7 +54,7 @@ const VendorDashboard = () => { const fetchData = async () => { try { setIsLoading(true); - const response = await fetch('/api/dashboard/procurement'); + const response = await apiFetch('/api/dashboard/procurement'); if (response.ok) { const result = await response.json(); setData(result); diff --git a/frontend/src/pages/Vendors.tsx b/frontend/src/pages/Vendors.tsx index 0547bca5..da772cc2 100644 --- a/frontend/src/pages/Vendors.tsx +++ b/frontend/src/pages/Vendors.tsx @@ -1,3 +1,4 @@ +ο»Ώimport { apiFetch } from '../api'; import React, { useState, useEffect } from 'react'; import { Search, Building2, Phone, Mail, MoreVertical, List, LayoutGrid, Edit2, Trash2, X, Loader2, AlertCircle, CheckCircle, Plus } from 'lucide-react'; import Pagination from '../components/Pagination'; @@ -43,7 +44,7 @@ const Vendors: React.FC = () => { const fetchVendors = async () => { try { setIsLoading(true); - const response = await fetch('/api/purchases/vendors'); + const response = await apiFetch('/api/purchases/vendors'); if (response.ok) { const data = await response.json(); setVendors(data); @@ -85,7 +86,7 @@ const Vendors: React.FC = () => { if (!window.confirm(`Are you sure you want to delete ${vendor.name}?`)) return; try { - const response = await fetch(`/api/purchases/vendors/${vendor.id}`, { + const response = await apiFetch(`/api/purchases/vendors/${vendor.id}`, { method: 'DELETE', }); if (response.ok) { @@ -111,7 +112,7 @@ const Vendors: React.FC = () => { setIsSaving(true); try { - const response = await fetch('/api/purchases/vendors', { + const response = await apiFetch('/api/purchases/vendors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(editingVendor ? { ...form, id: editingVendor.id } : form),