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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+15
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": "用户管理",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm text-gray-500 uppercase tracking-wider">{{index .Tr "dash_users"}}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">1</p>
|
||||
<p class="text-3xl font-bold text-gray-900 mt-1">{{.UserCount}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"}}
|
||||
</a>
|
||||
{{if eq .Role "admin"}}
|
||||
<a href="/admin/users"
|
||||
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 "dash_user_mgmt"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a href="/admin/settings/site"
|
||||
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 "settings_nav"}}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
{{define "user_form"}}
|
||||
{{template "header" .}}
|
||||
<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">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<!-- Username (read-only on edit) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_username"}}</label>
|
||||
{{if .FormIsEdit}}
|
||||
<input type="text" value="{{.FormUsername}}" readonly
|
||||
class="w-full px-4 py-2 border border-gray-200 rounded-lg bg-gray-100 text-gray-500 cursor-not-allowed">
|
||||
{{else}}
|
||||
<input type="text" name="username" value="{{.FormUsername}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "user_username"}}">
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_password"}}</label>
|
||||
<input type="password" name="password" value="{{.FormPassword}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "user_password"}}">
|
||||
{{if .FormIsEdit}}
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "user_password_hint"}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Display name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_display_name"}}</label>
|
||||
<input type="text" name="display_name" value="{{.FormDisplayName}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_email"}}</label>
|
||||
<input type="email" name="email" value="{{.FormEmail}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Gender -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_gender"}}</label>
|
||||
<select name="gender"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
|
||||
<option value="other" {{if eq .FormGender "other"}}selected{{end}}>{{.TrGenderOther}}</option>
|
||||
<option value="male" {{if eq .FormGender "male"}}selected{{end}}>{{.TrGenderMale}}</option>
|
||||
<option value="female" {{if eq .FormGender "female"}}selected{{end}}>{{.TrGenderFemale}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Birthday -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_birthday"}}</label>
|
||||
<input type="date" name="birthday" value="{{.FormBirthday}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_role"}}</label>
|
||||
<select name="role"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
|
||||
<option value="{{.RoleAuthor}}" {{if eq .FormRole .RoleAuthor}}selected{{end}}>{{index .Tr "role_author"}}</option>
|
||||
<option value="{{.RoleAdmin}}" {{if eq .FormRole .RoleAdmin}}selected{{end}}>{{index .Tr "role_admin"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_status"}}</label>
|
||||
<select name="status"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors bg-white">
|
||||
<option value="{{.StatusNormal}}" {{if eq .FormStatus .StatusNormal}}selected{{end}}>{{index .Tr "status_normal"}}</option>
|
||||
<option value="{{.StatusDisabled}}" {{if eq .FormStatus .StatusDisabled}}selected{{end}}>{{index .Tr "status_disabled"}}</option>
|
||||
<option value="{{.StatusLocked}}" {{if eq .FormStatus .StatusLocked}}selected{{end}}>{{index .Tr "status_locked"}}</option>
|
||||
<option value="{{.StatusUnactivated}}" {{if eq .FormStatus .StatusUnactivated}}selected{{end}}>{{index .Tr "status_unactivated"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
||||
{{index .Tr "settings_save"}}
|
||||
</button>
|
||||
<a href="/admin/users"
|
||||
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "article_back_home"}}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,79 @@
|
||||
{{define "user_list"}}
|
||||
{{template "header" .}}
|
||||
<section class="max-w-6xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "user_list_title"}}</h2>
|
||||
<a href="/admin/users/new"
|
||||
class="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{index .Tr "user_new"}}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{if .Success}}
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||
{{end}}
|
||||
{{if .Error}}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_username"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_display"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_email"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_role"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_status"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600">{{index .Tr "user_col_created"}}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 text-right">{{index .Tr "user_col_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{{range .Users}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-800 font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
{{if .Avatar}}
|
||||
<img src="/uploads/avatars/{{.Avatar}}" alt="" class="w-7 h-7 rounded-full object-cover">
|
||||
{{else}}
|
||||
<span class="w-7 h-7 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center text-xs font-bold">
|
||||
{{if .DisplayName}}{{slice .DisplayName 0 1}}{{else}}{{slice .Username 0 1}}{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{.Username}}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{if .Email}}{{.Email}}{{else}}—{{end}}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="text-xs {{.RoleBadge}} px-2 py-1 rounded">{{.RoleLabel}}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="text-xs {{.StatusBadge}} px-2 py-1 rounded">{{.StatusLabel}}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<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"}}');">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-12 text-center text-gray-500">{{index .Tr "user_empty"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -53,6 +53,9 @@
|
||||
<a href="/admin/comments" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "admin_comments"}}
|
||||
</a>
|
||||
<a href="/admin/users" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{{index .Tr "admin_users"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<form action="/logout" method="post" class="m-0">
|
||||
|
||||
Reference in New Issue
Block a user