User suspension added

This commit is contained in:
Sidharth Prabhu
2026-04-21 14:37:34 +05:30
parent 21c581b7eb
commit b4bf6ebeb3
13 changed files with 301 additions and 24 deletions

View File

@@ -14,7 +14,7 @@ public class WebConfig {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedOriginPatterns("http://*", "https://*", "file://*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);

View File

@@ -138,4 +138,18 @@ public class UserController {
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();
}
}
}

View File

@@ -8,10 +8,11 @@ public class DashboardStats {
private double growth;
private long totalExpenses;
private long periodExpenses;
private long suspendedUserCount;
public DashboardStats() {}
public DashboardStats(long totalSales, long periodRevenue, int activeOrders, int dailyCustomers, double growth, long totalExpenses, long periodExpenses) {
public DashboardStats(long totalSales, long periodRevenue, int activeOrders, int dailyCustomers, double growth, long totalExpenses, long periodExpenses, long suspendedUserCount) {
this.totalSales = totalSales;
this.periodRevenue = periodRevenue;
this.activeOrders = activeOrders;
@@ -19,6 +20,7 @@ public class DashboardStats {
this.growth = growth;
this.totalExpenses = totalExpenses;
this.periodExpenses = periodExpenses;
this.suspendedUserCount = suspendedUserCount;
}
public long getTotalSales() { return totalSales; }
@@ -41,4 +43,7 @@ public class DashboardStats {
public long getPeriodExpenses() { return periodExpenses; }
public void setPeriodExpenses(long periodExpenses) { this.periodExpenses = periodExpenses; }
public long getSuspendedUserCount() { return suspendedUserCount; }
public void setSuspendedUserCount(long suspendedUserCount) { this.suspendedUserCount = suspendedUserCount; }
}

View File

@@ -43,15 +43,17 @@ public class LoginResponse {
private String mobileNumber;
private String name;
private boolean isLoggedIn;
private boolean isSuspended;
private java.math.BigDecimal ritzTokenBalance;
public UserDto() {}
public UserDto(Long id, String mobileNumber, String name, boolean isLoggedIn, java.math.BigDecimal ritzTokenBalance) {
public UserDto(Long id, String mobileNumber, String name, boolean isLoggedIn, boolean isSuspended, java.math.BigDecimal ritzTokenBalance) {
this.id = id;
this.mobileNumber = mobileNumber;
this.name = name;
this.isLoggedIn = isLoggedIn;
this.isSuspended = isSuspended;
this.ritzTokenBalance = ritzTokenBalance;
}
@@ -67,6 +69,9 @@ public class LoginResponse {
public boolean isLoggedIn() { return isLoggedIn; }
public void setLoggedIn(boolean loggedIn) { this.isLoggedIn = loggedIn; }
public boolean isSuspended() { return isSuspended; }
public void setSuspended(boolean suspended) { isSuspended = suspended; }
public java.math.BigDecimal getRitzTokenBalance() { return ritzTokenBalance; }
public void setRitzTokenBalance(java.math.BigDecimal ritzTokenBalance) { this.ritzTokenBalance = ritzTokenBalance; }
}

View File

@@ -37,6 +37,9 @@ public class User {
@Column(nullable = false)
private java.math.BigDecimal ritzTokenBalance = java.math.BigDecimal.ZERO;
@Column(nullable = false, columnDefinition = "boolean default false")
private boolean isSuspended = false;
@Column(nullable = true)
private LocalDateTime lastLoginAt;
@@ -54,6 +57,9 @@ public class User {
public java.math.BigDecimal getRitzTokenBalance() { return ritzTokenBalance; }
public void setRitzTokenBalance(java.math.BigDecimal ritzTokenBalance) { this.ritzTokenBalance = ritzTokenBalance; }
public boolean isSuspended() { return isSuspended; }
public void setSuspended(boolean suspended) { isSuspended = suspended; }
public String getPinHash() { return pinHash; }
public void setPinHash(String pinHash) { this.pinHash = pinHash; }

View File

@@ -19,4 +19,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.id = :id")
Optional<User> findByIdWithLock(@org.springframework.data.repository.query.Param("id") Long id);
long countByIsSuspended(boolean isSuspended);
}

View File

@@ -61,7 +61,7 @@ public class UserService {
userRepository.save(user);
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.getRitzTokenBalance()
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance()
);
return new LoginResponse(true, "Registration successful. You are now logged in.", userDto);
@@ -79,6 +79,10 @@ public class UserService {
User user = userOpt.get();
if (user.isSuspended()) {
return new LoginResponse(false, "Your account has been suspended. Please contact the administrator.");
}
if (!passwordEncoder.matches(pin, user.getPinHash())) {
return new LoginResponse(false, "Incorrect PIN. Please try again.");
}
@@ -88,7 +92,7 @@ public class UserService {
userRepository.save(user);
LoginResponse.UserDto userDto = new LoginResponse.UserDto(
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.getRitzTokenBalance()
user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance()
);
return new LoginResponse(true, "Login successful.", userDto);
@@ -154,7 +158,7 @@ public class UserService {
return null;
}
User user = userOpt.get();
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.getRitzTokenBalance());
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance());
}
/**
@@ -173,6 +177,7 @@ public class UserService {
user.getMobileNumber(),
user.getName(),
user.isLoggedIn(),
user.isSuspended(),
user.getRitzTokenBalance()
));
}
@@ -212,7 +217,28 @@ public class UserService {
}
userRepository.save(user);
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.getRitzTokenBalance());
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance());
}
/**
* Toggle the suspension status of a user.
*/
public LoginResponse.UserDto toggleSuspension(Long userId) {
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return null;
}
User user = userOpt.get();
user.setSuspended(!user.isSuspended());
// If suspended, forcefully log them out
if (user.isSuspended()) {
user.setLoggedIn(false);
}
userRepository.save(user);
return new LoginResponse.UserDto(user.getId(), user.getMobileNumber(), user.getName(), user.isLoggedIn(), user.isSuspended(), user.getRitzTokenBalance());
}
/**

View File

@@ -7,6 +7,7 @@ interface UserDto {
mobileNumber: string;
name: string;
loggedIn: boolean;
isSuspended: boolean;
ritzTokenBalance: number;
}
@@ -66,7 +67,13 @@ const Customers: React.FC = () => {
if (response.ok) {
const data = await response.json();
if (data && data.content) {
setUsers(data.content);
// Normalize the data (backend sends 'suspended', frontend expects 'isSuspended')
const normalizedUsers = data.content.map((u: any) => ({
...u,
isSuspended: u.isSuspended !== undefined ? u.isSuspended : u.suspended,
loggedIn: u.loggedIn !== undefined ? u.loggedIn : u.isLoggedIn
}));
setUsers(normalizedUsers);
setTotalElements(data.totalElements);
} else {
setUsers([]);
@@ -115,6 +122,25 @@ 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`, {
method: 'PATCH',
});
if (response.ok) {
showNotification(`${user.name} ${user.isSuspended ? 'unsuspended' : 'suspended'} successfully`, 'success');
fetchUsers();
setOpenMenuId(null);
} else {
showNotification('Failed to update suspension status', 'error');
}
} catch (error) {
console.error('Error toggling suspension:', error);
showNotification('Error connecting to server', 'error');
}
};
const validateForm = () => {
const newErrors: {[key: string]: string} = {};
if (!editForm.name.trim()) newErrors.name = 'Full Name is required';
@@ -283,19 +309,16 @@ const Customers: React.FC = () => {
</span>
</div>
</td>
<td className="px-6 py-4">
<span className="text-sm font-bold text-[#231651]">
{user.ritzTokenBalance?.toLocaleString() || '0'}
</span>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold ${
user.loggedIn
user.isSuspended
? 'bg-red-100 text-red-700'
: user.loggedIn
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}>
<div className={`w-1.5 h-1.5 rounded-full ${user.loggedIn ? 'bg-green-500' : 'bg-gray-400'}`} />
{user.loggedIn ? 'Active Now' : 'Offline'}
<div className={`w-1.5 h-1.5 rounded-full ${user.isSuspended ? 'bg-red-500' : user.loggedIn ? 'bg-green-500' : 'bg-gray-400'}`} />
{user.isSuspended ? 'Suspended' : user.loggedIn ? 'Active Now' : 'Offline'}
</span>
</td>
<td className="px-6 py-4 text-right relative">
@@ -308,6 +331,10 @@ const Customers: React.FC = () => {
<button onClick={() => handleEditClick(user)} className="w-full flex items-center gap-3 px-4 py-3 text-sm hover:bg-gray-50 text-[#475569] font-medium border-b border-[#f1f5f9]">
<Edit2 size={16} /> Edit User
</button>
<button onClick={() => handleSuspendToggle(user)} className={`w-full flex items-center gap-3 px-4 py-3 text-sm hover:bg-gray-50 font-medium border-b border-[#f1f5f9] ${user.isSuspended ? 'text-amber-600' : 'text-red-500'}`}>
{user.isSuspended ? <UserCheck size={16} /> : <UserMinus size={16} />}
{user.isSuspended ? 'Unsuspend User' : 'Suspend User'}
</button>
<button onClick={() => handleDeleteClick(user)} className="w-full flex items-center gap-3 px-4 py-3 text-sm hover:bg-gray-50 text-red-600 font-medium font-bold">
<Trash2 size={16} /> Delete User
</button>
@@ -339,9 +366,13 @@ const Customers: React.FC = () => {
</button>
{openMenuId === user.id && (
<div className="absolute right-0 mt-2 w-40 bg-white rounded-xl shadow-2xl border border-[#e2e8f0] z-50 overflow-hidden">
<button onClick={() => handleEditClick(user)} className="w-full flex items-center gap-3 px-4 py-3 text-sm hover:bg-gray-50 text-[#475569] font-medium">
<button onClick={() => handleEditClick(user)} className="w-full flex items-center gap-3 px-4 py-3 text-sm hover:bg-gray-50 text-[#475569] font-medium border-b border-[#f1f5f9]">
<Edit2 size={16} /> Edit
</button>
<button onClick={() => handleSuspendToggle(user)} className={`w-full flex items-center gap-3 px-4 py-3 text-sm hover:bg-gray-50 font-medium border-b border-[#f1f5f9] ${user.isSuspended ? 'text-amber-600' : 'text-red-500'}`}>
{user.isSuspended ? <UserCheck size={16} /> : <UserMinus size={16} />}
{user.isSuspended ? 'Unsuspend' : 'Suspend'}
</button>
<button onClick={() => handleDeleteClick(user)} className="w-full flex items-center gap-3 px-4 py-3 text-sm hover:bg-gray-50 text-red-600 font-medium">
<Trash2 size={16} /> Delete
</button>
@@ -363,6 +394,11 @@ const Customers: React.FC = () => {
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 text-[10px] font-bold rounded-lg border border-blue-100 uppercase tracking-wider">
RIT STUDENT
</span>
{user.isSuspended && (
<span className="px-2 py-0.5 bg-red-50 text-red-600 text-[10px] font-bold rounded-lg border border-red-100 uppercase tracking-wider">
SUSPENDED
</span>
)}
<span className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded-lg border border-indigo-100 uppercase tracking-wider">
{user.ritzTokenBalance?.toLocaleString() || '0'} TOKENS
</span>

View File

@@ -5,7 +5,8 @@ import {
Wallet,
ArrowRight,
RotateCcw,
Star
Star,
UserMinus
} from 'lucide-react';
import {
AreaChart,
@@ -29,7 +30,8 @@ const StoreDashboard = () => {
totalSales: 0,
activeOrders: 0,
dailyCustomers: 0,
revenueGrowth: 0
revenueGrowth: 0,
suspendedUserCount: 0
});
const [trendingItems, setTrendingItems] = useState<any[]>([]);
const [salesData, setSalesData] = useState<any[]>([]);
@@ -107,7 +109,8 @@ const StoreDashboard = () => {
totalSales: data.stats.totalSales,
activeOrders: data.stats.activeOrders,
dailyCustomers: data.stats.dailyCustomers,
revenueGrowth: data.stats.growth
revenueGrowth: data.stats.growth,
suspendedUserCount: data.stats.suspendedUserCount
});
}
@@ -290,8 +293,21 @@ const StoreDashboard = () => {
</div>
</div>
{/* Restricted Accounts Card */}
<div title="Monitor suspended customer accounts" className="bg-white p-6 rounded-[24px] border border-slate-100 shadow-sm h-[130px] flex flex-col justify-between group cursor-pointer hover:border-red-500/30 transition-all">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-50 text-red-500 rounded-xl group-hover:scale-110 transition-transform">
<UserMinus size={20} />
</div>
<h4 className="text-[11px] font-black text-slate-400 uppercase tracking-widest leading-none">Restricted Accounts</h4>
</div>
<div className="text-center">
<h2 className="text-4xl font-black text-slate-800 tracking-tighter">{stats?.suspendedUserCount || 0}</h2>
</div>
</div>
{/* Expenses Card */}
<div title="Monitor store operational expenditures" className="bg-white p-6 rounded-[24px] border border-slate-100 shadow-sm h-[150px] flex flex-col justify-between group cursor-pointer hover:border-[#0f4475]/30 transition-all">
<div title="Monitor store operational expenditures" className="bg-white p-6 rounded-[24px] border border-slate-100 shadow-sm h-[130px] flex flex-col justify-between group cursor-pointer hover:border-[#0f4475]/30 transition-all">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 text-[#0f4475] rounded-xl shadow-sm">
<Wallet size={20} />

View File

@@ -117,12 +117,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (response.ok) {
const updatedUserDto = await response.json();
// The DTO from backend might have different structure, let's map it back to User type
const updatedUser: User = {
id: updatedUserDto.id,
name: updatedUserDto.name,
mobileNumber: updatedUserDto.mobileNumber,
isLoggedIn: updatedUserDto.loggedIn,
isSuspended: updatedUserDto.suspended,
ritzTokenBalance: updatedUserDto.ritzTokenBalance
};
setUser(updatedUser);
@@ -137,6 +137,33 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
};
// Background suspension check
useEffect(() => {
if (!user) return;
const checkSuspensionStatus = async () => {
try {
const response = await fetch(`${API_BASE_URL}/user/${user.mobileNumber}`, { cache: 'no-store' });
if (response.status === 404) {
logout(); // User deleted
return;
}
if (response.ok) {
const data = await response.json();
if (data.isSuspended || data.suspended) {
console.log("Account suspended. Logging out...");
logout();
}
}
} catch (error) {
console.error('Error checking user status:', error);
}
};
const intervalId = setInterval(checkSuspensionStatus, 30000); // Check every 30 seconds
return () => clearInterval(intervalId);
}, [user]);
return (
<AuthContext.Provider value={{ user, isLoading, checkUserExists, login, register, logout, changePin, updateProfile }}>
{children}

View File

@@ -152,6 +152,124 @@
color: var(--primary);
}
.error-message {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #ef4444;
padding: 12px 16px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
margin-bottom: 24px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
/* Suspension Modal Styles */
.suspension-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.suspension-modal {
background: rgba(255, 255, 255, 0.95);
width: 100%;
max-width: 400px;
border-radius: 28px;
padding: 40px 32px;
text-align: center;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.suspension-icon {
width: 80px;
height: 80px;
background: #fef2f2;
color: #ef4444;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
}
.suspension-modal h2 {
font-size: 24px;
font-weight: 800;
color: #1e293b;
margin-bottom: 12px;
}
.suspension-modal p {
color: #64748b;
font-size: 15px;
line-height: 1.6;
margin-bottom: 24px;
}
.suspension-details {
background: #f8fafc;
padding: 16px;
border-radius: 16px;
margin-bottom: 32px;
border: 1px solid #e2e8f0;
}
.suspension-details span {
color: #475569;
font-size: 13px;
font-weight: 600;
line-height: 1.5;
display: block;
}
.suspension-close-btn {
width: 100%;
padding: 16px;
background: #231651;
color: white;
border: none;
border-radius: 16px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.suspension-close-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(35, 22, 81, 0.4);
}
.suspension-close-btn:active {
transform: translateY(0);
}
@keyframes zoomIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-zoomIn {
animation: zoomIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.login-button {
background-color: var(--primary);
color: white;

View File

@@ -15,6 +15,7 @@ const LoginScreen: React.FC = () => {
const [pin, setPin] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSuspended, setIsSuspended] = useState(false);
const handleMobileSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -53,6 +54,8 @@ const LoginScreen: React.FC = () => {
if (result.success) {
navigate('/');
} else if (result.isSuspended) {
setIsSuspended(true);
} else {
setError(result.message);
}
@@ -215,6 +218,24 @@ const LoginScreen: React.FC = () => {
<p>RIT Canteen Management System</p>
</div>
</div>
{isSuspended && (
<div className="suspension-overlay">
<div className="suspension-modal animate-zoomIn">
<div className="suspension-icon">
<Lock size={40} />
</div>
<h2>Account Suspended</h2>
<p>Your access to the RIT Canteen system has been restricted by an administrator.</p>
<div className="suspension-details">
<span>Please contact the Admin or Manager to resolve this issue.</span>
</div>
<button className="suspension-close-btn" onClick={() => setIsSuspended(false)}>
Back to Login
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -33,6 +33,7 @@ export interface User {
mobileNumber: string;
name: string;
isLoggedIn: boolean;
isSuspended: boolean;
ritzTokenBalance: number;
}