- Send welcome email to new users who sign up via Apple Sign In - Create notification preferences (all enabled) when new accounts are created - Add comprehensive integration tests for contractor sharing: - Personal contractors only visible to creator - Residence-tied contractors visible to all users with residence access - Update/delete access control for shared contractors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
640 lines
18 KiB
Go
640 lines
18 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/treytartt/casera-api/internal/config"
|
|
"github.com/treytartt/casera-api/internal/dto/requests"
|
|
"github.com/treytartt/casera-api/internal/dto/responses"
|
|
"github.com/treytartt/casera-api/internal/models"
|
|
"github.com/treytartt/casera-api/internal/repositories"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
|
ErrUsernameTaken = errors.New("username already taken")
|
|
ErrEmailTaken = errors.New("email already taken")
|
|
ErrUserInactive = errors.New("user account is inactive")
|
|
ErrInvalidCode = errors.New("invalid verification code")
|
|
ErrCodeExpired = errors.New("verification code expired")
|
|
ErrAlreadyVerified = errors.New("email already verified")
|
|
ErrRateLimitExceeded = errors.New("too many requests, please try again later")
|
|
ErrInvalidResetToken = errors.New("invalid or expired reset token")
|
|
ErrAppleSignInFailed = errors.New("Apple Sign In failed")
|
|
)
|
|
|
|
// AuthService handles authentication business logic
|
|
type AuthService struct {
|
|
userRepo *repositories.UserRepository
|
|
notificationRepo *repositories.NotificationRepository
|
|
cfg *config.Config
|
|
}
|
|
|
|
// NewAuthService creates a new auth service
|
|
func NewAuthService(userRepo *repositories.UserRepository, cfg *config.Config) *AuthService {
|
|
return &AuthService{
|
|
userRepo: userRepo,
|
|
cfg: cfg,
|
|
}
|
|
}
|
|
|
|
// SetNotificationRepository sets the notification repository for creating notification preferences
|
|
func (s *AuthService) SetNotificationRepository(notificationRepo *repositories.NotificationRepository) {
|
|
s.notificationRepo = notificationRepo
|
|
}
|
|
|
|
// Login authenticates a user and returns a token
|
|
func (s *AuthService) Login(req *requests.LoginRequest) (*responses.LoginResponse, error) {
|
|
// Find user by username or email
|
|
identifier := req.Username
|
|
if identifier == "" {
|
|
identifier = req.Email
|
|
}
|
|
|
|
user, err := s.userRepo.FindByUsernameOrEmail(identifier)
|
|
if err != nil {
|
|
if errors.Is(err, repositories.ErrUserNotFound) {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
return nil, fmt.Errorf("failed to find user: %w", err)
|
|
}
|
|
|
|
// Check if user is active
|
|
if !user.IsActive {
|
|
return nil, ErrUserInactive
|
|
}
|
|
|
|
// Verify password
|
|
if !user.CheckPassword(req.Password) {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
|
|
// Get or create auth token
|
|
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create token: %w", err)
|
|
}
|
|
|
|
// Update last login
|
|
if err := s.userRepo.UpdateLastLogin(user.ID); err != nil {
|
|
// Log error but don't fail the login
|
|
fmt.Printf("Failed to update last login: %v\n", err)
|
|
}
|
|
|
|
return &responses.LoginResponse{
|
|
Token: token.Key,
|
|
User: responses.NewUserResponse(user),
|
|
}, nil
|
|
}
|
|
|
|
// Register creates a new user account
|
|
func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.RegisterResponse, string, error) {
|
|
// Check if username exists
|
|
exists, err := s.userRepo.ExistsByUsername(req.Username)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to check username: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, "", ErrUsernameTaken
|
|
}
|
|
|
|
// Check if email exists
|
|
exists, err = s.userRepo.ExistsByEmail(req.Email)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to check email: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, "", ErrEmailTaken
|
|
}
|
|
|
|
// Create user
|
|
user := &models.User{
|
|
Username: req.Username,
|
|
Email: req.Email,
|
|
FirstName: req.FirstName,
|
|
LastName: req.LastName,
|
|
IsActive: true,
|
|
}
|
|
|
|
// Hash password
|
|
if err := user.SetPassword(req.Password); err != nil {
|
|
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// Save user
|
|
if err := s.userRepo.Create(user); err != nil {
|
|
return nil, "", fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
// Create user profile
|
|
if _, err := s.userRepo.GetOrCreateProfile(user.ID); err != nil {
|
|
// Log error but don't fail registration
|
|
fmt.Printf("Failed to create user profile: %v\n", err)
|
|
}
|
|
|
|
// Create notification preferences with all options enabled
|
|
if s.notificationRepo != nil {
|
|
if _, err := s.notificationRepo.GetOrCreatePreferences(user.ID); err != nil {
|
|
// Log error but don't fail registration
|
|
fmt.Printf("Failed to create notification preferences: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Create auth token
|
|
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to create token: %w", err)
|
|
}
|
|
|
|
// Generate confirmation code - use fixed code in debug mode for easier local testing
|
|
var code string
|
|
if s.cfg.Server.Debug {
|
|
code = "123456"
|
|
} else {
|
|
code = generateSixDigitCode()
|
|
}
|
|
expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry)
|
|
|
|
if _, err := s.userRepo.CreateConfirmationCode(user.ID, code, expiresAt); err != nil {
|
|
// Log error but don't fail registration
|
|
fmt.Printf("Failed to create confirmation code: %v\n", err)
|
|
}
|
|
|
|
return &responses.RegisterResponse{
|
|
Token: token.Key,
|
|
User: responses.NewUserResponse(user),
|
|
Message: "Registration successful. Please check your email to verify your account.",
|
|
}, code, nil
|
|
}
|
|
|
|
// Logout invalidates a user's token
|
|
func (s *AuthService) Logout(token string) error {
|
|
return s.userRepo.DeleteToken(token)
|
|
}
|
|
|
|
// GetCurrentUser returns the current authenticated user with profile
|
|
func (s *AuthService) GetCurrentUser(userID uint) (*responses.CurrentUserResponse, error) {
|
|
user, err := s.userRepo.FindByIDWithProfile(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := responses.NewCurrentUserResponse(user)
|
|
return &response, nil
|
|
}
|
|
|
|
// UpdateProfile updates a user's profile
|
|
func (s *AuthService) UpdateProfile(userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) {
|
|
user, err := s.userRepo.FindByID(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if new email is taken (if email is being changed)
|
|
if req.Email != nil && *req.Email != user.Email {
|
|
exists, err := s.userRepo.ExistsByEmail(*req.Email)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check email: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, ErrEmailTaken
|
|
}
|
|
user.Email = *req.Email
|
|
}
|
|
|
|
if req.FirstName != nil {
|
|
user.FirstName = *req.FirstName
|
|
}
|
|
if req.LastName != nil {
|
|
user.LastName = *req.LastName
|
|
}
|
|
|
|
if err := s.userRepo.Update(user); err != nil {
|
|
return nil, fmt.Errorf("failed to update user: %w", err)
|
|
}
|
|
|
|
// Reload with profile
|
|
user, err = s.userRepo.FindByIDWithProfile(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := responses.NewCurrentUserResponse(user)
|
|
return &response, nil
|
|
}
|
|
|
|
// VerifyEmail verifies a user's email with a confirmation code
|
|
func (s *AuthService) VerifyEmail(userID uint, code string) error {
|
|
// Get user profile
|
|
profile, err := s.userRepo.GetOrCreateProfile(userID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get profile: %w", err)
|
|
}
|
|
|
|
// Check if already verified
|
|
if profile.Verified {
|
|
return ErrAlreadyVerified
|
|
}
|
|
|
|
// Check for test code in debug mode
|
|
if s.cfg.Server.Debug && code == "123456" {
|
|
if err := s.userRepo.SetProfileVerified(userID, true); err != nil {
|
|
return fmt.Errorf("failed to verify profile: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Find and validate confirmation code
|
|
confirmCode, err := s.userRepo.FindConfirmationCode(userID, code)
|
|
if err != nil {
|
|
if errors.Is(err, repositories.ErrCodeNotFound) {
|
|
return ErrInvalidCode
|
|
}
|
|
if errors.Is(err, repositories.ErrCodeExpired) {
|
|
return ErrCodeExpired
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Mark code as used
|
|
if err := s.userRepo.MarkConfirmationCodeUsed(confirmCode.ID); err != nil {
|
|
return fmt.Errorf("failed to mark code as used: %w", err)
|
|
}
|
|
|
|
// Set profile as verified
|
|
if err := s.userRepo.SetProfileVerified(userID, true); err != nil {
|
|
return fmt.Errorf("failed to verify profile: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ResendVerificationCode creates and returns a new verification code
|
|
func (s *AuthService) ResendVerificationCode(userID uint) (string, error) {
|
|
// Get user profile
|
|
profile, err := s.userRepo.GetOrCreateProfile(userID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get profile: %w", err)
|
|
}
|
|
|
|
// Check if already verified
|
|
if profile.Verified {
|
|
return "", ErrAlreadyVerified
|
|
}
|
|
|
|
// Generate new code - use fixed code in debug mode for easier local testing
|
|
var code string
|
|
if s.cfg.Server.Debug {
|
|
code = "123456"
|
|
} else {
|
|
code = generateSixDigitCode()
|
|
}
|
|
expiresAt := time.Now().UTC().Add(s.cfg.Security.ConfirmationExpiry)
|
|
|
|
if _, err := s.userRepo.CreateConfirmationCode(userID, code, expiresAt); err != nil {
|
|
return "", fmt.Errorf("failed to create confirmation code: %w", err)
|
|
}
|
|
|
|
return code, nil
|
|
}
|
|
|
|
// ForgotPassword initiates the password reset process
|
|
func (s *AuthService) ForgotPassword(email string) (string, *models.User, error) {
|
|
// Find user by email
|
|
user, err := s.userRepo.FindByEmail(email)
|
|
if err != nil {
|
|
if errors.Is(err, repositories.ErrUserNotFound) {
|
|
// Don't reveal that the email doesn't exist
|
|
return "", nil, nil
|
|
}
|
|
return "", nil, err
|
|
}
|
|
|
|
// Check rate limit
|
|
count, err := s.userRepo.CountRecentPasswordResetRequests(user.ID)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to check rate limit: %w", err)
|
|
}
|
|
if count >= int64(s.cfg.Security.MaxPasswordResetRate) {
|
|
return "", nil, ErrRateLimitExceeded
|
|
}
|
|
|
|
// Generate code and reset token - use fixed code in debug mode for easier local testing
|
|
var code string
|
|
if s.cfg.Server.Debug {
|
|
code = "123456"
|
|
} else {
|
|
code = generateSixDigitCode()
|
|
}
|
|
resetToken := generateResetToken()
|
|
expiresAt := time.Now().UTC().Add(s.cfg.Security.PasswordResetExpiry)
|
|
|
|
// Hash the code before storing
|
|
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to hash code: %w", err)
|
|
}
|
|
|
|
if _, err := s.userRepo.CreatePasswordResetCode(user.ID, string(codeHash), resetToken, expiresAt); err != nil {
|
|
return "", nil, fmt.Errorf("failed to create reset code: %w", err)
|
|
}
|
|
|
|
return code, user, nil
|
|
}
|
|
|
|
// VerifyResetCode verifies a password reset code and returns a reset token
|
|
func (s *AuthService) VerifyResetCode(email, code string) (string, error) {
|
|
// Find the reset code
|
|
resetCode, user, err := s.userRepo.FindPasswordResetCodeByEmail(email)
|
|
if err != nil {
|
|
if errors.Is(err, repositories.ErrUserNotFound) || errors.Is(err, repositories.ErrCodeNotFound) {
|
|
return "", ErrInvalidCode
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
// Check for test code in debug mode
|
|
if s.cfg.Server.Debug && code == "123456" {
|
|
return resetCode.ResetToken, nil
|
|
}
|
|
|
|
// Verify the code
|
|
if !resetCode.CheckCode(code) {
|
|
// Increment attempts
|
|
s.userRepo.IncrementResetCodeAttempts(resetCode.ID)
|
|
return "", ErrInvalidCode
|
|
}
|
|
|
|
// Check if code is still valid
|
|
if !resetCode.IsValid() {
|
|
if resetCode.Used {
|
|
return "", ErrInvalidCode
|
|
}
|
|
if resetCode.Attempts >= resetCode.MaxAttempts {
|
|
return "", ErrRateLimitExceeded
|
|
}
|
|
return "", ErrCodeExpired
|
|
}
|
|
|
|
_ = user // user available if needed
|
|
|
|
return resetCode.ResetToken, nil
|
|
}
|
|
|
|
// ResetPassword resets the user's password using a reset token
|
|
func (s *AuthService) ResetPassword(resetToken, newPassword string) error {
|
|
// Find the reset code by token
|
|
resetCode, err := s.userRepo.FindPasswordResetCodeByToken(resetToken)
|
|
if err != nil {
|
|
if errors.Is(err, repositories.ErrCodeNotFound) || errors.Is(err, repositories.ErrCodeExpired) {
|
|
return ErrInvalidResetToken
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Get the user
|
|
user, err := s.userRepo.FindByID(resetCode.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find user: %w", err)
|
|
}
|
|
|
|
// Update password
|
|
if err := user.SetPassword(newPassword); err != nil {
|
|
return fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
if err := s.userRepo.Update(user); err != nil {
|
|
return fmt.Errorf("failed to update user: %w", err)
|
|
}
|
|
|
|
// Mark reset code as used
|
|
if err := s.userRepo.MarkPasswordResetCodeUsed(resetCode.ID); err != nil {
|
|
// Log error but don't fail
|
|
fmt.Printf("Failed to mark reset code as used: %v\n", err)
|
|
}
|
|
|
|
// Invalidate all existing tokens for this user (security measure)
|
|
if err := s.userRepo.DeleteTokenByUserID(user.ID); err != nil {
|
|
// Log error but don't fail
|
|
fmt.Printf("Failed to delete user tokens: %v\n", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AppleSignIn handles Sign in with Apple authentication
|
|
func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthService, req *requests.AppleSignInRequest) (*responses.AppleSignInResponse, error) {
|
|
// 1. Verify the Apple JWT token
|
|
claims, err := appleAuth.VerifyIdentityToken(ctx, req.IDToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrAppleSignInFailed, err)
|
|
}
|
|
|
|
// Use the subject from claims as the authoritative Apple ID
|
|
appleID := claims.Subject
|
|
if appleID == "" {
|
|
appleID = req.UserID // Fallback to request UserID
|
|
}
|
|
|
|
// 2. Check if this Apple ID is already linked to an account
|
|
existingAuth, err := s.userRepo.FindByAppleID(appleID)
|
|
if err == nil && existingAuth != nil {
|
|
// User already linked with this Apple ID - log them in
|
|
user, err := s.userRepo.FindByIDWithProfile(existingAuth.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find user: %w", err)
|
|
}
|
|
|
|
if !user.IsActive {
|
|
return nil, ErrUserInactive
|
|
}
|
|
|
|
// Get or create token
|
|
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create token: %w", err)
|
|
}
|
|
|
|
// Update last login
|
|
_ = s.userRepo.UpdateLastLogin(user.ID)
|
|
|
|
return &responses.AppleSignInResponse{
|
|
Token: token.Key,
|
|
User: responses.NewUserResponse(user),
|
|
IsNewUser: false,
|
|
}, nil
|
|
}
|
|
|
|
// 3. Check if email matches an existing user (for account linking)
|
|
email := getEmailFromRequest(req.Email, claims.Email)
|
|
if email != "" {
|
|
existingUser, err := s.userRepo.FindByEmail(email)
|
|
if err == nil && existingUser != nil {
|
|
// Link Apple ID to existing account
|
|
appleAuthRecord := &models.AppleSocialAuth{
|
|
UserID: existingUser.ID,
|
|
AppleID: appleID,
|
|
Email: email,
|
|
IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(),
|
|
}
|
|
if err := s.userRepo.CreateAppleSocialAuth(appleAuthRecord); err != nil {
|
|
return nil, fmt.Errorf("failed to link Apple ID: %w", err)
|
|
}
|
|
|
|
// Mark as verified since Apple verified the email
|
|
_ = s.userRepo.SetProfileVerified(existingUser.ID, true)
|
|
|
|
// Get or create token
|
|
token, err := s.userRepo.GetOrCreateToken(existingUser.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create token: %w", err)
|
|
}
|
|
|
|
// Update last login
|
|
_ = s.userRepo.UpdateLastLogin(existingUser.ID)
|
|
|
|
// Reload user with profile
|
|
existingUser, _ = s.userRepo.FindByIDWithProfile(existingUser.ID)
|
|
|
|
return &responses.AppleSignInResponse{
|
|
Token: token.Key,
|
|
User: responses.NewUserResponse(existingUser),
|
|
IsNewUser: false,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// 4. Create new user
|
|
username := generateUniqueUsername(email, req.FirstName)
|
|
|
|
user := &models.User{
|
|
Username: username,
|
|
Email: getEmailOrDefault(email),
|
|
FirstName: getStringOrEmpty(req.FirstName),
|
|
LastName: getStringOrEmpty(req.LastName),
|
|
IsActive: true,
|
|
}
|
|
|
|
// Set a random password (user won't use it since they log in with Apple)
|
|
randomPassword := generateResetToken()
|
|
_ = user.SetPassword(randomPassword)
|
|
|
|
if err := s.userRepo.Create(user); err != nil {
|
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
// Create profile (already verified since Apple verified)
|
|
profile, _ := s.userRepo.GetOrCreateProfile(user.ID)
|
|
if profile != nil {
|
|
_ = s.userRepo.SetProfileVerified(user.ID, true)
|
|
}
|
|
|
|
// Create notification preferences with all options enabled
|
|
if s.notificationRepo != nil {
|
|
if _, err := s.notificationRepo.GetOrCreatePreferences(user.ID); err != nil {
|
|
// Log error but don't fail registration
|
|
fmt.Printf("Failed to create notification preferences: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Link Apple ID
|
|
appleAuthRecord := &models.AppleSocialAuth{
|
|
UserID: user.ID,
|
|
AppleID: appleID,
|
|
Email: getEmailOrDefault(email),
|
|
IsPrivateEmail: isPrivateRelayEmail(email) || claims.IsPrivateRelayEmail(),
|
|
}
|
|
if err := s.userRepo.CreateAppleSocialAuth(appleAuthRecord); err != nil {
|
|
return nil, fmt.Errorf("failed to create Apple auth: %w", err)
|
|
}
|
|
|
|
// Create token
|
|
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create token: %w", err)
|
|
}
|
|
|
|
// Reload user with profile
|
|
user, _ = s.userRepo.FindByIDWithProfile(user.ID)
|
|
|
|
return &responses.AppleSignInResponse{
|
|
Token: token.Key,
|
|
User: responses.NewUserResponse(user),
|
|
IsNewUser: true,
|
|
}, nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func generateSixDigitCode() string {
|
|
b := make([]byte, 4)
|
|
rand.Read(b)
|
|
num := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
|
if num < 0 {
|
|
num = -num
|
|
}
|
|
code := num % 1000000
|
|
return fmt.Sprintf("%06d", code)
|
|
}
|
|
|
|
func generateResetToken() string {
|
|
b := make([]byte, 32)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// Helper functions for Apple Sign In
|
|
|
|
func getEmailFromRequest(reqEmail *string, claimsEmail string) string {
|
|
if reqEmail != nil && *reqEmail != "" {
|
|
return *reqEmail
|
|
}
|
|
return claimsEmail
|
|
}
|
|
|
|
func getEmailOrDefault(email string) string {
|
|
if email == "" {
|
|
// Generate a placeholder email for users without one
|
|
return fmt.Sprintf("apple_%s@privaterelay.appleid.com", generateResetToken()[:16])
|
|
}
|
|
return email
|
|
}
|
|
|
|
func getStringOrEmpty(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|
|
|
|
func isPrivateRelayEmail(email string) bool {
|
|
return strings.HasSuffix(strings.ToLower(email), "@privaterelay.appleid.com")
|
|
}
|
|
|
|
func generateUniqueUsername(email string, firstName *string) string {
|
|
// Try using first part of email
|
|
if email != "" && !isPrivateRelayEmail(email) {
|
|
parts := strings.Split(email, "@")
|
|
if len(parts) > 0 && parts[0] != "" {
|
|
// Add random suffix to ensure uniqueness
|
|
return parts[0] + "_" + generateResetToken()[:6]
|
|
}
|
|
}
|
|
|
|
// Try using first name
|
|
if firstName != nil && *firstName != "" {
|
|
return strings.ToLower(*firstName) + "_" + generateResetToken()[:6]
|
|
}
|
|
|
|
// Fallback to random username
|
|
return "user_" + generateResetToken()[:10]
|
|
}
|