diff --git a/internal/api/otp.go b/internal/api/otp.go index 5f12b0bbe..2541b229f 100644 --- a/internal/api/otp.go +++ b/internal/api/otp.go @@ -79,6 +79,13 @@ func (a *API) Otp(w http.ResponseWriter, r *http.Request) error { params.Data = make(map[string]interface{}) } + // 🔥 Fix: If session user exists and email is provided, treat as email change request + if currentUser := getUserFromContext(r.Context()); currentUser != nil { + if params.Email != "" && !params.CreateUser { + return a.startEmailChangeVerification(currentUser, params.Email) + } + } + if ok, err := a.shouldCreateUser(r, params); !ok { return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeOTPDisabled, "Signups not allowed for otp") } else if err != nil { @@ -236,3 +243,25 @@ func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) (bool, error) } return true, nil } + +// startEmailChangeVerification initiates email change confirmation flow for phone-first users +func (a *API) startEmailChangeVerification(user *models.User, newEmail string) error { + db := a.db.WithContext(user.Context()) + config := a.config + + normalizedEmail, err := a.validateEmail(newEmail) + if err != nil { + return err + } + + // Generate confirmation token for email change + if err := user.GenerateEmailChange(db, normalizedEmail, config.Security.EmailMaxFrequency); err != nil { + return apierrors.NewInternalServerError("Could not generate email change token").WithInternalError(err) + } + + if err := a.sendEmailChange(db, user, normalizedEmail); err != nil { + return err + } + + return nil +} diff --git a/internal/api/otp_test.go b/internal/api/otp_test.go index 7a99f3d9c..daf27d8ef 100644 --- a/internal/api/otp_test.go +++ b/internal/api/otp_test.go @@ -36,7 +36,31 @@ func TestOtp(t *testing.T) { func (ts *OtpTestSuite) SetupTest() { models.TruncateAll(ts.API.db) +} + +// ✅ New test for attaching email to phone-first user +func (ts *OtpTestSuite) TestAttachEmailToPhoneUser() { + // Create a phone-only user + user := &models.User{Phone: "1234567890"} + require.NoError(ts.T(), ts.API.db.Create(user)) + + // Simulate logged-in request with this user + token := ts.API.createAccessTokenForUser(user) + body := map[string]interface{}{ + "email": "foo@example.com", + "create_user": false, + } + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(body)) + + req := httptest.NewRequest(http.MethodPost, "/otp", &buffer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusOK, w.Code) } func (ts *OtpTestSuite) TestOtpPKCE() { @@ -139,7 +163,6 @@ func (ts *OtpTestSuite) TestOtpPKCE() { require.Equal(ts.T(), c.expected.code, w.Code) data := make(map[string]interface{}) require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) - }) } }