feat(site): allow disabling email verification

This commit is contained in:
Artur Iusupov
2026-06-05 00:42:00 +04:00
committed by LinkinStars
parent 43a91313d8
commit d93e31e92a
25 changed files with 797 additions and 44 deletions
+6
View File
@@ -12096,6 +12096,9 @@ const docTemplate = `{
},
"allow_password_login": {
"type": "boolean"
},
"require_email_verification": {
"type": "boolean"
}
}
},
@@ -12116,6 +12119,9 @@ const docTemplate = `{
},
"allow_password_login": {
"type": "boolean"
},
"require_email_verification": {
"type": "boolean"
}
}
},
+6
View File
@@ -12069,6 +12069,9 @@
},
"allow_password_login": {
"type": "boolean"
},
"require_email_verification": {
"type": "boolean"
}
}
},
@@ -12089,6 +12092,9 @@
},
"allow_password_login": {
"type": "boolean"
},
"require_email_verification": {
"type": "boolean"
}
}
},
+4
View File
@@ -2516,6 +2516,8 @@ definitions:
type: boolean
allow_password_login:
type: boolean
require_email_verification:
type: boolean
type: object
schema.SiteLoginResp:
properties:
@@ -2529,6 +2531,8 @@ definitions:
type: boolean
allow_password_login:
type: boolean
require_email_verification:
type: boolean
type: object
schema.SiteMCPReq:
properties:
+4 -1
View File
@@ -2241,6 +2241,10 @@ ui:
title: Email registration
label: Allow email registration
text: Turn off to prevent anyone creating new account through email.
email_verification:
title: Email verification
label: Require email verification
text: When enabled, users must verify their email address before using the site.
allowed_email_domains:
title: Allowed email domains
text: Email domains that users must register accounts with. One domain per line. Ignored when empty.
@@ -2485,4 +2489,3 @@ ui:
copied: Copied
external_content_warning: External images/media are not displayed.
+5
View File
@@ -279,6 +279,7 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), nil)
return
}
applyEmailVerificationSetting(req, siteInfo)
req.IP = ctx.ClientIP()
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
@@ -305,6 +306,10 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
}
}
func applyEmailVerificationSetting(req *schema.UserRegisterReq, siteInfo *schema.SiteLoginResp) {
req.SkipEmailVerification = !siteInfo.RequireEmailVerification
}
// UserVerifyEmail godoc
// @Summary UserVerifyEmail
// @Description UserVerifyEmail
@@ -0,0 +1,37 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package controller
import (
"testing"
"github.com/apache/answer/internal/schema"
"github.com/stretchr/testify/assert"
)
func TestApplyEmailVerificationSetting(t *testing.T) {
req := &schema.UserRegisterReq{}
applyEmailVerificationSetting(req, &schema.SiteLoginResp{RequireEmailVerification: false})
assert.True(t, req.SkipEmailVerification)
req = &schema.UserRegisterReq{}
applyEmailVerificationSetting(req, &schema.SiteLoginResp{RequireEmailVerification: true})
assert.False(t, req.SkipEmailVerification)
}
+4 -3
View File
@@ -226,9 +226,10 @@ func (m *Mentor) initSiteInfoGeneralData() {
func (m *Mentor) initSiteInfoLoginConfig() {
loginConfig := map[string]any{
"allow_new_registrations": true,
"allow_email_registrations": true,
"allow_password_login": true,
"allow_new_registrations": true,
"allow_email_registrations": true,
"allow_password_login": true,
"require_email_verification": true,
}
loginConfigDataBytes, _ := json.Marshal(loginConfig)
_, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{
+1
View File
@@ -109,6 +109,7 @@ var migrations = []Migration{
NewMigration("v1.8.1", "ai feat", aiFeat, true),
NewMigration("v2.0.1", "change avatar type to text", updateAvatarType, false),
NewMigration("v2.0.2", "add reasoning content to ai conversation record", addAIConversationReasoningContent, false),
NewMigration("v2.0.3", "add require email verification login setting", addRequireEmailVerification, true),
}
func GetMigrations() []Migration {
+5 -4
View File
@@ -302,10 +302,11 @@ func splitLegalMenu(ctx context.Context, x *xorm.Engine) error {
PrivacyPolicyParsedText: oldSiteLegal.PrivacyPolicyParsedText,
}
siteLogin := &schema.SiteLoginResp{
AllowNewRegistrations: oldSiteLogin.AllowNewRegistrations,
AllowEmailRegistrations: oldSiteLogin.AllowEmailRegistrations,
AllowPasswordLogin: oldSiteLogin.AllowPasswordLogin,
AllowEmailDomains: oldSiteLogin.AllowEmailDomains,
AllowNewRegistrations: oldSiteLogin.AllowNewRegistrations,
AllowEmailRegistrations: oldSiteLogin.AllowEmailRegistrations,
AllowPasswordLogin: oldSiteLogin.AllowPasswordLogin,
AllowEmailDomains: oldSiteLogin.AllowEmailDomains,
RequireEmailVerification: true,
}
siteGeneral := &schema.SiteGeneralReq{
Name: oldSiteGeneral.Name,
+85
View File
@@ -0,0 +1,85 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package migrations
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/entity"
"xorm.io/xorm"
)
func addRequireEmailVerification(ctx context.Context, x *xorm.Engine) error {
loginSiteInfo := &entity.SiteInfo{}
exist, err := x.Context(ctx).Where("type = ?", constant.SiteTypeLogin).Get(loginSiteInfo)
if err != nil {
return fmt.Errorf("get login config failed: %w", err)
}
if !exist {
return nil
}
content, err := backfillRequireEmailVerification(loginSiteInfo.Content)
if err != nil {
return fmt.Errorf("backfill login config failed: %w", err)
}
loginSiteInfo.Content = content
_, err = x.Context(ctx).ID(loginSiteInfo.ID).Cols("content").Update(loginSiteInfo)
if err != nil {
return fmt.Errorf("update login config failed: %w", err)
}
return nil
}
func backfillRequireEmailVerification(content string) (string, error) {
if strings.TrimSpace(content) == "" {
content = "{}"
}
loginConfig := map[string]json.RawMessage{}
if err := json.Unmarshal([]byte(content), &loginConfig); err != nil {
return "", err
}
if loginConfig == nil {
loginConfig = map[string]json.RawMessage{}
}
requireEmailVerification, exists := loginConfig["require_email_verification"]
if !exists || bytes.Equal(bytes.TrimSpace(requireEmailVerification), []byte("null")) {
loginConfig["require_email_verification"] = json.RawMessage("true")
} else {
var value bool
if err := json.Unmarshal(requireEmailVerification, &value); err != nil {
return "", err
}
loginConfig["require_email_verification"] = requireEmailVerification
}
data, err := json.Marshal(loginConfig)
if err != nil {
return "", err
}
return string(data), nil
}
+172
View File
@@ -0,0 +1,172 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package migrations
import (
"context"
"encoding/json"
"testing"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/entity"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
func TestBackfillRequireEmailVerification(t *testing.T) {
tests := []struct {
name string
content string
expected bool
}{
{
name: "adds true for missing key",
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true}`,
expected: true,
},
{
name: "converts null to true",
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":null}`,
expected: true,
},
{
name: "preserves false",
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":false}`,
expected: false,
},
{
name: "preserves true",
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":true}`,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
content, err := backfillRequireEmailVerification(tt.content)
require.NoError(t, err)
var result map[string]bool
require.NoError(t, json.Unmarshal([]byte(content), &result))
assert.Equal(t, tt.expected, result["require_email_verification"])
})
}
}
func TestAddRequireEmailVerificationAbsentLoginRow(t *testing.T) {
x, err := xorm.NewEngine("sqlite", ":memory:")
require.NoError(t, err)
defer func() {
_ = x.Close()
}()
require.NoError(t, x.Sync(new(entity.SiteInfo)))
require.NoError(t, addRequireEmailVerification(context.TODO(), x))
}
func TestAddRequireEmailVerificationUpdatesLoginRow(t *testing.T) {
x, err := xorm.NewEngine("sqlite", ":memory:")
require.NoError(t, err)
defer func() {
_ = x.Close()
}()
require.NoError(t, x.Sync(new(entity.SiteInfo)))
_, err = x.Insert(&entity.SiteInfo{
Type: constant.SiteTypeLogin,
Content: `{"allow_new_registrations":true}`,
Status: 1,
})
require.NoError(t, err)
require.NoError(t, addRequireEmailVerification(context.TODO(), x))
login := &entity.SiteInfo{}
exist, err := x.Where("type = ?", constant.SiteTypeLogin).Get(login)
require.NoError(t, err)
require.True(t, exist)
var result struct {
RequireEmailVerification bool `json:"require_email_verification"`
}
require.NoError(t, json.Unmarshal([]byte(login.Content), &result))
assert.True(t, result.RequireEmailVerification)
}
func TestSplitLegalMenuKeepsRequireEmailVerificationDefaultTrue(t *testing.T) {
x, err := xorm.NewEngine("sqlite", ":memory:")
require.NoError(t, err)
defer func() {
_ = x.Close()
}()
require.NoError(t, x.Sync(new(entity.SiteInfo)))
_, err = x.Insert(&entity.SiteInfo{
Type: constant.SiteTypeLegal,
Content: `{
"terms_of_service_original_text":"tos",
"terms_of_service_parsed_text":"tos",
"privacy_policy_original_text":"privacy",
"privacy_policy_parsed_text":"privacy",
"external_content_display":"always_display"
}`,
Status: 1,
})
require.NoError(t, err)
_, err = x.Insert(&entity.SiteInfo{
Type: constant.SiteTypeLogin,
Content: `{
"allow_new_registrations":true,
"allow_email_registrations":true,
"allow_password_login":true,
"login_required":false,
"allow_email_domains":[]
}`,
Status: 1,
})
require.NoError(t, err)
_, err = x.Insert(&entity.SiteInfo{
Type: constant.SiteTypeGeneral,
Content: `{
"name":"site",
"short_description":"short",
"description":"description",
"site_url":"https://example.com",
"contact_email":"admin@example.com",
"check_update":true
}`,
Status: 1,
})
require.NoError(t, err)
require.NoError(t, splitLegalMenu(context.TODO(), x))
login := &entity.SiteInfo{}
exist, err := x.Where("type = ?", constant.SiteTypeLogin).Get(login)
require.NoError(t, err)
require.True(t, exist)
var result struct {
RequireEmailVerification bool `json:"require_email_verification"`
}
require.NoError(t, json.Unmarshal([]byte(login.Content), &result))
assert.True(t, result.RequireEmailVerification)
}
+41 -7
View File
@@ -21,6 +21,7 @@ package schema
import (
"context"
"encoding/json"
"fmt"
"net/mail"
"net/url"
@@ -216,12 +217,48 @@ type SiteUsersReq struct {
AllowUpdateLocation bool `json:"allow_update_location"`
}
// OptionalBool preserves whether a JSON boolean field was omitted, set to null,
// or set to a concrete boolean value.
type OptionalBool struct {
Set bool `json:"-"`
Null bool `json:"-"`
Value bool `json:"-"`
}
func (b *OptionalBool) UnmarshalJSON(data []byte) error {
b.Set = true
if string(data) == "null" {
b.Null = true
b.Value = false
return nil
}
b.Null = false
return json.Unmarshal(data, &b.Value)
}
func (b OptionalBool) MarshalJSON() ([]byte, error) {
if !b.Set || b.Null {
return []byte("null"), nil
}
return json.Marshal(b.Value)
}
// SiteLoginReq site login request
type SiteLoginReq struct {
AllowNewRegistrations bool `json:"allow_new_registrations"`
AllowEmailRegistrations bool `json:"allow_email_registrations"`
AllowPasswordLogin bool `json:"allow_password_login"`
AllowEmailDomains []string `json:"allow_email_domains"`
AllowNewRegistrations bool `json:"allow_new_registrations"`
AllowEmailRegistrations bool `json:"allow_email_registrations"`
AllowPasswordLogin bool `json:"allow_password_login"`
AllowEmailDomains []string `json:"allow_email_domains"`
RequireEmailVerification OptionalBool `json:"require_email_verification" swaggertype:"boolean"`
}
// SiteLoginResp site login response
type SiteLoginResp struct {
AllowNewRegistrations bool `json:"allow_new_registrations"`
AllowEmailRegistrations bool `json:"allow_email_registrations"`
AllowPasswordLogin bool `json:"allow_password_login"`
AllowEmailDomains []string `json:"allow_email_domains"`
RequireEmailVerification bool `json:"require_email_verification"`
}
// SiteCustomCssHTMLReq site custom css html
@@ -310,9 +347,6 @@ type SiteInterfaceResp SiteInterfaceReq
// SiteBrandingResp site branding response
type SiteBrandingResp SiteBrandingReq
// SiteLoginResp site login response
type SiteLoginResp SiteLoginReq
// SiteCustomCssHTMLResp site custom css html response
type SiteCustomCssHTMLResp SiteCustomCssHTMLReq
+7 -6
View File
@@ -219,12 +219,13 @@ type UserEmailLoginReq struct {
// UserRegisterReq user register request
type UserRegisterReq struct {
Name string `validate:"required,gte=2,lte=30" json:"name"`
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" `
Pass string `validate:"required,gte=8,lte=32" json:"pass"`
CaptchaID string `json:"captcha_id"`
CaptchaCode string `json:"captcha_code"`
IP string `json:"-" `
Name string `validate:"required,gte=2,lte=30" json:"name"`
Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" `
Pass string `validate:"required,gte=8,lte=32" json:"pass"`
CaptchaID string `json:"captcha_id"`
CaptchaCode string `json:"captcha_code"`
IP string `json:"-" `
SkipEmailVerification bool `json:"-"`
}
func (u *UserRegisterReq) Check() (errFields []*validator.FormErrorField, err error) {
+52 -9
View File
@@ -518,18 +518,20 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
log.Errorf("set default user notification config failed, err: %v", err)
}
// send email
data := &schema.EmailCodeContent{
Email: registerUserInfo.Email,
UserID: userInfo.ID,
}
code := token.GenerateToken()
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL)
err = applyRegistrationVerification(userInfo, registerUserInfo.SkipEmailVerification, registrationVerificationActions{
sendActivationEmail: func() error {
return us.sendRegistrationActivationEmail(ctx, userInfo)
},
activateUser: func() error {
return us.userActivity.UserActive(ctx, userInfo.ID)
},
markEmailAvailable: func() error {
return us.userRepo.UpdateEmailStatus(ctx, userInfo.ID, entity.EmailStatusAvailable)
},
})
if err != nil {
return nil, nil, err
}
go us.emailService.SendAndSaveCode(ctx, userInfo.ID, userInfo.EMail, title, body, code, data.ToJSONString())
roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID)
if err != nil {
@@ -560,6 +562,47 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo
return resp, nil, nil
}
type registrationVerificationActions struct {
sendActivationEmail func() error
activateUser func() error
markEmailAvailable func() error
}
func applyRegistrationVerification(
userInfo *entity.User, skipEmailVerification bool, actions registrationVerificationActions,
) error {
userInfo.MailStatus = entity.EmailStatusToBeVerified
if !skipEmailVerification {
return actions.sendActivationEmail()
}
if err := actions.activateUser(); err != nil {
log.Errorf("activate user during registration failed, fallback to email verification, err: %v", err)
return actions.sendActivationEmail()
}
if err := actions.markEmailAvailable(); err != nil {
log.Errorf("mark email available during registration failed, fallback to email verification, err: %v", err)
return actions.sendActivationEmail()
}
userInfo.MailStatus = entity.EmailStatusAvailable
return nil
}
func (us *UserService) sendRegistrationActivationEmail(ctx context.Context, userInfo *entity.User) error {
data := &schema.EmailCodeContent{
Email: userInfo.EMail,
UserID: userInfo.ID,
}
code := token.GenerateToken()
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL)
if err != nil {
return err
}
go us.emailService.SendAndSaveCode(ctx, userInfo.ID, userInfo.EMail, title, body, code, data.ToJSONString())
return nil
}
func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) error {
userInfo, has, err := us.userRepo.GetByUserID(ctx, userID)
if err != nil {
@@ -0,0 +1,155 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package content
import (
"errors"
"testing"
"github.com/apache/answer/internal/entity"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyRegistrationVerification(t *testing.T) {
t.Run("enabled sends activation email and leaves user inactive", func(t *testing.T) {
userInfo := &entity.User{}
calls := map[string]int{}
err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{
sendActivationEmail: func() error {
calls["sendActivationEmail"]++
return nil
},
activateUser: func() error {
calls["activateUser"]++
return nil
},
markEmailAvailable: func() error {
calls["markEmailAvailable"]++
return nil
},
})
require.NoError(t, err)
assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus)
assert.Equal(t, 1, calls["sendActivationEmail"])
assert.Zero(t, calls["activateUser"])
assert.Zero(t, calls["markEmailAvailable"])
})
t.Run("disabled activates once and marks email available", func(t *testing.T) {
userInfo := &entity.User{}
calls := map[string]int{}
err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{
sendActivationEmail: func() error {
calls["sendActivationEmail"]++
return nil
},
activateUser: func() error {
calls["activateUser"]++
return nil
},
markEmailAvailable: func() error {
calls["markEmailAvailable"]++
return nil
},
})
require.NoError(t, err)
assert.Equal(t, entity.EmailStatusAvailable, userInfo.MailStatus)
assert.Zero(t, calls["sendActivationEmail"])
assert.Equal(t, 1, calls["activateUser"])
assert.Equal(t, 1, calls["markEmailAvailable"])
})
t.Run("disabled user activation failure falls back to email verification", func(t *testing.T) {
userInfo := &entity.User{}
calls := map[string]int{}
err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{
sendActivationEmail: func() error {
calls["sendActivationEmail"]++
return nil
},
activateUser: func() error {
calls["activateUser"]++
return errors.New("activate failed")
},
markEmailAvailable: func() error {
calls["markEmailAvailable"]++
return nil
},
})
require.NoError(t, err)
assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus)
assert.Equal(t, 1, calls["sendActivationEmail"])
assert.Equal(t, 1, calls["activateUser"])
assert.Zero(t, calls["markEmailAvailable"])
})
t.Run("disabled email status failure falls back to email verification", func(t *testing.T) {
userInfo := &entity.User{}
calls := map[string]int{}
err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{
sendActivationEmail: func() error {
calls["sendActivationEmail"]++
return nil
},
activateUser: func() error {
calls["activateUser"]++
return nil
},
markEmailAvailable: func() error {
calls["markEmailAvailable"]++
return errors.New("update failed")
},
})
require.NoError(t, err)
assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus)
assert.Equal(t, 1, calls["sendActivationEmail"])
assert.Equal(t, 1, calls["activateUser"])
assert.Equal(t, 1, calls["markEmailAvailable"])
})
t.Run("fallback email failure returns before active status", func(t *testing.T) {
userInfo := &entity.User{}
expectedErr := errors.New("email failed")
err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{
sendActivationEmail: func() error {
return expectedErr
},
activateUser: func() error {
return errors.New("activate failed")
},
markEmailAvailable: func() error {
return nil
},
})
require.ErrorIs(t, err, expectedErr)
assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus)
})
}
+21 -1
View File
@@ -291,7 +291,27 @@ func (s *SiteInfoService) SaveSiteSecurity(ctx context.Context, req *schema.Site
// SaveSiteLogin save site legal configuration
func (s *SiteInfoService) SaveSiteLogin(ctx context.Context, req *schema.SiteLoginReq) (err error) {
content, _ := json.Marshal(req)
requireEmailVerification := true
if req.RequireEmailVerification.Set {
if !req.RequireEmailVerification.Null {
requireEmailVerification = req.RequireEmailVerification.Value
}
} else {
currentLogin, err := s.GetSiteLogin(ctx)
if err != nil {
return err
}
requireEmailVerification = currentLogin.RequireEmailVerification
}
loginConfig := &schema.SiteLoginResp{
AllowNewRegistrations: req.AllowNewRegistrations,
AllowEmailRegistrations: req.AllowEmailRegistrations,
AllowPasswordLogin: req.AllowPasswordLogin,
AllowEmailDomains: req.AllowEmailDomains,
RequireEmailVerification: requireEmailVerification,
}
content, _ := json.Marshal(loginConfig)
data := &entity.SiteInfo{
Type: constant.SiteTypeLogin,
Content: string(content),
@@ -0,0 +1,104 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package siteinfo
import (
"context"
"encoding/json"
"testing"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/mock"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestSiteInfoService_SaveSiteLoginRequireEmailVerification(t *testing.T) {
tests := []struct {
name string
currentContent string
requestPayload string
expectGet bool
expectedRequire bool
}{
{
name: "omitted preserves normalized default",
currentContent: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true}`,
requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[]}`,
expectGet: true,
expectedRequire: true,
},
{
name: "omitted preserves current false",
currentContent: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":false}`,
requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[]}`,
expectGet: true,
expectedRequire: false,
},
{
name: "null normalizes true",
requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":null}`,
expectedRequire: true,
},
{
name: "explicit false persists false",
requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":false}`,
expectedRequire: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
repo := mock.NewMockSiteInfoRepo(ctl)
if tt.expectGet {
repo.EXPECT().GetByType(gomock.Any(), constant.SiteTypeLogin).
Return(&entity.SiteInfo{Content: tt.currentContent}, true, nil)
}
var savedContent string
repo.EXPECT().SaveByType(gomock.Any(), constant.SiteTypeLogin, gomock.Any()).
DoAndReturn(func(_ context.Context, _ string, data *entity.SiteInfo) error {
savedContent = data.Content
return nil
})
req := &schema.SiteLoginReq{}
require.NoError(t, json.Unmarshal([]byte(tt.requestPayload), req))
service := &SiteInfoService{
siteInfoRepo: repo,
siteInfoCommonService: siteinfo_common.NewSiteInfoCommonService(repo),
}
require.NoError(t, service.SaveSiteLogin(context.TODO(), req))
assert.NotContains(t, savedContent, `"require_email_verification":null`)
saved := &schema.SiteLoginResp{}
require.NoError(t, json.Unmarshal([]byte(savedContent), saved))
assert.Equal(t, tt.expectedRequire, saved.RequireEmailVerification)
})
}
}
@@ -235,7 +235,7 @@ func (s *siteInfoCommonService) GetSiteSecurity(ctx context.Context) (resp *sche
// GetSiteLogin get site login config
func (s *siteInfoCommonService) GetSiteLogin(ctx context.Context) (resp *schema.SiteLoginResp, err error) {
resp = &schema.SiteLoginResp{}
resp = &schema.SiteLoginResp{RequireEmailVerification: true}
if err = s.GetSiteInfoByType(ctx, constant.SiteTypeLogin, resp); err != nil {
return nil, err
}
@@ -50,3 +50,47 @@ func TestSiteInfoCommonService_GetSiteGeneral(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "name", resp.Name)
}
func TestSiteInfoCommonService_GetSiteLoginRequireEmailVerification(t *testing.T) {
tests := []struct {
name string
content string
expected bool
}{
{
name: "missing key defaults true",
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true}`,
expected: true,
},
{
name: "null defaults true",
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":null}`,
expected: true,
},
{
name: "explicit false is preserved",
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":false}`,
expected: false,
},
{
name: "explicit true is preserved",
content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":true}`,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
repo := mock.NewMockSiteInfoRepo(ctl)
repo.EXPECT().GetByType(gomock.Any(), constant.SiteTypeLogin).
Return(&entity.SiteInfo{Content: tt.content}, true, nil)
siteInfoCommonService := NewSiteInfoCommonService(repo)
resp, err := siteInfoCommonService.GetSiteLogin(context.TODO())
require.NoError(t, err)
assert.Equal(t, tt.expected, resp.RequireEmailVerification)
})
}
}
+1
View File
@@ -488,6 +488,7 @@ export interface AdminSettingsLogin {
allow_email_registrations: boolean;
allow_email_domains: string[];
allow_password_login: boolean;
require_email_verification: boolean;
}
/**
+15
View File
@@ -47,6 +47,12 @@ const Index: FC = () => {
description: t('email_registration.text'),
default: true,
},
require_email_verification: {
type: 'boolean',
title: t('email_verification.title'),
description: t('email_verification.text'),
default: true,
},
allow_password_login: {
type: 'boolean',
title: t('password_login.title'),
@@ -73,6 +79,12 @@ const Index: FC = () => {
label: t('email_registration.label'),
},
},
require_email_verification: {
'ui:widget': 'switch',
'ui:options': {
label: t('email_verification.label'),
},
},
allow_password_login: {
'ui:widget': 'switch',
'ui:options': {
@@ -105,6 +117,7 @@ const Index: FC = () => {
allow_email_registrations: formData.allow_email_registrations.value,
allow_email_domains: allowedEmailDomains,
allow_password_login: formData.allow_password_login.value,
require_email_verification: formData.require_email_verification.value,
};
putLoginSetting(reqParams)
@@ -139,6 +152,8 @@ const Index: FC = () => {
setting.allow_email_domains.join('\n');
}
formMeta.allow_password_login.value = setting.allow_password_login;
formMeta.require_email_verification.value =
setting.require_email_verification;
setFormData({ ...formMeta });
}
});
@@ -23,14 +23,17 @@ import { Link } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { useCaptchaPlugin } from '@/utils/pluginKit';
import type { FormDataType, RegisterReqParams } from '@/common/interface';
import type {
FormDataType,
RegisterReqParams,
UserInfoRes,
} from '@/common/interface';
import { register } from '@/services';
import userStore from '@/stores/loggedUserInfo';
import { handleFormError, scrollToElementTop } from '@/utils';
import { useLegalClick } from '@/behaviour/useLegalClick';
interface Props {
callback: () => void;
callback: (user: UserInfoRes) => void;
}
const Index: React.FC<Props> = ({ callback }) => {
@@ -53,7 +56,6 @@ const Index: React.FC<Props> = ({ callback }) => {
},
});
const updateUser = userStore((state) => state.update);
const emailCaptcha = useCaptchaPlugin('email');
const nameRegex = /^[\w.-\s]{2,30}$/;
@@ -139,8 +141,7 @@ const Index: React.FC<Props> = ({ callback }) => {
register(reqParams)
.then(async (res) => {
await emailCaptcha?.close();
updateUser(res);
callback();
callback(res);
})
.catch((err) => {
if (err.isError) {
+15 -5
View File
@@ -20,12 +20,14 @@
import React, { useState } from 'react';
import { Container, Col } from 'react-bootstrap';
import { Trans, useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { usePageTags } from '@/hooks';
import type * as Type from '@/common/interface';
import { Unactivate, WelcomeTitle, PluginRender } from '@/components';
import { guard } from '@/utils';
import { loginSettingStore } from '@/stores';
import { loggedUserInfoStore, loginSettingStore } from '@/stores';
import { setupAppTheme } from '@/utils/localize';
import { PluginType } from '@/utils/pluginKit/interface';
import SignUpForm from './components/SignUpForm';
@@ -33,9 +35,17 @@ import SignUpForm from './components/SignUpForm';
const Index: React.FC = () => {
const [showForm, setShowForm] = useState(true);
const { t } = useTranslation('translation', { keyPrefix: 'login' });
const navigate = useNavigate();
const loginSetting = loginSettingStore((state) => state.login);
const onStep = () => {
setShowForm((bol) => !bol);
const updateUser = loggedUserInfoStore((state) => state.update);
const onRegister = (user: Type.UserInfoRes) => {
updateUser(user);
setupAppTheme();
if (user.mail_status === 2) {
setShowForm(false);
return;
}
guard.handleLoginRedirect(navigate);
};
usePageTags({
title: t('sign_up', { keyPrefix: 'page_title' }),
@@ -60,7 +70,7 @@ const Index: React.FC = () => {
slug_name="third_party_connector"
className="mb-5"
/>
{showSignupForm ? <SignUpForm callback={onStep} /> : null}
{showSignupForm ? <SignUpForm callback={onRegister} /> : null}
<div className="text-center mt-5">
<Trans i18nKey="login.info_login" ns="translation">
Already have an account? <Link to="/users/login">Log in</Link>
+4 -1
View File
@@ -129,7 +129,10 @@ export const login = (params: Type.LoginReqParams) => {
};
export const register = (params: Type.RegisterReqParams) => {
return request.post<any>('/answer/api/v1/user/register/email', params);
return request.post<Type.UserInfoRes>(
'/answer/api/v1/user/register/email',
params,
);
};
export const logout = () => {
+1
View File
@@ -32,6 +32,7 @@ const loginSetting = create<IType>((set) => ({
allow_email_registrations: true,
allow_email_domains: [],
allow_password_login: true,
require_email_verification: true,
},
update: (params) =>
set(() => {