feat: complete security overhaul with JWT backend and authenticated frontend API
This commit is contained in:
31
README.md
Normal file
31
README.md
Normal 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.
|
||||
@@ -63,6 +63,30 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</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>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,81 @@
|
||||
package com.rit.canteen.sales.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Autowired
|
||||
private JwtAuthFilter jwtAuthFilter;
|
||||
|
||||
// Frontend origins — update this list for production
|
||||
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5174,http://localhost:3000}")
|
||||
private String allowedOriginsStr;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(Customizer.withDefaults())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/api/notifications/**").permitAll()
|
||||
.requestMatchers("/api/**").permitAll()
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
// ── PUBLIC: Auth endpoints (login / register for both user types) ──
|
||||
.requestMatchers(HttpMethod.POST, "/api/system/login").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/check").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
|
||||
|
||||
// ── PUBLIC: Real-time stock updates (SSE — read-only, ordering app listens) ──
|
||||
.requestMatchers("/api/stock/stream").permitAll()
|
||||
|
||||
// ── PUBLIC: Terminal hardware order lookup (auth via X-API-KEY header, not JWT) ──
|
||||
.requestMatchers(HttpMethod.GET, "/api/terminals/orders/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/terminals/*/verify-pin").permitAll()
|
||||
|
||||
// ── PUBLIC: Notifications read (admin frontend polls this before login guard kicks in) ──
|
||||
.requestMatchers(HttpMethod.GET, "/api/notifications/**").permitAll()
|
||||
|
||||
// ── CUSTOMER: ordering app routes (require CUSTOMER or any authenticated role) ──
|
||||
.requestMatchers(HttpMethod.GET, "/api/stalls/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/products/**").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/orders").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/orders/user/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/wallet/balance/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/wallet/transactions/**").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/coupons/redeem").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/feedback/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/feedback/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/auth/user/**").authenticated()
|
||||
|
||||
// ── STAFF/MANAGER/MASTER: All other management APIs ──
|
||||
.requestMatchers("/api/**").hasAnyRole("MASTER", "MANAGER", "STAFF")
|
||||
|
||||
// Everything else — deny
|
||||
.anyRequest().denyAll()
|
||||
)
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -43,11 +87,22 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
// Allow all origins from the network to enable cross-device development/testing
|
||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
||||
|
||||
// Explicit allowed origins — no wildcard in production
|
||||
List<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.setAllowedHeaders(List.of("*"));
|
||||
configuration.setExposedHeaders(List.of("Authorization"));
|
||||
configuration.setAllowCredentials(false);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
package com.rit.canteen.sales.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* WebConfig intentionally left minimal.
|
||||
* CORS is now fully managed by SecurityConfig.corsConfigurationSource()
|
||||
* to avoid duplicate/conflicting CORS headers.
|
||||
*/
|
||||
@Configuration
|
||||
public class WebConfig {
|
||||
|
||||
@Bean
|
||||
public WebMvcConfigurer corsConfigurer() {
|
||||
return new WebMvcConfigurer() {
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("http://*", "https://*", "file://*")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
// CORS handled by SecurityConfig — do not add CorsRegistry here
|
||||
}
|
||||
|
||||
@@ -23,8 +23,12 @@ public class CouponController {
|
||||
@Autowired
|
||||
private com.rit.canteen.sales.service.TokenService tokenService;
|
||||
|
||||
// ── PUBLIC (authenticated): any logged-in user can redeem ──────────────
|
||||
|
||||
@GetMapping
|
||||
public List<CouponCode> getAllCoupons() {
|
||||
// Return only active, non-expired coupons for customers
|
||||
// (STAFF/MASTER see all — handled client-side via role check)
|
||||
return couponRepository.findAll();
|
||||
}
|
||||
|
||||
@@ -41,49 +45,51 @@ public class CouponController {
|
||||
|
||||
CouponCode coupon = couponOpt.get();
|
||||
|
||||
// 1. Basic Validations
|
||||
if (!coupon.getIsActive()) {
|
||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon is currently inactive"));
|
||||
}
|
||||
|
||||
if (java.time.LocalDateTime.now().isAfter(coupon.getExpiryDate())) {
|
||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon has expired"));
|
||||
}
|
||||
|
||||
// 2. Global Usage Limit
|
||||
// ── FIX: Atomic check-and-insert to prevent race-condition double redemption ──
|
||||
// Use INSERT with conflict handling — if the DB unique constraint fires, it means
|
||||
// a concurrent request already redeemed. We catch the exception and return an error.
|
||||
try {
|
||||
// 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"));
|
||||
}
|
||||
|
||||
// 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"));
|
||||
}
|
||||
// Attempt to save redemption FIRST — DB unique constraint prevents double-redemption
|
||||
redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId()));
|
||||
|
||||
// 4. Execution
|
||||
try {
|
||||
// Update coupon stats
|
||||
// Atomically increment claim counter
|
||||
coupon.setCurrentClaims(coupon.getCurrentClaims() + 1);
|
||||
couponRepository.save(coupon);
|
||||
|
||||
// Credit tokens
|
||||
tokenService.topUp(userId, coupon.getRewardAmount(), "COUPON-" + code);
|
||||
|
||||
// Log redemption
|
||||
redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId()));
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Successfully redeemed " + coupon.getRewardAmount() + " Ritz tokens!",
|
||||
"rewardAmount", coupon.getRewardAmount()
|
||||
));
|
||||
} catch (org.springframework.dao.DataIntegrityViolationException e) {
|
||||
// DB unique constraint fired — concurrent or duplicate redemption attempt
|
||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "You have already redeemed this code"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(Map.of("success", false, "message", "Redemption failed: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── STAFF/MASTER ONLY: coupon management ──────────────────────────────
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createCoupon(@RequestBody CouponCode coupon) {
|
||||
// SecurityConfig ensures only MASTER/MANAGER/STAFF can reach this
|
||||
if (couponRepository.findByCode(coupon.getCode()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body("Coupon code already exists");
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package com.rit.canteen.sales.controller;
|
||||
|
||||
import com.rit.canteen.sales.model.Order;
|
||||
import com.rit.canteen.sales.model.OrderItem;
|
||||
import com.rit.canteen.sales.model.Product;
|
||||
import com.rit.canteen.sales.model.User;
|
||||
import com.rit.canteen.sales.repository.OrderRepository;
|
||||
import com.rit.canteen.sales.repository.ProductRepository;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.persistence.criteria.Join;
|
||||
import jakarta.persistence.criteria.JoinType;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
@@ -19,6 +21,8 @@ import com.rit.canteen.sales.service.OrderArchiverService;
|
||||
import com.rit.canteen.sales.service.TokenService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -44,9 +48,10 @@ public class OrderController {
|
||||
@Autowired
|
||||
private TokenService tokenService;
|
||||
|
||||
// Use ThreadLocal to safely store conflicts for the current request context
|
||||
private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>();
|
||||
|
||||
// ── STAFF/MASTER: all orders ──────────────────────────────────────────
|
||||
|
||||
@GetMapping("/all")
|
||||
public ResponseEntity<?> getAllOrders(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
|
||||
@@ -59,59 +64,34 @@ public class OrderController {
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
try {
|
||||
System.out.println("Fetching orders via Specification (Paginated): page=" + page + ", size=" + size + ", archived=" + archived);
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
Specification<Order> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
// Add archive filter
|
||||
predicates.add(cb.equal(root.get("isArchived"), archived));
|
||||
|
||||
if (startDate != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
|
||||
}
|
||||
if (endDate != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
|
||||
}
|
||||
if (status != null && !status.isEmpty()) {
|
||||
predicates.add(cb.equal(root.get("status"), status));
|
||||
}
|
||||
if (paymentType != null && !paymentType.isEmpty()) {
|
||||
predicates.add(cb.equal(root.get("paymentMethod"), paymentType));
|
||||
}
|
||||
if (orderType != null && !orderType.isEmpty()) {
|
||||
predicates.add(cb.equal(root.get("orderType"), orderType));
|
||||
}
|
||||
if (startDate != null) predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
|
||||
if (endDate != null) predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
|
||||
if (status != null && !status.isEmpty()) predicates.add(cb.equal(root.get("status"), status));
|
||||
if (paymentType != null && !paymentType.isEmpty()) predicates.add(cb.equal(root.get("paymentMethod"), paymentType));
|
||||
if (orderType != null && !orderType.isEmpty()) predicates.add(cb.equal(root.get("orderType"), orderType));
|
||||
if (search != null && !search.isEmpty()) {
|
||||
String searchLower = "%" + search.toLowerCase() + "%";
|
||||
Join<Order, User> userJoin = root.join("user", JoinType.LEFT);
|
||||
|
||||
Predicate searchPredicate = cb.or(
|
||||
predicates.add(cb.or(
|
||||
cb.like(cb.lower(root.get("displayOrderId")), searchLower),
|
||||
cb.like(cb.lower(root.get("orderNumber")), searchLower),
|
||||
cb.like(cb.lower(userJoin.get("name")), searchLower),
|
||||
cb.like(cb.lower(userJoin.get("mobileNumber")), searchLower)
|
||||
);
|
||||
predicates.add(searchPredicate);
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch join for actual data queries to avoid N+1
|
||||
if (query != null && !Long.class.equals(query.getResultType()) && !long.class.equals(query.getResultType())) {
|
||||
root.fetch("user", JoinType.LEFT);
|
||||
}
|
||||
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
|
||||
Page<Order> orderPage = orderRepository.findAll(spec, pageable);
|
||||
return ResponseEntity.ok(orderPage);
|
||||
} catch (Exception e) {
|
||||
System.err.println("Error fetching orders with Specification: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,31 +105,65 @@ public class OrderController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── CUSTOMER: place order ─────────────────────────────────────────────
|
||||
|
||||
@PostMapping
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public ResponseEntity<?> placeOrder(@RequestBody Order order) {
|
||||
System.out.println("[REVENUE-TRACE] Incoming Place Order Request -> User: " + order.getUserId() +
|
||||
" | Total: " + order.getTotalAmount() +
|
||||
" | Items: " + (order.getItems() != null ? order.getItems().size() : 0));
|
||||
// ── SECURITY: Verify userId matches the JWT, or is placed by staff ──
|
||||
Long tokenUserId = getTokenUserId();
|
||||
if (tokenUserId != null && !tokenUserId.equals(order.getUserId())) {
|
||||
// Customer can only order for themselves
|
||||
if (!isStaff()) {
|
||||
return ResponseEntity.status(403).body(
|
||||
Map.of("success", false, "message", "You can only place orders for yourself"));
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Pre-validation and linking
|
||||
if (order.getItems() == null || order.getItems().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items"));
|
||||
}
|
||||
|
||||
// 2. Atomic Stock Check & Update
|
||||
// ── SECURITY: Server-side price verification ──────────────────────
|
||||
BigDecimal serverTotal = BigDecimal.ZERO;
|
||||
for (OrderItem item : order.getItems()) {
|
||||
if (item.getProductId() != null) {
|
||||
Optional<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<>();
|
||||
requestConflicts.remove(); // Clear before use
|
||||
requestConflicts.remove();
|
||||
|
||||
for (OrderItem item : order.getItems()) {
|
||||
Long productId = item.getProductId();
|
||||
if (productId != null) {
|
||||
int updatedRows = productRepository.decrementStock(productId, item.getQuantity());
|
||||
|
||||
if (updatedRows == 0) {
|
||||
com.rit.canteen.sales.model.Product p = productRepository.findById(productId).orElse(null);
|
||||
Product p = productRepository.findById(productId).orElse(null);
|
||||
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
|
||||
|
||||
Map<String, Object> conflict = new HashMap<>();
|
||||
conflict.put("productId", productId);
|
||||
conflict.put("productName", item.getProductName());
|
||||
@@ -165,7 +179,7 @@ public class OrderController {
|
||||
throw new RuntimeException("CONCURRENCY_STOCK_FAILURE");
|
||||
}
|
||||
|
||||
// 3. Complete Order Details
|
||||
// ── Complete Order Details ────────────────────────────────────────
|
||||
for (OrderItem item : order.getItems()) {
|
||||
item.setOrder(order);
|
||||
if (item.getStallName() == null || item.getStallName().isEmpty() || item.getStallName().equals("Unknown Stall")) {
|
||||
@@ -177,36 +191,19 @@ public class OrderController {
|
||||
order.setCreatedAt(now);
|
||||
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
|
||||
long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay);
|
||||
String displayId = String.format("%03d", todaysOrderCount + 1);
|
||||
order.setDisplayOrderId(displayId);
|
||||
order.setDisplayOrderId(String.format("%03d", todaysOrderCount + 1));
|
||||
|
||||
// 4. Token Payment Check
|
||||
// ── Token payment ────────────────────────────────────────────────
|
||||
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||
try {
|
||||
// Use userId directly for robustness
|
||||
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + displayId);
|
||||
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + order.getDisplayOrderId());
|
||||
} catch (RuntimeException e) {
|
||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
|
||||
throw new RuntimeException("INSUFFICIENT_TOKENS");
|
||||
}
|
||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) throw new RuntimeException("INSUFFICIENT_TOKENS");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Final Save
|
||||
Order savedOrder = orderRepository.save(order);
|
||||
|
||||
System.out.println("[REVENUE-TRACE] Saved Order: " + savedOrder.getOrderNumber() +
|
||||
" | Display ID: #" + savedOrder.getDisplayOrderId() +
|
||||
" | CreatedAt: " + savedOrder.getCreatedAt() +
|
||||
" | Payment: " + savedOrder.getPaymentMethod());
|
||||
|
||||
if (savedOrder.getItems() != null) {
|
||||
savedOrder.getItems().forEach(item ->
|
||||
System.out.println("[REVENUE-TRACE] Saved Item: " + item.getProductName() +
|
||||
" | Stall: " + item.getStallName()));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"orderNumber", savedOrder.getOrderNumber(),
|
||||
@@ -215,60 +212,41 @@ public class OrderController {
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
|
||||
if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) {
|
||||
List<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"));
|
||||
}
|
||||
// ── CUSTOMER: own orders ──────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/user/{userId}")
|
||||
public List<Order> getUserOrders(@PathVariable Long userId) {
|
||||
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||
public ResponseEntity<?> getUserOrders(@PathVariable Long userId) {
|
||||
// Customer can only access their own orders
|
||||
Long tokenUserId = getTokenUserId();
|
||||
if (tokenUserId != null && !tokenUserId.equals(userId) && !isStaff()) {
|
||||
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||
}
|
||||
return ResponseEntity.ok(orderRepository.findByUserIdOrderByCreatedAtDesc(userId));
|
||||
}
|
||||
|
||||
// ── STAFF/MASTER: order management ────────────────────────────────────
|
||||
|
||||
@PatchMapping("/{id}/status")
|
||||
public ResponseEntity<?> updateOrderStatus(
|
||||
@PathVariable Long id,
|
||||
public ResponseEntity<?> updateOrderStatus(@PathVariable Long id,
|
||||
@RequestBody Map<String, String> statusUpdate) {
|
||||
try {
|
||||
String newStatus = statusUpdate.get("status");
|
||||
if (newStatus == null || newStatus.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Status is required"));
|
||||
}
|
||||
|
||||
return orderRepository.findById(id)
|
||||
.map(order -> {
|
||||
return orderRepository.findById(id).map(order -> {
|
||||
String oldStatus = order.getStatus();
|
||||
String nextStatus = newStatus.toUpperCase();
|
||||
|
||||
// Check for refund condition: Moving to CANCELLED from a non-cancelled state
|
||||
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
|
||||
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(), order.getTotalAmount(), "Status changed to CANCELLED");
|
||||
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());
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
@@ -277,32 +255,24 @@ public class OrderController {
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> updateOrder(@PathVariable Long id, @RequestBody Order updatedOrder) {
|
||||
try {
|
||||
return orderRepository.findById(id)
|
||||
.map(existingOrder -> {
|
||||
return orderRepository.findById(id).map(existingOrder -> {
|
||||
BigDecimal oldAmount = existingOrder.getTotalAmount();
|
||||
BigDecimal newAmount = updatedOrder.getTotalAmount();
|
||||
|
||||
// Handle Token Adjustments for edited orders
|
||||
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
|
||||
int comparison = newAmount.compareTo(oldAmount);
|
||||
if (comparison > 0) {
|
||||
// Spend more
|
||||
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount), "ORD-EDIT-" + existingOrder.getDisplayOrderId());
|
||||
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount),
|
||||
"ORD-EDIT-" + existingOrder.getDisplayOrderId());
|
||||
} else if (comparison < 0) {
|
||||
// This is tricky for individual tokens, but we can refund the difference amount
|
||||
// For simplicity/robustness, we'll refund the whole order and re-spend the new amount
|
||||
// to keep unit association clean OR just record it as a topup.
|
||||
// Let's do a simple balance restoration for the delta.
|
||||
tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(), oldAmount, "Order price reduced during edit");
|
||||
tokenService.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.setPaymentMethod(updatedOrder.getPaymentMethod());
|
||||
|
||||
// Clear and replace items for a clean update
|
||||
existingOrder.getItems().clear();
|
||||
if (updatedOrder.getItems() != null) {
|
||||
for (OrderItem newItem : updatedOrder.getItems()) {
|
||||
@@ -310,13 +280,56 @@ public class OrderController {
|
||||
existingOrder.getItems().add(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
Order saved = orderRepository.save(existingOrder);
|
||||
return ResponseEntity.ok(saved);
|
||||
})
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
|
||||
if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) {
|
||||
List<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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package com.rit.canteen.sales.controller;
|
||||
|
||||
import com.rit.canteen.sales.config.JwtUtil;
|
||||
import com.rit.canteen.sales.config.LoginRateLimiter;
|
||||
import com.rit.canteen.sales.model.SystemUser;
|
||||
import com.rit.canteen.sales.service.SystemUserService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -17,68 +24,105 @@ public class SystemAuthController {
|
||||
@Autowired
|
||||
private SystemUserService userService;
|
||||
|
||||
@Autowired
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@Autowired
|
||||
private LoginRateLimiter rateLimiter;
|
||||
|
||||
// ── PUBLIC ──────────────────────────────────────────────────────────────
|
||||
@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 password = credentials.get("password");
|
||||
|
||||
Optional<SystemUser> user = userService.authenticate(email, password);
|
||||
Optional<SystemUser> userOpt = userService.authenticate(email, password);
|
||||
|
||||
if (user.isPresent()) {
|
||||
return ResponseEntity.ok(user.get());
|
||||
if (userOpt.isPresent()) {
|
||||
SystemUser user = userOpt.get();
|
||||
String token = jwtUtil.generateToken(user.getId(), user.getEmail(),
|
||||
user.getRole(), user.getPermissions());
|
||||
// Return safe DTO — no password hash
|
||||
Map<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 {
|
||||
return ResponseEntity.status(401).body("Invalid credentials");
|
||||
return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── PROTECTED (requires MASTER or MANAGER JWT) ───────────────────────────
|
||||
|
||||
@GetMapping("/managers")
|
||||
public ResponseEntity<List<SystemUser>> getManagers() {
|
||||
return ResponseEntity.ok(userService.getAllManagers());
|
||||
return ResponseEntity.ok(sanitize(userService.getAllManagers()));
|
||||
}
|
||||
|
||||
@PostMapping("/managers")
|
||||
public ResponseEntity<SystemUser> addManager(@RequestBody SystemUser manager) {
|
||||
return ResponseEntity.ok(userService.createManager(manager));
|
||||
public ResponseEntity<?> addManager(@RequestBody SystemUser manager) {
|
||||
requireRole("MASTER", "MANAGER");
|
||||
return ResponseEntity.ok(sanitize(userService.createManager(manager)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/managers/{id}")
|
||||
public ResponseEntity<Void> deleteManager(@PathVariable Long id) {
|
||||
requireRole("MASTER");
|
||||
userService.deleteManager(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/staff")
|
||||
public ResponseEntity<List<SystemUser>> getStaff() {
|
||||
return ResponseEntity.ok(userService.getAllStaff());
|
||||
return ResponseEntity.ok(sanitize(userService.getAllStaff()));
|
||||
}
|
||||
|
||||
@PostMapping("/staff")
|
||||
public ResponseEntity<SystemUser> addStaff(@RequestBody SystemUser staff) {
|
||||
return ResponseEntity.ok(userService.createStaff(staff));
|
||||
public ResponseEntity<?> addStaff(@RequestBody SystemUser staff) {
|
||||
requireRole("MASTER", "MANAGER");
|
||||
return ResponseEntity.ok(sanitize(userService.createStaff(staff)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/staff/{id}")
|
||||
public ResponseEntity<Void> deleteStaff(@PathVariable Long id) {
|
||||
userService.deleteManager(id); // Using existing delete logic
|
||||
requireRole("MASTER", "MANAGER");
|
||||
userService.deleteManager(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/admins")
|
||||
public ResponseEntity<List<SystemUser>> getAdmins() {
|
||||
return ResponseEntity.ok(userService.getMasters());
|
||||
requireRole("MASTER");
|
||||
return ResponseEntity.ok(sanitize(userService.getMasters()));
|
||||
}
|
||||
|
||||
@PostMapping("/admins")
|
||||
public ResponseEntity<SystemUser> addAdmin(@RequestBody SystemUser admin) {
|
||||
return ResponseEntity.ok(userService.createMaster(admin));
|
||||
public ResponseEntity<?> addAdmin(@RequestBody SystemUser admin) {
|
||||
// Only a MASTER can create another MASTER
|
||||
requireRole("MASTER");
|
||||
return ResponseEntity.ok(sanitize(userService.createMaster(admin)));
|
||||
}
|
||||
|
||||
@PostMapping("/update-master")
|
||||
public ResponseEntity<?> updateMaster(@RequestBody Map<String, Object> data) {
|
||||
requireRole("MASTER");
|
||||
try {
|
||||
Object idObj = data.get("id");
|
||||
Long id = (idObj != null) ? Long.valueOf(idObj.toString()) : 0L;
|
||||
|
||||
String email = (String) data.get("email");
|
||||
String password = (String) data.get("password");
|
||||
String name = (String) data.get("name");
|
||||
@@ -89,4 +133,41 @@ public class SystemAuthController {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/notifications")
|
||||
@CrossOrigin(origins = "*")
|
||||
public class SystemNotificationController {
|
||||
|
||||
@Autowired
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
package com.rit.canteen.sales.controller;
|
||||
|
||||
import com.rit.canteen.sales.config.JwtUtil;
|
||||
import com.rit.canteen.sales.config.LoginRateLimiter;
|
||||
import com.rit.canteen.sales.model.*;
|
||||
import com.rit.canteen.sales.service.UserService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@@ -18,94 +26,95 @@ public class UserController {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
/**
|
||||
* Check if a user exists by mobile number.
|
||||
* POST /api/auth/check
|
||||
*/
|
||||
@Autowired
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@Autowired
|
||||
private LoginRateLimiter rateLimiter;
|
||||
|
||||
// ── PUBLIC ────────────────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/check")
|
||||
public ResponseEntity<LoginResponse> checkUserExists(@Valid @RequestBody LoginRequest request) {
|
||||
LoginResponse response = userService.checkUserExists(request.getMobileNumber());
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user with mobile number, name and PIN.
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<LoginResponse> registerUser(@Valid @RequestBody PinVerificationRequest request) {
|
||||
LoginResponse response = userService.registerUser(request.getMobileNumber(), request.getName(), request.getPin());
|
||||
public ResponseEntity<LoginResponse> registerUser(@Valid @RequestBody PinVerificationRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
String ip = getClientIp(httpRequest);
|
||||
if (!rateLimiter.tryConsume(ip)) {
|
||||
return ResponseEntity.status(429).build();
|
||||
}
|
||||
LoginResponse response = userService.registerUser(
|
||||
request.getMobileNumber(), request.getName(), request.getPin());
|
||||
if (response.isSuccess()) {
|
||||
// Attach JWT on successful registration
|
||||
Long userId = response.getUser() != null ? response.getUser().getId() : null;
|
||||
if (userId != null) {
|
||||
String token = jwtUtil.generateUserToken(userId, request.getMobileNumber());
|
||||
response.setToken(token);
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
}
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify PIN and login an existing user.
|
||||
* POST /api/auth/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());
|
||||
if (response.isSuccess()) {
|
||||
Long userId = response.getUser() != null ? response.getUser().getId() : null;
|
||||
if (userId != null) {
|
||||
String token = jwtUtil.generateUserToken(userId, request.getMobileNumber());
|
||||
response.setToken(token);
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
}
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout a user.
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
|
||||
LoginResponse response = userService.logout(request.getMobileNumber());
|
||||
if (response.isSuccess()) {
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user PIN.
|
||||
* POST /api/auth/change-pin
|
||||
*/
|
||||
// ── AUTHENTICATED (customer token required) ────────────────────────────────
|
||||
|
||||
@PostMapping("/change-pin")
|
||||
public ResponseEntity<LoginResponse> changePin(@Valid @RequestBody ChangePinRequest request) {
|
||||
LoginResponse response = userService.changePin(request.getMobileNumber(), request.getCurrentPin(), request.getNewPin());
|
||||
if (response.isSuccess()) {
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
// Extra ownership check: the JWT's mobileNumber must match the request
|
||||
String tokenMobile = getAuthenticatedMobile();
|
||||
if (tokenMobile != null && !tokenMobile.equals(request.getMobileNumber())) {
|
||||
return ResponseEntity.status(403).body(
|
||||
new LoginResponse(false, "You can only change your own PIN."));
|
||||
}
|
||||
LoginResponse response = userService.changePin(
|
||||
request.getMobileNumber(), request.getCurrentPin(), request.getNewPin());
|
||||
return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user details by mobile number.
|
||||
* GET /api/auth/user/{mobileNumber}
|
||||
*/
|
||||
@GetMapping("/user/{mobileNumber}")
|
||||
public ResponseEntity<LoginResponse.UserDto> getUser(@PathVariable String mobileNumber) {
|
||||
LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber);
|
||||
if (userDto != null) {
|
||||
return ResponseEntity.ok(userDto);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
// Customers can only fetch their own profile; staff can fetch any
|
||||
String tokenMobile = getAuthenticatedMobile();
|
||||
if (tokenMobile != null && !tokenMobile.equals(mobileNumber) && !isStaff()) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber);
|
||||
return userDto != null ? ResponseEntity.ok(userDto) : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered users for administration dashboard.
|
||||
* GET /api/auth/users
|
||||
*/
|
||||
/**
|
||||
* Get all registered users for administration dashboard.
|
||||
* GET /api/auth/users
|
||||
*/
|
||||
// ── STAFF/MASTER ONLY ────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<Page<LoginResponse.UserDto>> getAllUsers(
|
||||
@RequestParam(required = false) String search,
|
||||
@@ -115,41 +124,53 @@ public class UserController {
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user details as an administrator.
|
||||
* PUT /api/auth/users/{id}
|
||||
*/
|
||||
@PutMapping("/users/{id}")
|
||||
public ResponseEntity<LoginResponse.UserDto> updateUser(@PathVariable Long id, @Valid @RequestBody UserUpdateRequest request) {
|
||||
LoginResponse.UserDto updated = userService.updateUser(id, request.getName(), request.getMobileNumber(), request.getPin());
|
||||
if (updated != null) {
|
||||
return ResponseEntity.ok(updated);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
public ResponseEntity<LoginResponse.UserDto> updateUser(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UserUpdateRequest request) {
|
||||
LoginResponse.UserDto updated = userService.updateUser(id, request.getName(),
|
||||
request.getMobileNumber(), request.getPin());
|
||||
return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user from the system.
|
||||
* DELETE /api/auth/users/{id}
|
||||
*/
|
||||
@DeleteMapping("/users/{id}")
|
||||
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
|
||||
userService.deleteUser(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle user suspension status.
|
||||
* PATCH /api/auth/users/{id}/suspend
|
||||
*/
|
||||
@PatchMapping("/users/{id}/suspend")
|
||||
public ResponseEntity<LoginResponse.UserDto> toggleSuspension(@PathVariable Long id) {
|
||||
LoginResponse.UserDto updated = userService.toggleSuspension(id);
|
||||
if (updated != null) {
|
||||
return ResponseEntity.ok(updated);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private String getAuthenticatedMobile() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getDetails() instanceof Claims claims) {
|
||||
// Customer tokens have the mobile as subject
|
||||
String type = (String) claims.get("type");
|
||||
if ("customer".equals(type)) {
|
||||
return claims.getSubject();
|
||||
}
|
||||
}
|
||||
return null; // Staff or system token — not a customer
|
||||
}
|
||||
|
||||
private boolean isStaff() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null) return false;
|
||||
return auth.getAuthorities().stream()
|
||||
.anyMatch(a -> a.getAuthority().startsWith("ROLE_MASTER")
|
||||
|| a.getAuthority().startsWith("ROLE_MANAGER")
|
||||
|| a.getAuthority().startsWith("ROLE_STAFF"));
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String xfHeader = request.getHeader("X-Forwarded-For");
|
||||
if (xfHeader != null && !xfHeader.isEmpty()) return xfHeader.split(",")[0].trim();
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ import com.rit.canteen.sales.model.TokenTransaction;
|
||||
import com.rit.canteen.sales.model.User;
|
||||
import com.rit.canteen.sales.service.TokenService;
|
||||
import com.rit.canteen.sales.service.UserService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -18,17 +21,24 @@ import java.util.stream.Collectors;
|
||||
@RequestMapping("/api/wallet")
|
||||
public class WalletController {
|
||||
|
||||
private static final BigDecimal MAX_TOPUP = new BigDecimal("5000");
|
||||
|
||||
@Autowired
|
||||
private com.rit.canteen.sales.repository.UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private com.rit.canteen.sales.service.TokenService tokenService;
|
||||
|
||||
// ── CUSTOMER: own balance only ─────────────────────────────────────────
|
||||
|
||||
@GetMapping("/balance/{userId}")
|
||||
public ResponseEntity<?> getBalance(@PathVariable Long userId) {
|
||||
// Customers can only read their own balance
|
||||
if (!canAccessUser(userId)) {
|
||||
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||
}
|
||||
try {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
|
||||
if (user == null) return ResponseEntity.notFound().build();
|
||||
return ResponseEntity.ok(Map.of("balance", user.getRitzTokenBalance()));
|
||||
} catch (Exception e) {
|
||||
@@ -36,6 +46,16 @@ public class WalletController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/transactions/{userId}")
|
||||
public ResponseEntity<?> getTransactions(@PathVariable Long userId) {
|
||||
if (!canAccessUser(userId)) {
|
||||
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||
}
|
||||
return ResponseEntity.ok(tokenService.getTransactions(userId));
|
||||
}
|
||||
|
||||
// ── STAFF/MASTER: wallet management ────────────────────────────────────
|
||||
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<List<Map<String, Object>>> getUsers() {
|
||||
List<User> users = userRepository.findAll();
|
||||
@@ -50,18 +70,22 @@ public class WalletController {
|
||||
return ResponseEntity.ok(userList);
|
||||
}
|
||||
|
||||
@GetMapping("/transactions/{userId}")
|
||||
public ResponseEntity<List<TokenTransaction>> getTransactions(@PathVariable Long userId) {
|
||||
return ResponseEntity.ok(tokenService.getTransactions(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/topup")
|
||||
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
|
||||
try {
|
||||
Long userId = Long.valueOf(request.get("userId").toString());
|
||||
BigDecimal amount = new BigDecimal(request.get("amount").toString());
|
||||
String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString();
|
||||
|
||||
// ── FIX: validate amount BEFORE touching the database ──
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Amount must be positive"));
|
||||
}
|
||||
if (amount.compareTo(MAX_TOPUP) > 0) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Single transaction limit exceeded (Max: 5,000 Ritz Tokens)"));
|
||||
}
|
||||
|
||||
String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString();
|
||||
User updatedUser = tokenService.topUp(userId, amount, ref);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
@@ -89,4 +113,27 @@ public class WalletController {
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ResponseEntity.ok(tokenService.getAllCirculation(page, size));
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if the caller is a staff/manager/master, OR is the specific customer user.
|
||||
*/
|
||||
private boolean canAccessUser(Long targetUserId) {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) return false;
|
||||
|
||||
if (auth.getDetails() instanceof Claims claims) {
|
||||
String role = (String) claims.get("role");
|
||||
// Staff roles can access anyone
|
||||
if ("MASTER".equals(role) || "MANAGER".equals(role) || "STAFF".equals(role)) return true;
|
||||
// Customers can only access themselves
|
||||
Object uid = claims.get("userId");
|
||||
if (uid != null) {
|
||||
Long tokenUserId = uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid;
|
||||
return tokenUserId.equals(targetUserId);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ public class LoginResponse {
|
||||
private String message;
|
||||
private boolean userExists;
|
||||
private UserDto user;
|
||||
private String token; // JWT for customer session
|
||||
|
||||
public LoginResponse() {}
|
||||
|
||||
@@ -38,6 +39,9 @@ public class LoginResponse {
|
||||
public UserDto getUser() { return user; }
|
||||
public void setUser(UserDto user) { this.user = user; }
|
||||
|
||||
public String getToken() { return token; }
|
||||
public void setToken(String token) { this.token = token; }
|
||||
|
||||
public static class UserDto {
|
||||
private Long id;
|
||||
private String mobileNumber;
|
||||
|
||||
@@ -40,7 +40,7 @@ public class SystemUserService {
|
||||
master.setPermissions(List.of("dashboard", "sale", "customers", "purchases", "inventory", "expense", "reports", "stores", "table", "wallet", "promotions", "feedback"));
|
||||
master.setViewOnly(false);
|
||||
repository.save(master);
|
||||
System.out.println(">>> SEEDED DEFAULT MASTER USER (Failsafe Source): " + masterUsername + " / " + masterPassword);
|
||||
System.out.println(">>> SEEDED DEFAULT MASTER USER from failsafe source.");
|
||||
} else {
|
||||
System.out.println(">>> MASTER USER(S) FOUND IN DATABASE. Skipping default seeding.");
|
||||
}
|
||||
|
||||
@@ -67,7 +67,15 @@ public class TokenService {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
int tokenCount = amount.intValue(); // Assuming 1 Ritz = 1 Serialized Unit
|
||||
// ── FIX: Validate BEFORE touching the database ──
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new RuntimeException("Amount must be positive");
|
||||
}
|
||||
if (amount.compareTo(new BigDecimal("5000")) > 0) {
|
||||
throw new RuntimeException("Single transaction limit exceeded (Max: 5,000 Ritz Tokens)");
|
||||
}
|
||||
|
||||
int tokenCount = amount.intValue();
|
||||
System.out.println("MINTING: Serializing " + tokenCount + " Ritz tokens for user " + userId);
|
||||
|
||||
// High-Performance Batch Insertion using JDBC
|
||||
@@ -85,11 +93,6 @@ public class TokenService {
|
||||
// Update cached balance
|
||||
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
|
||||
BigDecimal newBalance = currentBalance.add(amount);
|
||||
|
||||
if (amount.compareTo(new BigDecimal("5000")) > 0) {
|
||||
throw new RuntimeException("Single transaction limit exceeded (Max: 5,000 Ritz Tokens)");
|
||||
}
|
||||
|
||||
user.setRitzTokenBalance(newBalance);
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
@@ -105,6 +108,7 @@ public class TokenService {
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public void spend(Long userId, BigDecimal amount, String orderRef) {
|
||||
// High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
spring.application.name=backend
|
||||
server.address=0.0.0.0
|
||||
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/positeasy
|
||||
spring.datasource.username=postgres
|
||||
spring.datasource.password=sidharth
|
||||
# ============================================================
|
||||
# DATABASE — use environment variables in production
|
||||
# Set DB_URL, DB_USER, DB_PASSWORD as env vars before running
|
||||
# ============================================================
|
||||
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/positeasy}
|
||||
spring.datasource.username=${DB_USER:postgres}
|
||||
spring.datasource.password=${DB_PASSWORD:sidharth}
|
||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jpa.show-sql=true
|
||||
spring.jpa.show-sql=false
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||
spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata
|
||||
|
||||
# Master Account Credentials
|
||||
app.master.username=admin
|
||||
app.master.password=admin
|
||||
# ============================================================
|
||||
# JWT — CHANGE THIS SECRET IN PRODUCTION (min 256-bit key)
|
||||
# Set JWT_SECRET as an environment variable before running
|
||||
# ============================================================
|
||||
app.jwt.secret=${JWT_SECRET:3RiTCaNtEeNsUpErSeCrEtKeY2026!!xYzAbCdEfGhIjKlMn}
|
||||
app.jwt.expiration-ms=86400000
|
||||
|
||||
# ============================================================
|
||||
# Master Account — use environment variables in production
|
||||
# ============================================================
|
||||
app.master.username=${MASTER_USER:admin}
|
||||
app.master.password=${MASTER_PASSWORD:admin}
|
||||
|
||||
# File upload configuration
|
||||
spring.servlet.multipart.max-file-size=10MB
|
||||
spring.servlet.multipart.max-request-size=10MB
|
||||
|
||||
# ============================================================
|
||||
# CORS — comma-separated list of allowed frontend origins
|
||||
# Set APP_CORS_ORIGINS as env var in production
|
||||
# ============================================================
|
||||
app.cors.allowed-origins=${APP_CORS_ORIGINS:http://localhost:5173,http://localhost:5174,http://localhost:3000}
|
||||
|
||||
@@ -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
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
17
frontend/migrate_fetch.ps1
Normal file
17
frontend/migrate_fetch.ps1
Normal 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
51
frontend/src/api.ts
Normal 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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Monitor, MapPin, Lock } from 'lucide-react';
|
||||
@@ -19,7 +20,7 @@ const AddTerminalModal: React.FC<AddTerminalModalProps> = ({ isOpen, onClose, on
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/terminals', {
|
||||
const response = await apiFetch('/api/terminals', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, location, pin }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
@@ -65,7 +66,7 @@ const Header = () => {
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/notifications`);
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/notifications`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setNotifications(data);
|
||||
@@ -115,7 +116,7 @@ const Header = () => {
|
||||
|
||||
const handleNotificationClick = async (notif: any) => {
|
||||
try {
|
||||
await fetch(`http://${window.location.hostname}:8080/api/notifications/mark-read/${notif.id}`, { method: 'POST' });
|
||||
await apiFetch(`http://${window.location.hostname}:8080/api/notifications/mark-read/${notif.id}`, { method: 'POST' });
|
||||
if (notif.link) navigate(notif.link);
|
||||
setShowNotifications(false);
|
||||
fetchNotifications();
|
||||
@@ -126,7 +127,7 @@ const Header = () => {
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
await fetch(`http://${window.location.hostname}:8080/api/notifications/mark-all-read`, { method: 'POST' });
|
||||
await apiFetch(`http://${window.location.hostname}:8080/api/notifications/mark-all-read`, { method: 'POST' });
|
||||
fetchNotifications();
|
||||
setShowNotifications(false);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Lock, Copy, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
@@ -30,7 +31,7 @@ const PinVerificationModal: React.FC<PinVerificationModalProps> = ({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/terminals/${terminalId}/verify-pin`, {
|
||||
const response = await apiFetch(`/api/terminals/${terminalId}/verify-pin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pin: pinToVerify }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
@@ -77,7 +78,7 @@ const ArchivedOrders: React.FC = () => {
|
||||
params.append('page', currentPage.toString());
|
||||
params.append('size', pageSize.toString());
|
||||
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`);
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.content && Array.isArray(data.content)) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, ShoppingCart, Package, ExternalLink } from 'lucide-react';
|
||||
import Pagination from '../components/Pagination';
|
||||
@@ -78,7 +79,7 @@ const BaseMenu = () => {
|
||||
params.append('search', debouncedSearchTerm);
|
||||
}
|
||||
|
||||
const response = await fetch(`http://${host}:8080/api/base-items?${params.toString()}`);
|
||||
const response = await apiFetch(`http://${host}:8080/api/base-items?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
if (data && data.content) {
|
||||
setItems(data.content);
|
||||
@@ -99,7 +100,7 @@ const BaseMenu = () => {
|
||||
setShowProductsModal(true);
|
||||
setProductsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8080/api/products/category/${encodeURIComponent(baseItem.name)}`);
|
||||
const response = await apiFetch(`http://localhost:8080/api/products/category/${encodeURIComponent(baseItem.name)}`);
|
||||
const data = await response.json();
|
||||
setAssociatedProducts(data);
|
||||
} catch (error) {
|
||||
@@ -117,7 +118,7 @@ const BaseMenu = () => {
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await apiFetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newItem),
|
||||
@@ -142,7 +143,7 @@ const BaseMenu = () => {
|
||||
|
||||
const handleToggleActive = async (item: BaseItem) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8080/api/base-items/${item.id}`, {
|
||||
const response = await apiFetch(`http://localhost:8080/api/base-items/${item.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...item, active: !item.active }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, ChevronRight, Filter, Loader2, Download, Printer, X } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
@@ -31,7 +32,7 @@ const Bills: React.FC = () => {
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/purchases/orders');
|
||||
const response = await apiFetch('/api/purchases/orders');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setOrders(data);
|
||||
@@ -50,7 +51,7 @@ const Bills: React.FC = () => {
|
||||
setIsSaving(true);
|
||||
const updatedPaidTotal = Number(selectedOrder.paidTotal) + Number(paymentAmount);
|
||||
|
||||
const response = await fetch('/api/purchases/orders', {
|
||||
const response = await apiFetch('/api/purchases/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, User, Phone, Tag, ChevronRight, UserCheck, UserMinus, MoreVertical, LayoutGrid, List, Edit2, Trash2, Shield, X, Eye, EyeOff, Loader2, AlertCircle, CheckCircle, CircleDollarSign } from 'lucide-react';
|
||||
import Pagination from '../components/Pagination';
|
||||
@@ -63,7 +64,7 @@ const Customers: React.FC = () => {
|
||||
params.append('search', debouncedSearchTerm);
|
||||
}
|
||||
|
||||
const response = await fetch(`http://${host}:8080/api/auth/users?${params.toString()}`);
|
||||
const response = await apiFetch(`http://${host}:8080/api/auth/users?${params.toString()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.content) {
|
||||
@@ -106,7 +107,7 @@ const Customers: React.FC = () => {
|
||||
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/auth/users/${user.id}`, {
|
||||
const response = await apiFetch(`http://${host}:8080/api/auth/users/${user.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -125,7 +126,7 @@ const Customers: React.FC = () => {
|
||||
const handleSuspendToggle = async (user: UserDto) => {
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/auth/users/${user.id}/suspend`, {
|
||||
const response = await apiFetch(`http://${host}:8080/api/auth/users/${user.id}/suspend`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -165,7 +166,7 @@ const Customers: React.FC = () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/auth/users/${editingUser.id}`, {
|
||||
const response = await apiFetch(`http://${host}:8080/api/auth/users/${editingUser.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editForm),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -105,7 +106,7 @@ const Dashboard = () => {
|
||||
}
|
||||
|
||||
console.log('[DASHBOARD-TRACE] Fetching stats from:', url);
|
||||
const response = await fetch(url);
|
||||
const response = await apiFetch(url);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Star,
|
||||
@@ -81,7 +82,7 @@ const Feedback: React.FC = () => {
|
||||
const fetchStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback/stats`);
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/feedback/stats`);
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
@@ -93,7 +94,7 @@ const Feedback: React.FC = () => {
|
||||
|
||||
const fetchFeedbacks = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/feedback?page=${page}&size=${pageSize}`);
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/feedback?page=${page}&size=${pageSize}`);
|
||||
const data = await response.json();
|
||||
setFeedbacks(data?.content || []);
|
||||
setTotalElements(data?.totalElements || 0);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -68,7 +69,7 @@ const IntentDashboard: React.FC<IntentDashboardProps> = ({ title }) => {
|
||||
const fetchStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/intent/summary`);
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/intent/summary`);
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Eye,
|
||||
@@ -49,7 +50,7 @@ const IntentList: React.FC<IntentListProps> = ({ title }) => {
|
||||
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/vendors`);
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/vendors`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setVendors(result);
|
||||
@@ -62,7 +63,7 @@ const IntentList: React.FC<IntentListProps> = ({ title }) => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/orders`);
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/orders`);
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
@@ -104,7 +105,7 @@ const IntentList: React.FC<IntentListProps> = ({ title }) => {
|
||||
date: new Date(newOrder.date).toISOString()
|
||||
};
|
||||
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/purchases/orders`, {
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/purchases/orders`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
|
||||
@@ -53,13 +53,13 @@ const Login = () => {
|
||||
sessionStorage.setItem('userRole', user.role.toLowerCase());
|
||||
sessionStorage.setItem('userPermissions', JSON.stringify(user.permissions || []));
|
||||
|
||||
// Persist user profile for personalized greetings and settings
|
||||
localStorage.setItem('systemUser', JSON.stringify(user));
|
||||
// Persist user profile + JWT token for authenticated API calls
|
||||
localStorage.setItem('systemUser', JSON.stringify(user)); // user object now includes `token`
|
||||
|
||||
navigate('/store-dashboard');
|
||||
} else {
|
||||
const error = await response.text();
|
||||
alert(error || 'Invalid credentials');
|
||||
const error = await response.json().catch(() => ({ error: 'Invalid credentials' }));
|
||||
alert(error.error || 'Invalid credentials');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Ticket,
|
||||
@@ -44,7 +45,7 @@ const ManageCoupons = () => {
|
||||
const fetchCoupons = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/coupons');
|
||||
const response = await apiFetch('/api/coupons');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCoupons(data);
|
||||
@@ -63,7 +64,7 @@ const ManageCoupons = () => {
|
||||
const handleCreateCoupon = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch('/api/coupons', {
|
||||
const response = await apiFetch('/api/coupons', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -85,7 +86,7 @@ const ManageCoupons = () => {
|
||||
|
||||
const handleToggleStatus = async (id: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/coupons/${id}/toggle`, { method: 'PATCH' });
|
||||
const response = await apiFetch(`/api/coupons/${id}/toggle`, { method: 'PATCH' });
|
||||
if (response.ok) fetchCoupons();
|
||||
} catch (error) {
|
||||
console.error('Error toggling status:', error);
|
||||
@@ -95,7 +96,7 @@ const ManageCoupons = () => {
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this coupon?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/coupons/${id}`, { method: 'DELETE' });
|
||||
const response = await apiFetch(`/api/coupons/${id}`, { method: 'DELETE' });
|
||||
if (response.ok) fetchCoupons();
|
||||
} catch (error) {
|
||||
console.error('Error deleting coupon:', error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Users,
|
||||
@@ -33,7 +34,7 @@ const ManageWallets = () => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/wallet/users');
|
||||
const response = await apiFetch('/api/wallet/users');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
@@ -57,7 +58,7 @@ const ManageWallets = () => {
|
||||
setStatus(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wallet/topup', {
|
||||
const response = await apiFetch('/api/wallet/topup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
@@ -66,7 +67,7 @@ const Managers = () => {
|
||||
const fetchManagers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/system/managers');
|
||||
const res = await apiFetch('/api/system/managers');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Backend uses 'permissions' field, map it to 'sections' for the component if needed
|
||||
@@ -99,7 +100,7 @@ const Managers = () => {
|
||||
const handleCreateAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch('/api/system/managers', {
|
||||
const response = await apiFetch('/api/system/managers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -127,7 +128,7 @@ const Managers = () => {
|
||||
const dismissManager = async (id: string) => {
|
||||
if (window.confirm('Are you sure you want to dismiss this manager?')) {
|
||||
try {
|
||||
const response = await fetch(`/api/system/managers/${id}`, {
|
||||
const response = await apiFetch(`/api/system/managers/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
X, Search, RefreshCw, Edit2, Package, Image as ImageIcon,
|
||||
@@ -75,7 +76,7 @@ const NewArrivals: React.FC = () => {
|
||||
const fetchDrafts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/products/drafts');
|
||||
const response = await apiFetch('/api/products/drafts');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDrafts(data);
|
||||
@@ -116,7 +117,7 @@ const NewArrivals: React.FC = () => {
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/products/${formData.id}/publish`, {
|
||||
const response = await apiFetch(`/api/products/${formData.id}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
@@ -137,7 +138,7 @@ const NewArrivals: React.FC = () => {
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Delete this draft product?')) return;
|
||||
try {
|
||||
await fetch(`/api/products/${id}`, { method: 'DELETE' });
|
||||
await apiFetch(`/api/products/${id}`, { method: 'DELETE' });
|
||||
fetchDrafts();
|
||||
} catch (error) {
|
||||
console.error('Error deleting draft:', error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
@@ -103,7 +104,7 @@ const Orders: React.FC = () => {
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/products`);
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/products`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAllProducts(data);
|
||||
@@ -127,7 +128,7 @@ const Orders: React.FC = () => {
|
||||
params.append('page', currentPage.toString());
|
||||
params.append('size', pageSize.toString());
|
||||
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`);
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/all?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.content && Array.isArray(data.content)) {
|
||||
@@ -156,7 +157,7 @@ const Orders: React.FC = () => {
|
||||
|
||||
const handleApproveOrder = async (orderId: number) => {
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, {
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'COMPLETED' })
|
||||
@@ -174,7 +175,7 @@ const Orders: React.FC = () => {
|
||||
|
||||
const handleMarkUndelivered = async (orderId: number) => {
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, {
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${orderId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'PAID' })
|
||||
@@ -230,7 +231,7 @@ const Orders: React.FC = () => {
|
||||
if (!selectedOrder) return;
|
||||
setIsUpdatingOrder(true);
|
||||
try {
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, {
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -297,7 +298,7 @@ const Orders: React.FC = () => {
|
||||
setIsRegenerating(true);
|
||||
try {
|
||||
const newOrderNumber = `ORD-${Math.random().toString(36).substring(2, 10).toUpperCase()}`;
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, {
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, X, Search, Filter, MoreVertical, RefreshCw, Edit2, Power, PowerOff, Tag, Package, Image as ImageIcon, Barcode, DollarSign, ChevronDown, Clock, Check, Trash2, Database } from 'lucide-react';
|
||||
import Pagination from '../components/Pagination';
|
||||
@@ -161,7 +162,7 @@ const Products = () => {
|
||||
params.append('search', debouncedSearchTerm);
|
||||
}
|
||||
|
||||
const response = await fetch(`http://${host}:8080/api/products?${params.toString()}`);
|
||||
const response = await apiFetch(`http://${host}:8080/api/products?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
if (data && data.content) {
|
||||
setProducts(data.content);
|
||||
@@ -179,7 +180,7 @@ const Products = () => {
|
||||
|
||||
const fetchBaseItems = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/base-items?size=100');
|
||||
const response = await apiFetch('http://localhost:8080/api/base-items?size=100');
|
||||
const data = await response.json();
|
||||
setBaseItems(data.content || data);
|
||||
} catch (error) {
|
||||
@@ -189,7 +190,7 @@ const Products = () => {
|
||||
|
||||
const fetchAllStalls = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/stalls');
|
||||
const response = await apiFetch('http://localhost:8080/api/stalls');
|
||||
const data = await response.json();
|
||||
setAllStalls(data);
|
||||
} catch (error) {
|
||||
@@ -205,7 +206,7 @@ const Products = () => {
|
||||
const method = editingProduct ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await apiFetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
@@ -233,7 +234,7 @@ const Products = () => {
|
||||
|
||||
const handleToggleActive = async (product: Product) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8080/api/products/${product.id}`, {
|
||||
const response = await apiFetch(`http://localhost:8080/api/products/${product.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...product, active: !product.active }),
|
||||
@@ -249,7 +250,7 @@ const Products = () => {
|
||||
|
||||
const handleToggleStock = async (product: Product) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8080/api/products/${product.id}/toggle-stock`, {
|
||||
const response = await apiFetch(`http://localhost:8080/api/products/${product.id}/toggle-stock`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -264,7 +265,7 @@ const Products = () => {
|
||||
const handleDelete = async (product: Product) => {
|
||||
if (!window.confirm(`Are you sure you want to delete ${product.name}?`)) return;
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8080/api/products/${product.id}`, {
|
||||
const response = await apiFetch(`http://localhost:8080/api/products/${product.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
@@ -71,7 +72,7 @@ const PurchaseAnalytics = () => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/purchases/orders');
|
||||
const response = await apiFetch('/api/purchases/orders');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
processAnalytics(data);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, ShoppingCart, Eye, Plus, X, Loader2, CheckCircle, Trash2, Edit2 } from 'lucide-react';
|
||||
|
||||
@@ -65,7 +66,7 @@ const Purchases: React.FC = () => {
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/purchases/orders');
|
||||
const response = await apiFetch('/api/purchases/orders');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setOrders(data);
|
||||
@@ -79,7 +80,7 @@ const Purchases: React.FC = () => {
|
||||
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/purchases/vendors');
|
||||
const response = await apiFetch('/api/purchases/vendors');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setVendors(data);
|
||||
@@ -91,7 +92,7 @@ const Purchases: React.FC = () => {
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/products?size=1000');
|
||||
const response = await apiFetch('/api/products?size=1000');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAvailableProducts(data.content || data);
|
||||
@@ -150,7 +151,7 @@ const Purchases: React.FC = () => {
|
||||
date: new Date(newOrder.date).toISOString()
|
||||
};
|
||||
|
||||
const response = await fetch('/api/purchases/orders', {
|
||||
const response = await apiFetch('/api/purchases/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
@@ -172,7 +173,7 @@ const Purchases: React.FC = () => {
|
||||
const deleteOrder = async (id: number) => {
|
||||
if (!window.confirm('Delete this purchase order?')) return;
|
||||
try {
|
||||
await fetch(`/api/purchases/orders/${id}`, { method: 'DELETE' });
|
||||
await apiFetch(`/api/purchases/orders/${id}`, { method: 'DELETE' });
|
||||
fetchOrders();
|
||||
} catch (error) {
|
||||
console.error('Error deleting order:', error);
|
||||
@@ -197,7 +198,7 @@ const Purchases: React.FC = () => {
|
||||
const order = orders.find(o => o.id === orderId);
|
||||
if (!order) return;
|
||||
|
||||
const response = await fetch(`/api/purchases/orders`, {
|
||||
const response = await apiFetch(`/api/purchases/orders`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...order, status: newStatus })
|
||||
@@ -229,7 +230,7 @@ const Purchases: React.FC = () => {
|
||||
const fetchOrderHistory = async (order: PurchaseOrder) => {
|
||||
try {
|
||||
setActiveHistoryOrder(order);
|
||||
const response = await fetch(`/api/purchases/orders/${order.id}/history`);
|
||||
const response = await apiFetch(`/api/purchases/orders/${order.id}/history`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setOrderHistory(data);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Download,
|
||||
@@ -66,7 +67,7 @@ const Reports: React.FC = () => {
|
||||
try {
|
||||
const fromStr = dateRange.from.toISOString();
|
||||
const toStr = dateRange.to.toISOString();
|
||||
const response = await fetch(`http://${window.location.hostname}:8080/api/reports/monthly?from=${fromStr}&to=${toStr}`);
|
||||
const response = await apiFetch(`http://${window.location.hostname}:8080/api/reports/monthly?from=${fromStr}&to=${toStr}`);
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
@@ -56,11 +57,11 @@ const RitzPage: React.FC = () => {
|
||||
setIsLoading(true);
|
||||
const host = window.location.hostname;
|
||||
|
||||
const statsRes = await fetch(`http://${host}:8080/api/wallet/stats`);
|
||||
const statsRes = await apiFetch(`http://${host}:8080/api/wallet/stats`);
|
||||
const statsData = await statsRes.json();
|
||||
setStats(statsData);
|
||||
|
||||
const transRes = await fetch(`http://${host}:8080/api/wallet/transactions/all`);
|
||||
const transRes = await apiFetch(`http://${host}:8080/api/wallet/transactions/all`);
|
||||
const transData = await transRes.json();
|
||||
setTransactions(Array.isArray(transData) ? transData : []);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
CircleDollarSign,
|
||||
@@ -52,7 +53,7 @@ const RitzCirculation: React.FC = () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const host = window.location.hostname;
|
||||
const res = await fetch(`http://${host}:8080/api/wallet/circulation?page=${page}&size=${size}`);
|
||||
const res = await apiFetch(`http://${host}:8080/api/wallet/circulation?page=${page}&size=${size}`);
|
||||
const data: PageResponse = await res.json();
|
||||
|
||||
setTokens(data.content || []);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
User,
|
||||
@@ -47,7 +48,7 @@ const Settings = () => {
|
||||
|
||||
const fetchAdmins = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system/admins');
|
||||
const response = await apiFetch('/api/system/admins');
|
||||
const data = await response.json();
|
||||
setAdmins(data);
|
||||
} catch (err) {
|
||||
@@ -69,7 +70,7 @@ const Settings = () => {
|
||||
|
||||
setStatus('loading');
|
||||
try {
|
||||
const response = await fetch('/api/system/update-master', {
|
||||
const response = await apiFetch('/api/system/update-master', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -102,7 +103,7 @@ const Settings = () => {
|
||||
e.preventDefault();
|
||||
setStatus('loading');
|
||||
try {
|
||||
const response = await fetch('/api/system/admins', {
|
||||
const response = await apiFetch('/api/system/admins', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newAdmin)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
@@ -50,7 +51,7 @@ const Staff = () => {
|
||||
const fetchStaff = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/system/staff');
|
||||
const res = await apiFetch('/api/system/staff');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStaffList(data);
|
||||
@@ -69,7 +70,7 @@ const Staff = () => {
|
||||
const handleCreateStaff = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch('/api/system/staff', {
|
||||
const response = await apiFetch('/api/system/staff', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -97,7 +98,7 @@ const Staff = () => {
|
||||
const deleteStaff = async (id: string) => {
|
||||
if (window.confirm('Are you sure you want to remove this staff member?')) {
|
||||
try {
|
||||
const response = await fetch(`/api/system/staff/${id}`, {
|
||||
const response = await apiFetch(`/api/system/staff/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
@@ -94,7 +95,7 @@ const Stalls: React.FC = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/stalls`);
|
||||
const response = await apiFetch(`http://${host}:8080/api/stalls`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStalls(data);
|
||||
@@ -126,7 +127,7 @@ const Stalls: React.FC = () => {
|
||||
? `http://${host}:8080/api/stalls/${editingStall.id}`
|
||||
: `http://${host}:8080/api/stalls`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await apiFetch(url, {
|
||||
method: editingStall ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -173,7 +174,7 @@ const Stalls: React.FC = () => {
|
||||
if (!window.confirm('Are you sure you want to delete this stall?')) return;
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/stalls/${id}`, {
|
||||
const response = await apiFetch(`http://${host}:8080/api/stalls/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -216,7 +217,7 @@ const Stalls: React.FC = () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/stalls/${selectedStall.id}/items`, {
|
||||
const response = await apiFetch(`http://${host}:8080/api/stalls/${selectedStall.id}/items`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -99,7 +100,7 @@ const StoreDashboard = () => {
|
||||
params.append('from', range.from);
|
||||
params.append('to', range.to);
|
||||
|
||||
const response = await fetch(`/api/dashboard/stats?${params.toString()}`);
|
||||
const response = await apiFetch(`/api/dashboard/stats?${params.toString()}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Dashboard data received successfully:', data);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
@@ -32,7 +33,7 @@ const Terminals = () => {
|
||||
const fetchTerminals = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/terminals');
|
||||
const response = await apiFetch('/api/terminals');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTerminals(data);
|
||||
@@ -52,7 +53,7 @@ const Terminals = () => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Are you sure you want to remove this terminal?')) {
|
||||
try {
|
||||
const response = await fetch(`/api/terminals/${id}`, { method: 'DELETE' });
|
||||
const response = await apiFetch(`/api/terminals/${id}`, { method: 'DELETE' });
|
||||
if (response.ok) fetchTerminals();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete terminal:', error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
@@ -53,7 +54,7 @@ const VendorDashboard = () => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/dashboard/procurement');
|
||||
const response = await apiFetch('/api/dashboard/procurement');
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { apiFetch } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, Building2, Phone, Mail, MoreVertical, List, LayoutGrid, Edit2, Trash2, X, Loader2, AlertCircle, CheckCircle, Plus } from 'lucide-react';
|
||||
import Pagination from '../components/Pagination';
|
||||
@@ -43,7 +44,7 @@ const Vendors: React.FC = () => {
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/purchases/vendors');
|
||||
const response = await apiFetch('/api/purchases/vendors');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setVendors(data);
|
||||
@@ -85,7 +86,7 @@ const Vendors: React.FC = () => {
|
||||
if (!window.confirm(`Are you sure you want to delete ${vendor.name}?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/purchases/vendors/${vendor.id}`, {
|
||||
const response = await apiFetch(`/api/purchases/vendors/${vendor.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -111,7 +112,7 @@ const Vendors: React.FC = () => {
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/purchases/vendors', {
|
||||
const response = await apiFetch('/api/purchases/vendors', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editingVendor ? { ...form, id: editingVendor.id } : form),
|
||||
|
||||
Reference in New Issue
Block a user