From cefaeac61896fdf9dadc0dec61f4b25e0a2efb66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=97=BB=E9=A3=8E?= Date: Mon, 22 Jun 2026 18:11:39 +0800 Subject: [PATCH] feat: add admin user management with role-based access and self-protection - Add AdminRequired middleware for admin-role routes - Add login status check to reject disabled/locked accounts - Add CRUD handlers (list, create, edit, delete) for users - Add user_list and user_form templates with role/status badges - Add nav entry and dashboard button for user management - Add dynamic user count on admin dashboard - Fix userIDFromSession to handle all numeric session types - Self-protection: cannot delete/disable self or demote last admin - Add EN/ZH i18n keys for all user management strings --- handlers/admin.go | 5 + handlers/admin_user.go | 385 +++++++++++++++++++++++++++++++++ handlers/auth.go | 6 + handlers/settings.go | 18 +- i18n/i18n.go | 84 +++++++ main.go | 12 + middleware/auth.go | 22 ++ templates/admin/dashboard.html | 8 +- templates/admin/user_form.html | 105 +++++++++ templates/admin/user_list.html | 79 +++++++ templates/layouts/base.html | 3 + 11 files changed, 723 insertions(+), 4 deletions(-) create mode 100644 handlers/admin_user.go create mode 100644 templates/admin/user_form.html create mode 100644 templates/admin/user_list.html diff --git a/handlers/admin.go b/handlers/admin.go index 3e632c4..31f8c02 100644 --- a/handlers/admin.go +++ b/handlers/admin.go @@ -25,6 +25,11 @@ func AdminDashboard(db *gorm.DB) gin.HandlerFunc { data["PostCount"] = postCount data["PublishedCount"] = publishedCount + // User count for the stats card. + var userCount int64 + db.Model(&models.User{}).Count(&userCount) + data["UserCount"] = userCount + c.HTML(http.StatusOK, "dashboard", data) } } diff --git a/handlers/admin_user.go b/handlers/admin_user.go new file mode 100644 index 0000000..48e175d --- /dev/null +++ b/handlers/admin_user.go @@ -0,0 +1,385 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go_blog/models" + + "gorm.io/gorm" +) + +// userForm holds the posted user fields plus rendering metadata for the shared +// create/edit form template. +type userForm struct { + ID uint + Username string + Password string + DisplayName string + Email string + Gender string + Birthday string // YYYY-MM-DD from the date input + Role string + Status int + IsEdit bool + Action string + TitleText string +} + +// userListView augments a User with pre-rendered labels/badges so the template +// never invokes methods on an interface{}-wrapped struct. +type userListView struct { + models.User + RoleLabel string + RoleBadge string + StatusLabel string + StatusBadge string +} + +// parseUserForm reads the user form fields from the request. +func parseUserForm(c *gin.Context) userForm { + // Status is always sent from the elements. + data["RoleAdmin"] = models.RoleAdmin + data["RoleAuthor"] = models.RoleAuthor + data["StatusNormal"] = models.StatusNormal + data["StatusDisabled"] = models.StatusDisabled + data["StatusLocked"] = models.StatusLocked + data["StatusUnactivated"] = models.StatusUnactivated + data["TrGenderMale"] = tr["gender_male"] + data["TrGenderFemale"] = tr["gender_female"] + data["TrGenderOther"] = tr["gender_other"] + applyUserFormToData(data, f) + c.HTML(http.StatusOK, "user_form", data) +} + +// UserListPage renders the admin user management list. +func UserListPage(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + var users []models.User + db.Order("created_at DESC").Find(&users) + + views := make([]userListView, 0, len(users)) + for _, u := range users { + v := userListView{User: u} + switch u.Role { + case models.RoleAdmin: + v.RoleLabel = tr["role_admin"] + v.RoleBadge = "bg-purple-100 text-purple-700" + default: + v.RoleLabel = tr["role_author"] + v.RoleBadge = "bg-blue-100 text-blue-700" + } + switch u.Status { + case models.StatusNormal: + v.StatusLabel = tr["status_normal"] + v.StatusBadge = "bg-green-100 text-green-700" + case models.StatusDisabled: + v.StatusLabel = tr["status_disabled"] + v.StatusBadge = "bg-gray-200 text-gray-600" + case models.StatusLocked: + v.StatusLabel = tr["status_locked"] + v.StatusBadge = "bg-yellow-100 text-yellow-700" + default: + v.StatusLabel = tr["status_unactivated"] + v.StatusBadge = "bg-red-100 text-red-700" + } + views = append(views, v) + } + + data := DefaultData(c) + data["Title"] = tr["admin_users_title"] + data["Users"] = views + if msg := c.Query("saved"); msg == "1" { + switch c.Query("msg") { + case "created": + data["Success"] = tr["user_created"] + case "updated": + data["Success"] = tr["user_updated"] + case "deleted": + data["Success"] = tr["user_deleted"] + default: + data["Success"] = tr["settings_saved"] + } + } + if e := c.Query("error"); e != "" { + switch e { + case "self_disable": + data["Error"] = tr["user_cannot_disable_self"] + case "last_admin": + data["Error"] = tr["user_cannot_remove_last_admin"] + case "exists": + data["Error"] = tr["user_username_exists"] + } + } + c.HTML(http.StatusOK, "user_list", data) + } +} + +// UserCreatePage renders the empty user creation form. +func UserCreatePage(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + renderUserForm(c, userForm{ + Action: "/admin/users/new", + TitleText: tr["user_create_title"], + Role: models.RoleAuthor, + Status: models.StatusNormal, + IsEdit: false, + }, "") + } +} + +// UserCreate handles POST to create a new user. +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 + + if f.Username == "" { + renderUserForm(c, f, tr["user_username_required"]) + return + } + if f.Password == "" { + renderUserForm(c, f, tr["user_password_required"]) + return + } + if f.Role == "" { + f.Role = models.RoleAuthor + } + + // Username must be unique. + var exists int64 + db.Model(&models.User{}).Where("username = ?", f.Username).Count(&exists) + if exists > 0 { + renderUserForm(c, f, tr["user_username_exists"]) + return + } + + user := models.User{ + Username: f.Username, + DisplayName: f.DisplayName, + Email: f.Email, + Gender: f.Gender, + Role: f.Role, + Status: f.Status, + } + if t, err := time.Parse("2006-01-02", f.Birthday); err == nil { + user.Birthday = &t + } + if err := user.SetPassword(f.Password); err != nil { + renderUserForm(c, f, tr["article_error"]) + return + } + if err := db.Create(&user).Error; err != nil { + renderUserForm(c, f, tr["article_error"]) + return + } + c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=created") + } +} + +// UserEditPage renders the user edit form prefilled with an existing user. +func UserEditPage(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + id := c.Param("id") + var user models.User + if err := db.First(&user, id).Error; err != nil { + c.Redirect(http.StatusFound, "/admin/users") + return + } + birthday := "" + if user.Birthday != nil { + birthday = user.Birthday.Format("2006-01-02") + } + renderUserForm(c, userForm{ + ID: user.ID, + Username: user.Username, + DisplayName: user.DisplayName, + Email: user.Email, + Gender: user.Gender, + Birthday: birthday, + Role: user.Role, + Status: user.Status, + IsEdit: true, + Action: "/admin/users/" + id + "/edit", + TitleText: tr["user_edit_title"], + }, "") + } +} + +// UserUpdate handles POST to update an existing user. +func UserUpdate(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + tr := getTr(c) + id := c.Param("id") + f := parseUserForm(c) + f.ID = uintFormID(id) + f.IsEdit = true + f.Action = "/admin/users/" + id + "/edit" + f.TitleText = tr["user_edit_title"] + + var user models.User + if err := db.First(&user, id).Error; err != nil { + c.Redirect(http.StatusFound, "/admin/users") + return + } + + // Keep the original username (it is the login key + article FK source). + f.Username = user.Username + + currentID := userIDFromSession(c) + isSelf := user.ID == currentID + + // Self-protection: cannot disable/lock your own account. + 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"]) + return + } + + // Self-protection: cannot demote the last remaining admin. + if user.Role == models.RoleAdmin && f.Role != models.RoleAdmin { + 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") + return + } + } + if f.Role == "" { + f.Role = user.Role + } + + user.DisplayName = f.DisplayName + user.Email = f.Email + user.Gender = f.Gender + user.Role = f.Role + user.Status = f.Status + if t, err := time.Parse("2006-01-02", f.Birthday); err == nil { + user.Birthday = &t + } else { + user.Birthday = nil + } + + // Optional password reset (leave blank to keep current). + if f.Password != "" { + if err := user.SetPassword(f.Password); err != nil { + renderUserForm(c, f, tr["article_error"]) + return + } + } + + if err := db.Save(&user).Error; err != nil { + renderUserForm(c, f, tr["article_error"]) + return + } + c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=updated") + } +} + +// UserDelete soft-deletes a user, with self-protection and last-admin guards. +func UserDelete(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + targetID := uintFormID(id) + currentID := userIDFromSession(c) + + var user models.User + if err := db.First(&user, id).Error; err != nil { + c.Redirect(http.StatusFound, "/admin/users") + return + } + + // Cannot delete yourself. + if targetID == currentID { + c.Redirect(http.StatusFound, "/admin/users?error=self_disable") + return + } + // Cannot delete the last admin. + if user.Role == models.RoleAdmin { + 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") + return + } + } + + db.Delete(&user) + c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=deleted") + } +} diff --git a/handlers/auth.go b/handlers/auth.go index cf41080..1d2e490 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -40,6 +40,12 @@ func Login(db *gorm.DB) gin.HandlerFunc { return } + // Refuse login for non-normal accounts (disabled / locked / unactivated). + if user.Status != models.StatusNormal { + c.Redirect(http.StatusFound, "/login?error=1") + return + } + // Create session. session := sessions.Default(c) session.Set("user_id", user.ID) diff --git a/handlers/settings.go b/handlers/settings.go index d4baf01..4b816a7 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -36,11 +36,23 @@ func bytesToMB(b int64) string { return fmt.Sprintf("%.1f", float64(b)/float64(1024*1024)) } -// userIDFromSession extracts the logged-in user's ID, or 0 if absent. +// userIDFromSession extracts the logged-in user's ID, or 0 if absent. It +// defends against int/uint/int64/float64 storage in the session. func userIDFromSession(c *gin.Context) uint { session := sessions.Default(c) - if id, ok := session.Get("user_id").(uint); ok { - return id + userID := session.Get("user_id") + if userID == nil { + return 0 + } + switch v := userID.(type) { + case uint: + return v + case int: + return uint(v) + case int64: + return uint(v) + case float64: + return uint(v) } return 0 } diff --git a/i18n/i18n.go b/i18n/i18n.go index 57a594c..e9d810e 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -269,6 +269,48 @@ var translations = map[Lang]map[string]string{ "comment_settings_guest_approval": "Require approval for guest comments", "comment_settings_use_gravatar": "Use Gravatar avatars", "comment_settings_use_gravatar_hint": "When off, avatars show the author's initial on a colored background.", + + // Admin: user management + "admin_users": "Users", + "admin_users_title": "Users", + "user_list_title": "User Management", + "user_new": "New User", + "user_create_title": "Create User", + "user_edit_title": "Edit User", + "user_col_username": "Username", + "user_col_display": "Display Name", + "user_col_email": "Email", + "user_col_role": "Role", + "user_col_status": "Status", + "user_col_created": "Created", + "user_col_actions": "Actions", + "user_username": "Username", + "user_password": "Password", + "user_password_hint": "Leave blank to keep the current password.", + "user_display_name": "Display Name", + "user_email": "Email", + "user_gender": "Gender", + "user_birthday": "Birthday", + "user_role": "Role", + "user_status": "Status", + "role_admin": "Admin", + "role_author": "Author", + "status_normal": "Normal", + "status_disabled": "Disabled", + "status_locked": "Locked", + "status_unactivated": "Unactivated", + "user_created": "User created.", + "user_updated": "User updated.", + "user_deleted": "User deleted.", + "user_delete_confirm": "Delete this user?", + "user_empty": "No users.", + "user_username_required": "Username is required.", + "user_password_required": "Password is required.", + "user_username_exists": "Username already exists.", + "user_cannot_delete_self": "You cannot delete your own account.", + "user_cannot_disable_self": "You cannot disable or lock your own account.", + "user_cannot_remove_last_admin": "Cannot remove the last administrator.", + "dash_user_mgmt": "Manage Users", }, ZH: { // 导航 @@ -523,6 +565,48 @@ var translations = map[Lang]map[string]string{ "comment_settings_guest_approval": "非登录用户评论需审核", "comment_settings_use_gravatar": "使用 Gravatar 头像", "comment_settings_use_gravatar_hint": "关闭后,头像将显示作者名称首字母的彩色占位。", + + // 后台:用户管理 + "admin_users": "用户", + "admin_users_title": "用户", + "user_list_title": "用户管理", + "user_new": "新建用户", + "user_create_title": "新建用户", + "user_edit_title": "编辑用户", + "user_col_username": "用户名", + "user_col_display": "显示名称", + "user_col_email": "邮箱", + "user_col_role": "角色", + "user_col_status": "状态", + "user_col_created": "创建时间", + "user_col_actions": "操作", + "user_username": "用户名", + "user_password": "密码", + "user_password_hint": "留空则保持原密码不变。", + "user_display_name": "显示名称", + "user_email": "邮箱", + "user_gender": "性别", + "user_birthday": "生日", + "user_role": "角色", + "user_status": "状态", + "role_admin": "管理员", + "role_author": "作者", + "status_normal": "正常", + "status_disabled": "禁用", + "status_locked": "锁定", + "status_unactivated": "未激活", + "user_created": "用户已创建。", + "user_updated": "用户已更新。", + "user_deleted": "用户已删除。", + "user_delete_confirm": "确认删除该用户?", + "user_empty": "暂无用户。", + "user_username_required": "请填写用户名。", + "user_password_required": "请填写密码。", + "user_username_exists": "用户名已存在。", + "user_cannot_delete_self": "不能删除自己的账号。", + "user_cannot_disable_self": "不能禁用或锁定自己的账号。", + "user_cannot_remove_last_admin": "不能移除最后一个管理员。", + "dash_user_mgmt": "用户管理", }, } diff --git a/main.go b/main.go index b29730f..73565b9 100644 --- a/main.go +++ b/main.go @@ -74,6 +74,18 @@ func main() { admin.POST("/comments/:id/delete", handlers.CommentDelete(db)) } + // Protected admin user-management routes (admin role only). + users := router.Group("/admin/users") + users.Use(middleware.AuthRequired(), middleware.AdminRequired(db)) + { + 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)) + } + // Protected article attachment routes (AJAX uploads / management). attachments := router.Group("/admin/articles") attachments.Use(middleware.AuthRequired()) diff --git a/middleware/auth.go b/middleware/auth.go index 3d229dc..bfae8ec 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -26,6 +26,28 @@ func AuthRequired() gin.HandlerFunc { } } +// AdminRequired is middleware that restricts a route to admin-role users. It +// must run after AuthRequired (which guarantees a session user exists). Non-admin +// users are redirected back to the admin dashboard. +func AdminRequired(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + userID := session.Get("user_id") + if userID == nil { + c.Redirect(http.StatusFound, "/login") + c.Abort() + return + } + var user models.User + if err := db.First(&user, userID).Error; err != nil || user.Role != models.RoleAdmin { + c.Redirect(http.StatusFound, "/admin") + c.Abort() + return + } + c.Next() + } +} + // SetUserContext is global middleware that reads the session and sets // template-friendly context values for all pages (language, auth state, etc.). func SetUserContext(db *gorm.DB) gin.HandlerFunc { diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 1ac2244..8e7620b 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -30,7 +30,7 @@

{{index .Tr "dash_users"}}

-

1

+

{{.UserCount}}

@@ -50,6 +50,12 @@ class="inline-flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-lg font-medium hover:bg-gray-300 transition-colors"> {{index .Tr "article_manage"}} + {{if eq .Role "admin"}} + + {{index .Tr "dash_user_mgmt"}} + + {{end}} {{index .Tr "settings_nav"}} diff --git a/templates/admin/user_form.html b/templates/admin/user_form.html new file mode 100644 index 0000000..8db70e5 --- /dev/null +++ b/templates/admin/user_form.html @@ -0,0 +1,105 @@ +{{define "user_form"}} +{{template "header" .}} +
+

{{.FormTitleText}}

+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + +
+ +
+ + {{if .FormIsEdit}} + + {{else}} + + {{end}} +
+ + +
+ + + {{if .FormIsEdit}} +

{{index .Tr "user_password_hint"}}

+ {{end}} +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+{{template "footer" .}} +{{end}} diff --git a/templates/admin/user_list.html b/templates/admin/user_list.html new file mode 100644 index 0000000..e36ee66 --- /dev/null +++ b/templates/admin/user_list.html @@ -0,0 +1,79 @@ +{{define "user_list"}} +{{template "header" .}} +
+
+

{{index .Tr "user_list_title"}}

+ + + + + {{index .Tr "user_new"}} + +
+ + {{if .Success}} +
{{.Success}}
+ {{end}} + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ + + + + + + + + + + + + + {{range .Users}} + + + + + + + + + + {{else}} + + + + {{end}} + +
{{index .Tr "user_col_username"}}{{index .Tr "user_col_display"}}{{index .Tr "user_col_email"}}{{index .Tr "user_col_role"}}{{index .Tr "user_col_status"}}{{index .Tr "user_col_created"}}{{index .Tr "user_col_actions"}}
+
+ {{if .Avatar}} + + {{else}} + + {{if .DisplayName}}{{slice .DisplayName 0 1}}{{else}}{{slice .Username 0 1}}{{end}} + + {{end}} + {{.Username}} +
+
{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}{{if .Email}}{{.Email}}{{else}}—{{end}} + {{.RoleLabel}} + + {{.StatusLabel}} + {{.CreatedAt.Format "2006-01-02 15:04"}} + {{index $.Tr "article_edit"}} +
+ +
+
{{index .Tr "user_empty"}}
+
+
+{{template "footer" .}} +{{end}} diff --git a/templates/layouts/base.html b/templates/layouts/base.html index 7b66702..0f9fe44 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -53,6 +53,9 @@ {{index .Tr "admin_comments"}} + + {{index .Tr "admin_users"}} + {{end}}