package httpx import ( "errors" "net/http" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo/options" "bountyboard/internal/domain" "bountyboard/internal/store" ) func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() filter := bson.M{} if search := q.Get("q"); search != "" { filter["$or"] = []bson.M{ {"email": bson.M{"$regex": regexEscape(search), "$options": "i"}}, {"name": bson.M{"$regex": regexEscape(search), "$options": "i"}}, } } if role := q.Get("role"); role != "" { filter["roles."+role] = true } if cursor := q.Get("cursor"); cursor != "" { filter["_id"] = bson.M{"$gt": cursor} } limit := atoiDefault(q.Get("limit"), 100) if limit > 200 { limit = 200 } cur, err := s.store.DB.Collection("users").Find(r.Context(), filter, options.Find().SetSort(bson.D{{Key: "_id", Value: 1}}).SetLimit(int64(limit))) if err != nil { s.internalError(w, r, "list users", err) return } users := []domain.User{} if err := cur.All(r.Context(), &users); err != nil { s.internalError(w, r, "decode users", err) return } next := "" if len(users) == limit { next = users[len(users)-1].ID } writeJSON(w, http.StatusOK, map[string]any{"users": users, "nextCursor": next}) } func (s *Server) handleAdminPatchUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req struct { Roles *domain.Roles `json:"roles"` Disabled *bool `json:"disabled"` ForceReset *bool `json:"forceReset"` Name *string `json:"name"` } if !decodeJSON(w, r, &req) { return } actor := CurrentUser(r.Context()) target, err := s.store.UserByID(r.Context(), id) if err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "user not found") return } s.internalError(w, r, "load user", err) return } set := bson.M{} diff := map[string]any{} if req.Roles != nil { // An admin cannot remove their own admin flag — lockout guard. if target.ID == actor.ID && !req.Roles.Admin { writeError(w, http.StatusBadRequest, "self_demotion", "you cannot remove your own admin role") return } set["roles"] = *req.Roles diff["roles"] = *req.Roles } if req.Disabled != nil { if target.ID == actor.ID && *req.Disabled { writeError(w, http.StatusBadRequest, "self_disable", "you cannot disable your own account") return } set["disabled"] = *req.Disabled diff["disabled"] = *req.Disabled } if req.Name != nil && *req.Name != "" { set["name"] = *req.Name diff["name"] = *req.Name } if req.ForceReset != nil && *req.ForceReset { if target.Auth.Local == nil { writeError(w, http.StatusBadRequest, "no_local_auth", "user has no local password to reset") return } set["auth.local.mustChange"] = true diff["forceReset"] = true } if len(set) == 0 { writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields") return } if err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("users"), id, target.Version, bson.M{"$set": set}); err != nil { if errors.Is(err, store.ErrVersionConflict) { writeError(w, http.StatusConflict, "conflict", "user was modified concurrently; retry") return } s.internalError(w, r, "patch user", err) return } // Disabling revokes all sessions immediately. if req.Disabled != nil && *req.Disabled { if _, err := s.store.DeleteUserSessions(r.Context(), id); err != nil { s.log.Warn("revoke sessions of disabled user", "err", err) } } s.audit(r, "user.update", "user", id, diff) fresh, err := s.store.UserByID(r.Context(), id) if err != nil { s.internalError(w, r, "reload user", err) return } writeJSON(w, http.StatusOK, map[string]any{"user": fresh}) } func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == CurrentUser(r.Context()).ID { writeError(w, http.StatusBadRequest, "self_delete", "you cannot delete your own account") return } res, err := s.store.DB.Collection("users").DeleteOne(r.Context(), bson.M{"_id": id}) if err != nil { s.internalError(w, r, "delete user", err) return } if res.DeletedCount == 0 { writeError(w, http.StatusNotFound, "not_found", "user not found") return } if _, err := s.store.DeleteUserSessions(r.Context(), id); err != nil { s.log.Warn("revoke sessions of deleted user", "err", err) } s.audit(r, "user.delete", "user", id, nil) w.WriteHeader(http.StatusNoContent) } // regexEscape neutralizes regex metacharacters in user-supplied search text. func regexEscape(s string) string { out := make([]rune, 0, len(s)) for _, r := range s { switch r { case '.', '+', '*', '?', '(', ')', '[', ']', '{', '}', '^', '$', '|', '\\': out = append(out, '\\') } out = append(out, r) } return string(out) }