diff --git a/PROGRESS.md b/PROGRESS.md
index 5172811..52e0db9 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -5,3 +5,4 @@
- Phase 2 (store layer): mongo-driver v2 connection with startup retry, §4.9 indexes idempotent, optimistic-concurrency UpdateVersioned (ErrVersionConflict/ErrNotFound), AES-256-GCM credential crypto, HMAC-signed file URL tokens (1h TTL), GridFS store with MIME sniffing + size cap + sha256, /readyz mongo check (verified 503 on stopped Mongo) — unit + integration tests green.
- Phase 3 (auth): argon2id (PHC, spec params), login/register rate limiting, opaque Mongo sessions (30d sliding, TTL index), CSRF double-submit, bootstrap admin with forced password change, RBAC middleware + matrix tests, OIDC code flow with PKCE + email linking (verified-only), full integration suite incl. fake IdP — all green.
- Phase 4 (base UI shell): embedded templates + static assets (no build step), §10 beige/dark tokens with radius 2px, theme toggle persisted to localStorage + profile, login/register/change-password pages, profile page (avatar upload w/ MIME check, bio, contacts, arbitrary extra fields, version-conflict 409), role-based navigation, /files/{id} serving (session or signed token), CSP without inline scripts + security headers — unit + integration tests green, stack verified live.
+- Phase 5 (admin): customer CRUD wizard with per-system encrypted credentials + test connection (jira/ado/youtrack/demo connectors), user management (roles, disable w/ session revoke, force-reset, delete, self-lockout guards), runtime settings doc, audit log on every privileged mutation + list API, service-status panel (mongo/atomizer/WP health + latency, late-bound breaker/sync/jobs providers), tabbed admin UI - tests green. Fixed mongod WT_PANIC: container nofile ulimit was 1024, raised to 64000 in compose.
diff --git a/docker-compose.yml b/docker-compose.yml
index 2ade5c6..5d9e882 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,6 +4,12 @@ services:
restart: unless-stopped
# modest cache so the whole stack fits comfortably on small hosts
command: ["mongod", "--wiredTigerCacheSizeGB", "0.5"]
+ # WiredTiger needs far more FDs than the 1024 some hosts default to;
+ # running out crashes mongod with a WT_PANIC during file creation
+ ulimits:
+ nofile:
+ soft: 64000
+ hard: 64000
# Mongo is reachable only on the compose network; never published to the
# host (§12).
volumes:
diff --git a/internal/domain/customer.go b/internal/domain/customer.go
new file mode 100644
index 0000000..4df3b1b
--- /dev/null
+++ b/internal/domain/customer.go
@@ -0,0 +1,56 @@
+package domain
+
+import "time"
+
+type TicketingType string
+
+const (
+ TicketingJira TicketingType = "jira"
+ TicketingAzure TicketingType = "azure_devops"
+ TicketingYouTrack TicketingType = "youtrack"
+ // TicketingDemo fabricates tickets locally so the whole flow works
+ // offline (§11.11).
+ TicketingDemo TicketingType = "demo"
+)
+
+func (t TicketingType) Valid() bool {
+ switch t {
+ case TicketingJira, TicketingAzure, TicketingYouTrack, TicketingDemo:
+ return true
+ }
+ return false
+}
+
+type Ticketing struct {
+ Type TicketingType `bson:"type" json:"type"`
+ BaseURL string `bson:"baseUrl" json:"baseUrl"`
+ CredentialsEnc string `bson:"credentialsEnc" json:"-"`
+ ProjectKey string `bson:"projectKey" json:"projectKey"`
+ PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
+ LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
+ LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
+ LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
+}
+
+// Customer == "project" in the UI (§4.2).
+type Customer struct {
+ ID string `bson:"_id" json:"id"`
+ Name string `bson:"name" json:"name"`
+ Ticketing Ticketing `bson:"ticketing" json:"ticketing"`
+ ConsultantIDs []string `bson:"consultantIds" json:"consultantIds"`
+ DefaultBudget float64 `bson:"defaultBudget" json:"defaultBudget"`
+ Archived bool `bson:"archived" json:"archived"`
+ CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
+ UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"`
+ Version int64 `bson:"version" json:"version"`
+}
+
+// HasConsultant reports membership in the customer's consultant set.
+func (c *Customer) HasConsultant(userID string) bool {
+ for _, id := range c.ConsultantIDs {
+ if id == userID {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/domain/settings.go b/internal/domain/settings.go
new file mode 100644
index 0000000..e7d9842
--- /dev/null
+++ b/internal/domain/settings.go
@@ -0,0 +1,17 @@
+package domain
+
+import "time"
+
+// AppSettings is the single admin-editable runtime settings document
+// ({_id:"app"}, §4.8). Env vars are the defaults; these override where the
+// spec marks them overridable.
+type AppSettings struct {
+ ID string `bson:"_id" json:"-"`
+ BrandingName string `bson:"brandingName" json:"brandingName"`
+ AtomizerBaseURLOverride string `bson:"atomizerBaseUrlOverride" json:"atomizerBaseUrlOverride"`
+ HideCompetingClaims bool `bson:"hideCompetingClaims" json:"hideCompetingClaims"`
+ DefaultPollIntervalSec int `bson:"defaultPollIntervalSec" json:"defaultPollIntervalSec"`
+ DefaultCustomerBudget float64 `bson:"defaultCustomerBudget" json:"defaultCustomerBudget"`
+ UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"`
+ Version int64 `bson:"version" json:"version"`
+}
diff --git a/internal/http/admin.go b/internal/http/admin.go
new file mode 100644
index 0000000..2d1cf0f
--- /dev/null
+++ b/internal/http/admin.go
@@ -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
+}
diff --git a/internal/http/admin_customers.go b/internal/http/admin_customers.go
new file mode 100644
index 0000000..e893004
--- /dev/null
+++ b/internal/http/admin_customers.go
@@ -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"})
+}
diff --git a/internal/http/admin_integration_test.go b/internal/http/admin_integration_test.go
new file mode 100644
index 0000000..ab9f5f7
--- /dev/null
+++ b/internal/http/admin_integration_test.go
@@ -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")
+ }
+}
diff --git a/internal/http/admin_users.go b/internal/http/admin_users.go
new file mode 100644
index 0000000..49af5e6
--- /dev/null
+++ b/internal/http/admin_users.go
@@ -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)
+}
diff --git a/internal/http/providers.go b/internal/http/providers.go
new file mode 100644
index 0000000..0e68dfc
--- /dev/null
+++ b/internal/http/providers.go
@@ -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
+}
diff --git a/internal/http/server.go b/internal/http/server.go
index faa64f5..8c29e92 100644
--- a/internal/http/server.go
+++ b/internal/http/server.go
@@ -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{
diff --git a/internal/http/web.go b/internal/http/web.go
index b1ac78b..bd5bc3f 100644
--- a/internal/http/web.go
+++ b/internal/http/web.go
@@ -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",
}
diff --git a/internal/store/audit.go b/internal/store/audit.go
new file mode 100644
index 0000000..14286e9
--- /dev/null
+++ b/internal/store/audit.go
@@ -0,0 +1,63 @@
+package store
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo/options"
+
+ "bountyboard/internal/ulid"
+)
+
+// AuditEntry records one privileged mutation (§4.8).
+type AuditEntry struct {
+ ID string `bson:"_id" json:"id"`
+ At time.Time `bson:"at" json:"at"`
+ ActorID string `bson:"actorId" json:"actorId"`
+ ActorIP string `bson:"actorIp" json:"actorIp"`
+ Action string `bson:"action" json:"action"`
+ Entity string `bson:"entity" json:"entity"`
+ EntityID string `bson:"entityId" json:"entityId"`
+ Diff map[string]any `bson:"diff,omitempty" json:"diff,omitempty"`
+}
+
+// Audit appends to the audit log; failures are returned for logging but must
+// never abort the mutation they describe.
+func (s *Store) Audit(ctx context.Context, e AuditEntry) error {
+ e.ID = ulid.New()
+ e.At = time.Now().UTC()
+ if _, err := s.DB.Collection("auditLog").InsertOne(ctx, e); err != nil {
+ return fmt.Errorf("insert audit entry: %w", err)
+ }
+ return nil
+}
+
+// ListAudit returns newest-first entries with optional filters and ULID
+// cursor pagination.
+func (s *Store) ListAudit(ctx context.Context, actorID, entityID, cursor string, limit int) ([]AuditEntry, error) {
+ if limit <= 0 || limit > 200 {
+ limit = 50
+ }
+ filter := bson.M{}
+ if actorID != "" {
+ filter["actorId"] = actorID
+ }
+ if entityID != "" {
+ filter["entityId"] = entityID
+ }
+ if cursor != "" {
+ filter["_id"] = bson.M{"$lt": cursor}
+ }
+ cur, err := s.DB.Collection("auditLog").Find(ctx, filter,
+ options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(int64(limit)))
+ if err != nil {
+ return nil, fmt.Errorf("list audit: %w", err)
+ }
+ out := []AuditEntry{}
+ if err := cur.All(ctx, &out); err != nil {
+ return nil, fmt.Errorf("decode audit: %w", err)
+ }
+ return out, nil
+}
diff --git a/internal/store/customers.go b/internal/store/customers.go
new file mode 100644
index 0000000..afae851
--- /dev/null
+++ b/internal/store/customers.go
@@ -0,0 +1,106 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo"
+
+ "bountyboard/internal/domain"
+ "bountyboard/internal/ulid"
+)
+
+func (s *Store) customers() *mongo.Collection { return s.DB.Collection("customers") }
+
+func (s *Store) CreateCustomer(ctx context.Context, c *domain.Customer) error {
+ now := time.Now().UTC()
+ if c.ID == "" {
+ c.ID = ulid.New()
+ }
+ c.CreatedAt, c.UpdatedAt, c.Version = now, now, 1
+ if c.ConsultantIDs == nil {
+ c.ConsultantIDs = []string{}
+ }
+ if _, err := s.customers().InsertOne(ctx, c); err != nil {
+ if mongo.IsDuplicateKeyError(err) {
+ return fmt.Errorf("customer %q: %w", c.Name, ErrDuplicate)
+ }
+ return fmt.Errorf("insert customer: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) CustomerByID(ctx context.Context, id string) (*domain.Customer, error) {
+ var c domain.Customer
+ err := s.customers().FindOne(ctx, bson.M{"_id": id}).Decode(&c)
+ if errors.Is(err, mongo.ErrNoDocuments) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("find customer: %w", err)
+ }
+ return &c, nil
+}
+
+// ListCustomers returns customers, optionally restricted to a consultant and
+// optionally including archived ones, sorted by name.
+func (s *Store) ListCustomers(ctx context.Context, consultantID string, includeArchived bool) ([]domain.Customer, error) {
+ filter := bson.M{}
+ if !includeArchived {
+ filter["archived"] = false
+ }
+ if consultantID != "" {
+ filter["consultantIds"] = consultantID
+ }
+ cur, err := s.customers().Find(ctx, filter)
+ if err != nil {
+ return nil, fmt.Errorf("list customers: %w", err)
+ }
+ out := []domain.Customer{}
+ if err := cur.All(ctx, &out); err != nil {
+ return nil, fmt.Errorf("decode customers: %w", err)
+ }
+ return out, nil
+}
+
+// UpdateCustomer applies a version-checked partial update.
+func (s *Store) UpdateCustomer(ctx context.Context, id string, version int64, set bson.M) error {
+ return UpdateVersioned(ctx, s.customers(), id, version, bson.M{"$set": set})
+}
+
+func (s *Store) DeleteCustomer(ctx context.Context, id string) error {
+ res, err := s.customers().DeleteOne(ctx, bson.M{"_id": id})
+ if err != nil {
+ return fmt.Errorf("delete customer: %w", err)
+ }
+ if res.DeletedCount == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// SetSyncStatus records the outcome of a sync run (not version-checked:
+// background bookkeeping must not race with admin edits).
+func (s *Store) SetSyncStatus(ctx context.Context, id string, at time.Time, status, errMsg string) error {
+ _, err := s.customers().UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": bson.M{
+ "ticketing.lastSyncAt": at,
+ "ticketing.lastSyncStatus": status,
+ "ticketing.lastSyncError": errMsg,
+ }})
+ if err != nil {
+ return fmt.Errorf("set sync status: %w", err)
+ }
+ return nil
+}
+
+// CountTasksForCustomer guards customer deletion.
+func (s *Store) CountTasksForCustomer(ctx context.Context, customerID string) (int64, error) {
+ n, err := s.DB.Collection("tasks").CountDocuments(ctx, bson.M{"customerId": customerID})
+ if err != nil {
+ return 0, fmt.Errorf("count tasks: %w", err)
+ }
+ return n, nil
+}
diff --git a/internal/store/settings.go b/internal/store/settings.go
new file mode 100644
index 0000000..497343e
--- /dev/null
+++ b/internal/store/settings.go
@@ -0,0 +1,54 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo"
+
+ "bountyboard/internal/domain"
+)
+
+// GetSettings returns the app settings document, creating defaults on first
+// access.
+func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) {
+ var st domain.AppSettings
+ err := s.DB.Collection("settings").FindOne(ctx, bson.M{"_id": "app"}).Decode(&st)
+ if errors.Is(err, mongo.ErrNoDocuments) {
+ st = domain.AppSettings{
+ ID: "app",
+ BrandingName: "Bounty Board",
+ DefaultPollIntervalSec: 60,
+ DefaultCustomerBudget: 1000,
+ UpdatedAt: time.Now().UTC(),
+ Version: 1,
+ }
+ if _, err := s.DB.Collection("settings").InsertOne(ctx, st); err != nil &&
+ !mongo.IsDuplicateKeyError(err) {
+ return nil, fmt.Errorf("init settings: %w", err)
+ }
+ return &st, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get settings: %w", err)
+ }
+ return &st, nil
+}
+
+// PatchSettings applies a partial settings update (not version-raced in the
+// UI; last write wins is acceptable for a single admin page).
+func (s *Store) PatchSettings(ctx context.Context, set bson.M) (*domain.AppSettings, error) {
+ if _, err := s.GetSettings(ctx); err != nil { // ensure the doc exists
+ return nil, err
+ }
+ set["updatedAt"] = time.Now().UTC()
+ _, err := s.DB.Collection("settings").UpdateOne(ctx, bson.M{"_id": "app"},
+ bson.M{"$set": set, "$inc": bson.M{"version": 1}})
+ if err != nil {
+ return nil, fmt.Errorf("patch settings: %w", err)
+ }
+ return s.GetSettings(ctx)
+}
diff --git a/internal/sync/azure.go b/internal/sync/azure.go
new file mode 100644
index 0000000..7c7defe
--- /dev/null
+++ b/internal/sync/azure.go
@@ -0,0 +1,166 @@
+package sync
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// azureConnector talks to Azure DevOps REST 7.1 with PAT basic auth.
+type azureConnector struct {
+ baseURL string // https://dev.azure.com unless self-hosted
+ organization string
+ project string
+ pat string
+}
+
+func (a *azureConnector) Authorize(req *http.Request) {
+ req.SetBasicAuth("", a.pat)
+}
+
+func (a *azureConnector) orgURL() string {
+ return strings.TrimRight(a.baseURL, "/") + "/" + a.organization
+}
+
+func (a *azureConnector) TestConnection(ctx context.Context) error {
+ var out struct {
+ Count int `json:"count"`
+ }
+ if err := getJSON(ctx, a.Authorize, a.orgURL()+"/_apis/projects?api-version=7.1", &out); err != nil {
+ return fmt.Errorf("azure devops /_apis/projects: %w", err)
+ }
+ return nil
+}
+
+func (a *azureConnector) wiql(ctx context.Context, query string) ([]int, error) {
+ body, _ := json.Marshal(map[string]string{"query": query})
+ var out struct {
+ WorkItems []struct {
+ ID int `json:"id"`
+ } `json:"workItems"`
+ }
+ u := fmt.Sprintf("%s/%s/_apis/wit/wiql?api-version=7.1", a.orgURL(), a.project)
+ if err := doJSON(ctx, a.Authorize, http.MethodPost, u, body, &out); err != nil {
+ return nil, fmt.Errorf("azure wiql: %w", err)
+ }
+ ids := make([]int, len(out.WorkItems))
+ for i, wi := range out.WorkItems {
+ ids[i] = wi.ID
+ }
+ return ids, nil
+}
+
+type azureWorkItem struct {
+ ID int `json:"id"`
+ Fields map[string]any `json:"fields"`
+ Links struct {
+ HTML struct {
+ Href string `json:"href"`
+ } `json:"html"`
+ } `json:"_links"`
+ Relations []struct {
+ Rel string `json:"rel"`
+ URL string `json:"url"`
+ Attributes struct {
+ Name string `json:"name"`
+ } `json:"attributes"`
+ } `json:"relations"`
+}
+
+func (a *azureConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) {
+ q := fmt.Sprintf(`Select [System.Id] From WorkItems Where [System.AssignedTo] = '%s' AND [System.TeamProject] = '%s' AND [System.ChangedDate] >= '%s'`,
+ identity, a.project, since.UTC().Format("2006-01-02T15:04:05Z"))
+ ids, err := a.wiql(ctx, q)
+ if err != nil || len(ids) == 0 {
+ return nil, err
+ }
+
+ var out []Ticket
+ for start := 0; start < len(ids); start += 200 {
+ end := min(start+200, len(ids))
+ idStrs := make([]string, 0, end-start)
+ for _, id := range ids[start:end] {
+ idStrs = append(idStrs, fmt.Sprint(id))
+ }
+ var batch struct {
+ Value []azureWorkItem `json:"value"`
+ }
+ u := fmt.Sprintf("%s/%s/_apis/wit/workitems?ids=%s&$expand=all&api-version=7.1",
+ a.orgURL(), a.project, strings.Join(idStrs, ","))
+ if err := getJSON(ctx, a.Authorize, u, &batch); err != nil {
+ return nil, fmt.Errorf("azure workitems batch: %w", err)
+ }
+ for _, wi := range batch.Value {
+ out = append(out, a.toTicket(wi))
+ }
+ }
+ return out, nil
+}
+
+func (a *azureConnector) toTicket(wi azureWorkItem) Ticket {
+ title, _ := wi.Fields["System.Title"].(string)
+ descHTML, _ := wi.Fields["System.Description"].(string)
+ witype, _ := wi.Fields["System.WorkItemType"].(string)
+ url := wi.Links.HTML.Href
+ if url == "" {
+ url = fmt.Sprintf("%s/%s/_workitems/edit/%d", a.orgURL(), a.project, wi.ID)
+ }
+ t := Ticket{
+ Key: fmt.Sprintf("%s-%d", strings.ToUpper(a.project), wi.ID),
+ URL: url,
+ Type: azureType(witype),
+ Title: title,
+ Description: stripHTML(descHTML),
+ Raw: map[string]any{"id": wi.ID, "workItemType": witype},
+ }
+ for _, rel := range wi.Relations {
+ if rel.Rel == "AttachedFile" {
+ t.Attachments = append(t.Attachments, TicketAttachment{Name: rel.Attributes.Name, URL: rel.URL})
+ }
+ }
+ return t
+}
+
+func (a *azureConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) {
+ q := fmt.Sprintf(`Select [System.Id] From WorkItems Where [System.AssignedTo] = '%s' AND [System.TeamProject] = '%s'`,
+ identity, a.project)
+ ids, err := a.wiql(ctx, q)
+ if err != nil {
+ return nil, err
+ }
+ keys := make([]string, len(ids))
+ for i, id := range ids {
+ keys[i] = fmt.Sprintf("%s-%d", strings.ToUpper(a.project), id)
+ }
+ return keys, nil
+}
+
+func azureType(name string) string {
+ switch strings.ToLower(name) {
+ case "epic":
+ return "epic"
+ case "user story", "product backlog item":
+ return "story"
+ default:
+ return "task"
+ }
+}
+
+var tagRe = regexp.MustCompile(`<[^>]*>`)
+
+// stripHTML is a lossy HTML→text flattener for ADO descriptions.
+func stripHTML(s string) string {
+ s = strings.ReplaceAll(s, "
", "\n")
+ s = strings.ReplaceAll(s, "
", "\n")
+ s = strings.ReplaceAll(s, "
${esc(c.ticketing.type)} · project ${esc(c.ticketing.projectKey || '—')} · + poll ${c.ticketing.pollIntervalSec}s · budget ${c.defaultBudget} · + ${c.consultantIds.length} consultant(s) + ${c.ticketing.lastSyncAt && !c.ticketing.lastSyncAt.startsWith('0001') ? ' · last sync ' + new Date(c.ticketing.lastSyncAt).toLocaleString() : ''}
+No sync workers running.
'; + if (Array.isArray(s.syncWorkers) && s.syncWorkers.length) { + workers = s.syncWorkers.map((wkr) => ` +${esc(JSON.stringify(s.jobs))}
`; + } catch (e) { host.textContent = e.message; } +} +document.getElementById('status-refresh').addEventListener('click', loadStatus); + +// ---- audit ---- +let auditCursor = ''; +async function loadAudit(reset = true) { + try { + const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim()); + if (reset) auditCursor = ''; + const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`); + auditCursor = res.nextCursor; + const rows = res.entries.map((e) => { + const tr = document.createElement('tr'); + const cells = [ + new Date(e.at).toLocaleString(), e.actorId, e.action, + `${e.entity}/${e.entityId}`, e.diff ? JSON.stringify(e.diff) : '', + ]; + cells.forEach((c) => { + const td = document.createElement('td'); + td.textContent = c; + tr.appendChild(td); + }); + return tr; + }); + const tbody = document.querySelector('#audit-table tbody'); + if (reset) tbody.replaceChildren(...rows); + else tbody.append(...rows); + } catch (e) { showErr(e); } +} +document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true)); +document.getElementById('audit-more').addEventListener('click', () => loadAudit(false)); + +function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); +} + +const loaders = { + customers: loadCustomers, + users: loadUsers, + settings: loadSettings, + status: loadStatus, + audit: () => loadAudit(true), +}; +loadCustomers(); diff --git a/web/templates/admin.html b/web/templates/admin.html new file mode 100644 index 0000000..355f660 --- /dev/null +++ b/web/templates/admin.html @@ -0,0 +1,151 @@ +{{define "content"}} +| Name | Admin | Consultant | Developer | Status |
|---|
| When | Actor | Action | Entity | Details |
|---|