feat: enable Registration through invitation codes

This commit is contained in:
sBubshait 2025-08-07 03:37:09 +03:00
parent a74a6a3741
commit 2e7033791a
9 changed files with 346 additions and 5 deletions

View File

@ -55,7 +55,7 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/swagger-ui/**", "/v3/api-docs/**", "/docs/**", "/docs").permitAll()
.requestMatchers("/", "/login", "/checkInvitation", "/register", "/swagger-ui/**", "/v3/api-docs/**", "/docs/**", "/docs").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)

View File

@ -8,9 +8,14 @@ import online.wesal.wesal.dto.LoginRequest;
import online.wesal.wesal.dto.LoginResponse;
import online.wesal.wesal.dto.UpdateUserRequest;
import online.wesal.wesal.dto.UsernameSelectionRequest;
import online.wesal.wesal.dto.CheckInvitationRequest;
import online.wesal.wesal.dto.RegisterRequest;
import online.wesal.wesal.dto.RegisterResponse;
import online.wesal.wesal.entity.User;
import online.wesal.wesal.service.AuthService;
import online.wesal.wesal.service.UserService;
import online.wesal.wesal.service.InvitationCodeService;
import online.wesal.wesal.config.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ -29,6 +34,12 @@ public class AuthController {
@Autowired
private UserService userService;
@Autowired
private InvitationCodeService invitationCodeService;
@Autowired
private JwtUtil jwtUtil;
@GetMapping("/")
@Operation(summary = "Health check", description = "Returns API status")
public ResponseEntity<Map<String, Integer>> healthCheck() {
@ -107,6 +118,70 @@ public class AuthController {
}
}
@PostMapping("/checkInvitation")
@Operation(summary = "Check invitation code", description = "Validates if an invitation code is valid and not expired")
public ResponseEntity<Map<String, Object>> checkInvitation(@Valid @RequestBody CheckInvitationRequest request) {
try {
boolean isValid = invitationCodeService.isValidCode(request.getCode());
if (isValid) {
return ResponseEntity.ok(Map.of(
"status", true,
"message", "Invitation code is valid",
"data", Map.of("code", request.getCode(), "valid", true)
));
} else {
return ResponseEntity.ok(Map.of(
"status", false,
"message", "Invalid or expired invitation code",
"data", Map.of("code", request.getCode(), "valid", false)
));
}
} catch (Exception e) {
return ResponseEntity.ok(Map.of(
"status", false,
"message", "Error validating invitation code"
));
}
}
@PostMapping("/register")
@Operation(summary = "Register new user", description = "Register a new user with invitation code, phone number, and password")
public ResponseEntity<Map<String, Object>> register(@Valid @RequestBody RegisterRequest request) {
try {
User user = invitationCodeService.registerUserWithCode(
request.getCode(),
request.getPhoneNumber(),
request.getPassword()
);
// Auto-login: generate JWT token
String token = jwtUtil.generateToken(user.getPhoneNumber());
RegisterResponse registerResponse = new RegisterResponse(user, token);
return ResponseEntity.ok(Map.of(
"status", true,
"message", "User registered successfully",
"data", registerResponse
));
} catch (RuntimeException e) {
String message;
if (e.getMessage().contains("already exists")) {
message = "A user with this phone number already exists";
} else if (e.getMessage().contains("Invalid or expired")) {
message = "Invalid or expired invitation code";
} else {
message = "Registration failed. Please try again";
}
return ResponseEntity.ok(Map.of(
"status", false,
"message", message
));
}
}
@PostMapping("/admin/createUser")
@Operation(summary = "Create new user (Admin only)", description = "Creates a new user - requires admin privileges")
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) {

View File

@ -0,0 +1,23 @@
package online.wesal.wesal.dto;
import jakarta.validation.constraints.NotBlank;
public class CheckInvitationRequest {
@NotBlank
private String code;
public CheckInvitationRequest() {}
public CheckInvitationRequest(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
}

View File

@ -0,0 +1,51 @@
package online.wesal.wesal.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public class RegisterRequest {
@NotBlank
private String code;
@NotBlank
@Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "Phone number must be in international format starting with + and country code")
private String phoneNumber;
@NotBlank
@Size(min = 6)
private String password;
public RegisterRequest() {}
public RegisterRequest(String code, String phoneNumber, String password) {
this.code = code;
this.phoneNumber = phoneNumber;
this.password = password;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getPhoneNumber() {
return phoneNumber;
}
public void setPhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@ -0,0 +1,32 @@
package online.wesal.wesal.dto;
import online.wesal.wesal.entity.User;
public class RegisterResponse {
private User user;
private String token;
public RegisterResponse() {}
public RegisterResponse(User user, String token) {
this.user = user;
this.token = token;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
}

View File

@ -0,0 +1,72 @@
package online.wesal.wesal.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
@Entity
@Table(name = "invitation_codes")
public class InvitationCode {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
@NotBlank
@Size(max = 200)
private String code;
@Column(nullable = false)
private LocalDateTime expirationDate;
@Column(nullable = false)
private LocalDateTime createdDate;
public InvitationCode() {
this.createdDate = LocalDateTime.now();
this.expirationDate = LocalDateTime.now().plusDays(30);
}
public InvitationCode(String code) {
this();
this.code = code;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public LocalDateTime getExpirationDate() {
return expirationDate;
}
public void setExpirationDate(LocalDateTime expirationDate) {
this.expirationDate = expirationDate;
}
public LocalDateTime getCreatedDate() {
return createdDate;
}
public void setCreatedDate(LocalDateTime createdDate) {
this.createdDate = createdDate;
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(this.expirationDate);
}
}

View File

@ -46,7 +46,7 @@ public class User {
private String role = "USER";
@Column(columnDefinition = "TEXT")
private String subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]";
private String subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]";
public User() {}
@ -130,13 +130,13 @@ public class User {
public List<String> getSubscriptions() {
if (subscriptions == null || subscriptions.isEmpty()) {
return Arrays.asList("newinvites", "invitesfollowup", "appnews");
return Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews");
}
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(subscriptions, new TypeReference<List<String>>() {});
} catch (Exception e) {
return Arrays.asList("newinvites", "invitesfollowup", "appnews");
return Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews");
}
}
@ -145,7 +145,7 @@ public class User {
ObjectMapper mapper = new ObjectMapper();
this.subscriptions = mapper.writeValueAsString(subscriptions);
} catch (Exception e) {
this.subscriptions = "[\"newinvites\",\"invitesfollowup\",\"appnews\"]";
this.subscriptions = "[\"postcomments\",\"newinvites\",\"invitesfollowup\",\"appnews\"]";
}
}
}

View File

@ -0,0 +1,19 @@
package online.wesal.wesal.repository;
import online.wesal.wesal.entity.InvitationCode;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Optional;
@Repository
public interface InvitationCodeRepository extends JpaRepository<InvitationCode, Long> {
Optional<InvitationCode> findByCode(String code);
boolean existsByCode(String code);
@Query("SELECT ic FROM InvitationCode ic WHERE ic.code = :code AND ic.expirationDate > :currentTime")
Optional<InvitationCode> findValidCodeByCode(@Param("code") String code, @Param("currentTime") LocalDateTime currentTime);
}

View File

@ -0,0 +1,69 @@
package online.wesal.wesal.service;
import online.wesal.wesal.entity.InvitationCode;
import online.wesal.wesal.entity.User;
import online.wesal.wesal.repository.InvitationCodeRepository;
import online.wesal.wesal.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Optional;
@Service
public class InvitationCodeService {
@Autowired
private InvitationCodeRepository invitationCodeRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
public boolean isValidCode(String code) {
Optional<InvitationCode> invitationCodeOpt = invitationCodeRepository.findValidCodeByCode(code, LocalDateTime.now());
return invitationCodeOpt.isPresent();
}
public Optional<InvitationCode> findValidCode(String code) {
return invitationCodeRepository.findValidCodeByCode(code, LocalDateTime.now());
}
@Transactional
public User registerUserWithCode(String code, String phoneNumber, String password) {
// Check if phone number already exists
if (userRepository.existsByPhoneNumber(phoneNumber)) {
throw new RuntimeException("User with this phone number already exists");
}
// Validate invitation code
Optional<InvitationCode> invitationCodeOpt = findValidCode(code);
if (invitationCodeOpt.isEmpty()) {
throw new RuntimeException("Invalid or expired invitation code");
}
InvitationCode invitationCode = invitationCodeOpt.get();
// Create new user
User user = new User();
user.setPhoneNumber(phoneNumber);
user.setPassword(passwordEncoder.encode(password));
user.setDisplayName("Guest");
user.setRole("USER");
user.setActivated(false);
user.setSubscriptions(Arrays.asList("postcomments", "newinvites", "invitesfollowup", "appnews"));
// Save user
User savedUser = userRepository.save(user);
// Remove (delete) the invitation code so it can't be used again
invitationCodeRepository.delete(invitationCode);
return savedUser;
}
}