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:
parent
6ba56841b5
commit
5bf93d1d3f
99
api/api.go
99
api/api.go
@ -264,6 +264,10 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
|
|||||||
authProtected.GET("/admin/settings", h.getAdminSettings)
|
authProtected.GET("/admin/settings", h.getAdminSettings)
|
||||||
authProtected.POST("/admin/settings", h.updateAdminSettings)
|
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)
|
registerAdminRoutes(authProtected, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2367,6 +2371,101 @@ func (h *handler) oauthCallback(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusTemporaryRedirect, targetURL)
|
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 {
|
func (h *handler) generateState() string {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -13,6 +13,7 @@ require (
|
|||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.45.0
|
||||||
|
golang.org/x/oauth2 v0.34.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
@ -20,6 +21,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@ -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 h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
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=
|
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/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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
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 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@ -59,6 +59,11 @@ func (s *TokenService) ValidatePublicToken(publicToken string) bool {
|
|||||||
return publicToken == s.publicToken
|
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
|
// GenerateTokenPair generates a new token pair
|
||||||
func (s *TokenService) GenerateTokenPair(userID, email string, roles []string) (*TokenPair, error) {
|
func (s *TokenService) GenerateTokenPair(userID, email string, roles []string) (*TokenPair, error) {
|
||||||
// Generate refresh token (JWT)
|
// Generate refresh token (JWT)
|
||||||
|
|||||||
@ -1077,3 +1077,34 @@ RETURNING uuid, created_at, updated_at`
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -67,7 +67,9 @@ type Store interface {
|
|||||||
|
|
||||||
UpsertSubscription(ctx context.Context, subscription *Subscription) error
|
UpsertSubscription(ctx context.Context, subscription *Subscription) error
|
||||||
ListSubscriptionsByUser(ctx context.Context, userID string) ([]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
|
CreateIdentity(ctx context.Context, identity *Identity) error
|
||||||
|
ListUsers(ctx context.Context) ([]User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Domain level errors returned by the store implementation.
|
// 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
|
s.identities[key] = &stored
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user