feat: admin area with customers, users, settings, audit log, service status (phase 5)
- customer CRUD wizard backend: per-system credential shapes validated via
connector construction, AES-256-GCM at rest, write-only over the API
- ticketing connectors (jira/azure_devops/youtrack/demo) with test-connection
- user management: roles, disable (revokes sessions), force password reset,
delete; self-demotion/disable/delete guards
- admin-editable runtime settings ({_id:app}) incl. atomizer URL override
- audit log written on every privileged mutation + filterable list API
- service status panel: mongo/atomizer/work-performer health + latency with
late-bound breaker, sync and jobs status providers
- tabbed admin UI (customers wizard dialog, users table, settings, status, audit)
- compose: mongo nofile ulimit 64000 (1024 default crashed WiredTiger)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user