mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
new stuff to flag off beta magic links until were ready for a email blast.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<User, Integer> {
|
||||
boolean existsByRole(String role);
|
||||
|
||||
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
|
||||
|
||||
List<User> 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<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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<User> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
ai.openai.model=gpt-4.1-mini
|
||||
|
||||
Reference in New Issue
Block a user