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:
cnbugs
2026-08-09 21:45:41 +08:00
parent 4c8b7b5188
commit 09f6918aeb
12 changed files with 778 additions and 23 deletions
+80 -4
View File
@@ -21,11 +21,12 @@ type Store struct {
}
type Data struct {
Instances []model.Instance `json:"instances"`
Users []model.VPNUser `json:"users"`
Audits []model.AuditLog `json:"audits"`
Instances []model.Instance `json:"instances"`
Users []model.VPNUser `json:"users"`
Audits []model.AuditLog `json:"audits"`
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) {
@@ -317,4 +318,79 @@ func (s *Store) DeleteBackup(id string) error {
}
s.data.Backups = append(s.data.Backups[:idx], s.data.Backups[idx+1:]...)
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)
}