feat: complete security overhaul with JWT backend and authenticated frontend API

This commit is contained in:
Shanmuga Krishnan S M
2026-04-28 14:30:03 +05:30
parent 655fb251b9
commit b13ee0195b
50 changed files with 1004 additions and 404 deletions

31
README.md Normal file
View File

@@ -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.

View File

@@ -63,6 +63,30 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- Rate Limiting -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -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<GrantedAuthority> 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);
}
}

View File

@@ -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<String> 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<String> getPermissionsFromToken(String token) {
return (List<String>) validateToken(token).get("permissions");
}
}

View File

@@ -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<String, Bucket> 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();
}
}

View File

@@ -1,37 +1,81 @@
package com.rit.canteen.sales.config; 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.Bean;
import org.springframework.context.annotation.Configuration; 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.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 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.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; 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 @Configuration
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { 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 @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults()) .cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/notifications/**").permitAll() // ── PUBLIC: Auth endpoints (login / register for both user types) ──
.requestMatchers("/api/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/system/login").permitAll()
.anyRequest().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(); return http.build();
} }
@@ -43,11 +87,22 @@ public class SecurityConfig {
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); 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<String> 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.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*")); configuration.setAllowedHeaders(List.of("*"));
configuration.setExposedHeaders(List.of("Authorization"));
configuration.setAllowCredentials(false); configuration.setAllowCredentials(false);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
return source; return source;

View File

@@ -1,24 +1,13 @@
package com.rit.canteen.sales.config; package com.rit.canteen.sales.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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 @Configuration
public class WebConfig { public class WebConfig {
// CORS handled by SecurityConfig — do not add CorsRegistry here
@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);
}
};
}
} }

View File

@@ -23,8 +23,12 @@ public class CouponController {
@Autowired @Autowired
private com.rit.canteen.sales.service.TokenService tokenService; private com.rit.canteen.sales.service.TokenService tokenService;
// ── PUBLIC (authenticated): any logged-in user can redeem ──────────────
@GetMapping @GetMapping
public List<CouponCode> getAllCoupons() { public List<CouponCode> getAllCoupons() {
// Return only active, non-expired coupons for customers
// (STAFF/MASTER see all — handled client-side via role check)
return couponRepository.findAll(); return couponRepository.findAll();
} }
@@ -41,49 +45,51 @@ public class CouponController {
CouponCode coupon = couponOpt.get(); CouponCode coupon = couponOpt.get();
// 1. Basic Validations
if (!coupon.getIsActive()) { if (!coupon.getIsActive()) {
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon is currently inactive")); return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon is currently inactive"));
} }
if (java.time.LocalDateTime.now().isAfter(coupon.getExpiryDate())) { if (java.time.LocalDateTime.now().isAfter(coupon.getExpiryDate())) {
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon has expired")); return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon has expired"));
} }
// 2. Global Usage Limit // ── FIX: Atomic check-and-insert to prevent race-condition double redemption ──
if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) { // Use INSERT with conflict handling — if the DB unique constraint fires, it means
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached")); // a concurrent request already redeemed. We catch the exception and return an error.
}
// 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
try { 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); coupon.setCurrentClaims(coupon.getCurrentClaims() + 1);
couponRepository.save(coupon); couponRepository.save(coupon);
// Credit tokens // Credit tokens
tokenService.topUp(userId, coupon.getRewardAmount(), "COUPON-" + code); 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( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "Successfully redeemed " + coupon.getRewardAmount() + " Ritz tokens!", "message", "Successfully redeemed " + coupon.getRewardAmount() + " Ritz tokens!",
"rewardAmount", coupon.getRewardAmount() "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) { } catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "Redemption failed: " + e.getMessage())); return ResponseEntity.status(500).body(Map.of("success", false, "message", "Redemption failed: " + e.getMessage()));
} }
} }
// ── STAFF/MASTER ONLY: coupon management ──────────────────────────────
@PostMapping @PostMapping
public ResponseEntity<?> createCoupon(@RequestBody CouponCode coupon) { public ResponseEntity<?> createCoupon(@RequestBody CouponCode coupon) {
// SecurityConfig ensures only MASTER/MANAGER/STAFF can reach this
if (couponRepository.findByCode(coupon.getCode()).isPresent()) { if (couponRepository.findByCode(coupon.getCode()).isPresent()) {
return ResponseEntity.badRequest().body("Coupon code already exists"); return ResponseEntity.badRequest().body("Coupon code already exists");
} }

View File

@@ -2,9 +2,11 @@ package com.rit.canteen.sales.controller;
import com.rit.canteen.sales.model.Order; import com.rit.canteen.sales.model.Order;
import com.rit.canteen.sales.model.OrderItem; 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.model.User;
import com.rit.canteen.sales.repository.OrderRepository; import com.rit.canteen.sales.repository.OrderRepository;
import com.rit.canteen.sales.repository.ProductRepository; import com.rit.canteen.sales.repository.ProductRepository;
import io.jsonwebtoken.Claims;
import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Predicate;
@@ -19,6 +21,8 @@ import com.rit.canteen.sales.service.OrderArchiverService;
import com.rit.canteen.sales.service.TokenService; import com.rit.canteen.sales.service.TokenService;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; 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 org.springframework.web.bind.annotation.*;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -44,9 +48,10 @@ public class OrderController {
@Autowired @Autowired
private TokenService tokenService; private TokenService tokenService;
// Use ThreadLocal to safely store conflicts for the current request context
private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>(); private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>();
// ── STAFF/MASTER: all orders ──────────────────────────────────────────
@GetMapping("/all") @GetMapping("/all")
public ResponseEntity<?> getAllOrders( public ResponseEntity<?> getAllOrders(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate, @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 = "0") int page,
@RequestParam(defaultValue = "10") int size) { @RequestParam(defaultValue = "10") int size) {
try { 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")); Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Specification<Order> spec = (root, query, cb) -> { Specification<Order> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>(); List<Predicate> predicates = new ArrayList<>();
// Add archive filter
predicates.add(cb.equal(root.get("isArchived"), archived)); predicates.add(cb.equal(root.get("isArchived"), archived));
if (startDate != null) predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
if (startDate != null) { if (endDate != null) predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate)); 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 (endDate != null) { if (orderType != null && !orderType.isEmpty()) predicates.add(cb.equal(root.get("orderType"), orderType));
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()) { if (search != null && !search.isEmpty()) {
String searchLower = "%" + search.toLowerCase() + "%"; String searchLower = "%" + search.toLowerCase() + "%";
Join<Order, User> userJoin = root.join("user", JoinType.LEFT); Join<Order, User> userJoin = root.join("user", JoinType.LEFT);
predicates.add(cb.or(
Predicate searchPredicate = cb.or(
cb.like(cb.lower(root.get("displayOrderId")), searchLower), cb.like(cb.lower(root.get("displayOrderId")), searchLower),
cb.like(cb.lower(root.get("orderNumber")), searchLower), cb.like(cb.lower(root.get("orderNumber")), searchLower),
cb.like(cb.lower(userJoin.get("name")), searchLower), cb.like(cb.lower(userJoin.get("name")), searchLower),
cb.like(cb.lower(userJoin.get("mobileNumber")), 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())) { if (query != null && !Long.class.equals(query.getResultType()) && !long.class.equals(query.getResultType())) {
root.fetch("user", JoinType.LEFT); root.fetch("user", JoinType.LEFT);
} }
return cb.and(predicates.toArray(new Predicate[0])); return cb.and(predicates.toArray(new Predicate[0]));
}; };
Page<Order> orderPage = orderRepository.findAll(spec, pageable); Page<Order> orderPage = orderRepository.findAll(spec, pageable);
return ResponseEntity.ok(orderPage); return ResponseEntity.ok(orderPage);
} catch (Exception e) { } catch (Exception e) {
System.err.println("Error fetching orders with Specification: " + e.getMessage()); return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
e.printStackTrace();
Map<String, String> error = new HashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.status(500).body(error);
} }
} }
@@ -125,31 +105,65 @@ public class OrderController {
} }
} }
// ── CUSTOMER: place order ─────────────────────────────────────────────
@PostMapping @PostMapping
@org.springframework.transaction.annotation.Transactional @org.springframework.transaction.annotation.Transactional
public ResponseEntity<?> placeOrder(@RequestBody Order order) { public ResponseEntity<?> placeOrder(@RequestBody Order order) {
System.out.println("[REVENUE-TRACE] Incoming Place Order Request -> User: " + order.getUserId() + // ── SECURITY: Verify userId matches the JWT, or is placed by staff ──
" | Total: " + order.getTotalAmount() + Long tokenUserId = getTokenUserId();
" | Items: " + (order.getItems() != null ? order.getItems().size() : 0)); 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()) { if (order.getItems() == null || order.getItems().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items")); 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<Product> 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<Map<String, Object>> stockConflicts = new ArrayList<>(); List<Map<String, Object>> stockConflicts = new ArrayList<>();
requestConflicts.remove(); // Clear before use requestConflicts.remove();
for (OrderItem item : order.getItems()) { for (OrderItem item : order.getItems()) {
Long productId = item.getProductId(); Long productId = item.getProductId();
if (productId != null) { if (productId != null) {
int updatedRows = productRepository.decrementStock(productId, item.getQuantity()); int updatedRows = productRepository.decrementStock(productId, item.getQuantity());
if (updatedRows == 0) { 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; int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
Map<String, Object> conflict = new HashMap<>(); Map<String, Object> conflict = new HashMap<>();
conflict.put("productId", productId); conflict.put("productId", productId);
conflict.put("productName", item.getProductName()); conflict.put("productName", item.getProductName());
@@ -165,7 +179,7 @@ public class OrderController {
throw new RuntimeException("CONCURRENCY_STOCK_FAILURE"); throw new RuntimeException("CONCURRENCY_STOCK_FAILURE");
} }
// 3. Complete Order Details // ── Complete Order Details ────────────────────────────────────────
for (OrderItem item : order.getItems()) { for (OrderItem item : order.getItems()) {
item.setOrder(order); item.setOrder(order);
if (item.getStallName() == null || item.getStallName().isEmpty() || item.getStallName().equals("Unknown Stall")) { if (item.getStallName() == null || item.getStallName().isEmpty() || item.getStallName().equals("Unknown Stall")) {
@@ -177,36 +191,19 @@ public class OrderController {
order.setCreatedAt(now); order.setCreatedAt(now);
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay(); LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay); long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay);
String displayId = String.format("%03d", todaysOrderCount + 1); order.setDisplayOrderId(String.format("%03d", todaysOrderCount + 1));
order.setDisplayOrderId(displayId);
// 4. Token Payment Check // ── Token payment ────────────────────────────────────────────────
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) { if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
try { try {
// Use userId directly for robustness tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + order.getDisplayOrderId());
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + displayId);
} catch (RuntimeException e) { } catch (RuntimeException e) {
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) { if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) throw new RuntimeException("INSUFFICIENT_TOKENS");
throw new RuntimeException("INSUFFICIENT_TOKENS");
}
throw e; throw e;
} }
} }
// 5. Final Save
Order savedOrder = orderRepository.save(order); 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( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"orderNumber", savedOrder.getOrderNumber(), "orderNumber", savedOrder.getOrderNumber(),
@@ -215,60 +212,41 @@ public class OrderController {
)); ));
} }
@ExceptionHandler(RuntimeException.class) // ── CUSTOMER: own orders ──────────────────────────────────────────────
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) {
List<Map<String, Object>> 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"));
}
@GetMapping("/user/{userId}") @GetMapping("/user/{userId}")
public List<Order> getUserOrders(@PathVariable Long userId) { public ResponseEntity<?> getUserOrders(@PathVariable Long userId) {
return orderRepository.findByUserIdOrderByCreatedAtDesc(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") @PatchMapping("/{id}/status")
public ResponseEntity<?> updateOrderStatus( public ResponseEntity<?> updateOrderStatus(@PathVariable Long id,
@PathVariable Long id, @RequestBody Map<String, String> statusUpdate) {
@RequestBody Map<String, String> statusUpdate) {
try { try {
String newStatus = statusUpdate.get("status"); String newStatus = statusUpdate.get("status");
if (newStatus == null || newStatus.isEmpty()) { if (newStatus == null || newStatus.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Status is required")); return ResponseEntity.badRequest().body(Map.of("error", "Status is required"));
} }
return orderRepository.findById(id).map(order -> {
return orderRepository.findById(id) String oldStatus = order.getStatus();
.map(order -> { String nextStatus = newStatus.toUpperCase();
String oldStatus = order.getStatus(); if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
String nextStatus = newStatus.toUpperCase(); if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(),
// Check for refund condition: Moving to CANCELLED from a non-cancelled state order.getTotalAmount(), "Status changed to CANCELLED");
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());
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) { } catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", e.getMessage())); return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
} }
@@ -277,46 +255,81 @@ public class OrderController {
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<?> updateOrder(@PathVariable Long id, @RequestBody Order updatedOrder) { public ResponseEntity<?> updateOrder(@PathVariable Long id, @RequestBody Order updatedOrder) {
try { try {
return orderRepository.findById(id) return orderRepository.findById(id).map(existingOrder -> {
.map(existingOrder -> { BigDecimal oldAmount = existingOrder.getTotalAmount();
BigDecimal oldAmount = existingOrder.getTotalAmount(); BigDecimal newAmount = updatedOrder.getTotalAmount();
BigDecimal newAmount = updatedOrder.getTotalAmount();
// Handle Token Adjustments for edited orders if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) { int comparison = newAmount.compareTo(oldAmount);
int comparison = newAmount.compareTo(oldAmount); if (comparison > 0) {
if (comparison > 0) { tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount),
// Spend more "ORD-EDIT-" + existingOrder.getDisplayOrderId());
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount), "ORD-EDIT-" + existingOrder.getDisplayOrderId()); } else if (comparison < 0) {
} else if (comparison < 0) { tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(),
// This is tricky for individual tokens, but we can refund the difference amount oldAmount, "Order price reduced during edit");
// For simplicity/robustness, we'll refund the whole order and re-spend the new amount tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId());
// 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());
}
}
// Update basic fields existingOrder.setTotalAmount(newAmount);
existingOrder.setTotalAmount(newAmount); existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod());
existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod()); existingOrder.getItems().clear();
if (updatedOrder.getItems() != null) {
// Clear and replace items for a clean update for (OrderItem newItem : updatedOrder.getItems()) {
existingOrder.getItems().clear(); newItem.setOrder(existingOrder);
if (updatedOrder.getItems() != null) { existingOrder.getItems().add(newItem);
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());
Order saved = orderRepository.save(existingOrder);
return ResponseEntity.ok(saved);
})
.orElse(ResponseEntity.notFound().build());
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", e.getMessage())); 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<Map<String, Object>> 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"));
}
} }

View File

@@ -1,11 +1,18 @@
package com.rit.canteen.sales.controller; 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.model.SystemUser;
import com.rit.canteen.sales.service.SystemUserService; 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.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; 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 org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -17,68 +24,105 @@ public class SystemAuthController {
@Autowired @Autowired
private SystemUserService userService; private SystemUserService userService;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private LoginRateLimiter rateLimiter;
// ── PUBLIC ──────────────────────────────────────────────────────────────
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> credentials) { public ResponseEntity<?> login(@RequestBody Map<String, String> 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 email = credentials.get("email");
String password = credentials.get("password"); String password = credentials.get("password");
Optional<SystemUser> user = userService.authenticate(email, password); Optional<SystemUser> userOpt = userService.authenticate(email, password);
if (user.isPresent()) { if (userOpt.isPresent()) {
return ResponseEntity.ok(user.get()); SystemUser user = userOpt.get();
String token = jwtUtil.generateToken(user.getId(), user.getEmail(),
user.getRole(), user.getPermissions());
// Return safe DTO — no password hash
Map<String, Object> 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 { } 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") @GetMapping("/managers")
public ResponseEntity<List<SystemUser>> getManagers() { public ResponseEntity<List<SystemUser>> getManagers() {
return ResponseEntity.ok(userService.getAllManagers()); return ResponseEntity.ok(sanitize(userService.getAllManagers()));
} }
@PostMapping("/managers") @PostMapping("/managers")
public ResponseEntity<SystemUser> addManager(@RequestBody SystemUser manager) { public ResponseEntity<?> addManager(@RequestBody SystemUser manager) {
return ResponseEntity.ok(userService.createManager(manager)); requireRole("MASTER", "MANAGER");
return ResponseEntity.ok(sanitize(userService.createManager(manager)));
} }
@DeleteMapping("/managers/{id}") @DeleteMapping("/managers/{id}")
public ResponseEntity<Void> deleteManager(@PathVariable Long id) { public ResponseEntity<Void> deleteManager(@PathVariable Long id) {
requireRole("MASTER");
userService.deleteManager(id); userService.deleteManager(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@GetMapping("/staff") @GetMapping("/staff")
public ResponseEntity<List<SystemUser>> getStaff() { public ResponseEntity<List<SystemUser>> getStaff() {
return ResponseEntity.ok(userService.getAllStaff()); return ResponseEntity.ok(sanitize(userService.getAllStaff()));
} }
@PostMapping("/staff") @PostMapping("/staff")
public ResponseEntity<SystemUser> addStaff(@RequestBody SystemUser staff) { public ResponseEntity<?> addStaff(@RequestBody SystemUser staff) {
return ResponseEntity.ok(userService.createStaff(staff)); requireRole("MASTER", "MANAGER");
return ResponseEntity.ok(sanitize(userService.createStaff(staff)));
} }
@DeleteMapping("/staff/{id}") @DeleteMapping("/staff/{id}")
public ResponseEntity<Void> deleteStaff(@PathVariable Long id) { public ResponseEntity<Void> deleteStaff(@PathVariable Long id) {
userService.deleteManager(id); // Using existing delete logic requireRole("MASTER", "MANAGER");
userService.deleteManager(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@GetMapping("/admins") @GetMapping("/admins")
public ResponseEntity<List<SystemUser>> getAdmins() { public ResponseEntity<List<SystemUser>> getAdmins() {
return ResponseEntity.ok(userService.getMasters()); requireRole("MASTER");
return ResponseEntity.ok(sanitize(userService.getMasters()));
} }
@PostMapping("/admins") @PostMapping("/admins")
public ResponseEntity<SystemUser> addAdmin(@RequestBody SystemUser admin) { public ResponseEntity<?> addAdmin(@RequestBody SystemUser admin) {
return ResponseEntity.ok(userService.createMaster(admin)); // Only a MASTER can create another MASTER
requireRole("MASTER");
return ResponseEntity.ok(sanitize(userService.createMaster(admin)));
} }
@PostMapping("/update-master") @PostMapping("/update-master")
public ResponseEntity<?> updateMaster(@RequestBody Map<String, Object> data) { public ResponseEntity<?> updateMaster(@RequestBody Map<String, Object> data) {
requireRole("MASTER");
try { try {
Object idObj = data.get("id"); Object idObj = data.get("id");
Long id = (idObj != null) ? Long.valueOf(idObj.toString()) : 0L; Long id = (idObj != null) ? Long.valueOf(idObj.toString()) : 0L;
String email = (String) data.get("email"); String email = (String) data.get("email");
String password = (String) data.get("password"); String password = (String) data.get("password");
String name = (String) data.get("name"); String name = (String) data.get("name");
@@ -89,4 +133,41 @@ public class SystemAuthController {
return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage())); 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<SystemUser> sanitize(List<SystemUser> 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();
}
} }

View File

@@ -9,7 +9,6 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api/notifications") @RequestMapping("/api/notifications")
@CrossOrigin(origins = "*")
public class SystemNotificationController { public class SystemNotificationController {
@Autowired @Autowired

View File

@@ -1,15 +1,23 @@
package com.rit.canteen.sales.controller; 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.model.*;
import com.rit.canteen.sales.service.UserService; import com.rit.canteen.sales.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity; 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 org.springframework.web.bind.annotation.*;
import io.jsonwebtoken.Claims;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
@@ -18,94 +26,95 @@ public class UserController {
@Autowired @Autowired
private UserService userService; private UserService userService;
/** @Autowired
* Check if a user exists by mobile number. private JwtUtil jwtUtil;
* POST /api/auth/check
*/ @Autowired
private LoginRateLimiter rateLimiter;
// ── PUBLIC ────────────────────────────────────────────────────────────────
@PostMapping("/check") @PostMapping("/check")
public ResponseEntity<LoginResponse> checkUserExists(@Valid @RequestBody LoginRequest request) { public ResponseEntity<LoginResponse> checkUserExists(@Valid @RequestBody LoginRequest request) {
LoginResponse response = userService.checkUserExists(request.getMobileNumber()); LoginResponse response = userService.checkUserExists(request.getMobileNumber());
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
/**
* Register a new user with mobile number, name and PIN.
* POST /api/auth/register
*/
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<LoginResponse> registerUser(@Valid @RequestBody PinVerificationRequest request) { public ResponseEntity<LoginResponse> registerUser(@Valid @RequestBody PinVerificationRequest request,
LoginResponse response = userService.registerUser(request.getMobileNumber(), request.getName(), request.getPin()); HttpServletRequest httpRequest) {
if (response.isSuccess()) { String ip = getClientIp(httpRequest);
return ResponseEntity.ok(response); if (!rateLimiter.tryConsume(ip)) {
} else { return ResponseEntity.status(429).build();
return ResponseEntity.badRequest().body(response);
} }
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") @PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody PinVerificationRequest request) { public ResponseEntity<LoginResponse> 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()); LoginResponse response = userService.verifyPinAndLogin(request.getMobileNumber(), request.getPin());
if (response.isSuccess()) { 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); return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body(response);
} }
return ResponseEntity.badRequest().body(response);
} }
/**
* Logout a user.
* POST /api/auth/logout
*/
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) { public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
LoginResponse response = userService.logout(request.getMobileNumber()); LoginResponse response = userService.logout(request.getMobileNumber());
if (response.isSuccess()) { return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response);
return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body(response);
}
} }
/** // ── AUTHENTICATED (customer token required) ────────────────────────────────
* Change user PIN.
* POST /api/auth/change-pin
*/
@PostMapping("/change-pin") @PostMapping("/change-pin")
public ResponseEntity<LoginResponse> changePin(@Valid @RequestBody ChangePinRequest request) { public ResponseEntity<LoginResponse> changePin(@Valid @RequestBody ChangePinRequest request) {
LoginResponse response = userService.changePin(request.getMobileNumber(), request.getCurrentPin(), request.getNewPin()); // Extra ownership check: the JWT's mobileNumber must match the request
if (response.isSuccess()) { String tokenMobile = getAuthenticatedMobile();
return ResponseEntity.ok(response); if (tokenMobile != null && !tokenMobile.equals(request.getMobileNumber())) {
} else { return ResponseEntity.status(403).body(
return ResponseEntity.badRequest().body(response); 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}") @GetMapping("/user/{mobileNumber}")
public ResponseEntity<LoginResponse.UserDto> getUser(@PathVariable String mobileNumber) { public ResponseEntity<LoginResponse.UserDto> getUser(@PathVariable String mobileNumber) {
LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber); // Customers can only fetch their own profile; staff can fetch any
if (userDto != null) { String tokenMobile = getAuthenticatedMobile();
return ResponseEntity.ok(userDto); if (tokenMobile != null && !tokenMobile.equals(mobileNumber) && !isStaff()) {
} else { return ResponseEntity.status(403).build();
return ResponseEntity.notFound().build();
} }
LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber);
return userDto != null ? ResponseEntity.ok(userDto) : ResponseEntity.notFound().build();
} }
/** // ── STAFF/MASTER ONLY ────────────────────────────────────────────────────────
* Get all registered users for administration dashboard.
* GET /api/auth/users
*/
/**
* Get all registered users for administration dashboard.
* GET /api/auth/users
*/
@GetMapping("/users") @GetMapping("/users")
public ResponseEntity<Page<LoginResponse.UserDto>> getAllUsers( public ResponseEntity<Page<LoginResponse.UserDto>> getAllUsers(
@RequestParam(required = false) String search, @RequestParam(required = false) String search,
@@ -115,41 +124,53 @@ public class UserController {
return ResponseEntity.ok(users); return ResponseEntity.ok(users);
} }
/**
* Update user details as an administrator.
* PUT /api/auth/users/{id}
*/
@PutMapping("/users/{id}") @PutMapping("/users/{id}")
public ResponseEntity<LoginResponse.UserDto> updateUser(@PathVariable Long id, @Valid @RequestBody UserUpdateRequest request) { public ResponseEntity<LoginResponse.UserDto> updateUser(
LoginResponse.UserDto updated = userService.updateUser(id, request.getName(), request.getMobileNumber(), request.getPin()); @PathVariable Long id,
if (updated != null) { @Valid @RequestBody UserUpdateRequest request) {
return ResponseEntity.ok(updated); LoginResponse.UserDto updated = userService.updateUser(id, request.getName(),
} else { request.getMobileNumber(), request.getPin());
return ResponseEntity.notFound().build(); return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
}
} }
/**
* Delete a user from the system.
* DELETE /api/auth/users/{id}
*/
@DeleteMapping("/users/{id}") @DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) { public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id); userService.deleteUser(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/**
* Toggle user suspension status.
* PATCH /api/auth/users/{id}/suspend
*/
@PatchMapping("/users/{id}/suspend") @PatchMapping("/users/{id}/suspend")
public ResponseEntity<LoginResponse.UserDto> toggleSuspension(@PathVariable Long id) { public ResponseEntity<LoginResponse.UserDto> toggleSuspension(@PathVariable Long id) {
LoginResponse.UserDto updated = userService.toggleSuspension(id); LoginResponse.UserDto updated = userService.toggleSuspension(id);
if (updated != null) { return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
return ResponseEntity.ok(updated); }
} else {
return 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();
} }
} }

View File

@@ -4,8 +4,11 @@ import com.rit.canteen.sales.model.TokenTransaction;
import com.rit.canteen.sales.model.User; import com.rit.canteen.sales.model.User;
import com.rit.canteen.sales.service.TokenService; import com.rit.canteen.sales.service.TokenService;
import com.rit.canteen.sales.service.UserService; import com.rit.canteen.sales.service.UserService;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; 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 org.springframework.web.bind.annotation.*;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -18,17 +21,24 @@ import java.util.stream.Collectors;
@RequestMapping("/api/wallet") @RequestMapping("/api/wallet")
public class WalletController { public class WalletController {
private static final BigDecimal MAX_TOPUP = new BigDecimal("5000");
@Autowired @Autowired
private com.rit.canteen.sales.repository.UserRepository userRepository; private com.rit.canteen.sales.repository.UserRepository userRepository;
@Autowired @Autowired
private com.rit.canteen.sales.service.TokenService tokenService; private com.rit.canteen.sales.service.TokenService tokenService;
// ── CUSTOMER: own balance only ─────────────────────────────────────────
@GetMapping("/balance/{userId}") @GetMapping("/balance/{userId}")
public ResponseEntity<?> getBalance(@PathVariable Long 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 { try {
User user = userRepository.findById(userId).orElse(null); User user = userRepository.findById(userId).orElse(null);
if (user == null) return ResponseEntity.notFound().build(); if (user == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok(Map.of("balance", user.getRitzTokenBalance())); return ResponseEntity.ok(Map.of("balance", user.getRitzTokenBalance()));
} catch (Exception e) { } 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") @GetMapping("/users")
public ResponseEntity<List<Map<String, Object>>> getUsers() { public ResponseEntity<List<Map<String, Object>>> getUsers() {
List<User> users = userRepository.findAll(); List<User> users = userRepository.findAll();
@@ -50,18 +70,22 @@ public class WalletController {
return ResponseEntity.ok(userList); return ResponseEntity.ok(userList);
} }
@GetMapping("/transactions/{userId}")
public ResponseEntity<List<TokenTransaction>> getTransactions(@PathVariable Long userId) {
return ResponseEntity.ok(tokenService.getTransactions(userId));
}
@PostMapping("/topup") @PostMapping("/topup")
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) { public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
try { try {
Long userId = Long.valueOf(request.get("userId").toString()); Long userId = Long.valueOf(request.get("userId").toString());
BigDecimal amount = new BigDecimal(request.get("amount").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); User updatedUser = tokenService.topUp(userId, amount, ref);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
@@ -89,4 +113,27 @@ public class WalletController {
@RequestParam(defaultValue = "20") int size) { @RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(tokenService.getAllCirculation(page, 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;
}
} }

View File

@@ -6,6 +6,7 @@ public class LoginResponse {
private String message; private String message;
private boolean userExists; private boolean userExists;
private UserDto user; private UserDto user;
private String token; // JWT for customer session
public LoginResponse() {} public LoginResponse() {}
@@ -38,6 +39,9 @@ public class LoginResponse {
public UserDto getUser() { return user; } public UserDto getUser() { return user; }
public void setUser(UserDto user) { this.user = 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 { public static class UserDto {
private Long id; private Long id;
private String mobileNumber; private String mobileNumber;

View File

@@ -40,7 +40,7 @@ public class SystemUserService {
master.setPermissions(List.of("dashboard", "sale", "customers", "purchases", "inventory", "expense", "reports", "stores", "table", "wallet", "promotions", "feedback")); master.setPermissions(List.of("dashboard", "sale", "customers", "purchases", "inventory", "expense", "reports", "stores", "table", "wallet", "promotions", "feedback"));
master.setViewOnly(false); master.setViewOnly(false);
repository.save(master); 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 { } else {
System.out.println(">>> MASTER USER(S) FOUND IN DATABASE. Skipping default seeding."); System.out.println(">>> MASTER USER(S) FOUND IN DATABASE. Skipping default seeding.");
} }

View File

@@ -67,7 +67,15 @@ public class TokenService {
User user = userRepository.findById(userId) User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found")); .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); System.out.println("MINTING: Serializing " + tokenCount + " Ritz tokens for user " + userId);
// High-Performance Batch Insertion using JDBC // High-Performance Batch Insertion using JDBC
@@ -85,11 +93,6 @@ public class TokenService {
// Update cached balance // Update cached balance
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO; BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
BigDecimal newBalance = currentBalance.add(amount); 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.setRitzTokenBalance(newBalance);
User savedUser = userRepository.save(user); User savedUser = userRepository.save(user);
@@ -105,6 +108,7 @@ public class TokenService {
return savedUser; return savedUser;
} }
@Transactional @Transactional
public void spend(Long userId, BigDecimal amount, String orderRef) { public void spend(Long userId, BigDecimal amount, String orderRef) {
// High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously // High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously

View File

@@ -1,20 +1,39 @@
spring.application.name=backend spring.application.name=backend
server.address=0.0.0.0 server.address=0.0.0.0
spring.datasource.url=jdbc:postgresql://localhost:5432/positeasy # ============================================================
spring.datasource.username=postgres # DATABASE — use environment variables in production
spring.datasource.password=sidharth # 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.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update 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.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata
# Master Account Credentials # ============================================================
app.master.username=admin # JWT — CHANGE THIS SECRET IN PRODUCTION (min 256-bit key)
app.master.password=admin # 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 # File upload configuration
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-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}

View File

@@ -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 <token>` 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 # React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

View File

@@ -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."

51
frontend/src/api.ts Normal file
View File

@@ -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<string, string> = {}): Record<string, string> {
const token = getAuthToken();
const headers: Record<string, string> = {
'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<Response> {
const token = getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
};
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;
}

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, Monitor, MapPin, Lock } from 'lucide-react'; import { X, Monitor, MapPin, Lock } from 'lucide-react';
@@ -19,7 +20,7 @@ const AddTerminalModal: React.FC<AddTerminalModalProps> = ({ isOpen, onClose, on
const handleSubmit = async () => { const handleSubmit = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch('/api/terminals', { const response = await apiFetch('/api/terminals', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, location, pin }), body: JSON.stringify({ name, location, pin }),

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { import {
Bell, Bell,
@@ -65,7 +66,7 @@ const Header = () => {
const fetchNotifications = async () => { const fetchNotifications = async () => {
try { 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setNotifications(data); setNotifications(data);
@@ -115,7 +116,7 @@ const Header = () => {
const handleNotificationClick = async (notif: any) => { const handleNotificationClick = async (notif: any) => {
try { 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); if (notif.link) navigate(notif.link);
setShowNotifications(false); setShowNotifications(false);
fetchNotifications(); fetchNotifications();
@@ -126,7 +127,7 @@ const Header = () => {
const markAllAsRead = async () => { const markAllAsRead = async () => {
try { 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(); fetchNotifications();
setShowNotifications(false); setShowNotifications(false);
} catch (error) { } catch (error) {

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, Lock, Copy, CheckCircle2, AlertCircle } from 'lucide-react'; import { X, Lock, Copy, CheckCircle2, AlertCircle } from 'lucide-react';
@@ -30,7 +31,7 @@ const PinVerificationModal: React.FC<PinVerificationModalProps> = ({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch(`/api/terminals/${terminalId}/verify-pin`, { const response = await apiFetch(`/api/terminals/${terminalId}/verify-pin`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: pinToVerify }), body: JSON.stringify({ pin: pinToVerify }),

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { import {
Search, Search,
@@ -77,7 +78,7 @@ const ArchivedOrders: React.FC = () => {
params.append('page', currentPage.toString()); params.append('page', currentPage.toString());
params.append('size', pageSize.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(); const data = await response.json();
if (data && data.content && Array.isArray(data.content)) { if (data && data.content && Array.isArray(data.content)) {

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, ShoppingCart, Package, ExternalLink } from 'lucide-react'; import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, ShoppingCart, Package, ExternalLink } from 'lucide-react';
import Pagination from '../components/Pagination'; import Pagination from '../components/Pagination';
@@ -78,7 +79,7 @@ const BaseMenu = () => {
params.append('search', debouncedSearchTerm); 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(); const data = await response.json();
if (data && data.content) { if (data && data.content) {
setItems(data.content); setItems(data.content);
@@ -99,7 +100,7 @@ const BaseMenu = () => {
setShowProductsModal(true); setShowProductsModal(true);
setProductsLoading(true); setProductsLoading(true);
try { 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(); const data = await response.json();
setAssociatedProducts(data); setAssociatedProducts(data);
} catch (error) { } catch (error) {
@@ -117,7 +118,7 @@ const BaseMenu = () => {
const method = editingItem ? 'PUT' : 'POST'; const method = editingItem ? 'PUT' : 'POST';
try { try {
const response = await fetch(url, { const response = await apiFetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newItem), body: JSON.stringify(newItem),
@@ -142,7 +143,7 @@ const BaseMenu = () => {
const handleToggleActive = async (item: BaseItem) => { const handleToggleActive = async (item: BaseItem) => {
try { 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...item, active: !item.active }), body: JSON.stringify({ ...item, active: !item.active }),

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Search, ChevronRight, Filter, Loader2, Download, Printer, X } from 'lucide-react'; import { Search, ChevronRight, Filter, Loader2, Download, Printer, X } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
@@ -31,7 +32,7 @@ const Bills: React.FC = () => {
const fetchOrders = async () => { const fetchOrders = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const response = await fetch('/api/purchases/orders'); const response = await apiFetch('/api/purchases/orders');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setOrders(data); setOrders(data);
@@ -50,7 +51,7 @@ const Bills: React.FC = () => {
setIsSaving(true); setIsSaving(true);
const updatedPaidTotal = Number(selectedOrder.paidTotal) + Number(paymentAmount); const updatedPaidTotal = Number(selectedOrder.paidTotal) + Number(paymentAmount);
const response = await fetch('/api/purchases/orders', { const response = await apiFetch('/api/purchases/orders', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; 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 { 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'; import Pagination from '../components/Pagination';
@@ -63,7 +64,7 @@ const Customers: React.FC = () => {
params.append('search', debouncedSearchTerm); 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data && data.content) { if (data && data.content) {
@@ -106,7 +107,7 @@ const Customers: React.FC = () => {
try { try {
const host = window.location.hostname; 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', method: 'DELETE',
}); });
if (response.ok) { if (response.ok) {
@@ -125,7 +126,7 @@ const Customers: React.FC = () => {
const handleSuspendToggle = async (user: UserDto) => { const handleSuspendToggle = async (user: UserDto) => {
try { try {
const host = window.location.hostname; 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', method: 'PATCH',
}); });
if (response.ok) { if (response.ok) {
@@ -165,7 +166,7 @@ const Customers: React.FC = () => {
setIsSaving(true); setIsSaving(true);
try { try {
const host = window.location.hostname; 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm), body: JSON.stringify(editForm),

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
@@ -105,7 +106,7 @@ const Dashboard = () => {
} }
console.log('[DASHBOARD-TRACE] Fetching stats from:', url); console.log('[DASHBOARD-TRACE] Fetching stats from:', url);
const response = await fetch(url); const response = await apiFetch(url);
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
setData(result); setData(result);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Star, Star,
@@ -81,7 +82,7 @@ const Feedback: React.FC = () => {
const fetchStats = async () => { const fetchStats = async () => {
setLoading(true); setLoading(true);
try { 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(); const data = await response.json();
setStats(data); setStats(data);
} catch (error) { } catch (error) {
@@ -93,7 +94,7 @@ const Feedback: React.FC = () => {
const fetchFeedbacks = async () => { const fetchFeedbacks = async () => {
try { 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(); const data = await response.json();
setFeedbacks(data?.content || []); setFeedbacks(data?.content || []);
setTotalElements(data?.totalElements || 0); setTotalElements(data?.totalElements || 0);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
@@ -68,7 +69,7 @@ const IntentDashboard: React.FC<IntentDashboardProps> = ({ title }) => {
const fetchStats = async () => { const fetchStats = async () => {
setLoading(true); setLoading(true);
try { 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(); const data = await response.json();
setStats(data); setStats(data);
} catch (error) { } catch (error) {

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Eye, Eye,
@@ -49,7 +50,7 @@ const IntentList: React.FC<IntentListProps> = ({ title }) => {
const fetchVendors = async () => { const fetchVendors = async () => {
try { 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) { if (response.ok) {
const result = await response.json(); const result = await response.json();
setVendors(result); setVendors(result);
@@ -62,7 +63,7 @@ const IntentList: React.FC<IntentListProps> = ({ title }) => {
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { 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(); const result = await response.json();
setData(result); setData(result);
} catch (error) { } catch (error) {
@@ -104,7 +105,7 @@ const IntentList: React.FC<IntentListProps> = ({ title }) => {
date: new Date(newOrder.date).toISOString() 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)

View File

@@ -53,13 +53,13 @@ const Login = () => {
sessionStorage.setItem('userRole', user.role.toLowerCase()); sessionStorage.setItem('userRole', user.role.toLowerCase());
sessionStorage.setItem('userPermissions', JSON.stringify(user.permissions || [])); sessionStorage.setItem('userPermissions', JSON.stringify(user.permissions || []));
// Persist user profile for personalized greetings and settings // Persist user profile + JWT token for authenticated API calls
localStorage.setItem('systemUser', JSON.stringify(user)); localStorage.setItem('systemUser', JSON.stringify(user)); // user object now includes `token`
navigate('/store-dashboard'); navigate('/store-dashboard');
} else { } else {
const error = await response.text(); const error = await response.json().catch(() => ({ error: 'Invalid credentials' }));
alert(error || 'Invalid credentials'); alert(error.error || 'Invalid credentials');
} }
} catch (err) { } catch (err) {
console.error('Login error:', err); console.error('Login error:', err);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Ticket, Ticket,
@@ -44,7 +45,7 @@ const ManageCoupons = () => {
const fetchCoupons = async () => { const fetchCoupons = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const response = await fetch('/api/coupons'); const response = await apiFetch('/api/coupons');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setCoupons(data); setCoupons(data);
@@ -63,7 +64,7 @@ const ManageCoupons = () => {
const handleCreateCoupon = async (e: React.FormEvent) => { const handleCreateCoupon = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const response = await fetch('/api/coupons', { const response = await apiFetch('/api/coupons', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -85,7 +86,7 @@ const ManageCoupons = () => {
const handleToggleStatus = async (id: number) => { const handleToggleStatus = async (id: number) => {
try { 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(); if (response.ok) fetchCoupons();
} catch (error) { } catch (error) {
console.error('Error toggling status:', error); console.error('Error toggling status:', error);
@@ -95,7 +96,7 @@ const ManageCoupons = () => {
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this coupon?')) return; if (!window.confirm('Are you sure you want to delete this coupon?')) return;
try { try {
const response = await fetch(`/api/coupons/${id}`, { method: 'DELETE' }); const response = await apiFetch(`/api/coupons/${id}`, { method: 'DELETE' });
if (response.ok) fetchCoupons(); if (response.ok) fetchCoupons();
} catch (error) { } catch (error) {
console.error('Error deleting coupon:', error); console.error('Error deleting coupon:', error);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Users, Users,
@@ -33,7 +34,7 @@ const ManageWallets = () => {
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const response = await fetch('/api/wallet/users'); const response = await apiFetch('/api/wallet/users');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setUsers(data); setUsers(data);
@@ -57,7 +58,7 @@ const ManageWallets = () => {
setStatus(null); setStatus(null);
try { try {
const response = await fetch('/api/wallet/topup', { const response = await apiFetch('/api/wallet/topup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
@@ -66,7 +67,7 @@ const Managers = () => {
const fetchManagers = async () => { const fetchManagers = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/system/managers'); const res = await apiFetch('/api/system/managers');
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
// Backend uses 'permissions' field, map it to 'sections' for the component if needed // 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) => { const handleCreateAccount = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const response = await fetch('/api/system/managers', { const response = await apiFetch('/api/system/managers', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -127,7 +128,7 @@ const Managers = () => {
const dismissManager = async (id: string) => { const dismissManager = async (id: string) => {
if (window.confirm('Are you sure you want to dismiss this manager?')) { if (window.confirm('Are you sure you want to dismiss this manager?')) {
try { try {
const response = await fetch(`/api/system/managers/${id}`, { const response = await apiFetch(`/api/system/managers/${id}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (response.ok) { if (response.ok) {

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { import {
X, Search, RefreshCw, Edit2, Package, Image as ImageIcon, X, Search, RefreshCw, Edit2, Package, Image as ImageIcon,
@@ -75,7 +76,7 @@ const NewArrivals: React.FC = () => {
const fetchDrafts = async () => { const fetchDrafts = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch('/api/products/drafts'); const response = await apiFetch('/api/products/drafts');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setDrafts(data); setDrafts(data);
@@ -116,7 +117,7 @@ const NewArrivals: React.FC = () => {
setIsSaving(true); setIsSaving(true);
try { try {
const response = await fetch(`/api/products/${formData.id}/publish`, { const response = await apiFetch(`/api/products/${formData.id}/publish`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData), body: JSON.stringify(formData),
@@ -137,7 +138,7 @@ const NewArrivals: React.FC = () => {
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!window.confirm('Delete this draft product?')) return; if (!window.confirm('Delete this draft product?')) return;
try { try {
await fetch(`/api/products/${id}`, { method: 'DELETE' }); await apiFetch(`/api/products/${id}`, { method: 'DELETE' });
fetchDrafts(); fetchDrafts();
} catch (error) { } catch (error) {
console.error('Error deleting draft:', error); console.error('Error deleting draft:', error);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { import {
Search, Search,
@@ -103,7 +104,7 @@ const Orders: React.FC = () => {
const fetchProducts = async () => { const fetchProducts = async () => {
try { 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setAllProducts(data); setAllProducts(data);
@@ -127,7 +128,7 @@ const Orders: React.FC = () => {
params.append('page', currentPage.toString()); params.append('page', currentPage.toString());
params.append('size', pageSize.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(); const data = await response.json();
if (data && data.content && Array.isArray(data.content)) { if (data && data.content && Array.isArray(data.content)) {
@@ -156,7 +157,7 @@ const Orders: React.FC = () => {
const handleApproveOrder = async (orderId: number) => { const handleApproveOrder = async (orderId: number) => {
try { 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', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'COMPLETED' }) body: JSON.stringify({ status: 'COMPLETED' })
@@ -174,7 +175,7 @@ const Orders: React.FC = () => {
const handleMarkUndelivered = async (orderId: number) => { const handleMarkUndelivered = async (orderId: number) => {
try { 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', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'PAID' }) body: JSON.stringify({ status: 'PAID' })
@@ -230,7 +231,7 @@ const Orders: React.FC = () => {
if (!selectedOrder) return; if (!selectedOrder) return;
setIsUpdatingOrder(true); setIsUpdatingOrder(true);
try { 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -297,7 +298,7 @@ const Orders: React.FC = () => {
setIsRegenerating(true); setIsRegenerating(true);
try { try {
const newOrderNumber = `ORD-${Math.random().toString(36).substring(2, 10).toUpperCase()}`; 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect, useRef } from 'react'; 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 { 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'; import Pagination from '../components/Pagination';
@@ -161,7 +162,7 @@ const Products = () => {
params.append('search', debouncedSearchTerm); 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(); const data = await response.json();
if (data && data.content) { if (data && data.content) {
setProducts(data.content); setProducts(data.content);
@@ -179,7 +180,7 @@ const Products = () => {
const fetchBaseItems = async () => { const fetchBaseItems = async () => {
try { 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(); const data = await response.json();
setBaseItems(data.content || data); setBaseItems(data.content || data);
} catch (error) { } catch (error) {
@@ -189,7 +190,7 @@ const Products = () => {
const fetchAllStalls = async () => { const fetchAllStalls = async () => {
try { try {
const response = await fetch('http://localhost:8080/api/stalls'); const response = await apiFetch('http://localhost:8080/api/stalls');
const data = await response.json(); const data = await response.json();
setAllStalls(data); setAllStalls(data);
} catch (error) { } catch (error) {
@@ -205,7 +206,7 @@ const Products = () => {
const method = editingProduct ? 'PUT' : 'POST'; const method = editingProduct ? 'PUT' : 'POST';
try { try {
const response = await fetch(url, { const response = await apiFetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData), body: JSON.stringify(formData),
@@ -233,7 +234,7 @@ const Products = () => {
const handleToggleActive = async (product: Product) => { const handleToggleActive = async (product: Product) => {
try { 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...product, active: !product.active }), body: JSON.stringify({ ...product, active: !product.active }),
@@ -249,7 +250,7 @@ const Products = () => {
const handleToggleStock = async (product: Product) => { const handleToggleStock = async (product: Product) => {
try { 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', method: 'PATCH',
}); });
if (response.ok) { if (response.ok) {
@@ -264,7 +265,7 @@ const Products = () => {
const handleDelete = async (product: Product) => { const handleDelete = async (product: Product) => {
if (!window.confirm(`Are you sure you want to delete ${product.name}?`)) return; if (!window.confirm(`Are you sure you want to delete ${product.name}?`)) return;
try { 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', method: 'DELETE',
}); });
if (response.ok) { if (response.ok) {

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
TrendingUp, TrendingUp,
@@ -71,7 +72,7 @@ const PurchaseAnalytics = () => {
const fetchData = async () => { const fetchData = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const response = await fetch('/api/purchases/orders'); const response = await apiFetch('/api/purchases/orders');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
processAnalytics(data); processAnalytics(data);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Search, ShoppingCart, Eye, Plus, X, Loader2, CheckCircle, Trash2, Edit2 } from 'lucide-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 () => { const fetchOrders = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const response = await fetch('/api/purchases/orders'); const response = await apiFetch('/api/purchases/orders');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setOrders(data); setOrders(data);
@@ -79,7 +80,7 @@ const Purchases: React.FC = () => {
const fetchVendors = async () => { const fetchVendors = async () => {
try { try {
const response = await fetch('/api/purchases/vendors'); const response = await apiFetch('/api/purchases/vendors');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setVendors(data); setVendors(data);
@@ -91,7 +92,7 @@ const Purchases: React.FC = () => {
const fetchProducts = async () => { const fetchProducts = async () => {
try { try {
const response = await fetch('/api/products?size=1000'); const response = await apiFetch('/api/products?size=1000');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setAvailableProducts(data.content || data); setAvailableProducts(data.content || data);
@@ -150,7 +151,7 @@ const Purchases: React.FC = () => {
date: new Date(newOrder.date).toISOString() date: new Date(newOrder.date).toISOString()
}; };
const response = await fetch('/api/purchases/orders', { const response = await apiFetch('/api/purchases/orders', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -172,7 +173,7 @@ const Purchases: React.FC = () => {
const deleteOrder = async (id: number) => { const deleteOrder = async (id: number) => {
if (!window.confirm('Delete this purchase order?')) return; if (!window.confirm('Delete this purchase order?')) return;
try { try {
await fetch(`/api/purchases/orders/${id}`, { method: 'DELETE' }); await apiFetch(`/api/purchases/orders/${id}`, { method: 'DELETE' });
fetchOrders(); fetchOrders();
} catch (error) { } catch (error) {
console.error('Error deleting order:', error); console.error('Error deleting order:', error);
@@ -197,7 +198,7 @@ const Purchases: React.FC = () => {
const order = orders.find(o => o.id === orderId); const order = orders.find(o => o.id === orderId);
if (!order) return; if (!order) return;
const response = await fetch(`/api/purchases/orders`, { const response = await apiFetch(`/api/purchases/orders`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...order, status: newStatus }) body: JSON.stringify({ ...order, status: newStatus })
@@ -229,7 +230,7 @@ const Purchases: React.FC = () => {
const fetchOrderHistory = async (order: PurchaseOrder) => { const fetchOrderHistory = async (order: PurchaseOrder) => {
try { try {
setActiveHistoryOrder(order); 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setOrderHistory(data); setOrderHistory(data);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Download, Download,
@@ -66,7 +67,7 @@ const Reports: React.FC = () => {
try { try {
const fromStr = dateRange.from.toISOString(); const fromStr = dateRange.from.toISOString();
const toStr = dateRange.to.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(); const result = await response.json();
setData(result); setData(result);
} catch (error) { } catch (error) {

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Building2, Building2,
@@ -56,11 +57,11 @@ const RitzPage: React.FC = () => {
setIsLoading(true); setIsLoading(true);
const host = window.location.hostname; 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(); const statsData = await statsRes.json();
setStats(statsData); 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(); const transData = await transRes.json();
setTransactions(Array.isArray(transData) ? transData : []); setTransactions(Array.isArray(transData) ? transData : []);
} catch (error) { } catch (error) {

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
CircleDollarSign, CircleDollarSign,
@@ -52,7 +53,7 @@ const RitzCirculation: React.FC = () => {
try { try {
setIsLoading(true); setIsLoading(true);
const host = window.location.hostname; 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(); const data: PageResponse = await res.json();
setTokens(data.content || []); setTokens(data.content || []);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
User, User,
@@ -47,7 +48,7 @@ const Settings = () => {
const fetchAdmins = async () => { const fetchAdmins = async () => {
try { try {
const response = await fetch('/api/system/admins'); const response = await apiFetch('/api/system/admins');
const data = await response.json(); const data = await response.json();
setAdmins(data); setAdmins(data);
} catch (err) { } catch (err) {
@@ -69,7 +70,7 @@ const Settings = () => {
setStatus('loading'); setStatus('loading');
try { try {
const response = await fetch('/api/system/update-master', { const response = await apiFetch('/api/system/update-master', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -102,7 +103,7 @@ const Settings = () => {
e.preventDefault(); e.preventDefault();
setStatus('loading'); setStatus('loading');
try { try {
const response = await fetch('/api/system/admins', { const response = await apiFetch('/api/system/admins', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAdmin) body: JSON.stringify(newAdmin)

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
@@ -50,7 +51,7 @@ const Staff = () => {
const fetchStaff = async () => { const fetchStaff = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await fetch('/api/system/staff'); const res = await apiFetch('/api/system/staff');
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setStaffList(data); setStaffList(data);
@@ -69,7 +70,7 @@ const Staff = () => {
const handleCreateStaff = async (e: React.FormEvent) => { const handleCreateStaff = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const response = await fetch('/api/system/staff', { const response = await apiFetch('/api/system/staff', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -97,7 +98,7 @@ const Staff = () => {
const deleteStaff = async (id: string) => { const deleteStaff = async (id: string) => {
if (window.confirm('Are you sure you want to remove this staff member?')) { if (window.confirm('Are you sure you want to remove this staff member?')) {
try { try {
const response = await fetch(`/api/system/staff/${id}`, { const response = await apiFetch(`/api/system/staff/${id}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (response.ok) { if (response.ok) {

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
@@ -94,7 +95,7 @@ const Stalls: React.FC = () => {
try { try {
setLoading(true); setLoading(true);
const host = window.location.hostname; 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setStalls(data); setStalls(data);
@@ -126,7 +127,7 @@ const Stalls: React.FC = () => {
? `http://${host}:8080/api/stalls/${editingStall.id}` ? `http://${host}:8080/api/stalls/${editingStall.id}`
: `http://${host}:8080/api/stalls`; : `http://${host}:8080/api/stalls`;
const response = await fetch(url, { const response = await apiFetch(url, {
method: editingStall ? 'PUT' : 'POST', method: editingStall ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -173,7 +174,7 @@ const Stalls: React.FC = () => {
if (!window.confirm('Are you sure you want to delete this stall?')) return; if (!window.confirm('Are you sure you want to delete this stall?')) return;
try { try {
const host = window.location.hostname; 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' method: 'DELETE'
}); });
if (response.ok) { if (response.ok) {
@@ -216,7 +217,7 @@ const Stalls: React.FC = () => {
setIsSaving(true); setIsSaving(true);
try { try {
const host = window.location.hostname; 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
ChevronRight, ChevronRight,
@@ -99,7 +100,7 @@ const StoreDashboard = () => {
params.append('from', range.from); params.append('from', range.from);
params.append('to', range.to); 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) { if (response.ok) {
const data = await response.json(); const data = await response.json();
console.log('Dashboard data received successfully:', data); console.log('Dashboard data received successfully:', data);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
@@ -32,7 +33,7 @@ const Terminals = () => {
const fetchTerminals = async () => { const fetchTerminals = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch('/api/terminals'); const response = await apiFetch('/api/terminals');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setTerminals(data); setTerminals(data);
@@ -52,7 +53,7 @@ const Terminals = () => {
e.stopPropagation(); e.stopPropagation();
if (window.confirm('Are you sure you want to remove this terminal?')) { if (window.confirm('Are you sure you want to remove this terminal?')) {
try { try {
const response = await fetch(`/api/terminals/${id}`, { method: 'DELETE' }); const response = await apiFetch(`/api/terminals/${id}`, { method: 'DELETE' });
if (response.ok) fetchTerminals(); if (response.ok) fetchTerminals();
} catch (error) { } catch (error) {
console.error('Failed to delete terminal:', error); console.error('Failed to delete terminal:', error);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
@@ -53,7 +54,7 @@ const VendorDashboard = () => {
const fetchData = async () => { const fetchData = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const response = await fetch('/api/dashboard/procurement'); const response = await apiFetch('/api/dashboard/procurement');
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
setData(result); setData(result);

View File

@@ -1,3 +1,4 @@
import { apiFetch } from '../api';
import React, { useState, useEffect } from 'react'; 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 { Search, Building2, Phone, Mail, MoreVertical, List, LayoutGrid, Edit2, Trash2, X, Loader2, AlertCircle, CheckCircle, Plus } from 'lucide-react';
import Pagination from '../components/Pagination'; import Pagination from '../components/Pagination';
@@ -43,7 +44,7 @@ const Vendors: React.FC = () => {
const fetchVendors = async () => { const fetchVendors = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const response = await fetch('/api/purchases/vendors'); const response = await apiFetch('/api/purchases/vendors');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setVendors(data); setVendors(data);
@@ -85,7 +86,7 @@ const Vendors: React.FC = () => {
if (!window.confirm(`Are you sure you want to delete ${vendor.name}?`)) return; if (!window.confirm(`Are you sure you want to delete ${vendor.name}?`)) return;
try { try {
const response = await fetch(`/api/purchases/vendors/${vendor.id}`, { const response = await apiFetch(`/api/purchases/vendors/${vendor.id}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (response.ok) { if (response.ok) {
@@ -111,7 +112,7 @@ const Vendors: React.FC = () => {
setIsSaving(true); setIsSaving(true);
try { try {
const response = await fetch('/api/purchases/vendors', { const response = await apiFetch('/api/purchases/vendors', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingVendor ? { ...form, id: editingVendor.id } : form), body: JSON.stringify(editingVendor ? { ...form, id: editingVendor.id } : form),