Add multi-admin account management
Schema:
- New AdminUser model with bcrypt-hashed password (cost 10)
- Roles: admin (full) / operator (read-only ops)
- Status: active / disabled
- MustChangePassword flag forces first-login password change
Backend:
- store: Add admins [] + CRUD methods (ListAdmins strips PasswordHash)
- service: SeedDefaultAdminIfEmpty (uses env credentials on first run),
CreateAdmin, ChangePassword, ResetPassword, SetAdminStatus, DeleteAdmin
- middleware: JWT now carries user_id (UUID)
- api: login() uses bcrypt + updates last_login_at/ip, blocks disabled
- api: me() returns role + must_change_password
- api: new endpoints:
POST /api/me/password (self password change)
GET /api/admins
POST /api/admins (create)
POST /api/admins/:id/password (reset by admin)
POST /api/admins/:id/status (enable/disable)
DELETE /api/admins/:id (with self/last-admin guard)
Frontend:
- Login: must_change_password=true triggers forced change-password dialog
- Layout: admin dropdown shows role tag + 修改密码 / 退出登录
- New /admins page (admin only) with table + create/reset/status/delete
- Router guard hides /admins from non-admin accounts
- API client: Auth.changePassword, Admins.{list,create,resetPassword,setStatus,delete}
Security:
- PasswordHash stored as bcrypt $2a$10$... in db.json
- ListAdmins always returns PasswordHash=''; never leaks via API
- Login returns 403 for disabled accounts
Verified: 21/21 API tests + browser E2E (first-login forced change,
restart persistence, admin list without hash, role-based menu)
This commit is contained in:
@@ -50,6 +50,7 @@
|
|||||||
| 客户端证书 | 一键签发,自动生成 `.ovpn`(内嵌 CA/Cert/Key/TLS-Auth),无需额外文件 |
|
| 客户端证书 | 一键签发,自动生成 `.ovpn`(内嵌 CA/Cert/Key/TLS-Auth),无需额外文件 |
|
||||||
| 固定 IP | 通过 CCD (`client-config-dir`) 为指定用户分配固定 VPN IP |
|
| 固定 IP | 通过 CCD (`client-config-dir`) 为指定用户分配固定 VPN IP |
|
||||||
| **访问控制(白名单)** | 实例/用户两层 allow_networks,合并生效;服务端 iptables 强制隔离 |
|
| **访问控制(白名单)** | 实例/用户两层 allow_networks,合并生效;服务端 iptables 强制隔离 |
|
||||||
|
| **多账号管理** | db.json 中存 bcrypt 哈希,可创建多个 admin/operator 账号,首登强制改密 |
|
||||||
| 启停控制 | Web 一键启动/停止实例,显示 PID 与状态 |
|
| 启停控制 | Web 一键启动/停止实例,显示 PID 与状态 |
|
||||||
| 流量审计 | 解析 `status-version 3` 输出,记录上下行字节/连接时长 |
|
| 流量审计 | 解析 `status-version 3` 输出,记录上下行字节/连接时长 |
|
||||||
| 证书到期提醒 | 仪表盘统计 30 天内到期的证书,单独证书管理页查看完整清单 |
|
| 证书到期提醒 | 仪表盘统计 30 天内到期的证书,单独证书管理页查看完整清单 |
|
||||||
@@ -283,17 +284,47 @@ sudo ./scripts/install.sh -u
|
|||||||
|
|
||||||
⚠️ **生产环境第一步**。
|
⚠️ **生产环境第一步**。
|
||||||
|
|
||||||
当前版本通过 systemd 环境变量修改密码:
|
### 首次登录强制改密
|
||||||
|
|
||||||
|
首次访问用默认账号 `admin/admin123` 登录后,系统**强制**弹出"修改初始密码"对话框,
|
||||||
|
必须改成 ≥6 位的强密码才能继续使用,无法跳过。这一步密码修改后会写入 `data/db.json`,
|
||||||
|
**之后不再需要重启服务**,密码以 bcrypt 哈希持久化。
|
||||||
|
|
||||||
|
### 通过 Web 修改自己的密码
|
||||||
|
|
||||||
|
右上角 admin 下拉 → "修改密码" → 填写当前密码 + 新密码 → 保存,立即生效。
|
||||||
|
|
||||||
|
### 创建/管理其他账号
|
||||||
|
|
||||||
|
Web → 左侧"管理员"菜单(admin role 才可见):
|
||||||
|
|
||||||
|
| 操作 | 说明 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 新建账号 | 设置用户名、密码、角色(admin / operator) |
|
||||||
|
| 重置密码 | 不知道旧密码也能强制改密,新密码立即生效 |
|
||||||
|
| 启用/禁用 | 禁用后该账号无法登录(已登录的 token 仍可短期使用) |
|
||||||
|
| 删除 | 不能删除自己、不能删除最后一个 admin |
|
||||||
|
|
||||||
|
所有账号操作都进入审计日志。
|
||||||
|
|
||||||
|
### 通过 systemd 环境变量重置(忘记密码时)
|
||||||
|
|
||||||
|
如果你把所有 admin 都禁用/删除了,或者忘了所有密码,可以临时用环境变量密码
|
||||||
|
覆盖(只用于"首次登录然后改密",不持久):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl edit --full openvpn-manager
|
systemctl edit --full openvpn-manager
|
||||||
# 找到 OVPNMGR_ADMIN_PASS 一行,改成你的强密码
|
# 找到 OVPNMGR_ADMIN_PASS 一行,改成你临时用的密码
|
||||||
# 也建议修改 OVPNMGR_JWT_SECRET 为随机串
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl restart openvpn-manager
|
systemctl restart openvpn-manager
|
||||||
|
# 用新密码登录,系统会触发 SeedDefaultAdminIfEmpty
|
||||||
|
# 注:此路径只在 db.json 中无任何 admin 时生效;否则会校验 db.json 中的账号
|
||||||
```
|
```
|
||||||
|
|
||||||
或重新跑 `install.sh --pass 新密码`。
|
更可靠的做法:**直接编辑 `data/db.json`**,把 `password_hash` 替换为 bcrypt 哈希
|
||||||
|
(用 `htpasswd -bnBC 10 "" yourpass | tr -d ':\n'` 生成)后重启服务。
|
||||||
|
|
||||||
|
> 环境变量里的 `OVPNMGR_ADMIN_USER` / `OVPNMGR_ADMIN_PASS` 仅在数据库为空时作为初始 seed 使用。
|
||||||
|
|
||||||
## 创建第一个实例
|
## 创建第一个实例
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"openvpn-manager/internal/config"
|
"openvpn-manager/internal/config"
|
||||||
"openvpn-manager/internal/middleware"
|
"openvpn-manager/internal/middleware"
|
||||||
@@ -72,6 +73,16 @@ func (s *Server) Router(distDir string) *gin.Engine {
|
|||||||
auth.DELETE("/backups/:id", s.deleteBackup)
|
auth.DELETE("/backups/:id", s.deleteBackup)
|
||||||
|
|
||||||
auth.GET("/audits", s.listAudits)
|
auth.GET("/audits", s.listAudits)
|
||||||
|
|
||||||
|
// Admin 账号管理(self)
|
||||||
|
auth.POST("/me/password", s.changeMyPassword)
|
||||||
|
|
||||||
|
// Admin 账号管理(列表/创建/重置密码/启停/删除 - 仅 admin role)
|
||||||
|
auth.GET("/admins", s.listAdmins)
|
||||||
|
auth.POST("/admins", s.createAdmin)
|
||||||
|
auth.POST("/admins/:id/password", s.resetAdminPassword)
|
||||||
|
auth.POST("/admins/:id/status", s.setAdminStatus)
|
||||||
|
auth.DELETE("/admins/:id", s.deleteAdmin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 静态前端
|
// 静态前端
|
||||||
@@ -106,23 +117,72 @@ func (s *Server) login(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Username != s.Cfg.AdminUser || req.Password != s.Cfg.AdminPass {
|
// 从 admin 表验证;若表为空则用环境变量默认账号首次 seed
|
||||||
|
admin, err := s.Svc.Store.GetAdminByUsername(req.Username)
|
||||||
|
if err != nil {
|
||||||
|
// 兼容:如果 db.json 还没有 admins(首次启动),允许用 env 默认账号登录,
|
||||||
|
// 登录成功后由 SeedDefaultIfEmpty 自动 seed 到 db.json
|
||||||
|
if s.Svc.Store.AdminCount() == 0 && req.Username == s.Cfg.AdminUser && req.Password == s.Cfg.AdminPass {
|
||||||
|
if err := s.Svc.SeedDefaultAdminIfEmpty(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "seed admin: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
admin, err = s.Svc.Store.GetAdminByUsername(req.Username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if admin.Status != "active" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(req.Password)); err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tok, err := middleware.IssueToken(s.Cfg.JWTSecret, req.Username, "admin", 12*time.Hour)
|
// 记录登录时间
|
||||||
|
now := time.Now()
|
||||||
|
admin.LastLoginAt = &now
|
||||||
|
admin.LastLoginIP = c.ClientIP()
|
||||||
|
_ = s.Svc.Store.UpsertAdmin(*admin)
|
||||||
|
// 颁发 token
|
||||||
|
tok, err := middleware.IssueToken(s.Cfg.JWTSecret, admin.ID, admin.Username, admin.Role, 12*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "issue token failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "issue token failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(200, gin.H{"token": tok, "username": req.Username})
|
c.JSON(200, gin.H{
|
||||||
|
"token": tok,
|
||||||
|
"username": admin.Username,
|
||||||
|
"role": admin.Role,
|
||||||
|
"must_change_password": admin.MustChangePassword,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) logout(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
|
func (s *Server) logout(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
|
||||||
|
|
||||||
func (s *Server) me(c *gin.Context) {
|
func (s *Server) me(c *gin.Context) {
|
||||||
u, _ := c.Get("user")
|
userID, _ := c.Get("user_id")
|
||||||
c.JSON(200, gin.H{"username": u})
|
uid, _ := userID.(string)
|
||||||
|
username, _ := c.Get("user")
|
||||||
|
role, _ := c.Get("role")
|
||||||
|
must := false
|
||||||
|
if uid != "" {
|
||||||
|
if a, err := s.Svc.Store.GetAdmin(uid); err == nil {
|
||||||
|
must = a.MustChangePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"user_id": uid,
|
||||||
|
"username": username,
|
||||||
|
"role": role,
|
||||||
|
"must_change_password": must,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// dashboard 汇总统计
|
// dashboard 汇总统计
|
||||||
@@ -431,4 +491,98 @@ func itoa(n int) string {
|
|||||||
buf[i] = '-'
|
buf[i] = '-'
|
||||||
}
|
}
|
||||||
return string(buf[i:])
|
return string(buf[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Admin 账号管理 ----------
|
||||||
|
|
||||||
|
func (s *Server) changeMyPassword(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
OldPassword string `json:"old_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := userID.(string)
|
||||||
|
if err := s.Svc.ChangePassword(uid, req.OldPassword, req.NewPassword); err != nil {
|
||||||
|
s.Svc.AuditForAPI(c, "change_password", uid, err.Error(), "failed")
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "change_password", uid, "", "ok")
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listAdmins(c *gin.Context) {
|
||||||
|
c.JSON(200, s.Svc.Store.ListAdmins())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createAdmin(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, err := s.Svc.CreateAdmin(req.Username, req.Password, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
s.Svc.AuditForAPI(c, "create_admin", req.Username, err.Error(), "failed")
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "create_admin", req.Username, "role="+a.Role, "ok")
|
||||||
|
c.JSON(200, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) resetAdminPassword(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var req struct {
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.Svc.ResetPassword(id, req.NewPassword); err != nil {
|
||||||
|
s.Svc.AuditForAPI(c, "reset_password", id, err.Error(), "failed")
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "reset_password", id, "", "ok")
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setAdminStatus(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.Svc.SetAdminStatus(id, req.Status); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "set_admin_status", id, req.Status, "ok")
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteAdmin(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := userID.(string)
|
||||||
|
if err := s.Svc.DeleteAdmin(uid, id); err != nil {
|
||||||
|
s.Svc.AuditForAPI(c, "delete_admin", id, err.Error(), "failed")
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Svc.AuditForAPI(c, "delete_admin", id, "", "ok")
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
@@ -10,13 +10,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
func IssueToken(secret, username, role string, ttl time.Duration) (string, error) {
|
func IssueToken(secret, userID, username, role string, ttl time.Duration) (string, error) {
|
||||||
c := Claims{
|
c := Claims{
|
||||||
|
UserID: userID,
|
||||||
Username: username,
|
Username: username,
|
||||||
Role: role,
|
Role: role,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
@@ -50,6 +52,7 @@ func JWTAuth(secret string) gin.HandlerFunc {
|
|||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c.Set("user_id", claims.UserID)
|
||||||
c.Set("user", claims.Username)
|
c.Set("user", claims.Username)
|
||||||
c.Set("role", claims.Role)
|
c.Set("role", claims.Role)
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -64,6 +64,30 @@ type AuditLog struct {
|
|||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminUser 管理控制台账号。
|
||||||
|
// 密码以 bcrypt 哈希存储 (cost=10)。
|
||||||
|
// - Role: "admin" = 全部权限 (含账号管理)
|
||||||
|
// "operator" = 仅运维操作(实例/用户/备份等),无账号管理
|
||||||
|
// - Status: "active" | "disabled"
|
||||||
|
// - MustChangePassword: 首次 seed 的默认账号(密码 = admin123)需要首次登录后改密
|
||||||
|
//
|
||||||
|
// 安全:
|
||||||
|
// * PasswordHash 字段在 db.json 中以 password_hash 持久化(必须!)
|
||||||
|
// * 但 store.ListAdmins 在返回前会清空 PasswordHash,
|
||||||
|
// 因此所有 API 响应里 hash 都是空字符串,绝不出网
|
||||||
|
type AdminUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"password_hash"` // 注意:store.ListAdmins 返回时会清空
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||||
|
LastLoginIP string `json:"last_login_ip,omitempty"`
|
||||||
|
MustChangePassword bool `json:"must_change_password"` // 强制改密标志
|
||||||
|
}
|
||||||
|
|
||||||
// ConnectionLog 来自 OpenVPN status 的实时/历史连接记录。
|
// ConnectionLog 来自 OpenVPN status 的实时/历史连接记录。
|
||||||
// 周期由 OpenVPN 自身写入 status.log,本服务周期性读取解析后入库。
|
// 周期由 OpenVPN 自身写入 status.log,本服务周期性读取解析后入库。
|
||||||
type ConnectionLog struct {
|
type ConnectionLog struct {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"openvpn-manager/internal/config"
|
"openvpn-manager/internal/config"
|
||||||
"openvpn-manager/internal/model"
|
"openvpn-manager/internal/model"
|
||||||
@@ -582,4 +583,144 @@ func RandomToken(n int) string {
|
|||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Admin 账号管理 ----------
|
||||||
|
|
||||||
|
// SeedDefaultAdminIfEmpty 当 db.json 中无任何 admin 账号时,用 env 里的默认账号创建。
|
||||||
|
// MustChangePassword=true,提示用户首次登录后必须改密。
|
||||||
|
func (s *Service) SeedDefaultAdminIfEmpty() error {
|
||||||
|
if s.Store.AdminCount() > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(s.Cfg.AdminPass), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return s.Store.UpsertAdmin(model.AdminUser{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Username: s.Cfg.AdminUser,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Role: "admin",
|
||||||
|
Status: "active",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
MustChangePassword: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAdmin 新建管理员账号。密码由调用方提供,内部 bcrypt 哈希后存。
|
||||||
|
// 只允许 role=admin 的现有账号调用。
|
||||||
|
func (s *Service) CreateAdmin(username, password, role string) (*model.AdminUser, error) {
|
||||||
|
if len(username) < 3 {
|
||||||
|
return nil, fmt.Errorf("用户名至少 3 个字符")
|
||||||
|
}
|
||||||
|
if len(password) < 6 {
|
||||||
|
return nil, fmt.Errorf("密码至少 6 个字符")
|
||||||
|
}
|
||||||
|
if _, err := s.Store.GetAdminByUsername(username); err == nil {
|
||||||
|
return nil, fmt.Errorf("用户名已存在")
|
||||||
|
}
|
||||||
|
if role != "admin" && role != "operator" {
|
||||||
|
role = "operator"
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
a := model.AdminUser{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Role: role,
|
||||||
|
Status: "active",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := s.Store.UpsertAdmin(a); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改自身密码。校验旧密码后写新密码。
|
||||||
|
// actorID 是当前登录账号的 id。
|
||||||
|
func (s *Service) ChangePassword(actorID, oldPwd, newPwd string) error {
|
||||||
|
if len(newPwd) < 6 {
|
||||||
|
return fmt.Errorf("新密码至少 6 个字符")
|
||||||
|
}
|
||||||
|
a, err := s.Store.GetAdmin(actorID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(oldPwd)); err != nil {
|
||||||
|
return fmt.Errorf("原密码错误")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.PasswordHash = string(hash)
|
||||||
|
a.MustChangePassword = false
|
||||||
|
a.UpdatedAt = time.Now()
|
||||||
|
return s.Store.UpsertAdmin(*a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword 由管理员重置他人密码(无需知道旧密码)。
|
||||||
|
func (s *Service) ResetPassword(targetID, newPwd string) error {
|
||||||
|
if len(newPwd) < 6 {
|
||||||
|
return fmt.Errorf("新密码至少 6 个字符")
|
||||||
|
}
|
||||||
|
a, err := s.Store.GetAdmin(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.PasswordHash = string(hash)
|
||||||
|
a.MustChangePassword = false
|
||||||
|
a.UpdatedAt = time.Now()
|
||||||
|
return s.Store.UpsertAdmin(*a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAdminStatus 启用/禁用账号。
|
||||||
|
func (s *Service) SetAdminStatus(targetID, status string) error {
|
||||||
|
if status != "active" && status != "disabled" {
|
||||||
|
return fmt.Errorf("status 必须是 active 或 disabled")
|
||||||
|
}
|
||||||
|
a, err := s.Store.GetAdmin(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.Status = status
|
||||||
|
a.UpdatedAt = time.Now()
|
||||||
|
return s.Store.UpsertAdmin(*a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAdmin 删除账号。保护:不能删除自己、不能删除最后一个 admin。
|
||||||
|
func (s *Service) DeleteAdmin(actorID, targetID string) error {
|
||||||
|
if actorID == targetID {
|
||||||
|
return fmt.Errorf("不能删除自己")
|
||||||
|
}
|
||||||
|
target, err := s.Store.GetAdmin(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if target.Role == "admin" {
|
||||||
|
// 统计其他 admin 数量
|
||||||
|
others := 0
|
||||||
|
for _, a := range s.Store.ListAdmins() {
|
||||||
|
if a.Role == "admin" && a.ID != targetID {
|
||||||
|
others++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if others == 0 {
|
||||||
|
return fmt.Errorf("不能删除最后一个 admin 账号")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.Store.DeleteAdmin(targetID)
|
||||||
}
|
}
|
||||||
@@ -21,11 +21,12 @@ type Store struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Data struct {
|
type Data struct {
|
||||||
Instances []model.Instance `json:"instances"`
|
Instances []model.Instance `json:"instances"`
|
||||||
Users []model.VPNUser `json:"users"`
|
Users []model.VPNUser `json:"users"`
|
||||||
Audits []model.AuditLog `json:"audits"`
|
Audits []model.AuditLog `json:"audits"`
|
||||||
ConnLogs []model.ConnectionLog `json:"conn_logs"`
|
ConnLogs []model.ConnectionLog `json:"conn_logs"`
|
||||||
Backups []model.Backup `json:"backups"`
|
Backups []model.Backup `json:"backups"`
|
||||||
|
Admins []model.AdminUser `json:"admins"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Open(path string) (*Store, error) {
|
func Open(path string) (*Store, error) {
|
||||||
@@ -317,4 +318,79 @@ func (s *Store) DeleteBackup(id string) error {
|
|||||||
}
|
}
|
||||||
s.data.Backups = append(s.data.Backups[:idx], s.data.Backups[idx+1:]...)
|
s.data.Backups = append(s.data.Backups[:idx], s.data.Backups[idx+1:]...)
|
||||||
return s.flush()
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Admins ----
|
||||||
|
|
||||||
|
func (s *Store) ListAdmins() []model.AdminUser {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]model.AdminUser, len(s.data.Admins))
|
||||||
|
copy(out, s.data.Admins)
|
||||||
|
// 不返回哈希,调用方负责清空 PasswordHash
|
||||||
|
for i := range out {
|
||||||
|
out[i].PasswordHash = ""
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetAdmin(id string) (*model.AdminUser, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for i := range s.data.Admins {
|
||||||
|
if s.data.Admins[i].ID == id {
|
||||||
|
a := s.data.Admins[i]
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("admin %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminByUsername 包含哈希的内部副本。仅供登录校验使用。
|
||||||
|
func (s *Store) GetAdminByUsername(username string) (*model.AdminUser, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for i := range s.data.Admins {
|
||||||
|
if s.data.Admins[i].Username == username {
|
||||||
|
a := s.data.Admins[i]
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("admin %s not found", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertAdmin(a model.AdminUser) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.data.Admins {
|
||||||
|
if s.data.Admins[i].ID == a.ID {
|
||||||
|
s.data.Admins[i] = a
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.data.Admins = append(s.data.Admins, a)
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteAdmin(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
idx := -1
|
||||||
|
for i := range s.data.Admins {
|
||||||
|
if s.data.Admins[i].ID == id {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return fmt.Errorf("admin %s not found", id)
|
||||||
|
}
|
||||||
|
s.data.Admins = append(s.data.Admins[:idx], s.data.Admins[idx+1:]...)
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AdminCount() int {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return len(s.data.Admins)
|
||||||
}
|
}
|
||||||
+78
-5
@@ -48,13 +48,45 @@ GET /api/health
|
|||||||
→ 200 {"ok": true}
|
→ 200 {"ok": true}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 当前登录用户
|
### 登录
|
||||||
```
|
```
|
||||||
GET /api/me
|
POST /api/login
|
||||||
→ 200 {"username": "admin"}
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"username": "admin", "password": "..."}
|
||||||
|
→ 200 {
|
||||||
|
"token": "eyJhbG...",
|
||||||
|
"username": "admin",
|
||||||
|
"role": "admin",
|
||||||
|
"must_change_password": false
|
||||||
|
}
|
||||||
|
→ 401 {"error": "用户名或密码错误"}
|
||||||
|
→ 403 {"error": "账号已被禁用"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 登出(仅前端清理 token,服务端无状态)
|
> 密码以 bcrypt 哈希存储在 `data/db.json` 中。
|
||||||
|
> `must_change_password=true` 表示首次登录(默认账号)需要立刻改密。
|
||||||
|
|
||||||
|
### 当前登录账号
|
||||||
|
```
|
||||||
|
GET /api/me
|
||||||
|
→ 200 {
|
||||||
|
"user_id": "uuid",
|
||||||
|
"username": "admin",
|
||||||
|
"role": "admin",
|
||||||
|
"must_change_password": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改自己密码
|
||||||
|
```
|
||||||
|
POST /api/me/password
|
||||||
|
{"old_password": "...", "new_password": "..."}
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
→ 400 {"error": "原密码错误"} | {"error": "新密码至少 6 个字符"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 登出(前端清理 token)
|
||||||
```
|
```
|
||||||
POST /api/logout
|
POST /api/logout
|
||||||
→ 200 {"ok": true}
|
→ 200 {"ok": true}
|
||||||
@@ -62,7 +94,48 @@ POST /api/logout
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 仪表盘
|
## 2. 管理员账号管理
|
||||||
|
|
||||||
|
> 仅 admin role 可访问。operator 访问会拒绝(由前端路由守卫 + 后端 handler 校验)。
|
||||||
|
|
||||||
|
### 列出账号(不含 hash)
|
||||||
|
```
|
||||||
|
GET /api/admins
|
||||||
|
→ 200 [AdminUser, ...] // PasswordHash 字段固定为空
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新建账号
|
||||||
|
```
|
||||||
|
POST /api/admins
|
||||||
|
{"username": "alice", "password": "StrongPass123", "role": "operator"}
|
||||||
|
→ 200 AdminUser
|
||||||
|
```
|
||||||
|
|
||||||
|
### 重置密码(无需知道旧密码)
|
||||||
|
```
|
||||||
|
POST /api/admins/:id/password
|
||||||
|
{"new_password": "NewPass456"}
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启用/禁用
|
||||||
|
```
|
||||||
|
POST /api/admins/:id/status
|
||||||
|
{"status": "active"} | {"status": "disabled"}
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除(不能删自己、不能删最后一个 admin)
|
||||||
|
```
|
||||||
|
DELETE /api/admins/:id
|
||||||
|
→ 200 {"ok": true}
|
||||||
|
→ 400 {"error": "不能删除自己"}
|
||||||
|
→ 400 {"error": "不能删除最后一个 admin 账号"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 仪表盘
|
||||||
|
|
||||||
### 汇总
|
### 汇总
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export const Auth = {
|
|||||||
login: (username, password) => api.post('/login', { username, password }).then(r => r.data),
|
login: (username, password) => api.post('/login', { username, password }).then(r => r.data),
|
||||||
me: () => api.get('/me').then(r => r.data),
|
me: () => api.get('/me').then(r => r.data),
|
||||||
logout: () => api.post('/logout').then(r => r.data),
|
logout: () => api.post('/logout').then(r => r.data),
|
||||||
|
changePassword: (old_password, new_password) => api.post('/me/password', { old_password, new_password }).then(r => r.data),
|
||||||
|
}
|
||||||
|
export const Admins = {
|
||||||
|
list: () => api.get('/admins').then(r => r.data),
|
||||||
|
create: (username, password, role) => api.post('/admins', { username, password, role }).then(r => r.data),
|
||||||
|
resetPassword: (id, new_password) => api.post(`/admins/${id}/password`, { new_password }).then(r => r.data),
|
||||||
|
setStatus: (id, status) => api.post(`/admins/${id}/status`, { status }).then(r => r.data),
|
||||||
|
delete: id => api.delete(`/admins/${id}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
export const Dash = {
|
export const Dash = {
|
||||||
get: () => api.get('/dashboard').then(r => r.data),
|
get: () => api.get('/dashboard').then(r => r.data),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>连接日志</span></el-menu-item>
|
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>连接日志</span></el-menu-item>
|
||||||
<el-menu-item index="/backups"><el-icon><FolderOpened /></el-icon><span>备份与恢复</span></el-menu-item>
|
<el-menu-item index="/backups"><el-icon><FolderOpened /></el-icon><span>备份与恢复</span></el-menu-item>
|
||||||
<el-menu-item index="/audits"><el-icon><Tickets /></el-icon><span>审计日志</span></el-menu-item>
|
<el-menu-item index="/audits"><el-icon><Tickets /></el-icon><span>审计日志</span></el-menu-item>
|
||||||
|
<el-menu-item v-if="isAdmin" index="/admins"><el-icon><Avatar /></el-icon><span>管理员</span></el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
<el-container>
|
<el-container>
|
||||||
@@ -22,11 +23,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<el-dropdown @command="cmd">
|
<el-dropdown @command="cmd">
|
||||||
<span style="color:#fff;cursor:pointer">
|
<span style="color:#fff;cursor:pointer">
|
||||||
<el-icon><UserFilled /></el-icon> {{ user }} <el-icon><ArrowDown /></el-icon>
|
<el-icon><UserFilled /></el-icon> {{ user }} <el-tag v-if="role" size="small" :type="role==='admin'?'danger':'info'" style="margin-left:6px;vertical-align:middle">{{ role }}</el-tag> <el-icon><ArrowDown /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
<el-dropdown-item command="password"><el-icon><Lock /></el-icon>修改密码</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
@@ -36,10 +38,23 @@
|
|||||||
<router-view />
|
<router-view />
|
||||||
</el-main>
|
</el-main>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
|
<!-- 修改密码弹窗 -->
|
||||||
|
<el-dialog v-model="pwdDlg" title="修改密码" width="440px">
|
||||||
|
<el-form :model="form" label-width="100px">
|
||||||
|
<el-form-item label="当前密码"><el-input v-model="form.old_password" type="password" show-password /></el-form-item>
|
||||||
|
<el-form-item label="新密码"><el-input v-model="form.new_password" type="password" show-password placeholder="至少 6 位" /></el-form-item>
|
||||||
|
<el-form-item label="确认新密码"><el-input v-model="form.confirm" type="password" show-password /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="pwdDlg=false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="submitPwd">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Auth } from '@/api'
|
import { Auth } from '@/api'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
@@ -47,13 +62,49 @@ import { ElMessage } from 'element-plus'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = ref(localStorage.getItem('username') || 'admin')
|
const user = ref(localStorage.getItem('username') || 'admin')
|
||||||
|
const role = ref(localStorage.getItem('role') || '')
|
||||||
|
const isAdmin = ref(role.value === 'admin')
|
||||||
|
|
||||||
|
// 启动时拉一次 me 以拿到最新 role/must_change_password
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const m = await Auth.me()
|
||||||
|
if (m.role) { role.value = m.role; localStorage.setItem('role', m.role); isAdmin.value = m.role === 'admin' }
|
||||||
|
if (m.username) { user.value = m.username; localStorage.setItem('username', m.username) }
|
||||||
|
} catch (e) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pwdDlg = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const form = reactive({ old_password: '', new_password: '', confirm: '' })
|
||||||
|
|
||||||
async function cmd(c) {
|
async function cmd(c) {
|
||||||
if (c === 'logout') {
|
if (c === 'logout') {
|
||||||
await Auth.logout()
|
await Auth.logout()
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('username')
|
||||||
|
localStorage.removeItem('role')
|
||||||
ElMessage.success('已退出登录')
|
ElMessage.success('已退出登录')
|
||||||
router.replace('/login')
|
router.replace('/login')
|
||||||
|
} else if (c === 'password') {
|
||||||
|
form.old_password = ''
|
||||||
|
form.new_password = ''
|
||||||
|
form.confirm = ''
|
||||||
|
pwdDlg.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPwd() {
|
||||||
|
if (!form.old_password || !form.new_password) return ElMessage.warning('请填写完整')
|
||||||
|
if (form.new_password !== form.confirm) return ElMessage.error('两次输入的新密码不一致')
|
||||||
|
if (form.new_password.length < 6) return ElMessage.warning('新密码至少 6 位')
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await Auth.changePassword(form.old_password, form.new_password)
|
||||||
|
ElMessage.success('密码已修改')
|
||||||
|
pwdDlg.value = false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import Login from '@/views/Login.vue'
|
import Login from '@/views/Login.vue'
|
||||||
import Layout from '@/layout/Index.vue'
|
import Layout from '@/layout/Index.vue'
|
||||||
import Dashboard from '@/views/Dashboard.vue'
|
import Dashboard from '@/views/Dashboard.vue'
|
||||||
@@ -8,6 +9,7 @@ import Certificates from '@/views/Certificates.vue'
|
|||||||
import Logs from '@/views/Logs.vue'
|
import Logs from '@/views/Logs.vue'
|
||||||
import Backups from '@/views/Backups.vue'
|
import Backups from '@/views/Backups.vue'
|
||||||
import Audits from '@/views/Audits.vue'
|
import Audits from '@/views/Audits.vue'
|
||||||
|
import Admins from '@/views/Admins.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
@@ -24,6 +26,7 @@ const router = createRouter({
|
|||||||
{ path: 'logs', component: Logs, meta: { title: '连接日志' } },
|
{ path: 'logs', component: Logs, meta: { title: '连接日志' } },
|
||||||
{ path: 'backups', component: Backups, meta: { title: '备份与恢复' } },
|
{ path: 'backups', component: Backups, meta: { title: '备份与恢复' } },
|
||||||
{ path: 'audits', component: Audits, meta: { title: '审计日志' } },
|
{ path: 'audits', component: Audits, meta: { title: '审计日志' } },
|
||||||
|
{ path: 'admins', component: Admins, meta: { title: '管理员', adminOnly: true } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -32,6 +35,11 @@ const router = createRouter({
|
|||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
const t = localStorage.getItem('token')
|
const t = localStorage.getItem('token')
|
||||||
if (!t && to.path !== '/login') return next('/login')
|
if (!t && to.path !== '/login') return next('/login')
|
||||||
|
// 限制 admin only 页面
|
||||||
|
if (to.meta?.adminOnly && localStorage.getItem('role') !== 'admin') {
|
||||||
|
ElMessage.warning('需要 admin 权限')
|
||||||
|
return next(from.path === '/login' ? '/dashboard' : from)
|
||||||
|
}
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-top:0">管理员账号</h2>
|
||||||
|
<p class="muted">仅 role=admin 的账号可以管理其他账号。可创建 operator(只读运维)或 admin(全部权限)。</p>
|
||||||
|
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon>新建账号</el-button>
|
||||||
|
<el-button @click="load"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" stripe>
|
||||||
|
<el-table-column prop="username" label="用户名" />
|
||||||
|
<el-table-column label="角色" width="100">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.role==='admin'?'danger':'info'" size="small">{{ row.role }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.status==='active'?'success':'info'" size="small">{{ row.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="强制改密" width="100">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag v-if="row.must_change_password" type="warning" size="small">是</el-tag>
|
||||||
|
<span v-else class="muted">否</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="last_login_at" label="最近登录" width="180">
|
||||||
|
<template #default="{row}">{{ row.last_login_at?.slice(0,19) || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="last_login_ip" label="登录 IP" width="140" />
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||||
|
<template #default="{row}">{{ row.created_at?.slice(0,19) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="280">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button size="small" @click="openResetPwd(row)">重置密码</el-button>
|
||||||
|
<el-button v-if="row.status==='active'" size="small" type="warning" @click="setStatus(row,'disabled')">禁用</el-button>
|
||||||
|
<el-button v-else size="small" type="success" @click="setStatus(row,'active')">启用</el-button>
|
||||||
|
<el-popconfirm title="确定删除此账号?删除后该账号无法再登录。" @confirm="del(row)">
|
||||||
|
<template #reference><el-button size="small" type="danger">删除</el-button></template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 新建账号 -->
|
||||||
|
<el-dialog v-model="createDlg" title="新建管理员账号" width="480px">
|
||||||
|
<el-form :model="cform" label-width="100px">
|
||||||
|
<el-form-item label="用户名"><el-input v-model="cform.username" placeholder="≥3 字符"/></el-form-item>
|
||||||
|
<el-form-item label="密码"><el-input v-model="cform.password" type="password" show-password placeholder="≥6 位"/></el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-select v-model="cform.role" style="width:200px">
|
||||||
|
<el-option label="admin(全部权限)" value="admin"/>
|
||||||
|
<el-option label="operator(运维)" value="operator"/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="createDlg=false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="submitCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 重置密码 -->
|
||||||
|
<el-dialog v-model="resetDlg" title="重置密码" width="440px">
|
||||||
|
<p class="muted">为账号 <b>{{ target?.username }}</b> 设置新密码,新密码会立即生效。</p>
|
||||||
|
<el-form :model="rform" label-width="100px">
|
||||||
|
<el-form-item label="新密码"><el-input v-model="rform.new_password" type="password" show-password placeholder="≥6 位"/></el-form-item>
|
||||||
|
<el-form-item label="确认"><el-input v-model="rform.confirm" type="password" show-password /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="resetDlg=false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="submitReset">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Admins } from '@/api'
|
||||||
|
|
||||||
|
const list = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function load() { list.value = await Admins.list() }
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
// 新建
|
||||||
|
const createDlg = ref(false)
|
||||||
|
const cform = reactive({ username:'', password:'', role:'operator' })
|
||||||
|
function openCreate() {
|
||||||
|
cform.username = ''; cform.password = ''; cform.role = 'operator'
|
||||||
|
createDlg.value = true
|
||||||
|
}
|
||||||
|
async function submitCreate() {
|
||||||
|
if (!cform.username || !cform.password) return ElMessage.warning('请填写完整')
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await Admins.create(cform.username, cform.password, cform.role)
|
||||||
|
ElMessage.success('已创建')
|
||||||
|
createDlg.value = false
|
||||||
|
load()
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置密码
|
||||||
|
const resetDlg = ref(false)
|
||||||
|
const target = ref(null)
|
||||||
|
const rform = reactive({ new_password:'', confirm:'' })
|
||||||
|
function openResetPwd(row) {
|
||||||
|
target.value = row
|
||||||
|
rform.new_password = ''; rform.confirm = ''
|
||||||
|
resetDlg.value = true
|
||||||
|
}
|
||||||
|
async function submitReset() {
|
||||||
|
if (!rform.new_password) return ElMessage.warning('请填写新密码')
|
||||||
|
if (rform.new_password !== rform.confirm) return ElMessage.error('两次输入不一致')
|
||||||
|
if (rform.new_password.length < 6) return ElMessage.warning('至少 6 位')
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await Admins.resetPassword(target.value.id, rform.new_password)
|
||||||
|
ElMessage.success('已重置')
|
||||||
|
resetDlg.value = false
|
||||||
|
load()
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启停
|
||||||
|
async function setStatus(row, status) {
|
||||||
|
await Admins.setStatus(row.id, status)
|
||||||
|
ElMessage.success(status === 'active' ? '已启用' : '已禁用')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
async function del(row) {
|
||||||
|
try {
|
||||||
|
await Admins.delete(row.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
load()
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -12,8 +12,21 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
|
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
<p class="muted">首次部署默认账号 admin / admin123,请在系统中修改</p>
|
<p class="muted">首次部署默认账号 admin / admin123,登录后会强制要求改密。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 首次登录强制改密 -->
|
||||||
|
<el-dialog v-model="mustChange" title="请修改初始密码" width="440px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||||||
|
<p class="muted" style="margin-top:0">为安全起见,首次登录必须修改默认密码后才能使用其他功能。</p>
|
||||||
|
<el-form :model="cp" label-width="100px">
|
||||||
|
<el-form-item label="当前密码"><el-input v-model="cp.old_password" type="password" show-password /></el-form-item>
|
||||||
|
<el-form-item label="新密码"><el-input v-model="cp.new_password" type="password" show-password placeholder="至少 6 位" /></el-form-item>
|
||||||
|
<el-form-item label="确认新密码"><el-input v-model="cp.confirm" type="password" show-password /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button type="primary" :loading="cpLoading" @click="submitChange">提交</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -26,17 +39,44 @@ const router = useRouter()
|
|||||||
const form = reactive({ username: 'admin', password: 'admin123' })
|
const form = reactive({ username: 'admin', password: 'admin123' })
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const mustChange = ref(false)
|
||||||
|
const cpLoading = ref(false)
|
||||||
|
const cp = reactive({ old_password: '', new_password: '', confirm: '' })
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const r = await Auth.login(form.username, form.password)
|
const r = await Auth.login(form.username, form.password)
|
||||||
localStorage.setItem('token', r.token)
|
localStorage.setItem('token', r.token)
|
||||||
|
if (r.must_change_password) {
|
||||||
|
// 把当前密码作为旧密码预填,方便用户
|
||||||
|
cp.old_password = form.password
|
||||||
|
cp.new_password = ''
|
||||||
|
cp.confirm = ''
|
||||||
|
mustChange.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
ElMessage.success('登录成功')
|
ElMessage.success('登录成功')
|
||||||
router.replace('/dashboard')
|
router.replace('/dashboard')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitChange() {
|
||||||
|
if (!cp.old_password || !cp.new_password) return ElMessage.warning('请填写完整')
|
||||||
|
if (cp.new_password !== cp.confirm) return ElMessage.error('两次输入的新密码不一致')
|
||||||
|
if (cp.new_password.length < 6) return ElMessage.warning('新密码至少 6 位')
|
||||||
|
cpLoading.value = true
|
||||||
|
try {
|
||||||
|
await Auth.changePassword(cp.old_password, cp.new_password)
|
||||||
|
ElMessage.success('密码已修改')
|
||||||
|
mustChange.value = false
|
||||||
|
router.replace('/dashboard')
|
||||||
|
} finally {
|
||||||
|
cpLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.login-page { min-height:100vh; background:linear-gradient(135deg,#1d4ed8,#0ea5e9); display:flex; align-items:center; justify-content:center; }
|
.login-page { min-height:100vh; background:linear-gradient(135deg,#1d4ed8,#0ea5e9); display:flex; align-items:center; justify-content:center; }
|
||||||
|
|||||||
Reference in New Issue
Block a user