mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-21 01:01:05 -05:00
a lot of account settings and user table changes
This commit is contained in:
@@ -12,7 +12,10 @@ import group.goforward.battlbuilder.web.dto.auth.TokenRequest;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -45,9 +48,19 @@ public class AuthController {
|
|||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
|
public ResponseEntity<?> register(
|
||||||
|
@RequestBody RegisterRequest request,
|
||||||
|
HttpServletRequest httpRequest
|
||||||
|
) {
|
||||||
String email = request.getEmail().trim().toLowerCase();
|
String email = request.getEmail().trim().toLowerCase();
|
||||||
|
|
||||||
|
// ✅ Enforce acceptance
|
||||||
|
if (!Boolean.TRUE.equals(request.getAcceptedTos())) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.body("Terms of Service acceptance is required");
|
||||||
|
}
|
||||||
|
|
||||||
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
|
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(HttpStatus.CONFLICT)
|
.status(HttpStatus.CONFLICT)
|
||||||
@@ -58,12 +71,23 @@ public class AuthController {
|
|||||||
user.setUuid(UUID.randomUUID());
|
user.setUuid(UUID.randomUUID());
|
||||||
user.setEmail(email);
|
user.setEmail(email);
|
||||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||||
|
user.setPasswordSetAt(OffsetDateTime.now());
|
||||||
user.setDisplayName(request.getDisplayName());
|
user.setDisplayName(request.getDisplayName());
|
||||||
user.setRole("USER");
|
user.setRole("USER");
|
||||||
user.setIsActive(true);
|
user.setActive(true);
|
||||||
user.setCreatedAt(OffsetDateTime.now());
|
user.setCreatedAt(OffsetDateTime.now());
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
// ✅ Record ToS acceptance evidence
|
||||||
|
String tosVersion = StringUtils.hasText(request.getTosVersion())
|
||||||
|
? request.getTosVersion().trim()
|
||||||
|
: "2025-12-27"; // keep in sync with your ToS page
|
||||||
|
|
||||||
|
user.setTosAcceptedAt(OffsetDateTime.now());
|
||||||
|
user.setTosVersion(tosVersion);
|
||||||
|
user.setTosIp(extractClientIp(httpRequest));
|
||||||
|
user.setTosUserAgent(httpRequest.getHeader("User-Agent"));
|
||||||
|
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
String token = jwtService.generateToken(user);
|
String token = jwtService.generateToken(user);
|
||||||
@@ -78,6 +102,18 @@ public class AuthController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String extractClientIp(HttpServletRequest request) {
|
||||||
|
String xff = request.getHeader("X-Forwarded-For");
|
||||||
|
if (StringUtils.hasText(xff)) {
|
||||||
|
// first IP in the list
|
||||||
|
return xff.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
String realIp = request.getHeader("X-Real-IP");
|
||||||
|
if (StringUtils.hasText(realIp)) return realIp.trim();
|
||||||
|
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||||
String email = request.getEmail().trim().toLowerCase();
|
String email = request.getEmail().trim().toLowerCase();
|
||||||
@@ -85,7 +121,7 @@ public class AuthController {
|
|||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
if (user == null || !user.getIsActive()) {
|
if (user == null || !user.isActive()) {
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.status(HttpStatus.UNAUTHORIZED)
|
.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body("Invalid credentials");
|
.body("Invalid credentials");
|
||||||
@@ -151,6 +187,8 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("/password/forgot")
|
@PostMapping("/password/forgot")
|
||||||
public ResponseEntity<?> forgotPassword(@RequestBody Map<String, String> body) {
|
public ResponseEntity<?> forgotPassword(@RequestBody Map<String, String> body) {
|
||||||
String email = body.getOrDefault("email", "").trim();
|
String email = body.getOrDefault("email", "").trim();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package group.goforward.battlbuilder.controllers.admin;
|
package group.goforward.battlbuilder.controllers.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.services.auth.impl.BetaInviteService;
|
import group.goforward.battlbuilder.services.auth.impl.BetaInviteService;
|
||||||
|
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -24,5 +27,18 @@ public class AdminBetaInviteController {
|
|||||||
return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit);
|
return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/requests")
|
||||||
|
public Page<AdminBetaRequestDto> listBetaRequests(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "25") int size
|
||||||
|
) {
|
||||||
|
return betaInviteService.listPendingBetaUsers(page, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/requests/{userId}/invite")
|
||||||
|
public AdminInviteResponse inviteSingle(@PathVariable Integer userId) {
|
||||||
|
return betaInviteService.inviteSingleBetaUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {}
|
public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {}
|
||||||
}
|
}
|
||||||
@@ -98,11 +98,28 @@ public class MeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> toMeResponse(User user) {
|
private Map<String, Object> toMeResponse(User user) {
|
||||||
return Map.of(
|
Map<String, Object> out = new java.util.HashMap<>();
|
||||||
"email", user.getEmail(),
|
out.put("email", user.getEmail());
|
||||||
"displayName", user.getDisplayName(),
|
out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName());
|
||||||
"role", user.getRole()
|
out.put("username", user.getUsername() == null ? "" : user.getUsername());
|
||||||
);
|
out.put("role", user.getRole() == null ? "USER" : user.getRole());
|
||||||
|
out.put("uuid", String.valueOf(user.getUuid()));
|
||||||
|
out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeUsername(String raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
String s = raw.trim().toLowerCase();
|
||||||
|
return s.isBlank() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isReservedUsername(String u) {
|
||||||
|
return switch (u) {
|
||||||
|
case "admin", "support", "battl", "battlbuilders", "builder",
|
||||||
|
"api", "login", "register", "account", "privacy", "tos" -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
@@ -124,11 +141,41 @@ public class MeController {
|
|||||||
displayName = String.valueOf(body.get("displayName")).trim();
|
displayName = String.valueOf(body.get("displayName")).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayName == null || displayName.isBlank()) {
|
String username = null;
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "displayName is required");
|
if (body != null && body.get("username") != null) {
|
||||||
|
username = normalizeUsername(String.valueOf(body.get("username")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((displayName == null || displayName.isBlank()) && (username == null)) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// display name is flexible
|
||||||
|
if (displayName != null && !displayName.isBlank()) {
|
||||||
user.setDisplayName(displayName);
|
user.setDisplayName(displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// username is strict + unique
|
||||||
|
if (username != null) {
|
||||||
|
if (username.length() < 3 || username.length() > 20) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Username must be 3–20 characters");
|
||||||
|
}
|
||||||
|
if (!username.matches("^[a-z0-9_]+$")) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore");
|
||||||
|
}
|
||||||
|
if (isReservedUsername(username)) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "That username is reserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> {
|
||||||
|
if (!existing.getId().equals(user.getId())) {
|
||||||
|
throw new ResponseStatusException(CONFLICT, "Username already taken");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
user.setUsername(username);
|
||||||
|
}
|
||||||
|
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
@@ -149,9 +196,36 @@ public class MeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.setPasswordHash(passwordEncoder.encode(password));
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("ok", true));
|
return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/username-available")
|
||||||
|
public ResponseEntity<?> usernameAvailable(@RequestParam("username") String usernameRaw) {
|
||||||
|
String username = normalizeUsername(usernameRaw);
|
||||||
|
|
||||||
|
// Soft fail
|
||||||
|
if (username == null) return ResponseEntity.ok(Map.of("available", false));
|
||||||
|
|
||||||
|
if (username.length() < 3 || username.length() > 20) {
|
||||||
|
return ResponseEntity.ok(Map.of("available", false));
|
||||||
|
}
|
||||||
|
if (!username.matches("^[a-z0-9_]+$")) {
|
||||||
|
return ResponseEntity.ok(Map.of("available", false));
|
||||||
|
}
|
||||||
|
if (isReservedUsername(username)) {
|
||||||
|
return ResponseEntity.ok(Map.of("available", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
User me = requireUser();
|
||||||
|
|
||||||
|
boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username)
|
||||||
|
.map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available
|
||||||
|
.orElse(true);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of("available", available));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +76,26 @@ public class User {
|
|||||||
@Column(name = "login_count", nullable = false)
|
@Column(name = "login_count", nullable = false)
|
||||||
private Integer loginCount = 0;
|
private Integer loginCount = 0;
|
||||||
|
|
||||||
|
@Column(name = "tos_accepted_at")
|
||||||
|
private OffsetDateTime tosAcceptedAt;
|
||||||
|
|
||||||
|
@Column(name = "tos_version", length = 32)
|
||||||
|
private String tosVersion;
|
||||||
|
|
||||||
|
@Column(name = "tos_ip", length = 64)
|
||||||
|
private String tosIp;
|
||||||
|
|
||||||
|
@Column(name = "tos_user_agent", columnDefinition = "TEXT")
|
||||||
|
private String tosUserAgent;
|
||||||
|
|
||||||
|
@Column(name = "username", length = 32)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Column(name = "password_set_at")
|
||||||
|
private OffsetDateTime passwordSetAt;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- Getters / setters ---
|
// --- Getters / setters ---
|
||||||
|
|
||||||
public Integer getId() {
|
public Integer getId() {
|
||||||
@@ -126,13 +146,9 @@ public class User {
|
|||||||
this.role = role;
|
this.role = role;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean getIsActive() {
|
public boolean isActive() { return isActive; }
|
||||||
return isActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setIsActive(boolean active) {
|
public void setActive(boolean active) { this.isActive = active; }
|
||||||
isActive = active;
|
|
||||||
}
|
|
||||||
|
|
||||||
public OffsetDateTime getCreatedAt() {
|
public OffsetDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
@@ -206,6 +222,48 @@ public class User {
|
|||||||
this.loginCount = loginCount;
|
this.loginCount = loginCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public void setUsername(String username) { this.username = username; }
|
||||||
|
|
||||||
|
|
||||||
|
// --- ToS acceptance ---
|
||||||
|
|
||||||
|
public OffsetDateTime getTosAcceptedAt() {
|
||||||
|
return tosAcceptedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTosAcceptedAt(OffsetDateTime tosAcceptedAt) {
|
||||||
|
this.tosAcceptedAt = tosAcceptedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTosVersion() {
|
||||||
|
return tosVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTosVersion(String tosVersion) {
|
||||||
|
this.tosVersion = tosVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTosIp() {
|
||||||
|
return tosIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTosIp(String tosIp) {
|
||||||
|
this.tosIp = tosIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTosUserAgent() {
|
||||||
|
return tosUserAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTosUserAgent(String tosUserAgent) {
|
||||||
|
this.tosUserAgent = tosUserAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getPasswordSetAt() { return passwordSetAt; }
|
||||||
|
public void setPasswordSetAt(OffsetDateTime passwordSetAt) { this.passwordSetAt = passwordSetAt; }
|
||||||
|
|
||||||
|
|
||||||
// convenience helpers
|
// convenience helpers
|
||||||
|
|
||||||
@Transient
|
@Transient
|
||||||
|
|||||||
@@ -2,9 +2,27 @@ package group.goforward.battlbuilder.repos;
|
|||||||
|
|
||||||
import group.goforward.battlbuilder.model.AuthToken;
|
import group.goforward.battlbuilder.model.AuthToken;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
|
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
|
||||||
|
|
||||||
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
|
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
|
||||||
|
|
||||||
|
// ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists
|
||||||
|
@Query("""
|
||||||
|
select (count(t) > 0) from AuthToken t
|
||||||
|
where lower(t.email) = lower(:email)
|
||||||
|
and t.type = :type
|
||||||
|
and t.expiresAt > :now
|
||||||
|
and t.consumedAt is null
|
||||||
|
""")
|
||||||
|
boolean hasActiveToken(
|
||||||
|
@Param("email") String email,
|
||||||
|
@Param("type") AuthToken.TokenType type,
|
||||||
|
@Param("now") OffsetDateTime now
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,9 @@ package group.goforward.battlbuilder.repos;
|
|||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -22,6 +25,20 @@ public interface UserRepository extends JpaRepository<User, Integer> {
|
|||||||
|
|
||||||
List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role);
|
List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role);
|
||||||
|
|
||||||
|
// ✅ Pending beta requests (what you described)
|
||||||
|
Page<User> findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc(
|
||||||
|
String role,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
// ✅ Optional: find user by verification token for confirm flow (if you don’t already have it)
|
||||||
|
Optional<User> findByVerificationTokenAndDeletedAtIsNull(String verificationToken);
|
||||||
|
|
||||||
|
// Set Username
|
||||||
|
Optional<User> findByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
|
||||||
|
|
||||||
|
boolean existsByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
|
||||||
|
|
||||||
@Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true)
|
@Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true)
|
||||||
List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
|
List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ public class CustomUserDetails implements UserDetails {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean isAccountNonLocked() {
|
public boolean isAccountNonLocked() {
|
||||||
return user.getIsActive();
|
return user.isActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -54,6 +54,6 @@ public class CustomUserDetails implements UserDetails {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean isEnabled() {
|
public boolean isEnabled() {
|
||||||
return user.getIsActive() && user.getDeletedAt() == null;
|
return user.isActive() && user.getDeletedAt() == null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
User user = userRepository.findByUuid(userUuid).orElse(null);
|
User user = userRepository.findByUuid(userUuid).orElse(null);
|
||||||
if (user == null || !Boolean.TRUE.equals(user.getIsActive())) {
|
if (user == null || !Boolean.TRUE.equals(user.isActive())) {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
|
|
||||||
// Treat beta signups as users, but not active / not verified yet
|
// Treat beta signups as users, but not active / not verified yet
|
||||||
user.setRole("BETA");
|
user.setRole("BETA");
|
||||||
user.setIsActive(false);
|
user.setActive(false);
|
||||||
user.setDisplayName(null);
|
user.setDisplayName(null);
|
||||||
|
|
||||||
user.setCreatedAt(OffsetDateTime.now());
|
user.setCreatedAt(OffsetDateTime.now());
|
||||||
@@ -132,7 +132,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
// Allow magic link requests for:
|
// Allow magic link requests for:
|
||||||
// - active USERs, OR
|
// - active USERs, OR
|
||||||
// - BETA users (even if inactive), since they may not be activated yet
|
// - BETA users (even if inactive), since they may not be activated yet
|
||||||
if (!Boolean.TRUE.equals(user.getIsActive()) && !isBeta) return;
|
if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return;
|
||||||
|
|
||||||
// Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired)
|
// Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired)
|
||||||
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
|
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
|
||||||
@@ -171,14 +171,14 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
user.setEmail(email);
|
user.setEmail(email);
|
||||||
user.setDisplayName(null);
|
user.setDisplayName(null);
|
||||||
user.setRole("USER");
|
user.setRole("USER");
|
||||||
user.setIsActive(true);
|
user.setActive(true);
|
||||||
user.setCreatedAt(now);
|
user.setCreatedAt(now);
|
||||||
} else {
|
} else {
|
||||||
// Promote BETA -> USER on first successful confirm
|
// Promote BETA -> USER on first successful confirm
|
||||||
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||||
user.setRole("USER");
|
user.setRole("USER");
|
||||||
}
|
}
|
||||||
user.setIsActive(true);
|
user.setActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.setLastLoginAt(now);
|
user.setLastLoginAt(now);
|
||||||
@@ -207,8 +207,8 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||||
user.setRole("USER");
|
user.setRole("USER");
|
||||||
}
|
}
|
||||||
if (!Boolean.TRUE.equals(user.getIsActive())) {
|
if (!Boolean.TRUE.equals(user.isActive())) {
|
||||||
user.setIsActive(true);
|
user.setActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.setLastLoginAt(now);
|
user.setLastLoginAt(now);
|
||||||
@@ -263,6 +263,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||||
|
|
||||||
user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
|
user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
|
||||||
|
user.setPasswordSetAt(OffsetDateTime.now());
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
users.save(user);
|
users.save(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import group.goforward.battlbuilder.model.User;
|
|||||||
import group.goforward.battlbuilder.repos.AuthTokenRepository;
|
import group.goforward.battlbuilder.repos.AuthTokenRepository;
|
||||||
import group.goforward.battlbuilder.repos.UserRepository;
|
import group.goforward.battlbuilder.repos.UserRepository;
|
||||||
import group.goforward.battlbuilder.services.utils.TemplatedEmailService;
|
import group.goforward.battlbuilder.services.utils.TemplatedEmailService;
|
||||||
|
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -15,6 +20,7 @@ import java.time.OffsetDateTime;
|
|||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class BetaInviteService {
|
public class BetaInviteService {
|
||||||
@@ -31,7 +37,6 @@ public class BetaInviteService {
|
|||||||
|
|
||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
// ✅ Constructor injection
|
|
||||||
public BetaInviteService(
|
public BetaInviteService(
|
||||||
UserRepository users,
|
UserRepository users,
|
||||||
AuthTokenRepository tokens,
|
AuthTokenRepository tokens,
|
||||||
@@ -42,6 +47,9 @@ public class BetaInviteService {
|
|||||||
this.templatedEmailService = templatedEmailService;
|
this.templatedEmailService = templatedEmailService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch invite for all pending BETA users (role=BETA, is_active=false, deleted_at is null).
|
||||||
|
*/
|
||||||
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
|
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
|
||||||
|
|
||||||
List<User> betaUsers = (limit > 0)
|
List<User> betaUsers = (limit > 0)
|
||||||
@@ -51,6 +59,71 @@ public class BetaInviteService {
|
|||||||
int sent = 0;
|
int sent = 0;
|
||||||
|
|
||||||
for (User user : betaUsers) {
|
for (User user : betaUsers) {
|
||||||
|
inviteUser(user, tokenMinutes, dryRun);
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin UI list: all pending beta requests (role=BETA, is_active=false).
|
||||||
|
* Controller expects Page<AdminBetaRequestDto>.
|
||||||
|
*/
|
||||||
|
public Page<AdminBetaRequestDto> listPendingBetaUsers(int page, int size) {
|
||||||
|
int safePage = Math.max(0, page);
|
||||||
|
int safeSize = Math.min(Math.max(1, size), 100);
|
||||||
|
|
||||||
|
List<User> pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
|
||||||
|
|
||||||
|
int from = Math.min(safePage * safeSize, pending.size());
|
||||||
|
int to = Math.min(from + safeSize, pending.size());
|
||||||
|
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
|
List<AdminBetaRequestDto> dtos = pending.subList(from, to).stream()
|
||||||
|
.map(u -> {
|
||||||
|
AdminBetaRequestDto dto = AdminBetaRequestDto.from(u);
|
||||||
|
dto.invited = tokens.hasActiveToken(
|
||||||
|
u.getEmail(),
|
||||||
|
AuthToken.TokenType.MAGIC_LOGIN,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invite a single beta request by userId.
|
||||||
|
*/
|
||||||
|
public AdminInviteResponse inviteSingleBetaUser(Integer userId) {
|
||||||
|
if (userId == null) {
|
||||||
|
return new AdminInviteResponse(false, null, "userId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = users.findById(userId).orElse(null);
|
||||||
|
if (user == null || user.getDeletedAt() != null) {
|
||||||
|
return new AdminInviteResponse(false, null, "User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) {
|
||||||
|
return new AdminInviteResponse(false, user.getEmail(), "User is not a pending beta request");
|
||||||
|
}
|
||||||
|
|
||||||
|
int tokenMinutes = 30; // default for single-invite; feel free to parametrize later
|
||||||
|
String magicUrl = inviteUser(user, tokenMinutes, false);
|
||||||
|
|
||||||
|
return new AdminInviteResponse(true, user.getEmail(), magicUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates token, persists hash, and (optionally) sends email.
|
||||||
|
* Returns the magicUrl for logging / admin response.
|
||||||
|
*/
|
||||||
|
private String inviteUser(User user, int tokenMinutes, boolean dryRun) {
|
||||||
String email = user.getEmail();
|
String email = user.getEmail();
|
||||||
|
|
||||||
String magicToken = generateToken();
|
String magicToken = generateToken();
|
||||||
@@ -65,7 +138,7 @@ public class BetaInviteService {
|
|||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
templatedEmailService.send(
|
templatedEmailService.send(
|
||||||
"beta_invite", // template_key
|
"beta_invite",
|
||||||
email,
|
email,
|
||||||
Map.of(
|
Map.of(
|
||||||
"minutes", String.valueOf(tokenMinutes),
|
"minutes", String.valueOf(tokenMinutes),
|
||||||
@@ -74,10 +147,7 @@ public class BetaInviteService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
sent++;
|
return magicUrl;
|
||||||
}
|
|
||||||
|
|
||||||
return sent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveToken(
|
private void saveToken(
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package group.goforward.battlbuilder.web.dto.admin;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.model.AuthToken;
|
||||||
|
import group.goforward.battlbuilder.model.User;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class AdminBetaRequestDto {
|
||||||
|
public Integer id;
|
||||||
|
public UUID uuid;
|
||||||
|
public String email;
|
||||||
|
public String displayName;
|
||||||
|
public boolean invited; // token exists
|
||||||
|
public boolean verified; // email_verified_at exists
|
||||||
|
public boolean active; // is_active
|
||||||
|
public OffsetDateTime createdAt;
|
||||||
|
public OffsetDateTime updatedAt;
|
||||||
|
|
||||||
|
public static AdminBetaRequestDto from(User u) {
|
||||||
|
AdminBetaRequestDto dto = new AdminBetaRequestDto();
|
||||||
|
dto.id = u.getId();
|
||||||
|
dto.uuid = u.getUuid();
|
||||||
|
dto.email = u.getEmail();
|
||||||
|
dto.displayName = u.getDisplayName();
|
||||||
|
dto.verified = u.getEmailVerifiedAt() != null;
|
||||||
|
dto.active = u.isActive();
|
||||||
|
dto.createdAt = u.getCreatedAt();
|
||||||
|
dto.updatedAt = u.getUpdatedAt();
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package group.goforward.battlbuilder.web.dto.admin;
|
||||||
|
|
||||||
|
public class AdminInviteResponse {
|
||||||
|
public boolean ok;
|
||||||
|
public String email;
|
||||||
|
public String inviteUrl; // in dev you can show/copy this
|
||||||
|
|
||||||
|
public AdminInviteResponse(boolean ok, String email, String inviteUrl) {
|
||||||
|
this.ok = ok;
|
||||||
|
this.email = email;
|
||||||
|
this.inviteUrl = inviteUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,4 +28,13 @@ public class RegisterRequest {
|
|||||||
public void setDisplayName(String displayName) {
|
public void setDisplayName(String displayName) {
|
||||||
this.displayName = displayName;
|
this.displayName = displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Boolean acceptedTos;
|
||||||
|
private String tosVersion;
|
||||||
|
|
||||||
|
public Boolean getAcceptedTos() { return acceptedTos; }
|
||||||
|
public void setAcceptedTos(Boolean acceptedTos) { this.acceptedTos = acceptedTos; }
|
||||||
|
|
||||||
|
public String getTosVersion() { return tosVersion; }
|
||||||
|
public void setTosVersion(String tosVersion) { this.tosVersion = tosVersion; }
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user