Files
kevin cefaeac618 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
2026-06-22 18:11:39 +08:00

386 lines
10 KiB
Go

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")
}
}