- 设置 → 管理员:仅 id=1 的账户在“其他人”那一行看到「编辑」入口,自己那行不显示
- 新增编辑页 /settings/users/{id}/edit:可改用户名、显示名称,密码留空则不修改
- 重置密码后立即清除该账户的所有登录会话,强制对方用新密码重新登录;只改昵称/用户名不影响会话
- 服务端三重校验:非 id=1 拒绝、不能编辑自己、用户名 3-32 字符且唯一、密码 ≥6 位且两次一致
- 所有修改写入操作日志(旧用户名/昵称 → 新值,是否重置密码)
- 设置页根据当前账户显示不同提示:超管看到可编辑说明,其他人只看到“只有初始管理员可以修改”
数据库:无需变更(复用 users 表与 sessions 表)
测试:
- 新增单测 TestUpdateUserByAdmin:昵称更新、会话保留、改密后旧会话失效、新密码可登录旧密码失效、重名返回 ErrDuplicate、空用户名拒绝
- 功能验证 20 项:入口显示/隐藏、普通管理员与自我编辑被拒、改密踢下线、新旧密码、长度/一致性/重名校验、留空密码、日志记录
- 原有 119 项验收回归全绿;go vet / go test ./... 通过;版本号 1.4.0
This commit is contained in:
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -957,3 +958,64 @@ func TestUserColorAndAssignee(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserByAdmin(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
admin, err := db.CreateUser("admin", "老板", "daili123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := db.CreateUser("dashou", "阿强", "dashou123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 建一个会话,稍后用密码重置把它踢掉
|
||||
token := "tok-" + domain.Date(domain.Today())
|
||||
if err := db.CreateSession(token, u.ID, time.Hour, "127.0.0.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.SessionUser(token); err != nil {
|
||||
t.Fatalf("会话应有效: %v", err)
|
||||
}
|
||||
|
||||
// 只改昵称:会话保留
|
||||
if err := db.UpdateUserByAdmin(u.ID, "dashou", "强哥", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := db.UserByID(u.ID)
|
||||
if got.DisplayName != "强哥" || got.Username != "dashou" {
|
||||
t.Fatalf("昵称未更新: %+v", got)
|
||||
}
|
||||
if _, err := db.SessionUser(token); err != nil {
|
||||
t.Fatalf("未改密码不应踢下线: %v", err)
|
||||
}
|
||||
|
||||
// 改用户名 + 重置密码:会话失效,新密码可登录
|
||||
if err := db.UpdateUserByAdmin(u.ID, "laoban", "强哥", "newpass123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.SessionUser(token); err == nil {
|
||||
t.Fatal("改密后旧会话应失效")
|
||||
}
|
||||
if _, err := db.Authenticate("laoban", "newpass123"); err != nil {
|
||||
t.Fatalf("新密码应可登录: %v", err)
|
||||
}
|
||||
if _, err := db.Authenticate("laoban", "dashou123"); err == nil {
|
||||
t.Fatal("旧密码不应还能登录")
|
||||
}
|
||||
|
||||
// 用户名冲突
|
||||
if err := db.UpdateUserByAdmin(u.ID, "admin", "", ""); !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatalf("重名应返回 ErrDuplicate, got %v", err)
|
||||
}
|
||||
// 空用户名
|
||||
if err := db.UpdateUserByAdmin(u.ID, " ", "", ""); err == nil {
|
||||
t.Fatal("空用户名应报错")
|
||||
}
|
||||
// 超管自己也在表里,但接口层才限制“只能改别人”
|
||||
if admin.ID != 1 {
|
||||
t.Fatalf("首个账户 id 应为 1,实际 %d", admin.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,46 @@ func (db *DB) ActiveUsers() ([]User, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteUserSessions 清除某个账户的全部登录会话。
|
||||
func (db *DB) DeleteUserSessions(userID int64) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM sessions WHERE user_id=?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateUserByAdmin 由超管修改用户名/昵称,password 非空时同时重置密码。
|
||||
// 重置密码会强制该账户下线。
|
||||
func (db *DB) UpdateUserByAdmin(userID int64, username, displayName, password string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if username == "" {
|
||||
return errors.New("用户名不能为空")
|
||||
}
|
||||
hash := ""
|
||||
if password != "" {
|
||||
var err error
|
||||
hash, err = pw.Hash(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := db.sql.Exec(`UPDATE users SET username=?, display_name=? WHERE id=?`,
|
||||
username, displayName, userID); err != nil {
|
||||
if isUnique(err) {
|
||||
return ErrDuplicate
|
||||
}
|
||||
return err
|
||||
}
|
||||
if hash != "" {
|
||||
if _, err := db.sql.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DeleteUserSessions(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate 校验用户名口令。
|
||||
func (db *DB) Authenticate(username, password string) (*User, error) {
|
||||
var hash string
|
||||
|
||||
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -152,6 +153,95 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, r, "settings", p)
|
||||
}
|
||||
|
||||
type userFormData struct {
|
||||
Target *store.User
|
||||
}
|
||||
|
||||
// isSuperAdmin 只有一个超级管理员(id=1)可以修改他人资料。
|
||||
func isSuperAdmin(u *store.User) bool { return u != nil && u.ID == 1 }
|
||||
|
||||
// handleUserEditPage 超管编辑其他管理员。
|
||||
func (s *Server) handleUserEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
me := s.currentUser(r)
|
||||
if !isSuperAdmin(me) {
|
||||
s.redirect(w, r, "/settings", "只有初始管理员可以修改其他人的资料")
|
||||
return
|
||||
}
|
||||
id := pathID(r)
|
||||
if id == me.ID {
|
||||
s.redirect(w, r, "/settings", "自己的资料请用「修改我的密码」")
|
||||
return
|
||||
}
|
||||
u, err := s.db.UserByID(id)
|
||||
if err != nil {
|
||||
s.redirect(w, r, "/settings", "账户不存在")
|
||||
return
|
||||
}
|
||||
p := s.newPage(r, "编辑管理员", "settings")
|
||||
p.D = &userFormData{Target: u}
|
||||
s.render(w, r, "user_form", p)
|
||||
}
|
||||
|
||||
// handleUserEditSave 保存超管对其他管理员的修改。
|
||||
func (s *Server) handleUserEditSave(w http.ResponseWriter, r *http.Request) {
|
||||
me := s.currentUser(r)
|
||||
if !isSuperAdmin(me) {
|
||||
s.redirect(w, r, "/settings", "只有初始管理员可以修改其他人的资料")
|
||||
return
|
||||
}
|
||||
id := pathID(r)
|
||||
if id == me.ID {
|
||||
s.redirect(w, r, "/settings", "自己的资料请用「修改我的密码」")
|
||||
return
|
||||
}
|
||||
u, err := s.db.UserByID(id)
|
||||
if err != nil {
|
||||
s.redirect(w, r, "/settings", "账户不存在")
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
displayName := strings.TrimSpace(r.PostFormValue("display_name"))
|
||||
password := r.PostFormValue("password")
|
||||
password2 := r.PostFormValue("password2")
|
||||
|
||||
back := "/settings/users/" + itoa(id) + "/edit"
|
||||
if l := len([]rune(username)); l < 3 || l > 32 {
|
||||
s.redirect(w, r, back, "用户名长度需在 3-32 个字符之间")
|
||||
return
|
||||
}
|
||||
if len([]rune(displayName)) > 32 {
|
||||
s.redirect(w, r, back, "显示名称不能超过 32 个字符")
|
||||
return
|
||||
}
|
||||
if password != "" {
|
||||
if len([]rune(password)) < 6 {
|
||||
s.redirect(w, r, back, "新密码至少 6 位")
|
||||
return
|
||||
}
|
||||
if password != password2 {
|
||||
s.redirect(w, r, back, "两次输入的新密码不一致")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.db.UpdateUserByAdmin(id, username, displayName, password); err != nil {
|
||||
msg := "保存失败:" + err.Error()
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
msg = "用户名已被占用"
|
||||
}
|
||||
s.redirect(w, r, back, msg)
|
||||
return
|
||||
}
|
||||
detail := u.Username + " → " + username
|
||||
if displayName != u.DisplayName {
|
||||
detail += ";昵称 " + u.DisplayName + " → " + displayName
|
||||
}
|
||||
if password != "" {
|
||||
detail += ";已重置密码并强制下线"
|
||||
}
|
||||
s.logOp(r, "修改管理员资料", "user", id, detail)
|
||||
s.redirect(w, r, "/settings", "已更新「"+username+"」的资料")
|
||||
}
|
||||
|
||||
// handleUserColor 设置管理员(打手)颜色。
|
||||
func (s *Server) handleUserColor(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathID(r)
|
||||
|
||||
@@ -138,6 +138,8 @@ func (s *Server) routes() http.Handler {
|
||||
mux.HandleFunc("POST /settings/users", s.auth(s.handleUserCreate))
|
||||
mux.HandleFunc("POST /settings/users/{id}/toggle", s.auth(s.handleUserToggle))
|
||||
mux.HandleFunc("POST /settings/users/{id}/color", s.auth(s.handleUserColor))
|
||||
mux.HandleFunc("GET /settings/users/{id}/edit", s.auth(s.handleUserEditPage))
|
||||
mux.HandleFunc("POST /settings/users/{id}/edit", s.auth(s.handleUserEditSave))
|
||||
mux.HandleFunc("POST /settings/backup", s.auth(s.handleBackupNow))
|
||||
mux.HandleFunc("GET /export/{kind}", s.auth(s.handleExport))
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<section class="section card">
|
||||
<h2>管理员</h2>
|
||||
<p class="muted small">所有账户权限相同,任何操作都会记在日志里。每个管理员的颜色用于日历左侧竖条和待办标识。</p>
|
||||
<p class="muted small">{{if eq .User.ID 1}}你是初始管理员,可以点「编辑」修改其他管理员的用户名、昵称和密码。{{else}}只有初始管理员(首个账号)可以修改其他管理员的资料。{{end}}</p>
|
||||
<div class="table-wrap">
|
||||
<table class="table tight">
|
||||
<thead><tr><th>用户名</th><th>显示名称</th><th>打手颜色</th><th>状态</th><th>创建时间</th><th>最近登录</th><th></th></tr></thead>
|
||||
@@ -48,6 +49,9 @@
|
||||
{{if eq .ID $me.ID}}
|
||||
<span class="muted small">当前账户</span>
|
||||
{{else}}
|
||||
{{if eq $me.ID 1}}
|
||||
<a class="btn btn-sm" href="/settings/users/{{.ID}}/edit">编辑</a>
|
||||
{{end}}
|
||||
<form method="post" action="/settings/users/{{.ID}}/toggle">
|
||||
<button class="btn btn-sm btn-ghost">{{if .IsActive}}停用{{else}}启用{{end}}</button>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{{define "content"}}
|
||||
<div class="page narrow">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>编辑管理员</h1>
|
||||
<p class="muted">修改 {{.D.Target.Username}} 的登录名、昵称,或重置密码。</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<a class="btn btn-ghost" href="/settings">返回设置</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form method="post" action="/settings/users/{{.D.Target.ID}}/edit" class="form card">
|
||||
<div class="grid-2">
|
||||
<label class="field"><span>用户名 <i>*</i></span>
|
||||
<input type="text" name="username" value="{{.D.Target.Username}}" required minlength="3" maxlength="32" autocomplete="off">
|
||||
</label>
|
||||
<label class="field"><span>显示名称</span>
|
||||
<input type="text" name="display_name" value="{{.D.Target.DisplayName}}" maxlength="32" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<label class="field"><span>新密码</span>
|
||||
<input type="password" name="password" minlength="6" autocomplete="new-password" placeholder="留空则不修改">
|
||||
</label>
|
||||
<label class="field"><span>再输一次</span>
|
||||
<input type="password" name="password2" minlength="6" autocomplete="new-password" placeholder="与上面一致">
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted small">重置密码后该账户的登录会话会立即失效,需要用新密码重新登录。当前账户:{{.User.Username}}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" type="submit">保存修改</button>
|
||||
<a class="btn btn-ghost" href="/settings">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user