feat: add user management APIs for management page

- Add ListUsers to Store interface and implementations
- Add user listing API endpoint (GET /api/users)
- Add role management endpoints (POST/DELETE /api/auth/admin/users/:userId/role)
- Add GeneratePublicToken to TokenService for OAuth callback
- Add CancelSubscription to Store interface
- Update go.mod with oauth2 dependencies
This commit is contained in:
Haitao Pan 2026-01-30 08:59:55 +08:00
parent 2a530e7b9d
commit ead440cb8d
6 changed files with 163 additions and 0 deletions

View File

@ -264,6 +264,10 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
authProtected.GET("/admin/settings", h.getAdminSettings)
authProtected.POST("/admin/settings", h.updateAdminSettings)
authProtected.GET("/users", h.listUsers)
authProtected.POST("/admin/users/:userId/role", h.updateUserRole)
authProtected.DELETE("/admin/users/:userId/role", h.resetUserRole)
registerAdminRoutes(authProtected, h)
}
@ -2367,6 +2371,101 @@ func (h *handler) oauthCallback(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, targetURL)
}
func (h *handler) listUsers(c *gin.Context) {
if _, ok := h.requireAdminOrOperator(c); !ok {
return
}
users, err := h.store.ListUsers(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "list_users_failed", "failed to fetch users")
return
}
sanitized := make([]gin.H, 0, len(users))
for _, u := range users {
sanitized = append(sanitized, sanitizeUser(&u, nil))
}
c.JSON(http.StatusOK, sanitized)
}
func (h *handler) updateUserRole(c *gin.Context) {
if _, ok := h.requireAdminOrOperator(c); !ok {
return
}
userId := c.Param("userId")
if userId == "" {
respondError(c, http.StatusBadRequest, "userId_required", "userId is required")
return
}
var req struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload")
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if _, ok := allowedAdminRoles[role]; !ok {
respondError(c, http.StatusBadRequest, "invalid_role", "specified role is not allowed")
return
}
user, err := h.store.GetUserByID(c.Request.Context(), userId)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
respondError(c, http.StatusNotFound, "user_not_found", "user not found")
return
}
respondError(c, http.StatusInternalServerError, "update_failed", "failed to fetch user")
return
}
user.Role = role
// Role field update will trigger Level update in store if implemented according to plan
// In store.go, normalizeUserRoleFields handles it.
if err := h.store.UpdateUser(c.Request.Context(), user); err != nil {
respondError(c, http.StatusInternalServerError, "update_failed", "failed to update user")
return
}
c.JSON(http.StatusOK, gin.H{"message": "role updated", "user": sanitizeUser(user, nil)})
}
func (h *handler) resetUserRole(c *gin.Context) {
if _, ok := h.requireAdminOrOperator(c); !ok {
return
}
userId := c.Param("userId")
if userId == "" {
respondError(c, http.StatusBadRequest, "userId_required", "userId is required")
return
}
user, err := h.store.GetUserByID(c.Request.Context(), userId)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
respondError(c, http.StatusNotFound, "user_not_found", "user not found")
return
}
respondError(c, http.StatusInternalServerError, "update_failed", "failed to fetch user")
return
}
user.Role = store.RoleUser
if err := h.store.UpdateUser(c.Request.Context(), user); err != nil {
respondError(c, http.StatusInternalServerError, "update_failed", "failed to update user")
return
}
c.JSON(http.StatusOK, gin.H{"message": "role reset", "user": sanitizeUser(user, nil)})
}
func (h *handler) generateState() string {
b := make([]byte, 16)
rand.Read(b)

2
go.mod
View File

@ -13,6 +13,7 @@ require (
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.45.0
golang.org/x/oauth2 v0.34.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
@ -20,6 +21,7 @@ require (
)
require (
cloud.google.com/go/compute/metadata v0.8.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect

6
go.sum
View File

@ -1,3 +1,5 @@
cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
@ -119,6 +121,10 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@ -59,6 +59,11 @@ func (s *TokenService) ValidatePublicToken(publicToken string) bool {
return publicToken == s.publicToken
}
// GeneratePublicToken returns the configured public token.
func (s *TokenService) GeneratePublicToken(userID, email string, roles []string) string {
return s.publicToken
}
// GenerateTokenPair generates a new token pair
func (s *TokenService) GenerateTokenPair(userID, email string, roles []string) (*TokenPair, error) {
// Generate refresh token (JWT)

View File

@ -1077,3 +1077,34 @@ RETURNING uuid, created_at, updated_at`
return nil
}
// ListUsers returns all users from the postgres store.
func (s *postgresStore) ListUsers(ctx context.Context) ([]User, error) {
caps, err := s.capabilities(ctx)
if err != nil {
return nil, err
}
query := s.selectUserQuery(caps, "ORDER BY created_at ASC")
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, *user)
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}

View File

@ -67,7 +67,9 @@ type Store interface {
UpsertSubscription(ctx context.Context, subscription *Subscription) error
ListSubscriptionsByUser(ctx context.Context, userID string) ([]Subscription, error)
CancelSubscription(ctx context.Context, userID, externalID string, cancelledAt time.Time) (*Subscription, error)
CreateIdentity(ctx context.Context, identity *Identity) error
ListUsers(ctx context.Context) ([]User, error)
}
// Domain level errors returned by the store implementation.
@ -612,3 +614,21 @@ func (s *memoryStore) CreateIdentity(ctx context.Context, identity *Identity) er
s.identities[key] = &stored
return nil
}
// ListUsers returns all users in the in-memory store.
func (s *memoryStore) ListUsers(ctx context.Context) ([]User, error) {
_ = ctx
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]User, 0, len(s.byID))
for _, user := range s.byID {
result = append(result, *cloneUser(user))
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt.Before(result[j].CreatedAt)
})
return result, nil
}