From dc1c829dab896c6cab7cdffd5ad776e2c4c0182e Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 27 Dec 2025 20:00:58 -0500 Subject: [PATCH] a lot of account settings and user table changes --- .../controllers/AuthController.java | 44 ++++++- .../admin/AdminBetaInviteController.java | 16 +++ .../controllers/api/MeController.java | 92 ++++++++++++-- .../goforward/battlbuilder/model/User.java | 70 ++++++++++- .../repos/AuthTokenRepository.java | 18 +++ .../battlbuilder/repos/UserRepository.java | 17 +++ .../security/CustomUserDetails.java | 4 +- .../security/JwtAuthenticationFilter.java | 2 +- .../auth/impl/BetaAuthServiceImpl.java | 13 +- .../services/auth/impl/BetaInviteService.java | 118 ++++++++++++++---- .../web/dto/admin/AdminBetaRequestDto.java | 32 +++++ .../web/dto/admin/AdminInviteResponse.java | 13 ++ .../web/dto/auth/RegisterRequest.java | 9 ++ 13 files changed, 397 insertions(+), 51 deletions(-) create mode 100644 src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminBetaRequestDto.java create mode 100644 src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminInviteResponse.java diff --git a/src/main/java/group/goforward/battlbuilder/controllers/AuthController.java b/src/main/java/group/goforward/battlbuilder/controllers/AuthController.java index 7a6984f..c2bc2d2 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/AuthController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/AuthController.java @@ -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 body) { String email = body.getOrDefault("email", "").trim(); diff --git a/src/main/java/group/goforward/battlbuilder/controllers/admin/AdminBetaInviteController.java b/src/main/java/group/goforward/battlbuilder/controllers/admin/AdminBetaInviteController.java index 6394318..5eb4a79 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/admin/AdminBetaInviteController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/admin/AdminBetaInviteController.java @@ -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 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) {} } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controllers/api/MeController.java b/src/main/java/group/goforward/battlbuilder/controllers/api/MeController.java index 7f5d52c..e901ef2 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/api/MeController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/api/MeController.java @@ -98,11 +98,28 @@ public class MeController { } private Map toMeResponse(User user) { - return Map.of( - "email", user.getEmail(), - "displayName", user.getDisplayName(), - "role", user.getRole() - ); + Map 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)); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/User.java b/src/main/java/group/goforward/battlbuilder/model/User.java index a28da4e..f504e7f 100644 --- a/src/main/java/group/goforward/battlbuilder/model/User.java +++ b/src/main/java/group/goforward/battlbuilder/model/User.java @@ -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 diff --git a/src/main/java/group/goforward/battlbuilder/repos/AuthTokenRepository.java b/src/main/java/group/goforward/battlbuilder/repos/AuthTokenRepository.java index 8963e91..478fe29 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/AuthTokenRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/AuthTokenRepository.java @@ -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 { + Optional 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 + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/UserRepository.java b/src/main/java/group/goforward/battlbuilder/repos/UserRepository.java index a1a05f1..6547f4b 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/UserRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/UserRepository.java @@ -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 { List findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role); + // ✅ Pending beta requests (what you described) + Page findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc( + String role, + Pageable pageable + ); + + // ✅ Optional: find user by verification token for confirm flow (if you don’t already have it) + Optional findByVerificationTokenAndDeletedAtIsNull(String verificationToken); + + // Set Username + Optional 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 findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/security/CustomUserDetails.java b/src/main/java/group/goforward/battlbuilder/security/CustomUserDetails.java index a229009..b3d9358 100644 --- a/src/main/java/group/goforward/battlbuilder/security/CustomUserDetails.java +++ b/src/main/java/group/goforward/battlbuilder/security/CustomUserDetails.java @@ -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; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java b/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java index 6a2baf9..24c5ca6 100644 --- a/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java +++ b/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java @@ -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; } diff --git a/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaAuthServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaAuthServiceImpl.java index 4ca63e3..b005e71 100644 --- a/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaAuthServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaAuthServiceImpl.java @@ -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); } diff --git a/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaInviteService.java b/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaInviteService.java index 6055f08..0ca9f0b 100644 --- a/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaInviteService.java +++ b/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaInviteService.java @@ -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 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. + */ + public Page listPendingBetaUsers(int page, int size) { + int safePage = Math.max(0, page); + int safeSize = Math.min(Math.max(1, size), 100); + + List 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 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, diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminBetaRequestDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminBetaRequestDto.java new file mode 100644 index 0000000..a86f29f --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminBetaRequestDto.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminInviteResponse.java b/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminInviteResponse.java new file mode 100644 index 0000000..3865d86 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/admin/AdminInviteResponse.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/auth/RegisterRequest.java b/src/main/java/group/goforward/battlbuilder/web/dto/auth/RegisterRequest.java index 2b4d8dd..c59762c 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/auth/RegisterRequest.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/auth/RegisterRequest.java @@ -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; } } \ No newline at end of file