mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +08:00
feat(user): require email when admin creates a user
- Add `email` as a required field in `createUserRequest` - Enforce email format verification and database uniqueness checks in the admin user creation handler - Update the admin user creation frontend modal with validation and form field - Update the corresponding backend unit tests and regenerate Swagger docs
This commit is contained in:
@@ -6286,10 +6286,15 @@ const docTemplate = `{
|
|||||||
"user.createUserRequest": {
|
"user.createUserRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
"email",
|
||||||
"password",
|
"password",
|
||||||
"username"
|
"username"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 255
|
||||||
|
},
|
||||||
"is_active": {
|
"is_active": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6279,10 +6279,15 @@
|
|||||||
"user.createUserRequest": {
|
"user.createUserRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
"email",
|
||||||
"password",
|
"password",
|
||||||
"username"
|
"username"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 255
|
||||||
|
},
|
||||||
"is_active": {
|
"is_active": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -987,6 +987,9 @@ definitions:
|
|||||||
type: object
|
type: object
|
||||||
user.createUserRequest:
|
user.createUserRequest:
|
||||||
properties:
|
properties:
|
||||||
|
email:
|
||||||
|
maxLength: 255
|
||||||
|
type: string
|
||||||
is_active:
|
is_active:
|
||||||
type: boolean
|
type: boolean
|
||||||
is_admin:
|
is_admin:
|
||||||
@@ -1003,6 +1006,7 @@ definitions:
|
|||||||
minLength: 3
|
minLength: 3
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
|
- email
|
||||||
- password
|
- password
|
||||||
- username
|
- username
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const emptyForm: CreateUserRequest = {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
nickname: "",
|
nickname: "",
|
||||||
|
email: "",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
}
|
}
|
||||||
@@ -46,6 +47,12 @@ export function CreateUserModal({
|
|||||||
newErrors.username = "用户名长度不能少于 3 位"
|
newErrors.username = "用户名长度不能少于 3 位"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!form.email.trim()) {
|
||||||
|
newErrors.email = "邮箱不能为空"
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||||
|
newErrors.email = "邮箱格式不正确"
|
||||||
|
}
|
||||||
|
|
||||||
if (!form.password) {
|
if (!form.password) {
|
||||||
newErrors.password = "密码不能为空"
|
newErrors.password = "密码不能为空"
|
||||||
} else if (form.password.length < 8) {
|
} else if (form.password.length < 8) {
|
||||||
@@ -66,6 +73,7 @@ export function CreateUserModal({
|
|||||||
...form,
|
...form,
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
nickname: form.nickname?.trim() || undefined,
|
nickname: form.nickname?.trim() || undefined,
|
||||||
|
email: form.email.trim(),
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -109,6 +117,20 @@ export function CreateUserModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">邮箱</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||||
|
placeholder="请输入邮箱地址"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-xs text-destructive">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="password">密码</Label>
|
<Label htmlFor="password">密码</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -340,6 +340,8 @@ export interface CreateUserRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
/** 昵称 */
|
/** 昵称 */
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
|
/** 邮箱 */
|
||||||
|
email: string;
|
||||||
/** 是否激活 */
|
/** 是否激活 */
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
/** 是否管理员 */
|
/** 是否管理员 */
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ const (
|
|||||||
usernameRequired = "用户名不能为空"
|
usernameRequired = "用户名不能为空"
|
||||||
passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||||
createUserFailed = "创建用户失败"
|
createUserFailed = "创建用户失败"
|
||||||
|
emailRequired = "邮箱不能为空"
|
||||||
|
emailExists = "邮箱已被注册"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ type createUserRequest struct {
|
|||||||
Username string `json:"username" binding:"required,min=3,max=64"`
|
Username string `json:"username" binding:"required,min=3,max=64"`
|
||||||
Password string `json:"password" binding:"required,min=8,max=64"`
|
Password string `json:"password" binding:"required,min=8,max=64"`
|
||||||
Nickname string `json:"nickname" binding:"omitempty,max=64"`
|
Nickname string `json:"nickname" binding:"omitempty,max=64"`
|
||||||
|
Email string `json:"email" binding:"required,email,max=255"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
}
|
}
|
||||||
@@ -342,11 +343,16 @@ func CreateUser(c *gin.Context) {
|
|||||||
req.Username = strings.TrimSpace(req.Username)
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
req.Nickname = strings.TrimSpace(req.Nickname)
|
req.Nickname = strings.TrimSpace(req.Nickname)
|
||||||
req.Password = strings.TrimSpace(req.Password)
|
req.Password = strings.TrimSpace(req.Password)
|
||||||
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
|
|
||||||
if req.Username == "" {
|
if req.Username == "" {
|
||||||
c.JSON(http.StatusBadRequest, util.Err(usernameRequired))
|
c.JSON(http.StatusBadRequest, util.Err(usernameRequired))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.Email == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(emailRequired))
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(req.Password) < minPasswordLength {
|
if len(req.Password) < minPasswordLength {
|
||||||
c.JSON(http.StatusBadRequest, util.Err(passwordTooShort))
|
c.JSON(http.StatusBadRequest, util.Err(passwordTooShort))
|
||||||
return
|
return
|
||||||
@@ -363,10 +369,21 @@ func CreateUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var emailCount int64
|
||||||
|
if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", req.Email).Count(&emailCount).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if emailCount > 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(emailExists))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
newUser := model.User{
|
newUser := model.User{
|
||||||
ID: idgen.NextUint64ID(),
|
ID: idgen.NextUint64ID(),
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Nickname: req.Nickname,
|
Nickname: req.Nickname,
|
||||||
|
Email: req.Email,
|
||||||
IsActive: req.IsActive,
|
IsActive: req.IsActive,
|
||||||
IsAdmin: req.IsAdmin,
|
IsAdmin: req.IsAdmin,
|
||||||
LastLoginAt: time.Time{},
|
LastLoginAt: time.Time{},
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ func TestCreateUser(t *testing.T) {
|
|||||||
Username: "newuser",
|
Username: "newuser",
|
||||||
Password: "newpassword123",
|
Password: "newpassword123",
|
||||||
Nickname: "New Nickname",
|
Nickname: "New Nickname",
|
||||||
|
Email: "newuser@example.com",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
}
|
}
|
||||||
@@ -350,6 +351,9 @@ func TestCreateUser(t *testing.T) {
|
|||||||
if err := dbConn.Where("username = ?", "newuser").First(&dbUser).Error; err != nil {
|
if err := dbConn.Where("username = ?", "newuser").First(&dbUser).Error; err != nil {
|
||||||
t.Fatalf("failed to find user in db: %v", err)
|
t.Fatalf("failed to find user in db: %v", err)
|
||||||
}
|
}
|
||||||
|
if dbUser.Email != "newuser@example.com" {
|
||||||
|
t.Errorf("expected email 'newuser@example.com', got '%s'", dbUser.Email)
|
||||||
|
}
|
||||||
if !dbUser.CheckPassword("newpassword123") {
|
if !dbUser.CheckPassword("newpassword123") {
|
||||||
t.Error("password was not hashed correctly")
|
t.Error("password was not hashed correctly")
|
||||||
}
|
}
|
||||||
@@ -361,6 +365,7 @@ func TestCreateUser(t *testing.T) {
|
|||||||
ID: 2001,
|
ID: 2001,
|
||||||
Username: "dupuser",
|
Username: "dupuser",
|
||||||
Nickname: "Dup User",
|
Nickname: "Dup User",
|
||||||
|
Email: "dupuser@example.com",
|
||||||
}
|
}
|
||||||
dbConn.Create(&existing)
|
dbConn.Create(&existing)
|
||||||
|
|
||||||
@@ -368,6 +373,7 @@ func TestCreateUser(t *testing.T) {
|
|||||||
Username: "dupuser",
|
Username: "dupuser",
|
||||||
Password: "password123",
|
Password: "password123",
|
||||||
Nickname: "Another Nick",
|
Nickname: "Another Nick",
|
||||||
|
Email: "another@example.com",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
@@ -387,10 +393,44 @@ func TestCreateUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("create user with duplicate email", func(t *testing.T) {
|
||||||
|
existing := model.User{
|
||||||
|
ID: 2002,
|
||||||
|
Username: "existingemail",
|
||||||
|
Nickname: "Existing Email",
|
||||||
|
Email: "dupemail@example.com",
|
||||||
|
}
|
||||||
|
dbConn.Create(&existing)
|
||||||
|
|
||||||
|
payload := createUserRequest{
|
||||||
|
Username: "newuser2",
|
||||||
|
Password: "password123",
|
||||||
|
Nickname: "New User 2",
|
||||||
|
Email: "dupemail@example.com",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp util.ResponseAny
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if resp.ErrorMsg != emailExists {
|
||||||
|
t.Errorf("expected error '%s', got '%s'", emailExists, resp.ErrorMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("validation error - password too short", func(t *testing.T) {
|
t.Run("validation error - password too short", func(t *testing.T) {
|
||||||
payload := createUserRequest{
|
payload := createUserRequest{
|
||||||
Username: "shortpass",
|
Username: "shortpass",
|
||||||
Password: "123",
|
Password: "123",
|
||||||
|
Email: "shortpass@example.com",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
@@ -403,6 +443,24 @@ func TestCreateUser(t *testing.T) {
|
|||||||
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
|
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("validation error - invalid email format", func(t *testing.T) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"username": "bademail",
|
||||||
|
"password": "password123",
|
||||||
|
"email": "not-an-email",
|
||||||
|
"is_active": true,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteUser(t *testing.T) {
|
func TestDeleteUser(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user