mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -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.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
@@ -45,9 +48,19 @@ public class AuthController {
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
|
||||
public ResponseEntity<?> register(
|
||||
@RequestBody RegisterRequest request,
|
||||
HttpServletRequest httpRequest
|
||||
) {
|
||||
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)) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CONFLICT)
|
||||
@@ -58,12 +71,23 @@ public class AuthController {
|
||||
user.setUuid(UUID.randomUUID());
|
||||
user.setEmail(email);
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||
user.setPasswordSetAt(OffsetDateTime.now());
|
||||
user.setDisplayName(request.getDisplayName());
|
||||
user.setRole("USER");
|
||||
user.setIsActive(true);
|
||||
user.setActive(true);
|
||||
user.setCreatedAt(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);
|
||||
|
||||
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")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||
String email = request.getEmail().trim().toLowerCase();
|
||||
@@ -85,7 +121,7 @@ public class AuthController {
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElse(null);
|
||||
|
||||
if (user == null || !user.getIsActive()) {
|
||||
if (user == null || !user.isActive()) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Invalid credentials");
|
||||
@@ -151,6 +187,8 @@ public class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@PostMapping("/password/forgot")
|
||||
public ResponseEntity<?> forgotPassword(@RequestBody Map<String, String> body) {
|
||||
String email = body.getOrDefault("email", "").trim();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package group.goforward.battlbuilder.controllers.admin;
|
||||
|
||||
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.*;
|
||||
|
||||
@RestController
|
||||
@@ -24,5 +27,18 @@ public class AdminBetaInviteController {
|
||||
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) {}
|
||||
}
|
||||
@@ -98,11 +98,28 @@ public class MeController {
|
||||
}
|
||||
|
||||
private Map<String, Object> toMeResponse(User user) {
|
||||
return Map.of(
|
||||
"email", user.getEmail(),
|
||||
"displayName", user.getDisplayName(),
|
||||
"role", user.getRole()
|
||||
);
|
||||
Map<String, Object> out = new java.util.HashMap<>();
|
||||
out.put("email", user.getEmail());
|
||||
out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName());
|
||||
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();
|
||||
}
|
||||
|
||||
if (displayName == null || displayName.isBlank()) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "displayName is required");
|
||||
String username = null;
|
||||
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);
|
||||
}
|
||||
|
||||
// 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.setDisplayName(displayName);
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
@@ -149,9 +196,36 @@ public class MeController {
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(password));
|
||||
user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
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)
|
||||
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 ---
|
||||
|
||||
public Integer getId() {
|
||||
@@ -126,13 +146,9 @@ public class User {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public boolean getIsActive() {
|
||||
return isActive;
|
||||
}
|
||||
public boolean isActive() { return isActive; }
|
||||
|
||||
public void setIsActive(boolean active) {
|
||||
isActive = active;
|
||||
}
|
||||
public void setActive(boolean active) { this.isActive = active; }
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
@@ -206,6 +222,48 @@ public class User {
|
||||
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
|
||||
|
||||
@Transient
|
||||
|
||||
@@ -2,9 +2,27 @@ package group.goforward.battlbuilder.repos;
|
||||
|
||||
import group.goforward.battlbuilder.model.AuthToken;
|
||||
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;
|
||||
|
||||
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
|
||||
|
||||
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 org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -22,6 +25,20 @@ public interface UserRepository extends JpaRepository<User, Integer> {
|
||||
|
||||
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)
|
||||
List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public class CustomUserDetails implements UserDetails {
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return user.getIsActive();
|
||||
return user.isActive();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -54,6 +54,6 @@ public class CustomUserDetails implements UserDetails {
|
||||
|
||||
@Override
|
||||
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);
|
||||
if (user == null || !Boolean.TRUE.equals(user.getIsActive())) {
|
||||
if (user == null || !Boolean.TRUE.equals(user.isActive())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
|
||||
// Treat beta signups as users, but not active / not verified yet
|
||||
user.setRole("BETA");
|
||||
user.setIsActive(false);
|
||||
user.setActive(false);
|
||||
user.setDisplayName(null);
|
||||
|
||||
user.setCreatedAt(OffsetDateTime.now());
|
||||
@@ -132,7 +132,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
// Allow magic link requests for:
|
||||
// - active USERs, OR
|
||||
// - 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)
|
||||
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
|
||||
@@ -171,14 +171,14 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
user.setEmail(email);
|
||||
user.setDisplayName(null);
|
||||
user.setRole("USER");
|
||||
user.setIsActive(true);
|
||||
user.setActive(true);
|
||||
user.setCreatedAt(now);
|
||||
} else {
|
||||
// Promote BETA -> USER on first successful confirm
|
||||
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||
user.setRole("USER");
|
||||
}
|
||||
user.setIsActive(true);
|
||||
user.setActive(true);
|
||||
}
|
||||
|
||||
user.setLastLoginAt(now);
|
||||
@@ -207,8 +207,8 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||
user.setRole("USER");
|
||||
}
|
||||
if (!Boolean.TRUE.equals(user.getIsActive())) {
|
||||
user.setIsActive(true);
|
||||
if (!Boolean.TRUE.equals(user.isActive())) {
|
||||
user.setActive(true);
|
||||
}
|
||||
|
||||
user.setLastLoginAt(now);
|
||||
@@ -263,6 +263,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
|
||||
user.setPasswordSetAt(OffsetDateTime.now());
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repos.AuthTokenRepository;
|
||||
import group.goforward.battlbuilder.repos.UserRepository;
|
||||
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.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -15,6 +20,7 @@ import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class BetaInviteService {
|
||||
@@ -31,7 +37,6 @@ public class BetaInviteService {
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
// ✅ Constructor injection
|
||||
public BetaInviteService(
|
||||
UserRepository users,
|
||||
AuthTokenRepository tokens,
|
||||
@@ -42,6 +47,9 @@ public class BetaInviteService {
|
||||
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) {
|
||||
|
||||
List<User> betaUsers = (limit > 0)
|
||||
@@ -51,35 +59,97 @@ public class BetaInviteService {
|
||||
int sent = 0;
|
||||
|
||||
for (User user : betaUsers) {
|
||||
String email = user.getEmail();
|
||||
|
||||
String magicToken = generateToken();
|
||||
saveToken(
|
||||
email,
|
||||
AuthToken.TokenType.MAGIC_LOGIN,
|
||||
magicToken,
|
||||
OffsetDateTime.now().plusMinutes(tokenMinutes)
|
||||
);
|
||||
|
||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
||||
|
||||
if (!dryRun) {
|
||||
templatedEmailService.send(
|
||||
"beta_invite", // template_key
|
||||
email,
|
||||
Map.of(
|
||||
"minutes", String.valueOf(tokenMinutes),
|
||||
"magicUrl", magicUrl
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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 magicToken = generateToken();
|
||||
saveToken(
|
||||
email,
|
||||
AuthToken.TokenType.MAGIC_LOGIN,
|
||||
magicToken,
|
||||
OffsetDateTime.now().plusMinutes(tokenMinutes)
|
||||
);
|
||||
|
||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
||||
|
||||
if (!dryRun) {
|
||||
templatedEmailService.send(
|
||||
"beta_invite",
|
||||
email,
|
||||
Map.of(
|
||||
"minutes", String.valueOf(tokenMinutes),
|
||||
"magicUrl", magicUrl
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return magicUrl;
|
||||
}
|
||||
|
||||
private void saveToken(
|
||||
String email,
|
||||
AuthToken.TokenType type,
|
||||
|
||||
@@ -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) {
|
||||
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