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:
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"openvpn-manager/internal/config"
|
||||
"openvpn-manager/internal/middleware"
|
||||
@@ -72,6 +73,16 @@ func (s *Server) Router(distDir string) *gin.Engine {
|
||||
auth.DELETE("/backups/:id", s.deleteBackup)
|
||||
|
||||
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"})
|
||||
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": "用户名或密码错误"})
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "issue token failed"})
|
||||
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) me(c *gin.Context) {
|
||||
u, _ := c.Get("user")
|
||||
c.JSON(200, gin.H{"username": u})
|
||||
userID, _ := c.Get("user_id")
|
||||
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 汇总统计
|
||||
@@ -431,4 +491,98 @@ func itoa(n int) 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 {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
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{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
@@ -50,6 +52,7 @@ func JWTAuth(secret string) gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("user", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
|
||||
@@ -64,6 +64,30 @@ type AuditLog struct {
|
||||
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 的实时/历史连接记录。
|
||||
// 周期由 OpenVPN 自身写入 status.log,本服务周期性读取解析后入库。
|
||||
type ConnectionLog struct {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"openvpn-manager/internal/config"
|
||||
"openvpn-manager/internal/model"
|
||||
@@ -582,4 +583,144 @@ func RandomToken(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(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 {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user