- 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
386 lines
10 KiB
Go
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")
|
|
}
|
|
}
|