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:
2026-06-22 18:11:39 +08:00
parent 6139b3ef2c
commit cefaeac618
11 changed files with 723 additions and 4 deletions
+5
View File
@@ -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)
}
}
+385
View File
@@ -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")
}
}
+6
View File
@@ -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
View File
@@ -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
}
+84
View File
@@ -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": "用户管理",
},
}
+12
View File
@@ -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())
+22
View File
@@ -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 {
+7 -1
View File
@@ -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"}}
+105
View File
@@ -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}}
+79
View File
@@ -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}}
+3
View File
@@ -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">