diff --git a/src/main/java/group/goforward/battlbuilder/cli/BetaInviteCliRunner.java b/src/main/java/group/goforward/battlbuilder/cli/BetaInviteCliRunner.java new file mode 100644 index 0000000..39c02a1 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/cli/BetaInviteCliRunner.java @@ -0,0 +1,39 @@ +package group.goforward.battlbuilder.cli; + +import group.goforward.battlbuilder.services.auth.impl.BetaInviteService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +public class BetaInviteCliRunner implements CommandLineRunner { + + private final BetaInviteService inviteService; + + @Value("${app.beta.invite.run:false}") + private boolean run; + + @Value("${app.beta.invite.limit:0}") + private int limit; + + @Value("${app.beta.invite.dryRun:true}") + private boolean dryRun; + + @Value("${app.beta.invite.tokenMinutes:30}") + private int tokenMinutes; + + public BetaInviteCliRunner(BetaInviteService inviteService) { + this.inviteService = inviteService; + } + + @Override + public void run(String... args) { + if (!run) return; + + int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun); + System.out.println("✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun); + + // Exit so it behaves like a CLI command + System.exit(0); + } +} \ 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 e896ed3..a1a05f1 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/UserRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/UserRepository.java @@ -2,7 +2,9 @@ package group.goforward.battlbuilder.repos; import group.goforward.battlbuilder.model.User; import org.springframework.data.jpa.repository.JpaRepository; - +import java.util.List; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; import java.util.UUID; @@ -17,4 +19,9 @@ public interface UserRepository extends JpaRepository { boolean existsByRole(String role); Optional findByUuidAndDeletedAtIsNull(UUID uuid); + + List findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role); + + @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/services/auth/impl/BetaAuthServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaAuthServiceImpl.java index 4c9775f..4ca63e3 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 @@ -34,6 +34,15 @@ public class BetaAuthServiceImpl implements BetaAuthService { @Value("${app.authTokenPepper:change-me}") private String tokenPepper; + /** + * When true: + * - Signup captures users (role=BETA, inactive) + * - NO tokens are generated + * - NO emails are sent + */ + @Value("${app.beta.captureOnly:true}") + private boolean betaCaptureOnly; + private final SecureRandom secureRandom = new SecureRandom(); public BetaAuthServiceImpl( @@ -51,13 +60,39 @@ public class BetaAuthServiceImpl implements BetaAuthService { } /** - * Send ONE email with a "confirm+login" token. + * A: Beta signup (capture lead + optionally email confirm+login token). * The Next page will call /api/auth/beta/confirm and receive AuthResponse. */ @Override public void signup(String rawEmail, String useCase) { String email = normalizeEmail(rawEmail); + // ✅ Create or update a "beta lead" user record + User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); + + if (user == null) { + user = new User(); + user.setUuid(UUID.randomUUID()); + user.setEmail(email); + + // Treat beta signups as users, but not active / not verified yet + user.setRole("BETA"); + user.setIsActive(false); + user.setDisplayName(null); + + user.setCreatedAt(OffsetDateTime.now()); + } + + // Optional: stash useCase somewhere if desired + // user.setPreferences(mergeUseCase(user.getPreferences(), useCase)); + + user.setUpdatedAt(OffsetDateTime.now()); + users.save(user); + + // 🚫 Capture-only mode: do not create tokens, do not send email + if (betaCaptureOnly) return; + + // --- Invite mode (later) --- // 24h confirm token String verifyToken = generateToken(); saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24)); @@ -78,7 +113,7 @@ public class BetaAuthServiceImpl implements BetaAuthService { } /** - * ✅ B: Existing users only — request a magic login link (no signup/confirm). + * B: Existing users only — request a magic login link (no signup/confirm). * Caller must always return OK to avoid email enumeration. */ @Override @@ -89,11 +124,18 @@ public class BetaAuthServiceImpl implements BetaAuthService { User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); if (user == null) return; - if (!Boolean.TRUE.equals(user.getIsActive())) return; + boolean isBeta = "BETA".equalsIgnoreCase(user.getRole()); - // Optional: restrict magic links to normal beta users only - // If you want admins to also use magic links, remove this. - if (!"USER".equalsIgnoreCase(user.getRole())) return; + // If capture-only mode is enabled, do not generate tokens or send email + if (betaCaptureOnly) return; + + // 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; + + // Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired) + if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return; // 30 minute magic token String magicToken = generateToken(); @@ -113,7 +155,7 @@ public class BetaAuthServiceImpl implements BetaAuthService { } /** - * Consumes BETA_VERIFY token, creates/activates user, and returns JWT immediately. + * Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately. */ @Override public AuthResponse confirmAndExchange(String token) { @@ -121,6 +163,7 @@ public class BetaAuthServiceImpl implements BetaAuthService { String email = authToken.getEmail(); User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); + OffsetDateTime now = OffsetDateTime.now(); if (user == null) { user = new User(); @@ -129,27 +172,27 @@ public class BetaAuthServiceImpl implements BetaAuthService { user.setDisplayName(null); user.setRole("USER"); user.setIsActive(true); - user.setCreatedAt(OffsetDateTime.now()); - user.setUpdatedAt(OffsetDateTime.now()); - users.save(user); - } else if (!Boolean.TRUE.equals(user.getIsActive())) { + user.setCreatedAt(now); + } else { + // Promote BETA -> USER on first successful confirm + if ("BETA".equalsIgnoreCase(user.getRole())) { + user.setRole("USER"); + } user.setIsActive(true); - user.setUpdatedAt(OffsetDateTime.now()); - users.save(user); } - user.setLastLoginAt(OffsetDateTime.now()); + user.setLastLoginAt(now); user.incrementLoginCount(); - user.setUpdatedAt(OffsetDateTime.now()); + user.setUpdatedAt(now); users.save(user); String jwt = jwtService.generateToken(user); - return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole()); } /** * Consumes MAGIC_LOGIN token and returns JWT (returning users). + * Also promotes BETA->USER and activates the account on first successful login. */ @Override public AuthResponse exchangeMagicToken(String token) { @@ -158,13 +201,22 @@ public class BetaAuthServiceImpl implements BetaAuthService { User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail()) .orElseThrow(() -> new IllegalStateException("User not found for magic token")); - user.setLastLoginAt(OffsetDateTime.now()); + OffsetDateTime now = OffsetDateTime.now(); + + // Promote/activate beta users on first successful magic login + if ("BETA".equalsIgnoreCase(user.getRole())) { + user.setRole("USER"); + } + if (!Boolean.TRUE.equals(user.getIsActive())) { + user.setIsActive(true); + } + + user.setLastLoginAt(now); user.incrementLoginCount(); - user.setUpdatedAt(OffsetDateTime.now()); + user.setUpdatedAt(now); users.save(user); String jwt = jwtService.generateToken(user); - return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole()); } @@ -179,6 +231,9 @@ public class BetaAuthServiceImpl implements BetaAuthService { User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null); if (user == null) return; + // If capture-only mode is enabled, do not generate tokens or send email + if (betaCaptureOnly) return; + String resetToken = generateToken(); saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30)); 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 new file mode 100644 index 0000000..372875e --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaInviteService.java @@ -0,0 +1,102 @@ +package group.goforward.battlbuilder.services.auth.impl; + +import group.goforward.battlbuilder.model.AuthToken; +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repos.AuthTokenRepository; +import group.goforward.battlbuilder.repos.UserRepository; +import group.goforward.battlbuilder.services.utils.EmailService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.OffsetDateTime; +import java.util.HexFormat; +import java.util.List; + +@Service +public class BetaInviteService { + + private final UserRepository users; + private final AuthTokenRepository tokens; + private final EmailService emailService; + + @Value("${app.publicBaseUrl:http://localhost:3000}") + private String publicBaseUrl; + + @Value("${app.authTokenPepper:change-me}") + private String tokenPepper; + + private final SecureRandom secureRandom = new SecureRandom(); + + public BetaInviteService(UserRepository users, AuthTokenRepository tokens, EmailService emailService) { + this.users = users; + this.tokens = tokens; + this.emailService = emailService; + } + + public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) { + // You may need to adjust this query depending on your repo methods. + // See NOTE below if you don’t have this finder. + List betaUsers = (limit > 0) + ? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit) + : users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA"); + + 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; + + String subject = "Your Battl Builders beta access link"; + String body = """ + You’re in. + + Here’s your secure sign-in link (expires in %d minutes): + %s + + If you didn’t request this, you can ignore this email. + """.formatted(tokenMinutes, magicUrl); + + if (!dryRun) { + emailService.sendEmail(email, subject, body); + } + + sent++; + } + + return sent; + } + + private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) { + AuthToken t = new AuthToken(); + t.setEmail(email); + t.setType(type); + t.setTokenHash(hashToken(token)); + t.setExpiresAt(expiresAt); + t.setCreatedAt(OffsetDateTime.now()); + tokens.save(t); + } + + private String generateToken() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + private String hashToken(String token) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (Exception e) { + throw new RuntimeException("Failed to hash token", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java index 32b59e6..bcb0639 100644 --- a/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java @@ -27,6 +27,10 @@ public class EmailServiceImpl implements EmailService { @Value("${spring.mail.username}") private String fromEmail; + // Kill switch for beta sign up(default true so dev + future prod work normally) + @Value("${app.email.outbound-enabled:true}") + private boolean outboundEnabled; + /** * Sends an email and persists send status. * Uses multipart=true to avoid MimeMessageHelper errors when setting text. @@ -45,6 +49,13 @@ public class EmailServiceImpl implements EmailService { emailRequest = emailRequestRepository.save(emailRequest); + // ✅ Capture-only mode: record it, but don’t send + if (!outboundEnabled) { + emailRequest.setStatus(EmailStatus.PENDING); // <-- if you don't have this enum, use PENDING + emailRequest.setErrorMessage("Outbound email suppressed by config (app.email.outbound-enabled=false)"); + return emailRequestRepository.save(emailRequest); + } + try { MimeMessage message = mailSender.createMimeMessage(); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9d14611..d345f16 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -34,7 +34,6 @@ spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.properties.mail.smtp.starttls.required=true #Database settings - spring.datasource.hikari.max-lifetime=600000 minio.endpoint=https://minioapi.dev.gofwd.group @@ -55,8 +54,17 @@ app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d0 # Magic Token Duration security.jwt.access-token-days=30 +# Beta Invite Email Toggle +app.beta.captureOnly=true +app.email.outbound-enabled=false + +# CLI invite runner (off by default) +app.beta.invite.run=false +app.beta.invite.limit=0 # 0 = no limit +app.beta.invite.dryRun=true # true = generate + log, but do NOT send emails +app.beta.invite.tokenMinutes=30 + # Ai Enrichment Settings ai.minConfidence=0.75 - ai.openai.apiKey=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 -ai.openai.model=gpt-4.1-mini \ No newline at end of file +ai.openai.model=gpt-4.1-mini