a lot of account settings and user table changes

This commit is contained in:
2025-12-27 20:00:58 -05:00
parent 5d25176f0d
commit dc1c829dab
13 changed files with 397 additions and 51 deletions

View File

@@ -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();

View File

@@ -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) {}
}

View File

@@ -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 320 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));
}
}

View File

@@ -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

View File

@@ -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
);
}

View File

@@ -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 dont 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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}