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,194 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"bountyboard/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) routesAdmin(mux *http.ServeMux) {
|
||||
adm := func(h http.HandlerFunc) http.Handler { return s.authedRole("admin", h) }
|
||||
|
||||
mux.Handle("GET /api/v1/admin/users", adm(s.handleAdminListUsers))
|
||||
mux.Handle("PATCH /api/v1/admin/users/{id}", adm(s.handleAdminPatchUser))
|
||||
mux.Handle("DELETE /api/v1/admin/users/{id}", adm(s.handleAdminDeleteUser))
|
||||
|
||||
mux.Handle("GET /api/v1/admin/customers", adm(s.handleAdminListCustomers))
|
||||
mux.Handle("POST /api/v1/admin/customers", adm(s.handleAdminCreateCustomer))
|
||||
mux.Handle("GET /api/v1/admin/customers/{id}", adm(s.handleAdminGetCustomer))
|
||||
mux.Handle("PATCH /api/v1/admin/customers/{id}", adm(s.handleAdminPatchCustomer))
|
||||
mux.Handle("DELETE /api/v1/admin/customers/{id}", adm(s.handleAdminDeleteCustomer))
|
||||
mux.Handle("POST /api/v1/admin/customers/test-connection", adm(s.handleAdminTestConnectionUnsaved))
|
||||
mux.Handle("POST /api/v1/admin/customers/{id}/test-connection", adm(s.handleAdminTestConnection))
|
||||
mux.Handle("POST /api/v1/admin/customers/{id}/sync-now", adm(s.handleAdminSyncNow))
|
||||
|
||||
mux.Handle("GET /api/v1/admin/settings", adm(s.handleAdminGetSettings))
|
||||
mux.Handle("PATCH /api/v1/admin/settings", adm(s.handleAdminPatchSettings))
|
||||
mux.Handle("GET /api/v1/admin/audit-log", adm(s.handleAdminAuditLog))
|
||||
mux.Handle("GET /api/v1/admin/service-status", adm(s.handleAdminServiceStatus))
|
||||
}
|
||||
|
||||
// audit records a privileged mutation; failure is logged, never fatal.
|
||||
func (s *Server) audit(r *http.Request, action, entity, entityID string, diff map[string]any) {
|
||||
err := s.store.Audit(r.Context(), store.AuditEntry{
|
||||
ActorID: CurrentUser(r.Context()).ID,
|
||||
ActorIP: ClientIP(r.Context()).String(),
|
||||
Action: action,
|
||||
Entity: entity,
|
||||
EntityID: entityID,
|
||||
Diff: diff,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Error("audit write failed", "action", action, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := s.store.GetSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internalError(w, r, "get settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"settings": st})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPatchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
BrandingName *string `json:"brandingName"`
|
||||
AtomizerBaseURLOverride *string `json:"atomizerBaseUrlOverride"`
|
||||
HideCompetingClaims *bool `json:"hideCompetingClaims"`
|
||||
DefaultPollIntervalSec *int `json:"defaultPollIntervalSec"`
|
||||
DefaultCustomerBudget *float64 `json:"defaultCustomerBudget"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
set := bson.M{}
|
||||
if req.BrandingName != nil {
|
||||
set["brandingName"] = *req.BrandingName
|
||||
}
|
||||
if req.AtomizerBaseURLOverride != nil {
|
||||
set["atomizerBaseUrlOverride"] = *req.AtomizerBaseURLOverride
|
||||
}
|
||||
if req.HideCompetingClaims != nil {
|
||||
set["hideCompetingClaims"] = *req.HideCompetingClaims
|
||||
}
|
||||
if req.DefaultPollIntervalSec != nil {
|
||||
if *req.DefaultPollIntervalSec < 10 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "poll interval must be >= 10s")
|
||||
return
|
||||
}
|
||||
set["defaultPollIntervalSec"] = *req.DefaultPollIntervalSec
|
||||
}
|
||||
if req.DefaultCustomerBudget != nil {
|
||||
set["defaultCustomerBudget"] = *req.DefaultCustomerBudget
|
||||
}
|
||||
if len(set) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields")
|
||||
return
|
||||
}
|
||||
st, err := s.store.PatchSettings(r.Context(), set)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "patch settings", err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "settings.update", "settings", "app", map[string]any{"set": set})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"settings": st})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"),
|
||||
q.Get("cursor"), atoiDefault(q.Get("limit"), 50))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list audit", err)
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(entries) > 0 {
|
||||
next = entries[len(entries)-1].ID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next})
|
||||
}
|
||||
|
||||
// serviceStatus is the §11.17 admin panel payload. Breaker and queue fields
|
||||
// are filled in by the phases that introduce them.
|
||||
type externalStatus struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Healthy bool `json:"healthy"`
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Breaker string `json:"breaker"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminServiceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
|
||||
atomizer := s.probeExternal(ctx, "atomizer", s.effectiveAtomizerBaseURL(ctx))
|
||||
performer := s.probeExternal(ctx, "work-performer", s.cfg.WorkPerformerBaseURL)
|
||||
if s.atomizer != nil {
|
||||
atomizer.Breaker = s.atomizer.BreakerState()
|
||||
}
|
||||
if s.performer != nil {
|
||||
performer.Breaker = s.performer.BreakerState()
|
||||
}
|
||||
|
||||
mongoOK := true
|
||||
mongoErr := ""
|
||||
if err := s.store.Ping(ctx); err != nil {
|
||||
mongoOK, mongoErr = false, err.Error()
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"mongo": map[string]any{"healthy": mongoOK, "error": mongoErr},
|
||||
"atomizer": atomizer,
|
||||
"workPerformer": performer,
|
||||
"syncWorkers": s.syncStatuses(),
|
||||
"jobs": s.jobStatuses(),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) probeExternal(ctx context.Context, name, baseURL string) externalStatus {
|
||||
st := externalStatus{Name: name, URL: baseURL, Breaker: "unknown"}
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/healthz", nil)
|
||||
if err != nil {
|
||||
st.Error = err.Error()
|
||||
return st
|
||||
}
|
||||
resp, err := healthProbeClient.Do(req)
|
||||
st.LatencyMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
st.Error = err.Error()
|
||||
return st
|
||||
}
|
||||
resp.Body.Close()
|
||||
st.Healthy = resp.StatusCode == http.StatusOK
|
||||
if !st.Healthy {
|
||||
st.Error = resp.Status
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
var healthProbeClient = &http.Client{Timeout: 3 * time.Second}
|
||||
|
||||
func atoiDefault(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
var n int
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return def
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"bountyboard/internal/crypto"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/store"
|
||||
syncpkg "bountyboard/internal/sync"
|
||||
)
|
||||
|
||||
// customerPayload is the create/update body. Credentials are write-only:
|
||||
// they are encrypted at rest and never returned.
|
||||
type customerPayload struct {
|
||||
Name *string `json:"name"`
|
||||
Type *domain.TicketingType `json:"type"`
|
||||
BaseURL *string `json:"baseUrl"`
|
||||
ProjectKey *string `json:"projectKey"`
|
||||
Credentials *syncpkg.Credentials `json:"credentials"`
|
||||
PollInterval *int `json:"pollIntervalSec"`
|
||||
ConsultantIDs *[]string `json:"consultantIds"`
|
||||
DefaultBudget *float64 `json:"defaultBudget"`
|
||||
Archived *bool `json:"archived"`
|
||||
Version *int64 `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Server) sealCredentials(creds syncpkg.Credentials) (string, error) {
|
||||
plain, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return crypto.Seal(s.cfg.CredentialsEncKey, plain)
|
||||
}
|
||||
|
||||
func (s *Server) openCredentials(c *domain.Customer) (syncpkg.Credentials, error) {
|
||||
if c.Ticketing.CredentialsEnc == "" {
|
||||
return syncpkg.Credentials{}, nil
|
||||
}
|
||||
plain, err := crypto.Open(s.cfg.CredentialsEncKey, c.Ticketing.CredentialsEnc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var creds syncpkg.Credentials
|
||||
if err := json.Unmarshal(plain, &creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// customerJSON augments the customer with a hasCredentials flag (the
|
||||
// ciphertext itself is never serialized).
|
||||
func customerJSON(c *domain.Customer) map[string]any {
|
||||
return map[string]any{
|
||||
"id": c.ID, "name": c.Name,
|
||||
"ticketing": map[string]any{
|
||||
"type": c.Ticketing.Type, "baseUrl": c.Ticketing.BaseURL,
|
||||
"projectKey": c.Ticketing.ProjectKey, "pollIntervalSec": c.Ticketing.PollIntervalSec,
|
||||
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
|
||||
"lastSyncError": c.Ticketing.LastSyncError,
|
||||
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
||||
},
|
||||
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
|
||||
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
|
||||
"version": c.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
include := r.URL.Query().Get("includeArchived") == "true"
|
||||
customers, err := s.store.ListCustomers(r.Context(), "", include)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list customers", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, len(customers))
|
||||
for i := range customers {
|
||||
out[i] = customerJSON(&customers[i])
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"customers": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := s.store.CustomerByID(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "customer not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "get customer", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)})
|
||||
}
|
||||
|
||||
func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
|
||||
for _, id := range ids {
|
||||
u, err := s.store.UserByID(ctx, id)
|
||||
if err != nil || !u.Roles.Consultant {
|
||||
return id, false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
var req customerPayload
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Name == nil || strings.TrimSpace(*req.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_name", "customer name is required")
|
||||
return
|
||||
}
|
||||
if req.Type == nil || !req.Type.Valid() {
|
||||
writeError(w, http.StatusBadRequest, "invalid_type", "ticketing type must be jira, azure_devops, youtrack or demo")
|
||||
return
|
||||
}
|
||||
settings, err := s.store.GetSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internalError(w, r, "get settings", err)
|
||||
return
|
||||
}
|
||||
|
||||
c := &domain.Customer{
|
||||
Name: strings.TrimSpace(*req.Name),
|
||||
Ticketing: domain.Ticketing{
|
||||
Type: *req.Type,
|
||||
PollIntervalSec: settings.DefaultPollIntervalSec,
|
||||
},
|
||||
DefaultBudget: settings.DefaultCustomerBudget,
|
||||
ConsultantIDs: []string{},
|
||||
}
|
||||
if req.BaseURL != nil {
|
||||
c.Ticketing.BaseURL = strings.TrimRight(strings.TrimSpace(*req.BaseURL), "/")
|
||||
}
|
||||
if req.ProjectKey != nil {
|
||||
c.Ticketing.ProjectKey = strings.TrimSpace(*req.ProjectKey)
|
||||
}
|
||||
if req.PollInterval != nil && *req.PollInterval >= 10 {
|
||||
c.Ticketing.PollIntervalSec = *req.PollInterval
|
||||
}
|
||||
if req.DefaultBudget != nil {
|
||||
c.DefaultBudget = *req.DefaultBudget
|
||||
}
|
||||
if req.ConsultantIDs != nil {
|
||||
if bad, ok := s.validateConsultants(r.Context(), *req.ConsultantIDs); !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid_consultant", "not a consultant: "+bad)
|
||||
return
|
||||
}
|
||||
c.ConsultantIDs = *req.ConsultantIDs
|
||||
}
|
||||
creds := syncpkg.Credentials{}
|
||||
if req.Credentials != nil {
|
||||
creds = *req.Credentials
|
||||
}
|
||||
// Validate credential shape by constructing the connector.
|
||||
if _, err := syncpkg.NewConnector(c.Ticketing.Type, c.Ticketing.BaseURL, c.Ticketing.ProjectKey, creds); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_credentials", err.Error())
|
||||
return
|
||||
}
|
||||
if c.Ticketing.CredentialsEnc, err = s.sealCredentials(creds); err != nil {
|
||||
s.internalError(w, r, "seal credentials", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.CreateCustomer(r.Context(), c); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
writeError(w, http.StatusConflict, "name_taken", "a customer with this name exists")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "create customer", err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "customer.create", "customer", c.ID, map[string]any{"name": c.Name, "type": c.Ticketing.Type})
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"customer": customerJSON(c)})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req customerPayload
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
c, err := s.store.CustomerByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "customer not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "get customer", err)
|
||||
return
|
||||
}
|
||||
|
||||
set := bson.M{}
|
||||
diff := map[string]any{}
|
||||
if req.Name != nil && strings.TrimSpace(*req.Name) != "" {
|
||||
set["name"] = strings.TrimSpace(*req.Name)
|
||||
diff["name"] = set["name"]
|
||||
}
|
||||
if req.Type != nil {
|
||||
if !req.Type.Valid() {
|
||||
writeError(w, http.StatusBadRequest, "invalid_type", "bad ticketing type")
|
||||
return
|
||||
}
|
||||
set["ticketing.type"] = *req.Type
|
||||
diff["type"] = *req.Type
|
||||
}
|
||||
if req.BaseURL != nil {
|
||||
set["ticketing.baseUrl"] = strings.TrimRight(strings.TrimSpace(*req.BaseURL), "/")
|
||||
diff["baseUrl"] = set["ticketing.baseUrl"]
|
||||
}
|
||||
if req.ProjectKey != nil {
|
||||
set["ticketing.projectKey"] = strings.TrimSpace(*req.ProjectKey)
|
||||
diff["projectKey"] = set["ticketing.projectKey"]
|
||||
}
|
||||
if req.PollInterval != nil {
|
||||
if *req.PollInterval < 10 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "poll interval must be >= 10s")
|
||||
return
|
||||
}
|
||||
set["ticketing.pollIntervalSec"] = *req.PollInterval
|
||||
diff["pollIntervalSec"] = *req.PollInterval
|
||||
}
|
||||
if req.ConsultantIDs != nil {
|
||||
if bad, ok := s.validateConsultants(r.Context(), *req.ConsultantIDs); !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid_consultant", "not a consultant: "+bad)
|
||||
return
|
||||
}
|
||||
set["consultantIds"] = *req.ConsultantIDs
|
||||
diff["consultantIds"] = *req.ConsultantIDs
|
||||
}
|
||||
if req.DefaultBudget != nil {
|
||||
set["defaultBudget"] = *req.DefaultBudget
|
||||
diff["defaultBudget"] = *req.DefaultBudget
|
||||
}
|
||||
if req.Archived != nil {
|
||||
set["archived"] = *req.Archived
|
||||
diff["archived"] = *req.Archived
|
||||
}
|
||||
if req.Credentials != nil {
|
||||
enc, err := s.sealCredentials(*req.Credentials)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "seal credentials", err)
|
||||
return
|
||||
}
|
||||
set["ticketing.credentialsEnc"] = enc
|
||||
diff["credentials"] = "rotated"
|
||||
}
|
||||
if len(set) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields")
|
||||
return
|
||||
}
|
||||
version := c.Version
|
||||
if req.Version != nil {
|
||||
version = *req.Version
|
||||
}
|
||||
if err := s.store.UpdateCustomer(r.Context(), id, version, set); err != nil {
|
||||
if errors.Is(err, store.ErrVersionConflict) {
|
||||
writeError(w, http.StatusConflict, "conflict", "customer was modified concurrently; reload")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "update customer", err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "customer.update", "customer", id, diff)
|
||||
fresh, err := s.store.CustomerByID(r.Context(), id)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "reload customer", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(fresh)})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
n, err := s.store.CountTasksForCustomer(r.Context(), id)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "count tasks", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
writeError(w, http.StatusConflict, "has_tasks",
|
||||
"customer has imported tasks; archive it instead of deleting")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteCustomer(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "customer not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "delete customer", err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "customer.delete", "customer", id, nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleAdminTestConnection tests the stored configuration of an existing
|
||||
// customer.
|
||||
func (s *Server) handleAdminTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := s.store.CustomerByID(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "customer not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "get customer", err)
|
||||
return
|
||||
}
|
||||
creds, err := s.openCredentials(c)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "decrypt credentials", err)
|
||||
return
|
||||
}
|
||||
s.runConnectionTest(w, r, c.Ticketing.Type, c.Ticketing.BaseURL, c.Ticketing.ProjectKey, creds)
|
||||
}
|
||||
|
||||
// handleAdminTestConnectionUnsaved tests credentials supplied in the wizard
|
||||
// before the customer is saved.
|
||||
func (s *Server) handleAdminTestConnectionUnsaved(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Type domain.TicketingType `json:"type"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
ProjectKey string `json:"projectKey"`
|
||||
Credentials syncpkg.Credentials `json:"credentials"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.runConnectionTest(w, r, req.Type, strings.TrimRight(req.BaseURL, "/"), req.ProjectKey, req.Credentials)
|
||||
}
|
||||
|
||||
func (s *Server) runConnectionTest(w http.ResponseWriter, r *http.Request,
|
||||
t domain.TicketingType, baseURL, projectKey string, creds syncpkg.Credentials) {
|
||||
conn, err := syncpkg.NewConnector(t, baseURL, projectKey, creds)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if err := conn.TestConnection(ctx); err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error(),
|
||||
"latencyMs": time.Since(start).Milliseconds()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true,
|
||||
"latencyMs": time.Since(start).Milliseconds()})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := s.store.CustomerByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "customer not found")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "get customer", err)
|
||||
return
|
||||
}
|
||||
if s.syncTrigger == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "sync_unavailable", "sync manager is not running")
|
||||
return
|
||||
}
|
||||
s.syncTrigger(id)
|
||||
s.audit(r, "customer.sync-now", "customer", id, nil)
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued"})
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//go:build integration
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/store"
|
||||
)
|
||||
|
||||
// promote grants roles to a user directly in the store.
|
||||
func promote(t *testing.T, st *store.Store, userID string, roles map[string]bool) {
|
||||
t.Helper()
|
||||
set := bson.M{}
|
||||
for role, v := range roles {
|
||||
set["roles."+role] = v
|
||||
}
|
||||
if _, err := st.DB.Collection("users").UpdateOne(t.Context(),
|
||||
bson.M{"_id": userID}, bson.M{"$set": set}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// adminClient registers + promotes an admin and returns a logged-in client.
|
||||
func adminClient(t *testing.T, ts string, st *store.Store, email string) (*http.Client, string, domain.User) {
|
||||
t.Helper()
|
||||
c := newClient(t)
|
||||
u := registerUser(t, ts, c, email, "Admin "+email)
|
||||
promote(t, st, u.ID, map[string]bool{"admin": true})
|
||||
return c, csrfFrom(t, c, ts), u
|
||||
}
|
||||
|
||||
func TestAdminRBACDenied(t *testing.T) {
|
||||
ts, _, _, _ := newAuthStack(t, nil)
|
||||
c := newClient(t)
|
||||
registerUser(t, ts.URL, c, "plebeian@example.com", "Plebeian")
|
||||
csrf := csrfFrom(t, c, ts.URL)
|
||||
|
||||
// developer is denied on every admin route
|
||||
for _, probe := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/admin/users"},
|
||||
{"GET", "/api/v1/admin/customers"},
|
||||
{"GET", "/api/v1/admin/settings"},
|
||||
{"GET", "/api/v1/admin/audit-log"},
|
||||
{"GET", "/api/v1/admin/service-status"},
|
||||
} {
|
||||
req, _ := http.NewRequest(probe.method, ts.URL+probe.path, nil)
|
||||
req.Header.Set(csrfHeader, csrf)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s as developer: %d, want 403", probe.method, probe.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCustomerLifecycle(t *testing.T) {
|
||||
ts, _, st, _ := newAuthStack(t, nil)
|
||||
c, csrf, _ := adminClient(t, ts.URL, st, "boss@example.com")
|
||||
|
||||
// a consultant to assign
|
||||
cc := newClient(t)
|
||||
consultant := registerUser(t, ts.URL, cc, "cons@example.com", "Cons")
|
||||
promote(t, st, consultant.ID, map[string]bool{"consultant": true})
|
||||
|
||||
// create demo customer
|
||||
resp := postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{
|
||||
"name": "ACME Demo", "type": "demo", "projectKey": "ACME",
|
||||
"pollIntervalSec": 15, "defaultBudget": 2000,
|
||||
"consultantIds": []string{consultant.ID},
|
||||
"credentials": map[string]string{},
|
||||
}, csrf)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create customer: %d", resp.StatusCode)
|
||||
}
|
||||
var created struct {
|
||||
Customer struct {
|
||||
ID string `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
Ticketing struct {
|
||||
HasCredentials bool `json:"hasCredentials"`
|
||||
} `json:"ticketing"`
|
||||
} `json:"customer"`
|
||||
}
|
||||
bodyJSON(t, resp, &created)
|
||||
id := created.Customer.ID
|
||||
|
||||
// jira customer requires credentials
|
||||
resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{
|
||||
"name": "Broken Jira", "type": "jira", "credentials": map[string]string{"email": "x@y.z"},
|
||||
}, csrf)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("jira without token: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// duplicate name
|
||||
resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers", map[string]any{
|
||||
"name": "ACME Demo", "type": "demo",
|
||||
}, csrf)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("duplicate name: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// stored credentials round-trip through test-connection (demo always ok)
|
||||
resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id+"/test-connection", map[string]any{}, csrf)
|
||||
var test struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
bodyJSON(t, resp, &test)
|
||||
if !test.OK {
|
||||
t.Fatal("demo test-connection should pass")
|
||||
}
|
||||
|
||||
// unsaved-credentials test endpoint (jira shape validation)
|
||||
resp = postJSON(t, c, ts.URL+"/api/v1/admin/customers/test-connection", map[string]any{
|
||||
"type": "jira", "baseUrl": "https://nope.invalid", "credentials": map[string]string{},
|
||||
}, csrf)
|
||||
bodyJSON(t, resp, &test)
|
||||
if test.OK {
|
||||
t.Fatal("jira with empty credentials must fail the test")
|
||||
}
|
||||
|
||||
// patch: rename + archive
|
||||
resp = patchJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id, map[string]any{
|
||||
"name": "ACME Renamed", "archived": true, "version": created.Customer.Version,
|
||||
}, csrf)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch customer: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// stale version conflicts
|
||||
resp = patchJSON(t, c, ts.URL+"/api/v1/admin/customers/"+id, map[string]any{
|
||||
"name": "Stale", "version": created.Customer.Version,
|
||||
}, csrf)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("stale customer patch: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// delete works while no tasks exist
|
||||
req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/admin/customers/"+id, nil)
|
||||
req.Header.Set(csrfHeader, csrf)
|
||||
dResp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dResp.Body.Close()
|
||||
if dResp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete customer: %d", dResp.StatusCode)
|
||||
}
|
||||
|
||||
// audit log recorded the mutations
|
||||
aResp, err := c.Get(ts.URL + "/api/v1/admin/audit-log")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var audit struct {
|
||||
Entries []store.AuditEntry `json:"entries"`
|
||||
}
|
||||
bodyJSON(t, aResp, &audit)
|
||||
actions := map[string]bool{}
|
||||
for _, e := range audit.Entries {
|
||||
actions[e.Action] = true
|
||||
}
|
||||
for _, want := range []string{"customer.create", "customer.update", "customer.delete"} {
|
||||
if !actions[want] {
|
||||
t.Errorf("audit log missing action %q (have %v)", want, actions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserManagement(t *testing.T) {
|
||||
ts, _, st, _ := newAuthStack(t, nil)
|
||||
c, csrf, admin := adminClient(t, ts.URL, st, "root2@example.com")
|
||||
|
||||
tc := newClient(t)
|
||||
target := registerUser(t, ts.URL, tc, "target@example.com", "Target")
|
||||
|
||||
// search finds the user
|
||||
resp, err := c.Get(ts.URL + "/api/v1/admin/users?q=target")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var list struct {
|
||||
Users []domain.User `json:"users"`
|
||||
}
|
||||
bodyJSON(t, resp, &list)
|
||||
if len(list.Users) != 1 || list.Users[0].ID != target.ID {
|
||||
t.Fatalf("search: %+v", list.Users)
|
||||
}
|
||||
|
||||
// grant consultant role
|
||||
resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{
|
||||
"roles": map[string]bool{"admin": false, "consultant": true, "developer": true},
|
||||
}, csrf)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("grant role: %d", resp.StatusCode)
|
||||
}
|
||||
var patched struct {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
bodyJSON(t, resp, &patched)
|
||||
if !patched.User.Roles.Consultant {
|
||||
t.Fatal("consultant role not granted")
|
||||
}
|
||||
|
||||
// force reset marks mustChange
|
||||
resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{"forceReset": true}, csrf)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("force reset: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
fresh, err := st.UserByID(t.Context(), target.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !fresh.MustChangePassword() {
|
||||
t.Fatal("forceReset did not set mustChange")
|
||||
}
|
||||
|
||||
// disabling kicks the user's session
|
||||
resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+target.ID, map[string]any{"disabled": true}, csrf)
|
||||
resp.Body.Close()
|
||||
meResp, _ := tc.Get(ts.URL + "/api/v1/auth/me")
|
||||
meResp.Body.Close()
|
||||
if meResp.StatusCode == http.StatusOK {
|
||||
t.Fatal("disabled user still has a working session")
|
||||
}
|
||||
|
||||
// self-demotion and self-disable are blocked
|
||||
for i, body := range []map[string]any{
|
||||
{"roles": map[string]bool{"admin": false, "consultant": false, "developer": true}},
|
||||
{"disabled": true},
|
||||
} {
|
||||
resp = patchJSON(t, c, ts.URL+"/api/v1/admin/users/"+admin.ID, body, csrf)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("self-mutation %d: %d, want 400", i, resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// delete target
|
||||
req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/admin/users/"+target.ID, nil)
|
||||
req.Header.Set(csrfHeader, csrf)
|
||||
dResp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dResp.Body.Close()
|
||||
if dResp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete user: %d", dResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettings(t *testing.T) {
|
||||
ts, _, st, _ := newAuthStack(t, nil)
|
||||
c, csrf, _ := adminClient(t, ts.URL, st, "settings@example.com")
|
||||
|
||||
resp, err := c.Get(ts.URL + "/api/v1/admin/settings")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got struct {
|
||||
Settings domain.AppSettings `json:"settings"`
|
||||
}
|
||||
bodyJSON(t, resp, &got)
|
||||
if got.Settings.DefaultPollIntervalSec != 60 {
|
||||
t.Fatalf("default poll = %d", got.Settings.DefaultPollIntervalSec)
|
||||
}
|
||||
|
||||
resp = patchJSON(t, c, ts.URL+"/api/v1/admin/settings", map[string]any{
|
||||
"brandingName": "ACME Bounties", "defaultPollIntervalSec": 30,
|
||||
"hideCompetingClaims": true,
|
||||
}, csrf)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch settings: %d", resp.StatusCode)
|
||||
}
|
||||
bodyJSON(t, resp, &got)
|
||||
if got.Settings.BrandingName != "ACME Bounties" || got.Settings.DefaultPollIntervalSec != 30 ||
|
||||
!got.Settings.HideCompetingClaims {
|
||||
t.Fatalf("settings after patch: %+v", got.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminServiceStatusShape(t *testing.T) {
|
||||
ts, _, st, _ := newAuthStack(t, nil)
|
||||
c, _, _ := adminClient(t, ts.URL, st, fmt.Sprintf("status-%d@example.com", 1))
|
||||
|
||||
resp, err := c.Get(ts.URL + "/api/v1/admin/service-status")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out map[string]any
|
||||
bodyJSON(t, resp, &out)
|
||||
for _, key := range []string{"mongo", "atomizer", "workPerformer", "syncWorkers", "jobs"} {
|
||||
if _, ok := out[key]; !ok {
|
||||
t.Errorf("service-status missing %q", key)
|
||||
}
|
||||
}
|
||||
mongo := out["mongo"].(map[string]any)
|
||||
if mongo["healthy"] != true {
|
||||
t.Error("mongo should be healthy in tests")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package httpx
|
||||
|
||||
import "context"
|
||||
|
||||
// Late-bound providers: these are wired by later subsystems (atomizer
|
||||
// client, work-performer client, sync manager, job runner) after the server
|
||||
// is constructed. All accessors are nil-safe so the admin status panel
|
||||
// degrades gracefully while subsystems are absent (e.g. in tests).
|
||||
|
||||
// BreakerInfo exposes a circuit breaker's state for the status panel.
|
||||
type BreakerInfo interface {
|
||||
BreakerState() string // closed | open | half-open
|
||||
}
|
||||
|
||||
// StatusProvider returns arbitrary JSON-marshalable status payloads.
|
||||
type StatusProvider interface {
|
||||
Statuses() any
|
||||
}
|
||||
|
||||
func (s *Server) SetAtomizerInfo(b BreakerInfo) { s.atomizer = b }
|
||||
func (s *Server) SetPerformerInfo(b BreakerInfo) { s.performer = b }
|
||||
|
||||
// SetSyncTrigger wires the sync manager's "sync now" entry point.
|
||||
func (s *Server) SetSyncTrigger(f func(customerID string)) { s.syncTrigger = f }
|
||||
func (s *Server) SetSyncStatusProvider(p StatusProvider) {
|
||||
s.syncProvider = p
|
||||
}
|
||||
func (s *Server) SetJobsStatusProvider(p StatusProvider) {
|
||||
s.jobsProvider = p
|
||||
}
|
||||
|
||||
func (s *Server) syncStatuses() any {
|
||||
if s.syncProvider == nil {
|
||||
return []any{}
|
||||
}
|
||||
return s.syncProvider.Statuses()
|
||||
}
|
||||
|
||||
func (s *Server) jobStatuses() any {
|
||||
if s.jobsProvider == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return s.jobsProvider.Statuses()
|
||||
}
|
||||
|
||||
// effectiveAtomizerBaseURL honors the admin settings override (§3) over the
|
||||
// env default.
|
||||
func (s *Server) effectiveAtomizerBaseURL(ctx context.Context) string {
|
||||
if s.store != nil {
|
||||
if st, err := s.store.GetSettings(ctx); err == nil && st.AtomizerBaseURLOverride != "" {
|
||||
return st.AtomizerBaseURLOverride
|
||||
}
|
||||
}
|
||||
return s.cfg.AtomizerBaseURL
|
||||
}
|
||||
@@ -31,6 +31,13 @@ type Server struct {
|
||||
checks []ReadinessCheck
|
||||
startedAt time.Time
|
||||
httpSrv *http.Server
|
||||
|
||||
// late-bound subsystem hooks (see providers.go)
|
||||
atomizer BreakerInfo
|
||||
performer BreakerInfo
|
||||
syncProvider StatusProvider
|
||||
jobsProvider StatusProvider
|
||||
syncTrigger func(customerID string)
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server {
|
||||
@@ -58,6 +65,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
|
||||
mux.HandleFunc("GET /metricsz", s.handleMetricsz)
|
||||
s.routesAuth(mux)
|
||||
s.routesProfile(mux)
|
||||
s.routesAdmin(mux)
|
||||
s.routesWeb(mux)
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
|
||||
+11
-1
@@ -170,6 +170,17 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /admin", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||
if !u.Roles.Admin {
|
||||
s.render(w, r, "placeholder.html", &pageData{Title: "Not authorized", User: u})
|
||||
return
|
||||
}
|
||||
s.render(w, r, "admin.html", &pageData{
|
||||
Title: "Administration", Active: "admin", User: u,
|
||||
Scripts: []string{"/static/js/admin.js"},
|
||||
})
|
||||
}))
|
||||
|
||||
// Placeholders for areas built in later phases; replaced as they land.
|
||||
placeholders := map[string]string{
|
||||
"/board": "Bounty Board",
|
||||
@@ -177,7 +188,6 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
||||
"/consultant/board": "Atomization Board",
|
||||
"/consultant/reviews": "Review Queue",
|
||||
"/consultant/pool": "Developer Pool",
|
||||
"/admin": "Administration",
|
||||
"/messages": "Messages",
|
||||
"/metrics": "Metrics",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user