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
This commit is contained in:
@@ -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 <select> (0/1/2/3); treat an absent field
|
||||
// as Normal, but keep an explicit 0 (Disabled) intact.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// uintFormID parses a route :id into a uint (0 when absent/invalid).
|
||||
func uintFormID(s string) uint {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return uint(n)
|
||||
}
|
||||
|
||||
// applyUserFormToData writes the form values into the template data map so the
|
||||
// form is repopulated on render (initial load or validation error).
|
||||
func applyUserFormToData(data gin.H, f userForm) {
|
||||
data["FormID"] = f.ID
|
||||
data["FormUsername"] = f.Username
|
||||
data["FormPassword"] = f.Password
|
||||
data["FormDisplayName"] = f.DisplayName
|
||||
data["FormEmail"] = f.Email
|
||||
data["FormGender"] = f.Gender
|
||||
data["FormBirthday"] = f.Birthday
|
||||
data["FormRole"] = f.Role
|
||||
data["FormStatus"] = f.Status
|
||||
data["FormIsEdit"] = f.IsEdit
|
||||
data["FormAction"] = f.Action
|
||||
data["FormTitleText"] = f.TitleText
|
||||
}
|
||||
|
||||
// renderUserForm renders the shared user form template with the given form
|
||||
// values and optional error message.
|
||||
func renderUserForm(c *gin.Context, f userForm, errMsg string) {
|
||||
tr := getTr(c)
|
||||
data := DefaultData(c)
|
||||
data["Title"] = f.TitleText
|
||||
if errMsg != "" {
|
||||
data["Error"] = errMsg
|
||||
}
|
||||
// Role/status options for the <select> 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user