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:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, "<br>", "\n")
|
||||
s = strings.ReplaceAll(s, "<br/>", "\n")
|
||||
s = strings.ReplaceAll(s, "</p>", "\n")
|
||||
s = tagRe.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package sync imports tickets from customer ticketing systems by polling
|
||||
// (§5.3) and exposes per-system connectors used for both syncing and the
|
||||
// admin "Test connection" button.
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
)
|
||||
|
||||
// Ticket is the system-agnostic shape a connector returns.
|
||||
type Ticket struct {
|
||||
Key string
|
||||
URL string
|
||||
Type string // epic | story | task
|
||||
Title string
|
||||
Description string
|
||||
Attachments []TicketAttachment
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
type TicketAttachment struct {
|
||||
Name string
|
||||
URL string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
// Connector talks to one customer's ticketing system.
|
||||
type Connector interface {
|
||||
// TestConnection performs a cheap authenticated call (§5.3).
|
||||
TestConnection(ctx context.Context) error
|
||||
// FetchUpdated returns tickets assigned to identity updated since the
|
||||
// watermark (callers pass lastSyncAt − 5 min for the overlap window).
|
||||
FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error)
|
||||
// ListAssignedKeys returns every ticket key currently assigned to
|
||||
// identity — used to flag orphaned tasks.
|
||||
ListAssignedKeys(ctx context.Context, identity string) ([]string, error)
|
||||
// Authorize adds the system's auth headers to an attachment download.
|
||||
Authorize(req *http.Request)
|
||||
}
|
||||
|
||||
// Credentials is the decrypted per-system credential map (§4.2 shapes).
|
||||
type Credentials map[string]string
|
||||
|
||||
// NewConnector builds the connector for a ticketing config + decrypted
|
||||
// credentials.
|
||||
func NewConnector(t domain.TicketingType, baseURL, projectKey string, creds Credentials) (Connector, error) {
|
||||
switch t {
|
||||
case domain.TicketingJira:
|
||||
if creds["email"] == "" || creds["apiToken"] == "" {
|
||||
return nil, fmt.Errorf("jira requires email and apiToken")
|
||||
}
|
||||
return &jiraConnector{baseURL: baseURL, projectKey: projectKey, email: creds["email"], apiToken: creds["apiToken"]}, nil
|
||||
case domain.TicketingAzure:
|
||||
if creds["organization"] == "" || creds["project"] == "" || creds["pat"] == "" {
|
||||
return nil, fmt.Errorf("azure_devops requires organization, project and pat")
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://dev.azure.com"
|
||||
}
|
||||
return &azureConnector{baseURL: baseURL, organization: creds["organization"], project: creds["project"], pat: creds["pat"]}, nil
|
||||
case domain.TicketingYouTrack:
|
||||
if creds["permanentToken"] == "" {
|
||||
return nil, fmt.Errorf("youtrack requires permanentToken")
|
||||
}
|
||||
return &youtrackConnector{baseURL: baseURL, projectKey: projectKey, token: creds["permanentToken"]}, nil
|
||||
case domain.TicketingDemo:
|
||||
return &demoConnector{projectKey: projectKey}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown ticketing type %q", t)
|
||||
}
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// getJSON performs an authorized GET and decodes JSON, with one retry on
|
||||
// 5xx/network errors (§12).
|
||||
func getJSON(ctx context.Context, authorize func(*http.Request), url string, dst any) error {
|
||||
return doJSON(ctx, authorize, http.MethodGet, url, nil, dst)
|
||||
}
|
||||
|
||||
func doJSON(ctx context.Context, authorize func(*http.Request), method, url string, body []byte, dst any) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
authorize(req)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
lastErr = fmt.Errorf("%s %s: status %d", method, url, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("%s %s: status %d: %.200s", method, url, resp.StatusCode, data)
|
||||
}
|
||||
if dst == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data, dst); err != nil {
|
||||
return fmt.Errorf("decode %s: %w", url, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func bodyReader(b []byte) io.Reader {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// demoConnector fabricates tickets locally (§11.11) so the entire
|
||||
// import→atomize→publish→claim flow is demonstrable offline. Deterministic:
|
||||
// the same project key always yields the same tickets. A project key ending
|
||||
// in "-ORPHAN" omits the last ticket from ListAssignedKeys to exercise the
|
||||
// orphan flow.
|
||||
type demoConnector struct {
|
||||
projectKey string
|
||||
}
|
||||
|
||||
var demoTickets = []struct {
|
||||
suffix int
|
||||
typ string
|
||||
title string
|
||||
description string
|
||||
}{
|
||||
{1, "epic", "Customer self-service portal",
|
||||
"Build a portal where customers can view invoices, update billing details, and download usage reports. Must integrate with the existing SSO and billing systems."},
|
||||
{2, "story", "User import from CSV",
|
||||
"As an admin I can bulk-import users from a CSV file with column mapping, validation preview, and an idempotent re-run option. Errors are reported per row."},
|
||||
{3, "story", "Email notification preferences",
|
||||
"As a user I can choose which events trigger email notifications (digest, immediate, off) and unsubscribe via a one-click link that does not require login."},
|
||||
{4, "task", "Rate-limit public API endpoints",
|
||||
"Add per-token rate limiting to all public API endpoints with sensible defaults, 429 responses with Retry-After, and metrics for limit hits."},
|
||||
{5, "task", "Fix timezone handling in reports",
|
||||
"Scheduled reports render dates in server time instead of the workspace timezone. Audit all report templates and normalize via the workspace setting."},
|
||||
}
|
||||
|
||||
func (d *demoConnector) Authorize(*http.Request) {}
|
||||
|
||||
func (d *demoConnector) TestConnection(context.Context) error { return nil }
|
||||
|
||||
func (d *demoConnector) key(i int) string {
|
||||
base := strings.TrimSuffix(strings.ToUpper(d.projectKey), "-ORPHAN")
|
||||
if base == "" {
|
||||
base = "DEMO"
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", base, i)
|
||||
}
|
||||
|
||||
func (d *demoConnector) FetchUpdated(_ context.Context, _ string, _ time.Time) ([]Ticket, error) {
|
||||
out := make([]Ticket, 0, len(demoTickets))
|
||||
for _, dt := range demoTickets {
|
||||
out = append(out, Ticket{
|
||||
Key: d.key(dt.suffix),
|
||||
URL: "https://demo.invalid/browse/" + d.key(dt.suffix),
|
||||
Type: dt.typ,
|
||||
Title: dt.title,
|
||||
Description: dt.description,
|
||||
Raw: map[string]any{"demo": true, "suffix": dt.suffix},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *demoConnector) ListAssignedKeys(context.Context, string) ([]string, error) {
|
||||
n := len(demoTickets)
|
||||
if strings.HasSuffix(strings.ToUpper(d.projectKey), "-ORPHAN") {
|
||||
n-- // last ticket "disappears" upstream → orphan flow
|
||||
}
|
||||
keys := make([]string, 0, n)
|
||||
for _, dt := range demoTickets[:n] {
|
||||
keys = append(keys, d.key(dt.suffix))
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jiraConnector talks to Jira Cloud REST v3 with email+API-token basic auth.
|
||||
type jiraConnector struct {
|
||||
baseURL string
|
||||
projectKey string
|
||||
email string
|
||||
apiToken string
|
||||
}
|
||||
|
||||
func (j *jiraConnector) Authorize(req *http.Request) {
|
||||
req.SetBasicAuth(j.email, j.apiToken)
|
||||
}
|
||||
|
||||
func (j *jiraConnector) TestConnection(ctx context.Context) error {
|
||||
var me struct {
|
||||
AccountID string `json:"accountId"`
|
||||
}
|
||||
if err := getJSON(ctx, j.Authorize, j.baseURL+"/rest/api/3/myself", &me); err != nil {
|
||||
return fmt.Errorf("jira /myself: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type jiraIssue struct {
|
||||
Key string `json:"key"`
|
||||
Fields struct {
|
||||
Summary string `json:"summary"`
|
||||
Description map[string]any `json:"description"` // ADF document
|
||||
IssueType struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"issuetype"`
|
||||
Attachment []struct {
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
MimeType string `json:"mimeType"`
|
||||
} `json:"attachment"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
|
||||
func (j *jiraConnector) search(ctx context.Context, jql, fields string) ([]jiraIssue, error) {
|
||||
var all []jiraIssue
|
||||
for startAt := 0; ; {
|
||||
var page struct {
|
||||
Issues []jiraIssue `json:"issues"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
u := fmt.Sprintf("%s/rest/api/3/search?jql=%s&fields=%s&maxResults=100&startAt=%d",
|
||||
j.baseURL, url.QueryEscape(jql), url.QueryEscape(fields), startAt)
|
||||
if err := getJSON(ctx, j.Authorize, u, &page); err != nil {
|
||||
return nil, fmt.Errorf("jira search: %w", err)
|
||||
}
|
||||
all = append(all, page.Issues...)
|
||||
startAt += len(page.Issues)
|
||||
if len(page.Issues) == 0 || startAt >= page.Total {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (j *jiraConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) {
|
||||
jql := fmt.Sprintf(`assignee = %q AND project = %q AND updated >= %q ORDER BY updated ASC`,
|
||||
identity, j.projectKey, since.UTC().Format("2006/01/02 15:04"))
|
||||
issues, err := j.search(ctx, jql, "summary,description,issuetype,attachment")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Ticket, 0, len(issues))
|
||||
for _, is := range issues {
|
||||
t := Ticket{
|
||||
Key: is.Key,
|
||||
URL: j.baseURL + "/browse/" + is.Key,
|
||||
Type: jiraType(is.Fields.IssueType.Name),
|
||||
Title: is.Fields.Summary,
|
||||
Description: adfToText(is.Fields.Description),
|
||||
Raw: map[string]any{"key": is.Key, "issueType": is.Fields.IssueType.Name},
|
||||
}
|
||||
for _, a := range is.Fields.Attachment {
|
||||
t.Attachments = append(t.Attachments, TicketAttachment{Name: a.Filename, URL: a.Content, MimeType: a.MimeType})
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (j *jiraConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) {
|
||||
jql := fmt.Sprintf(`assignee = %q AND project = %q`, identity, j.projectKey)
|
||||
issues, err := j.search(ctx, jql, "key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, len(issues))
|
||||
for i, is := range issues {
|
||||
keys[i] = is.Key
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func jiraType(name string) string {
|
||||
switch strings.ToLower(name) {
|
||||
case "epic":
|
||||
return "epic"
|
||||
case "story", "user story":
|
||||
return "story"
|
||||
default:
|
||||
return "task"
|
||||
}
|
||||
}
|
||||
|
||||
// adfToText flattens an Atlassian Document Format tree into plain text —
|
||||
// good enough for atomization input.
|
||||
func adfToText(node map[string]any) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
var walk func(n map[string]any)
|
||||
walk = func(n map[string]any) {
|
||||
if text, ok := n["text"].(string); ok {
|
||||
sb.WriteString(text)
|
||||
}
|
||||
if t, _ := n["type"].(string); t == "paragraph" || t == "heading" || t == "listItem" {
|
||||
defer sb.WriteString("\n")
|
||||
}
|
||||
if content, ok := n["content"].([]any); ok {
|
||||
for _, child := range content {
|
||||
if m, ok := child.(map[string]any); ok {
|
||||
walk(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// youtrackConnector talks to YouTrack's REST API with a permanent token.
|
||||
type youtrackConnector struct {
|
||||
baseURL string
|
||||
projectKey string
|
||||
token string
|
||||
}
|
||||
|
||||
func (y *youtrackConnector) Authorize(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+y.token)
|
||||
}
|
||||
|
||||
func (y *youtrackConnector) TestConnection(ctx context.Context) error {
|
||||
var me struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
if err := getJSON(ctx, y.Authorize, y.baseURL+"/api/users/me?fields=login", &me); err != nil {
|
||||
return fmt.Errorf("youtrack /api/users/me: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ytIssue struct {
|
||||
IDReadable string `json:"idReadable"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Attachments []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mimeType"`
|
||||
} `json:"attachments"`
|
||||
CustomFields []struct {
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"customFields"`
|
||||
}
|
||||
|
||||
func (y *youtrackConnector) query(ctx context.Context, q, fields string) ([]ytIssue, error) {
|
||||
var all []ytIssue
|
||||
for skip := 0; ; skip += 100 {
|
||||
var page []ytIssue
|
||||
u := fmt.Sprintf("%s/api/issues?query=%s&fields=%s&$top=100&$skip=%d",
|
||||
y.baseURL, url.QueryEscape(q), url.QueryEscape(fields), skip)
|
||||
if err := getJSON(ctx, y.Authorize, u, &page); err != nil {
|
||||
return nil, fmt.Errorf("youtrack issues: %w", err)
|
||||
}
|
||||
all = append(all, page...)
|
||||
if len(page) < 100 {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ytFields = "idReadable,summary,description,attachments(name,url,mimeType),customFields(name,value(name))"
|
||||
|
||||
func (y *youtrackConnector) FetchUpdated(ctx context.Context, identity string, since time.Time) ([]Ticket, error) {
|
||||
q := fmt.Sprintf("assignee: %s project: %s updated: %s .. *",
|
||||
identity, y.projectKey, since.UTC().Format("2006-01-02T15:04"))
|
||||
issues, err := y.query(ctx, q, ytFields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Ticket, 0, len(issues))
|
||||
for _, is := range issues {
|
||||
out = append(out, y.toTicket(is))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (y *youtrackConnector) toTicket(is ytIssue) Ticket {
|
||||
issueType := "task"
|
||||
for _, cf := range is.CustomFields {
|
||||
if cf.Name == "Type" {
|
||||
if v, ok := cf.Value.(map[string]any); ok {
|
||||
if name, ok := v["name"].(string); ok {
|
||||
issueType = ytType(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t := Ticket{
|
||||
Key: is.IDReadable,
|
||||
URL: y.baseURL + "/issue/" + is.IDReadable,
|
||||
Type: issueType,
|
||||
Title: is.Summary,
|
||||
Description: is.Description,
|
||||
Raw: map[string]any{"idReadable": is.IDReadable},
|
||||
}
|
||||
for _, a := range is.Attachments {
|
||||
u := a.URL
|
||||
if strings.HasPrefix(u, "/") {
|
||||
u = y.baseURL + u
|
||||
}
|
||||
t.Attachments = append(t.Attachments, TicketAttachment{Name: a.Name, URL: u, MimeType: a.MimeType})
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (y *youtrackConnector) ListAssignedKeys(ctx context.Context, identity string) ([]string, error) {
|
||||
q := fmt.Sprintf("assignee: %s project: %s", identity, y.projectKey)
|
||||
issues, err := y.query(ctx, q, "idReadable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, len(issues))
|
||||
for i, is := range issues {
|
||||
keys[i] = is.IDReadable
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func ytType(name string) string {
|
||||
switch strings.ToLower(name) {
|
||||
case "epic":
|
||||
return "epic"
|
||||
case "story", "user story", "feature":
|
||||
return "story"
|
||||
default:
|
||||
return "task"
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,21 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.toast.ok { border-left-color: var(--ok); }
|
||||
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.tabs [aria-selected=true] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
|
||||
|
||||
dialog {
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 24px; max-width: 90vw; max-height: 90vh; overflow: auto;
|
||||
}
|
||||
dialog::backdrop { background: rgba(0,0,0,0.4); }
|
||||
fieldset {
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
margin: 0 0 16px; padding: 16px;
|
||||
}
|
||||
legend { font-weight: 600; padding: 0 8px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row { flex-direction: column; gap: 0; }
|
||||
.topnav { flex-wrap: wrap; }
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
|
||||
const errorBox = document.getElementById('error');
|
||||
const showErr = (e) => { errorBox.textContent = e.message || String(e); };
|
||||
|
||||
// ---- tabs ----
|
||||
const tabs = document.querySelectorAll('[role=tab]');
|
||||
tabs.forEach((btn) => btn.addEventListener('click', () => {
|
||||
tabs.forEach((b) => b.setAttribute('aria-selected', b === btn ? 'true' : 'false'));
|
||||
document.querySelectorAll('[role=tabpanel]').forEach((p) => { p.hidden = true; });
|
||||
document.getElementById('tab-' + btn.dataset.tab).hidden = false;
|
||||
loaders[btn.dataset.tab]();
|
||||
}));
|
||||
|
||||
// ---- customers ----
|
||||
const dlg = document.getElementById('customer-dialog');
|
||||
let editingCustomer = null; // null = creating
|
||||
let consultants = [];
|
||||
|
||||
function credsFromForm(type) {
|
||||
switch (type) {
|
||||
case 'jira':
|
||||
return { email: val('c-jira-email'), apiToken: val('c-jira-token') };
|
||||
case 'azure_devops':
|
||||
return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') };
|
||||
case 'youtrack':
|
||||
return { permanentToken: val('c-yt-token') };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
const val = (id) => document.getElementById(id).value.trim();
|
||||
|
||||
function credsEntered(type) {
|
||||
const c = credsFromForm(type);
|
||||
return Object.values(c).some(Boolean);
|
||||
}
|
||||
|
||||
document.getElementById('c-type').addEventListener('change', (e) => {
|
||||
document.querySelectorAll('.creds').forEach((f) => { f.hidden = true; });
|
||||
document.getElementById('creds-' + e.target.value).hidden = false;
|
||||
document.getElementById('f-baseurl').style.display =
|
||||
e.target.value === 'demo' ? 'none' : '';
|
||||
});
|
||||
|
||||
async function loadConsultants() {
|
||||
const res = await api('GET', '/api/v1/admin/users?role=consultant&limit=200');
|
||||
consultants = res.users;
|
||||
const sel = document.getElementById('c-consultants');
|
||||
sel.replaceChildren(...consultants.map((u) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = u.id;
|
||||
o.textContent = `${u.name} <${u.email}>`;
|
||||
return o;
|
||||
}));
|
||||
}
|
||||
|
||||
function openCustomerDialog(customer) {
|
||||
editingCustomer = customer || null;
|
||||
document.getElementById('customer-dialog-title').textContent =
|
||||
customer ? `Edit ${customer.name}` : 'New customer';
|
||||
document.getElementById('c-name').value = customer ? customer.name : '';
|
||||
document.getElementById('c-type').value = customer ? customer.ticketing.type : 'jira';
|
||||
document.getElementById('c-project').value = customer ? customer.ticketing.projectKey : '';
|
||||
document.getElementById('c-baseurl').value = customer ? customer.ticketing.baseUrl : '';
|
||||
document.getElementById('c-poll').value = customer ? customer.ticketing.pollIntervalSec : 60;
|
||||
document.getElementById('c-budget').value = customer ? customer.defaultBudget : 1000;
|
||||
document.querySelectorAll('.creds input').forEach((i) => { i.value = ''; });
|
||||
document.getElementById('c-type').dispatchEvent(new Event('change'));
|
||||
const sel = document.getElementById('c-consultants');
|
||||
[...sel.options].forEach((o) => {
|
||||
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
|
||||
});
|
||||
document.getElementById('c-test-result').textContent =
|
||||
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
|
||||
dlg.showModal();
|
||||
}
|
||||
|
||||
document.getElementById('customer-new').addEventListener('click', async () => {
|
||||
try { await loadConsultants(); openCustomerDialog(null); } catch (e) { showErr(e); }
|
||||
});
|
||||
document.getElementById('c-cancel').addEventListener('click', () => dlg.close());
|
||||
|
||||
document.getElementById('c-test').addEventListener('click', async () => {
|
||||
const out = document.getElementById('c-test-result');
|
||||
out.textContent = 'Testing…';
|
||||
const type = val('c-type');
|
||||
try {
|
||||
let res;
|
||||
if (editingCustomer && !credsEntered(type)) {
|
||||
res = await api('POST', `/api/v1/admin/customers/${editingCustomer.id}/test-connection`, {});
|
||||
} else {
|
||||
res = await api('POST', '/api/v1/admin/customers/test-connection', {
|
||||
type, baseUrl: val('c-baseurl'), projectKey: val('c-project'),
|
||||
credentials: credsFromForm(type),
|
||||
});
|
||||
}
|
||||
out.textContent = res.ok ? `✓ Connected (${res.latencyMs} ms)` : `✗ ${res.error}`;
|
||||
out.style.color = res.ok ? 'var(--ok)' : 'var(--err)';
|
||||
} catch (e) { out.textContent = '✗ ' + e.message; out.style.color = 'var(--err)'; }
|
||||
});
|
||||
|
||||
document.getElementById('customer-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const type = val('c-type');
|
||||
const body = {
|
||||
name: val('c-name'),
|
||||
type,
|
||||
baseUrl: val('c-baseurl'),
|
||||
projectKey: val('c-project'),
|
||||
pollIntervalSec: parseInt(val('c-poll'), 10) || 60,
|
||||
defaultBudget: parseFloat(val('c-budget')) || 0,
|
||||
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
|
||||
};
|
||||
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
|
||||
try {
|
||||
if (editingCustomer) {
|
||||
body.version = editingCustomer.version;
|
||||
await api('PATCH', `/api/v1/admin/customers/${editingCustomer.id}`, body);
|
||||
} else {
|
||||
await api('POST', '/api/v1/admin/customers', body);
|
||||
}
|
||||
dlg.close();
|
||||
toast('Customer saved.', 'ok');
|
||||
loadCustomers();
|
||||
} catch (err) {
|
||||
if (err.status === 409) toast('Conflict: ' + err.message, 'err');
|
||||
else showErr(err);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/admin/customers?includeArchived=true');
|
||||
const host = document.getElementById('customer-list');
|
||||
host.replaceChildren(...res.customers.map(renderCustomerCard));
|
||||
if (!res.customers.length) {
|
||||
host.textContent = 'No customers yet — create the first one.';
|
||||
}
|
||||
} catch (e) { showErr(e); }
|
||||
}
|
||||
|
||||
function renderCustomerCard(c) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const syncBadge = c.ticketing.lastSyncStatus === 'ok'
|
||||
? `<span class="badge" style="color:var(--ok)">sync ok</span>`
|
||||
: c.ticketing.lastSyncStatus === 'error'
|
||||
? `<span class="badge" style="color:var(--err)" title="${esc(c.ticketing.lastSyncError)}">sync error</span>`
|
||||
: '<span class="badge">never synced</span>';
|
||||
card.innerHTML = `
|
||||
<div class="spread">
|
||||
<h3>${esc(c.name)} ${c.archived ? '<span class="badge">archived</span>' : ''}</h3>
|
||||
<span>${syncBadge}</span>
|
||||
</div>
|
||||
<p class="muted">${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() : ''}</p>
|
||||
<div>
|
||||
<button class="btn small" data-act="edit">Edit</button>
|
||||
<button class="btn small" data-act="sync">Sync now</button>
|
||||
<button class="btn small" data-act="archive">${c.archived ? 'Unarchive' : 'Archive'}</button>
|
||||
<button class="btn small danger" data-act="delete">Delete</button>
|
||||
</div>`;
|
||||
card.querySelector('[data-act=edit]').addEventListener('click', async () => {
|
||||
try { await loadConsultants(); openCustomerDialog(c); } catch (e) { showErr(e); }
|
||||
});
|
||||
card.querySelector('[data-act=sync]').addEventListener('click', async () => {
|
||||
try { await api('POST', `/api/v1/admin/customers/${c.id}/sync-now`, {}); toast('Sync queued.', 'ok'); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
card.querySelector('[data-act=archive]').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('PATCH', `/api/v1/admin/customers/${c.id}`, { archived: !c.archived, version: c.version });
|
||||
loadCustomers();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
card.querySelector('[data-act=delete]').addEventListener('click', async () => {
|
||||
if (!confirm(`Delete customer "${c.name}"? This cannot be undone.`)) return;
|
||||
try { await api('DELETE', `/api/v1/admin/customers/${c.id}`); loadCustomers(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---- users ----
|
||||
let userSearchTimer;
|
||||
document.getElementById('user-search').addEventListener('input', () => {
|
||||
clearTimeout(userSearchTimer);
|
||||
userSearchTimer = setTimeout(loadUsers, 250);
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const q = encodeURIComponent(document.getElementById('user-search').value.trim());
|
||||
const res = await api('GET', `/api/v1/admin/users?q=${q}&limit=200`);
|
||||
const tbody = document.querySelector('#user-table tbody');
|
||||
tbody.replaceChildren(...res.users.map(renderUserRow));
|
||||
} catch (e) { showErr(e); }
|
||||
}
|
||||
|
||||
function renderUserRow(u) {
|
||||
const tr = document.createElement('tr');
|
||||
const roleCell = (role) => {
|
||||
const td = document.createElement('td');
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = u.roles[role];
|
||||
cb.setAttribute('aria-label', `${role} role for ${u.name}`);
|
||||
cb.addEventListener('change', async () => {
|
||||
const roles = { ...u.roles, [role]: cb.checked };
|
||||
try {
|
||||
const res = await api('PATCH', `/api/v1/admin/users/${u.id}`, { roles });
|
||||
u = res.user;
|
||||
toast(`Roles updated for ${u.name}.`, 'ok');
|
||||
} catch (e) { cb.checked = !cb.checked; toast(e.message, 'err'); }
|
||||
});
|
||||
td.appendChild(cb);
|
||||
return td;
|
||||
};
|
||||
const name = document.createElement('td'); name.textContent = u.name;
|
||||
const email = document.createElement('td'); email.textContent = u.email;
|
||||
const status = document.createElement('td');
|
||||
status.textContent = u.disabled ? 'disabled' : 'active';
|
||||
const actions = document.createElement('td');
|
||||
|
||||
const disableBtn = document.createElement('button');
|
||||
disableBtn.className = 'btn small';
|
||||
disableBtn.textContent = u.disabled ? 'Enable' : 'Disable';
|
||||
disableBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await api('PATCH', `/api/v1/admin/users/${u.id}`, { disabled: !u.disabled });
|
||||
u = res.user; status.textContent = u.disabled ? 'disabled' : 'active';
|
||||
disableBtn.textContent = u.disabled ? 'Enable' : 'Disable';
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.className = 'btn small';
|
||||
resetBtn.textContent = 'Force reset';
|
||||
resetBtn.addEventListener('click', async () => {
|
||||
try { await api('PATCH', `/api/v1/admin/users/${u.id}`, { forceReset: true }); toast('Password reset forced.', 'ok'); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'btn small danger';
|
||||
delBtn.textContent = 'Delete';
|
||||
delBtn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete user ${u.email}?`)) return;
|
||||
try { await api('DELETE', `/api/v1/admin/users/${u.id}`); tr.remove(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
actions.append(disableBtn, document.createTextNode(' '), resetBtn, document.createTextNode(' '), delBtn);
|
||||
tr.append(name, email, roleCell('admin'), roleCell('consultant'), roleCell('developer'), status, actions);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// ---- settings ----
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/admin/settings');
|
||||
const s = res.settings;
|
||||
document.getElementById('s-branding').value = s.brandingName;
|
||||
document.getElementById('s-atomizer').value = s.atomizerBaseUrlOverride;
|
||||
document.getElementById('s-poll').value = s.defaultPollIntervalSec;
|
||||
document.getElementById('s-budget').value = s.defaultCustomerBudget;
|
||||
document.getElementById('s-hideclaims').checked = s.hideCompetingClaims;
|
||||
} catch (e) { showErr(e); }
|
||||
}
|
||||
|
||||
document.getElementById('settings-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api('PATCH', '/api/v1/admin/settings', {
|
||||
brandingName: val('s-branding'),
|
||||
atomizerBaseUrlOverride: val('s-atomizer'),
|
||||
defaultPollIntervalSec: parseInt(val('s-poll'), 10),
|
||||
defaultCustomerBudget: parseFloat(val('s-budget')),
|
||||
hideCompetingClaims: document.getElementById('s-hideclaims').checked,
|
||||
});
|
||||
toast('Settings saved.', 'ok');
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// ---- status ----
|
||||
async function loadStatus() {
|
||||
const host = document.getElementById('status-body');
|
||||
host.textContent = 'Loading…';
|
||||
try {
|
||||
const s = await api('GET', '/api/v1/admin/service-status');
|
||||
const ext = (e) => `
|
||||
<div class="spread">
|
||||
<span><strong>${esc(e.name)}</strong> <span class="muted">${esc(e.url)}</span></span>
|
||||
<span>${e.healthy
|
||||
? `<span class="badge" style="color:var(--ok)">healthy · ${e.latencyMs} ms</span>`
|
||||
: `<span class="badge" style="color:var(--err)" title="${esc(e.error || '')}">down</span>`}
|
||||
<span class="badge">breaker: ${esc(e.breaker)}</span></span>
|
||||
</div>`;
|
||||
let workers = '<p class="muted">No sync workers running.</p>';
|
||||
if (Array.isArray(s.syncWorkers) && s.syncWorkers.length) {
|
||||
workers = s.syncWorkers.map((wkr) => `
|
||||
<div class="spread">
|
||||
<span>${esc(wkr.customerName)} <span class="muted">every ${wkr.pollIntervalSec}s</span></span>
|
||||
<span class="badge" style="color:${wkr.lastStatus === 'error' ? 'var(--err)' : 'var(--ok)'}"
|
||||
title="${esc(wkr.lastError || '')}">${esc(wkr.lastStatus || 'pending')}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
host.innerHTML = `
|
||||
<div class="spread"><strong>MongoDB</strong>
|
||||
${s.mongo.healthy ? '<span class="badge" style="color:var(--ok)">healthy</span>'
|
||||
: `<span class="badge" style="color:var(--err)">${esc(s.mongo.error)}</span>`}</div>
|
||||
${ext(s.atomizer)}
|
||||
${ext(s.workPerformer)}
|
||||
<h3>Sync workers</h3>${workers}
|
||||
<h3>Job queue</h3>
|
||||
<p class="muted">${esc(JSON.stringify(s.jobs))}</p>`;
|
||||
} 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();
|
||||
@@ -0,0 +1,151 @@
|
||||
{{define "content"}}
|
||||
<h1>Administration</h1>
|
||||
<div class="error-box" id="error" role="alert"></div>
|
||||
|
||||
<nav class="tabs" role="tablist" aria-label="Admin sections">
|
||||
<button class="btn" role="tab" data-tab="customers" aria-selected="true">Customers</button>
|
||||
<button class="btn" role="tab" data-tab="users">Users</button>
|
||||
<button class="btn" role="tab" data-tab="settings">Settings</button>
|
||||
<button class="btn" role="tab" data-tab="status">Service status</button>
|
||||
<button class="btn" role="tab" data-tab="audit">Audit log</button>
|
||||
</nav>
|
||||
|
||||
<section id="tab-customers" class="card" role="tabpanel">
|
||||
<div class="spread">
|
||||
<h2>Customers</h2>
|
||||
<button class="btn primary" id="customer-new">+ New customer</button>
|
||||
</div>
|
||||
<div id="customer-list" class="stack"></div>
|
||||
|
||||
<dialog id="customer-dialog">
|
||||
<form id="customer-form" method="dialog" class="stack" style="min-width:480px">
|
||||
<h2 id="customer-dialog-title">New customer</h2>
|
||||
<div class="field">
|
||||
<label for="c-name">Name</label>
|
||||
<input type="text" id="c-name" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="c-type">Ticketing system</label>
|
||||
<select id="c-type">
|
||||
<option value="jira">Jira Cloud</option>
|
||||
<option value="azure_devops">Azure DevOps</option>
|
||||
<option value="youtrack">YouTrack</option>
|
||||
<option value="demo">Demo (offline)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="c-project">Project key</label>
|
||||
<input type="text" id="c-project" placeholder="ACME">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="f-baseurl">
|
||||
<label for="c-baseurl">Base URL</label>
|
||||
<input type="text" id="c-baseurl" placeholder="https://acme.atlassian.net">
|
||||
</div>
|
||||
<fieldset id="creds-jira" class="creds">
|
||||
<legend>Jira credentials</legend>
|
||||
<div class="field"><label for="c-jira-email">Email</label><input type="text" id="c-jira-email" autocomplete="off"></div>
|
||||
<div class="field"><label for="c-jira-token">API token</label><input type="password" id="c-jira-token" autocomplete="off"></div>
|
||||
</fieldset>
|
||||
<fieldset id="creds-azure_devops" class="creds" hidden>
|
||||
<legend>Azure DevOps credentials</legend>
|
||||
<div class="field"><label for="c-ado-org">Organization</label><input type="text" id="c-ado-org"></div>
|
||||
<div class="field"><label for="c-ado-project">Project</label><input type="text" id="c-ado-project"></div>
|
||||
<div class="field"><label for="c-ado-pat">Personal access token</label><input type="password" id="c-ado-pat" autocomplete="off"></div>
|
||||
</fieldset>
|
||||
<fieldset id="creds-youtrack" class="creds" hidden>
|
||||
<legend>YouTrack credentials</legend>
|
||||
<div class="field"><label for="c-yt-token">Permanent token</label><input type="password" id="c-yt-token" autocomplete="off"></div>
|
||||
</fieldset>
|
||||
<fieldset id="creds-demo" class="creds" hidden>
|
||||
<legend>Demo</legend>
|
||||
<p class="hint">No credentials required — tickets are fabricated locally.</p>
|
||||
</fieldset>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="c-poll">Poll interval (seconds)</label>
|
||||
<input type="number" id="c-poll" min="10" value="60">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="c-budget">Default budget</label>
|
||||
<input type="number" id="c-budget" min="0" step="0.01" value="1000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="c-consultants">Assigned consultants</label>
|
||||
<select id="c-consultants" multiple size="4"></select>
|
||||
<p class="hint">Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.</p>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<span id="c-test-result" class="hint" aria-live="polite"></span>
|
||||
<span>
|
||||
<button class="btn" type="button" id="c-test">Test connection</button>
|
||||
<button class="btn" type="button" id="c-cancel">Cancel</button>
|
||||
<button class="btn primary" type="submit" id="c-save">Save</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</section>
|
||||
|
||||
<section id="tab-users" class="card" role="tabpanel" hidden>
|
||||
<div class="spread">
|
||||
<h2>Users</h2>
|
||||
<input type="text" id="user-search" placeholder="Search name or email…" aria-label="Search users" style="max-width:280px">
|
||||
</div>
|
||||
<table class="list" id="user-table">
|
||||
<thead><tr><th>Name</th><th>Email</th><th>Admin</th><th>Consultant</th><th>Developer</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="tab-settings" class="card" role="tabpanel" hidden>
|
||||
<h2>Application settings</h2>
|
||||
<form id="settings-form" class="stack" style="max-width:480px">
|
||||
<div class="field">
|
||||
<label for="s-branding">Branding name</label>
|
||||
<input type="text" id="s-branding">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="s-atomizer">Atomizer base URL override</label>
|
||||
<input type="text" id="s-atomizer" placeholder="leave empty to use the environment default">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="s-poll">Default poll interval (seconds)</label>
|
||||
<input type="number" id="s-poll" min="10">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="s-budget">Default customer budget</label>
|
||||
<input type="number" id="s-budget" min="0" step="0.01">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><input type="checkbox" id="s-hideclaims"> Hide competing claim requests from developers</label>
|
||||
</div>
|
||||
<button class="btn primary" type="submit">Save settings</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="tab-status" class="card" role="tabpanel" hidden>
|
||||
<div class="spread">
|
||||
<h2>Service status</h2>
|
||||
<button class="btn small" id="status-refresh">Refresh</button>
|
||||
</div>
|
||||
<div id="status-body" class="stack"></div>
|
||||
</section>
|
||||
|
||||
<section id="tab-audit" class="card" role="tabpanel" hidden>
|
||||
<div class="spread">
|
||||
<h2>Audit log</h2>
|
||||
<span>
|
||||
<input type="text" id="audit-entity" placeholder="Filter by entity id" aria-label="Filter by entity id">
|
||||
<button class="btn small" id="audit-apply">Apply</button>
|
||||
</span>
|
||||
</div>
|
||||
<table class="list" id="audit-table">
|
||||
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Entity</th><th>Details</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<button class="btn mt" id="audit-more">Load more</button>
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user