diff --git a/api/api.go b/api/api.go index a15a945..7663008 100644 --- a/api/api.go +++ b/api/api.go @@ -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) diff --git a/go.mod b/go.mod index 7846cd1..93912f0 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 716d944..49fed0a 100644 --- a/go.sum +++ b/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/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= diff --git a/internal/auth/token_service.go b/internal/auth/token_service.go index 144a1f2..1998fc2 100644 --- a/internal/auth/token_service.go +++ b/internal/auth/token_service.go @@ -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) diff --git a/internal/store/postgres.go b/internal/store/postgres.go index dba599e..2ba5c8d 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go index ad8d7da..225e944 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 +}