feat: 用户管理接口 JSON 化——/api/admin/users CRUD
- admin_user.go:userForm 加 json tags,parseUserFormJSON(绑定+去白,
ID 取路由参数);UserCreate/Update/Delete 校验错误改 APIError
(400 校验、409 user_username_exists、403 自防/最后管理员、404
user_not_found、500),成功 {ok,redirect:?saved=1&msg=...}
- i18n:新增 user_not_found(中英)
- main.go:旧 POST /admin/users/new|:id/edit|:id/delete 移除,
改 POST/PUT/DELETE /api/admin/users[/:id](自防屏障保留)
- user_form.html:表单改 blogAPI(FormID 分流 POST/PUT),错误内联
- user_list.html:删除改 blogDelete 委托
- p2/security 测试:环境路由同步 + 用户校验/注入用例改 JSON 断言
(TestAdminUserPasswordAndEmailEnforcement、TestAdminUserRoutesRejectNonNumericIDs)
- main_test 冒烟补用户 API 断言;go build/vet/test 全绿
This commit is contained in:
8 files changed
+133
-140
No files matched your search
+57
-72
@@ -13,17 +13,18 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// userForm 保存提交的用户字段,外加共享创建/编辑表单模板的渲染元数据。
|
||||
// userForm 是后台用户创建/更新接口的 JSON 请求体,
|
||||
// 同时供共享创建/编辑表单模板的渲染使用(IsEdit/Action/TitleText 非绑定字段)。
|
||||
type userForm struct {
|
||||
ID uint
|
||||
Username string
|
||||
Password string
|
||||
DisplayName string
|
||||
Email string
|
||||
Gender string
|
||||
Birthday string // 来自日期输入的 YYYY-MM-DD
|
||||
Role string
|
||||
Status int
|
||||
ID uint `json:"-"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Gender string `json:"gender"`
|
||||
Birthday string `json:"birthday"` // 来自日期输入的 YYYY-MM-DD
|
||||
Role string `json:"role"`
|
||||
Status int `json:"status"`
|
||||
IsEdit bool
|
||||
Action string
|
||||
TitleText string
|
||||
@@ -39,27 +40,21 @@ type userListView struct {
|
||||
StatusBadge string
|
||||
}
|
||||
|
||||
// parseUserForm 从请求中读取用户表单字段。
|
||||
func parseUserForm(c *gin.Context) userForm {
|
||||
// 状态始终由 <select>(0/1/2/3)发送;缺省视为正常,
|
||||
// 但显式的 0(禁用)必须保留。
|
||||
status := models.StatusNormal
|
||||
if raw := strings.TrimSpace(c.PostForm("status")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
status = n
|
||||
}
|
||||
}
|
||||
return userForm{
|
||||
ID: uintFormID(c.Param("id")),
|
||||
Username: strings.TrimSpace(c.PostForm("username")),
|
||||
Password: c.PostForm("password"),
|
||||
DisplayName: strings.TrimSpace(c.PostForm("display_name")),
|
||||
Email: strings.TrimSpace(c.PostForm("email")),
|
||||
Gender: strings.TrimSpace(c.PostForm("gender")),
|
||||
Birthday: strings.TrimSpace(c.PostForm("birthday")),
|
||||
Role: strings.TrimSpace(c.PostForm("role")),
|
||||
Status: status,
|
||||
// parseUserFormJSON 绑定 JSON 请求体的用户字段并去除空白。
|
||||
// 绑定失败时已写入 400 响应并返回 ok=false。ID 来自路由参数。
|
||||
func parseUserFormJSON(c *gin.Context) (userForm, bool) {
|
||||
var f userForm
|
||||
if !bindJSON(c, &f) {
|
||||
return f, false
|
||||
}
|
||||
f.ID = uintFormID(c.Param("id"))
|
||||
f.Username = strings.TrimSpace(f.Username)
|
||||
f.DisplayName = strings.TrimSpace(f.DisplayName)
|
||||
f.Email = strings.TrimSpace(f.Email)
|
||||
f.Gender = strings.TrimSpace(f.Gender)
|
||||
f.Birthday = strings.TrimSpace(f.Birthday)
|
||||
f.Role = strings.TrimSpace(f.Role)
|
||||
return f, true
|
||||
}
|
||||
|
||||
// uintFormID 将路由 :id 解析为 uint(不存在/非法时为 0)。
|
||||
@@ -194,28 +189,27 @@ func UserCreatePage(db *gorm.DB) gin.HandlerFunc {
|
||||
// UserCreate 处理 POST 创建新用户。
|
||||
func UserCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
f := parseUserForm(c)
|
||||
f.Action = "/admin/users/new"
|
||||
f.TitleText = tr["user_create_title"]
|
||||
f.IsEdit = false
|
||||
f, ok := parseUserFormJSON(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if f.Username == "" {
|
||||
renderUserForm(c, f, tr["user_username_required"])
|
||||
APIError(c, http.StatusBadRequest, "user_username_required")
|
||||
return
|
||||
}
|
||||
if f.Password == "" {
|
||||
renderUserForm(c, f, tr["user_password_required"])
|
||||
APIError(c, http.StatusBadRequest, "user_password_required")
|
||||
return
|
||||
}
|
||||
// SECURITY (#23):执行平台最小密码长度。
|
||||
if !validatePassword(f.Password) {
|
||||
renderUserForm(c, f, tr["user_password_short"])
|
||||
APIError(c, http.StatusBadRequest, "user_password_short")
|
||||
return
|
||||
}
|
||||
// SECURITY (#24):拒绝格式非法的邮箱地址。
|
||||
if !validateEmail(f.Email) {
|
||||
renderUserForm(c, f, tr["user_email_invalid"])
|
||||
APIError(c, http.StatusBadRequest, "user_email_invalid")
|
||||
return
|
||||
}
|
||||
if f.Role == "" {
|
||||
@@ -226,7 +220,7 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
var exists int64
|
||||
db.Model(&models.User{}).Where("username = ?", f.Username).Count(&exists)
|
||||
if exists > 0 {
|
||||
renderUserForm(c, f, tr["user_username_exists"])
|
||||
APIError(c, http.StatusConflict, "user_username_exists")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -242,14 +236,14 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc {
|
||||
user.Birthday = &t
|
||||
}
|
||||
if err := user.SetPassword(f.Password); err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=created")
|
||||
APIOK(c, "/admin/users?saved=1&msg=created", gin.H{"user_id": user.ID})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,20 +287,19 @@ func UserEditPage(db *gorm.DB) gin.HandlerFunc {
|
||||
// UserUpdate 处理 POST 更新现有用户。
|
||||
func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tr := getTr(c)
|
||||
f := parseUserForm(c)
|
||||
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
|
||||
if f.ID == 0 {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
f, ok := parseUserFormJSON(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
|
||||
if f.ID == 0 {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
f.IsEdit = true
|
||||
f.Action = fmt.Sprintf("/admin/users/%d/edit", f.ID)
|
||||
f.TitleText = tr["user_edit_title"]
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, f.ID).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
APIError(c, http.StatusNotFound, "user_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -316,11 +309,11 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
// SECURITY (#23/#24):在进行任何其他修改前校验提交的密码/邮箱——
|
||||
// 密码重置或个人资料编辑必须遵守与注册相同的规则。
|
||||
if f.Password != "" && !validatePassword(f.Password) {
|
||||
renderUserForm(c, f, tr["user_password_short"])
|
||||
APIError(c, http.StatusBadRequest, "user_password_short")
|
||||
return
|
||||
}
|
||||
if !validateEmail(f.Email) {
|
||||
renderUserForm(c, f, tr["user_email_invalid"])
|
||||
APIError(c, http.StatusBadRequest, "user_email_invalid")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -329,15 +322,7 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
// 自我保护:不能禁用/锁定自己的账户。
|
||||
if isSelf && f.Status != models.StatusNormal {
|
||||
f.DisplayName = user.DisplayName
|
||||
f.Email = user.Email
|
||||
f.Gender = user.Gender
|
||||
f.Role = user.Role
|
||||
f.Status = user.Status
|
||||
if user.Birthday != nil {
|
||||
f.Birthday = user.Birthday.Format("2006-01-02")
|
||||
}
|
||||
renderUserForm(c, f, tr["user_cannot_disable_self"])
|
||||
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -346,7 +331,7 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
var adminCount int64
|
||||
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=last_admin")
|
||||
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -368,16 +353,16 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
|
||||
// 可选密码重置(留空表示保持当前密码)。
|
||||
if f.Password != "" {
|
||||
if err := user.SetPassword(f.Password); err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
renderUserForm(c, f, tr["article_error"])
|
||||
APIError(c, http.StatusInternalServerError, "article_error")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=updated")
|
||||
APIOK(c, "/admin/users?saved=1&msg=updated", nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,20 +372,20 @@ func UserDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
|
||||
targetID := uintFormID(c.Param("id"))
|
||||
if targetID == 0 {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return
|
||||
}
|
||||
currentID := userIDFromSession(c)
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, targetID).Error; err != nil {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
APIError(c, http.StatusNotFound, "user_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
// 不能删除自己。
|
||||
if targetID == currentID {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=self_disable")
|
||||
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
|
||||
return
|
||||
}
|
||||
// 不能删除最后一位管理员。
|
||||
@@ -408,12 +393,12 @@ func UserDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
var adminCount int64
|
||||
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.Redirect(http.StatusFound, "/admin/users?error=last_admin")
|
||||
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
db.Delete(&user)
|
||||
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=deleted")
|
||||
APIOK(c, "/admin/users?saved=1&msg=deleted", nil)
|
||||
}
|
||||
}
|
||||
@@ -164,27 +164,19 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
|
||||
token := e.csrfTokenFor(t, admin)
|
||||
|
||||
// 创建:短密码被拒绝(不创建任何行)。
|
||||
fields := url.Values{}
|
||||
fields.Set("username", "charlie")
|
||||
fields.Set("password", "ab")
|
||||
fields.Set("role", models.RoleAuthor)
|
||||
w := postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create with short password: status = %d, want 200 (re-render)", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "6 characters") {
|
||||
t.Fatal("short-password error message not rendered")
|
||||
create := gin.H{"username": "charlie", "password": "ab", "role": models.RoleAuthor}
|
||||
w := postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "user_password_short" {
|
||||
t.Fatalf("create with short password: status = %d, code = %q, want 400/user_password_short",
|
||||
w.Code, respCode(w))
|
||||
}
|
||||
|
||||
// 创建:非法邮箱被拒绝。
|
||||
fields = url.Values{}
|
||||
fields.Set("username", "charlie")
|
||||
fields.Set("password", "longenough")
|
||||
fields.Set("email", "abc")
|
||||
fields.Set("role", models.RoleAuthor)
|
||||
w = postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create with invalid email: status = %d, want 200", w.Code)
|
||||
create["password"] = "longenough"
|
||||
create["email"] = "abc"
|
||||
w = postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "user_email_invalid" {
|
||||
t.Fatalf("create with invalid email: status = %d, code = %q", w.Code, respCode(w))
|
||||
}
|
||||
|
||||
var count int64
|
||||
@@ -194,14 +186,10 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
|
||||
}
|
||||
|
||||
// 创建:合法数据成功。
|
||||
fields = url.Values{}
|
||||
fields.Set("username", "charlie")
|
||||
fields.Set("password", "longenough")
|
||||
fields.Set("email", "charlie@example.com")
|
||||
fields.Set("role", models.RoleAuthor)
|
||||
w = postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("create valid user: status = %d", w.Code)
|
||||
create["email"] = "charlie@example.com"
|
||||
w = postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
|
||||
if w.Code != http.StatusOK || !respOK(w) {
|
||||
t.Fatalf("create valid user: status = %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
|
||||
if count != 1 {
|
||||
@@ -210,12 +198,10 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
|
||||
|
||||
// 更新(密码重置路径):短密码被拒绝,哈希保持不变。
|
||||
aliceID := userIDByUsername(t, e.db, "alice")
|
||||
fields = url.Values{}
|
||||
fields.Set("password", "x")
|
||||
fields.Set("role", models.RoleAuthor)
|
||||
w = postForm(e, http.MethodPost, fmt.Sprintf("/admin/users/%d/edit", aliceID), admin, token, fields)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update with short password: status = %d, want 200", w.Code)
|
||||
w = postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", aliceID), admin, token,
|
||||
gin.H{"password": "x", "role": models.RoleAuthor})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "user_password_short" {
|
||||
t.Fatalf("update with short password: status = %d, code = %q", w.Code, respCode(w))
|
||||
}
|
||||
var u models.User
|
||||
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
|
||||
@@ -226,12 +212,10 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
|
||||
}
|
||||
|
||||
// 更新:非法邮箱被拒绝,旧值保留。
|
||||
fields = url.Values{}
|
||||
fields.Set("email", "bad")
|
||||
fields.Set("role", models.RoleAuthor)
|
||||
w = postForm(e, http.MethodPost, fmt.Sprintf("/admin/users/%d/edit", aliceID), admin, token, fields)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update with invalid email: status = %d, want 200", w.Code)
|
||||
w = postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", aliceID), admin, token,
|
||||
gin.H{"email": "bad", "role": models.RoleAuthor})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "user_email_invalid" {
|
||||
t.Fatalf("update with invalid email: status = %d, code = %q", w.Code, respCode(w))
|
||||
}
|
||||
e.db.Where("username = ?", "alice").First(&u)
|
||||
if u.Email != "" {
|
||||
|
||||
+16
-20
@@ -120,13 +120,17 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
||||
// 后台用户管理路由(SQL 注入回归覆盖,#19)。
|
||||
admin := r.Group("/admin", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
admin.POST("/users/new", UserCreate(db))
|
||||
admin.GET("/users/:id/edit", UserEditPage(db))
|
||||
admin.POST("/users/:id/edit", UserUpdate(db))
|
||||
admin.POST("/users/:id/delete", UserDelete(db))
|
||||
admin.GET("/comments", CommentListPage(db))
|
||||
}
|
||||
|
||||
usersAPI := r.Group("/api/admin/users", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
usersAPI.POST("", UserCreate(db))
|
||||
usersAPI.PUT("/:id", UserUpdate(db))
|
||||
usersAPI.DELETE("/:id", UserDelete(db))
|
||||
}
|
||||
|
||||
return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter}
|
||||
}
|
||||
|
||||
@@ -473,25 +477,17 @@ func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) {
|
||||
t.Fatalf("GET edit with id %q: location = %q, want /admin/users", id, loc)
|
||||
}
|
||||
|
||||
// POST 更新不得修改任何内容(尝试提权)。
|
||||
form := url.Values{}
|
||||
form.Set("_csrf", token)
|
||||
form.Set("role", models.RoleAdmin)
|
||||
form.Set("status", "1")
|
||||
form.Set("display_name", "hacked")
|
||||
w = e.do(http.MethodPost, "/admin/users/"+url.PathEscape(id)+"/edit", admin,
|
||||
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
|
||||
if w.Code != http.StatusFound || w.Header().Get("Location") != "/admin/users" {
|
||||
t.Fatalf("POST edit with id %q: status = %d, location = %q", id, w.Code, w.Header().Get("Location"))
|
||||
// PUT 更新不得修改任何内容(尝试提权)。
|
||||
w = postJSON(e, http.MethodPut, "/api/admin/users/"+url.PathEscape(id), admin, token,
|
||||
gin.H{"role": models.RoleAdmin, "status": 1, "display_name": "hacked"})
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "api_invalid_request" {
|
||||
t.Fatalf("PUT edit with id %q: status = %d, code = %q", id, w.Code, respCode(w))
|
||||
}
|
||||
|
||||
// POST 删除不得删除任何内容。
|
||||
form = url.Values{}
|
||||
form.Set("_csrf", token)
|
||||
w = e.do(http.MethodPost, "/admin/users/"+url.PathEscape(id)+"/delete", admin,
|
||||
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("POST delete with id %q: status = %d, want 302", id, w.Code)
|
||||
// DELETE 删除不得删除任何内容。
|
||||
w = postJSON(e, http.MethodDelete, "/api/admin/users/"+url.PathEscape(id), admin, token, nil)
|
||||
if w.Code != http.StatusBadRequest || respCode(w) != "api_invalid_request" {
|
||||
t.Fatalf("DELETE user with id %q: status = %d, code = %q", id, w.Code, respCode(w))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -450,6 +450,7 @@ var translations = map[Lang]map[string]string{
|
||||
"api_unauthorized": "Please sign in first.",
|
||||
"api_forbidden": "You do not have permission to perform this action.",
|
||||
"api_invalid_request": "Invalid request body.",
|
||||
"user_not_found": "User not found.",
|
||||
},
|
||||
ZH: {
|
||||
// 导航
|
||||
@@ -885,6 +886,7 @@ var translations = map[Lang]map[string]string{
|
||||
"api_unauthorized": "请先登录。",
|
||||
"api_forbidden": "您没有权限执行此操作。",
|
||||
"api_invalid_request": "请求参数格式不正确。",
|
||||
"user_not_found": "用户不存在。",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -214,10 +214,15 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi
|
||||
{
|
||||
users.GET("", handlers.UserListPage(db))
|
||||
users.GET("/new", handlers.UserCreatePage(db))
|
||||
users.POST("/new", handlers.UserCreate(db))
|
||||
users.GET("/:id/edit", handlers.UserEditPage(db))
|
||||
users.POST("/:id/edit", handlers.UserUpdate(db))
|
||||
users.POST("/:id/delete", handlers.UserDelete(db))
|
||||
}
|
||||
|
||||
usersAPI := router.Group("/api/admin/users")
|
||||
usersAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
usersAPI.POST("", handlers.UserCreate(db))
|
||||
usersAPI.PUT("/:id", handlers.UserUpdate(db))
|
||||
usersAPI.DELETE("/:id", handlers.UserDelete(db))
|
||||
}
|
||||
|
||||
// 受保护的后台文章附件 API / 路由(仅管理员角色)。
|
||||
|
||||
@@ -158,6 +158,10 @@ func TestRegisterRoutesSmoke(t *testing.T) {
|
||||
"POST /api/my/articles": "",
|
||||
"PUT /api/my/articles/:id": "",
|
||||
"DELETE /api/my/articles/:id": "",
|
||||
// 用户 CRUD API。
|
||||
"POST /api/admin/users": "",
|
||||
"PUT /api/admin/users/:id": "",
|
||||
"DELETE /api/admin/users/:id": "",
|
||||
// 搬移的附件/头像端点。
|
||||
"POST /api/admin/articles/attachments": "",
|
||||
"DELETE /api/admin/articles/attachments/:id": "",
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
<section class="max-w-3xl mx-auto px-4 py-10">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
<div id="userFormError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<form id="userForm" action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<!-- Username (read-only on edit) -->
|
||||
<div>
|
||||
@@ -103,4 +101,22 @@
|
||||
</form>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('userForm');
|
||||
if (!form) return;
|
||||
var userId = {{ .FormID }};
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
var method = userId ? 'PUT' : 'POST';
|
||||
var url = userId ? '/api/admin/users/' + userId : '/api/admin/users';
|
||||
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || '/admin/users'; }
|
||||
else { blogShowError('userFormError', r.error || 'Failed to save user.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -59,9 +59,10 @@
|
||||
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
<a href="/admin/users/{{.ID}}/edit"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
|
||||
<form action="/admin/users/{{.ID}}/delete" method="post" class="inline"
|
||||
onsubmit="return confirm('{{index $.Tr "user_delete_confirm"}}');">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||
<form class="inline blog-delete-form"
|
||||
data-url="/api/admin/users/{{.ID}}"
|
||||
data-confirm="{{index $.Tr "user_delete_confirm"}}"
|
||||
onsubmit="return blogDelete(this)">
|
||||
<button type="submit"
|
||||
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user