Files
member-console/internal/server/operator.go
T
cgalo5758 36e58cd821 Flatten operator sidebar to seven entries
Move sub-surfaces into their sections: billing views get a pill row,
org types a header button. Replace inline IdP handoff copy with an SVG
icon and tooltip, add help icons to dense form rows, and delete the
registry-driven sidebar nav plumbing. Update specs and tests.
2026-08-23 18:26:11 -05:00

520 lines
20 KiB
Go

package server
import (
"bytes"
"context"
"database/sql"
"html/template"
"io/fs"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"github.com/spf13/viper"
)
// OperatorHandler handles the main operator page
type OperatorHandler struct {
AuthConfig *auth.Config
Logger *slog.Logger
Templates *SafeTemplates
Database *sql.DB // for the registry-driven Integration nav on the landing surface
BillingQ billing.Querier
EntitlementsQ entitlements.Querier
OrgQ organization.Querier
IdentityQ identity.Querier
// IntegrationQ backs the landing surface's System panel (registered
// providers + outbox health). Derived from Database in the constructor;
// a separate field so tests can inject a fake without a live database.
IntegrationQ integration.Querier
// IntegrationConfigs backs the System panel's configuration-readiness
// signal (configurationReadiness) — the same declared-config list the
// Integrations list and the per-integration settings page read.
IntegrationConfigs []IntegrationConfigInfo
// StripeQ backs the setup checklist's Stripe-price-mapping predicate
// (AnyMappedPrice), composed with BillingQ.AnyActivePrice to derive the
// conditional "sync a price to Stripe" step (setup_state.go). Derived
// from Database in the constructor, matching IntegrationQ.
StripeQ stripedb.Querier
}
// OperatorHandlerConfig holds configuration for the operator handler
type OperatorHandlerConfig struct {
AuthConfig *auth.Config
Logger *slog.Logger
Database *sql.DB
BillingQ billing.Querier
EntitlementsQ entitlements.Querier
OrgQ organization.Querier
IdentityQ identity.Querier
IntegrationConfigs []IntegrationConfigInfo
}
// NewOperatorHandler creates a new OperatorHandler
func NewOperatorHandler(cfg OperatorHandlerConfig) (*OperatorHandler, error) {
// Parse operator template
templateSubFS, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
return nil, err
}
// operator.html references the `renderBody` template func used by the
// MPA page handlers' BodyTemplate dispatch. This handler now uses it too
// (GetSetupPage, operator_setup.go), so — unlike the former no-op stub —
// it must actually dispatch: look up the named template inside this same
// set and render it. The closure captures `tmpl` (assigned below); funcs
// must be installed before parsing so the parser accepts references to
// "renderBody", so we pre-construct the empty template, install the
// func, then ParseFS. Mirrors NewOperatorPartialsHandler's renderBody.
// Also parses the lookup-result partial — operator.html `{{ template }}`s
// it inline, and the OperatorLookup handler renders it standalone for
// HTMX swap responses — and the setup-checklist partial, shared by the
// landing region and GetSetupPage's BodyTemplate dispatch.
var tmpl *template.Template
tmpl = template.New("operator").Funcs(template.FuncMap{
"renderBody": func(name string, data any) (template.HTML, error) {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
},
"deploymentName": config.DeploymentName,
})
tmpl, err = tmpl.ParseFS(templateSubFS, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html")
if err != nil {
return nil, err
}
return &OperatorHandler{
AuthConfig: cfg.AuthConfig,
Logger: cfg.Logger,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
Database: cfg.Database,
BillingQ: cfg.BillingQ,
EntitlementsQ: cfg.EntitlementsQ,
OrgQ: cfg.OrgQ,
IdentityQ: cfg.IdentityQ,
IntegrationQ: integration.New(cfg.Database),
IntegrationConfigs: cfg.IntegrationConfigs,
StripeQ: stripedb.New(cfg.Database),
}, nil
}
// RegisterRoutes registers operator routes
func (h *OperatorHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /operator", h.requireOperatorRole(h.GetOperatorPage))
mux.HandleFunc("POST /operator/lookup", h.requireOperatorRole(h.OperatorLookup))
mux.HandleFunc("GET /operator/setup", h.requireOperatorRole(h.GetSetupPage))
}
// requireOperatorRole is middleware that checks for the operator role
func (h *OperatorHandler) requireOperatorRole(next http.HandlerFunc) http.HandlerFunc {
return RequireOperatorRole(h.AuthConfig, h.Logger, next)
}
// RequireOperatorRole wraps next so it only runs for a request whose session
// holds the operator-member role; otherwise it responds 403 and logs the
// denial. Exported (beyond the OperatorHandler/OperatorPartialsHandler
// methods that delegate to it below) so an integration registering its own
// operator surface — design.md Decision 8; e.g.
// internal/integrations/fedwiki/web's read-only sites page — gates it with
// the same check core's operator pages use, instead of duplicating the
// role check inline.
func RequireOperatorRole(authConfig *auth.Config, logger *slog.Logger, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !authConfig.HasRole(r, OperatorRole) {
logger.Warn("operator access denied",
slog.String("path", r.URL.Path),
slog.String("reason", "missing operator-member role"))
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next(w, r)
}
}
// OperatorPageData holds data for the operator page template.
type OperatorPageData struct {
Name string
Username string
Email string
KeycloakAccountURL string
CSRFToken string
// IAPosition is the positional tuple `<group>:<capability>[:<instance>]`
// declared by every operator page per the breadcrumb requirement. The
// landing surface declares `runtime:landing` since it sits in the
// runtime group as the curated hot-path entry point.
IAPosition string
// ActiveCapability is the sidebar-link key the current page belongs to
// ("organizations", "grants", ...). Drives the sidebar `active` class.
// Empty on the landing surface (Overview light up).
ActiveCapability string
// BodyTemplate names the partial template to render inside <main>. When
// non-empty the operator.html shell dispatches via renderBody; when
// empty it renders the curated landing surface.
BodyTemplate string
BodyData any
// Activity is the unified-timeline rows for the landing surface's
// "Recent activity" section (operator-composite-expansion D4).
// Populated only on the landing branch; nil/empty on MPA pages.
Activity []ActivityEvent
// Overview holds the landing surface's "At a glance" counts and "System"
// signals. Populated only on the landing branch; the zero value renders
// the surface's empty states, which is what MPA pages get.
Overview OverviewData
// Setup is the setup checklist's derived state, shared by the landing
// region (rendered here, ahead of Overview's tiles, only while
// Setup.RequiredIncomplete) and GetSetupPage's full-page rendering
// (setup_state.go, operator_setup.go). Populated only on the landing
// branch; the zero value's RequiredIncomplete is false, so MPA pages
// render no setup region (they have none to render).
Setup SetupState
// Lookup state for the landing-surface lookup affordance. LookupTerm
// retains the operator's input on re-render so they can edit and
// re-submit; LookupCandidates is populated on ambiguous matches;
// LookupNoMatch is set on zero-match. Unique matches don't end up
// here — they 303-redirect to the resolved detail page.
LookupTerm string
LookupCandidates []LookupCandidateViewModel
LookupNoMatch bool
}
// LookupCandidateViewModel represents one candidate person in the
// disambiguation list rendered when a lookup term matches multiple
// persons by name substring.
type LookupCandidateViewModel struct {
PersonID string
DisplayName string
Email string
}
// ActivityEvent is one row in the landing-surface unified timeline. Each
// row identifies its event type, the org (and person, when applicable),
// a short human summary, and a precomputed link target. Per D4 the
// template branches on EventType to render the right badge/icon — the
// loader doesn't pick visual styles, it picks data.
type ActivityEvent struct {
EventType string // "grant_issued" | "transition" | "invoice_created" | "payment_received"
Timestamp string // pre-formatted display string ("Jan 2, 2006 3:04 PM")
SortKey int64 // unix nanos, used to sort the merged slice
OrgID string
OrgName string
PersonID string // empty when no person attribution (system/webhook actor or billing event)
PersonName string
Summary string // human-readable description of what happened
// Href is the entry's own addressable operator surface (D8/task 7.4:
// "feed entries link to their subjects where addressable"): the org
// detail page for a grant or transition, the billing view for an
// invoice or payment. Empty when the entry has no addressable surface —
// the template renders those entries as plain text, never a dead link.
Href string
}
// GetOperatorPage handles GET /operator
func (h *OperatorHandler) GetOperatorPage(w http.ResponseWriter, r *http.Request) {
// Permanent redirect for legacy ?tab=<id> bookmarks. Slated for
// removal in M8 once operator analytics show no remaining hits.
if tab := r.URL.Query().Get("tab"); tab != "" {
if redirect, ok := legacyTabRedirects[tab]; ok {
http.Redirect(w, r, redirect, http.StatusMovedPermanently)
return
}
}
h.renderLanding(w, r, "", nil, false)
}
// OperatorLookup handles POST /operator/lookup — resolves the operator's
// typed lookup term to a person detail, an org composite, a disambiguation
// listing, or a no-match notice. Resolution order:
// 1. email-exact + name-substring against active persons (one query); if
// one match → HX-Redirect to person detail; if many → render the
// result partial with the disambiguation list (HTMX swap).
// 2. org slug exact; if found → HX-Redirect to org composite.
// 3. otherwise → render the result partial with a no-match notice.
//
// HTMX (hx-post on the form) handles the X-CSRF-Token header via the
// body's hx-headers; this is the same CSRF path every other operator
// form uses. Native form POST hit a brittle Origin-check path in
// gorilla CSRF for some browsers (origin=null on certain submissions),
// so we route through HTMX instead.
func (h *OperatorHandler) OperatorLookup(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderLookupResult(w, "", nil, true)
return
}
term := strings.TrimSpace(r.FormValue("term"))
if term == "" {
// Empty submit: nothing to render. Return empty body so HTMX clears
// any previous result without showing a state.
h.renderLookupResult(w, "", nil, false)
return
}
ctx := r.Context()
persons, err := h.IdentityQ.LookupPersons(ctx, term)
if err != nil {
h.Logger.Warn("lookup: persons query failed", slog.Any("error", err))
}
switch len(persons) {
case 1:
w.Header().Set("HX-Redirect", "/operator/persons/"+persons[0].PersonID)
w.WriteHeader(http.StatusOK)
return
case 0:
// Fall through to org-slug check.
default:
candidates := make([]LookupCandidateViewModel, len(persons))
for i, p := range persons {
candidates[i] = LookupCandidateViewModel{
PersonID: p.PersonID,
DisplayName: p.DisplayName,
Email: p.PrimaryEmail,
}
}
h.renderLookupResult(w, term, candidates, false)
return
}
if org, err := h.OrgQ.GetOrganizationBySlug(ctx, term); err == nil {
w.Header().Set("HX-Redirect", "/operator/organizations/"+org.OrgID)
w.WriteHeader(http.StatusOK)
return
}
h.renderLookupResult(w, term, nil, true)
}
// renderLookupResult renders the lookup-result partial for HTMX swap.
// `term` populates the no-match alert / disambiguation header; `candidates`
// drives the person picker; `noMatch` triggers the warning alert.
func (h *OperatorHandler) renderLookupResult(w http.ResponseWriter, term string, candidates []LookupCandidateViewModel, noMatch bool) {
data := struct {
LookupTerm string
LookupCandidates []LookupCandidateViewModel
LookupNoMatch bool
}{
LookupTerm: term,
LookupCandidates: candidates,
LookupNoMatch: noMatch,
}
h.Templates.Render(w, "operator_lookup_result.html", data)
}
// renderLanding renders the operator landing surface with optional lookup
// state. `lookupTerm` populates the form input on re-render so the
// operator can edit and resubmit; `candidates` triggers the disambiguation
// list; `noMatch` triggers the no-match notice. All three are off on a
// plain landing render.
func (h *OperatorHandler) renderLanding(w http.ResponseWriter, r *http.Request, lookupTerm string, candidates []LookupCandidateViewModel, noMatch bool) {
ctx := r.Context()
limit := parseActivityLimit(r.URL.Query().Get("limit"))
data := OperatorPageData{
Name: h.AuthConfig.GetUserName(ctx),
Username: h.AuthConfig.GetUsername(ctx),
Email: h.AuthConfig.GetUserEmail(ctx),
KeycloakAccountURL: viper.GetString("oidc-idp-issuer-url") + "/account",
CSRFToken: middleware.CSRFToken(r),
IAPosition: "runtime:landing",
Overview: h.loadOverview(ctx),
Setup: h.DeriveSetupState(ctx),
Activity: h.loadRecentActivity(ctx, limit),
LookupTerm: lookupTerm,
LookupCandidates: candidates,
LookupNoMatch: noMatch,
}
h.Templates.Render(w, "operator.html", data)
}
// parseActivityLimit clamps the ?limit=N param to [1, 100] with a default
// of 20. Per D4 the activity feed is one unified timeline and one knob —
// no separate ?activity_limit / ?billing_limit splits.
func parseActivityLimit(raw string) int32 {
const defaultLimit, maxLimit = 20, 100
if raw == "" {
return defaultLimit
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return defaultLimit
}
if n > maxLimit {
return maxLimit
}
return int32(n)
}
// loadRecentActivity assembles the landing-surface unified timeline by
// fetching `limit` rows from each event source (grants, transitions,
// invoices, payments), resolving org and person UUIDs to display names,
// then merging by timestamp DESC and trimming back to `limit`. Per-source
// over-fetch + post-merge trim is correct because no single source's
// recent N rows are guaranteed to be the global recent N.
//
// Failures from any individual source are logged and the source is
// dropped — the timeline degrades gracefully rather than 500ing the
// landing surface. An empty result is the legitimate empty state.
func (h *OperatorHandler) loadRecentActivity(ctx context.Context, limit int32) []ActivityEvent {
events := make([]ActivityEvent, 0, int(limit)*4)
if grants, err := h.EntitlementsQ.ListRecentGrants(ctx, limit); err == nil {
for _, g := range grants {
orgID := ""
if g.GrantedToOrgID.Valid {
orgID = g.GrantedToOrgID.UUID.String()
}
personID := ""
if g.GrantedByPersonID.Valid {
personID = g.GrantedByPersonID.UUID.String()
}
productName := ""
if p, err := h.BillingQ.GetProductByID(ctx, g.ProductID); err == nil {
productName = p.Name
}
events = append(events, ActivityEvent{
EventType: "grant_issued",
Timestamp: g.CreatedAt.Format("Jan 2, 2006 3:04 PM"),
SortKey: g.CreatedAt.UnixNano(),
OrgID: orgID,
PersonID: personID,
Summary: "Issued " + productName + " (" + g.GrantReason + ")",
Href: "/operator/organizations/" + orgID,
})
}
} else {
h.Logger.Warn("activity feed: list recent grants failed", slog.Any("error", err))
}
if transitions, err := h.EntitlementsQ.ListRecentTransitions(ctx, limit); err == nil {
for _, t := range transitions {
personID := ""
if t.ActorID.Valid {
personID = t.ActorID.UUID.String()
}
from := "—"
if t.FromRank.Valid {
from = strconv.Itoa(int(t.FromRank.Int32))
}
to := "—"
if t.ToRank.Valid {
to = strconv.Itoa(int(t.ToRank.Int32))
}
events = append(events, ActivityEvent{
EventType: "transition",
Timestamp: t.EffectiveAt.Format("Jan 2, 2006 3:04 PM"),
SortKey: t.EffectiveAt.UnixNano(),
OrgID: t.OrgID,
PersonID: personID,
Summary: t.TransitionType + " (rank " + from + " → " + to + ", by " + t.ActorType + ")",
Href: "/operator/organizations/" + t.OrgID,
})
}
} else {
h.Logger.Warn("activity feed: list recent transitions failed", slog.Any("error", err))
}
if invoices, err := h.BillingQ.ListRecentInvoices(ctx, limit); err == nil {
for _, inv := range invoices {
events = append(events, ActivityEvent{
EventType: "invoice_created",
Timestamp: inv.CreatedAt.Format("Jan 2, 2006 3:04 PM"),
SortKey: inv.CreatedAt.UnixNano(),
OrgID: inv.OrgID,
Summary: "Invoice " + formatCurrency(inv.AmountDue, inv.Currency) + " · " + inv.Status,
Href: "/operator/billing/invoices",
})
}
} else {
h.Logger.Warn("activity feed: list recent invoices failed", slog.Any("error", err))
}
if payments, err := h.BillingQ.ListRecentPayments(ctx, limit); err == nil {
for _, p := range payments {
events = append(events, ActivityEvent{
EventType: "payment_received",
Timestamp: p.CreatedAt.Format("Jan 2, 2006 3:04 PM"),
SortKey: p.CreatedAt.UnixNano(),
OrgID: p.OrgID,
Summary: "Payment " + formatCurrency(p.Amount, p.Currency) + " · " + p.Status,
Href: "/operator/billing/payments",
})
}
} else {
h.Logger.Warn("activity feed: list recent payments failed", slog.Any("error", err))
}
// Merge by timestamp DESC, then trim to limit.
sort.Slice(events, func(i, j int) bool { return events[i].SortKey > events[j].SortKey })
if int32(len(events)) > limit {
events = events[:limit]
}
// Resolve org and person display names in a second pass. Per-row
// lookups would be N+1 — collect distinct IDs first, then map.
orgNames := make(map[string]string)
personNames := make(map[string]string)
for _, e := range events {
if e.OrgID != "" {
orgNames[e.OrgID] = ""
}
if e.PersonID != "" {
personNames[e.PersonID] = ""
}
}
for orgID := range orgNames {
if o, err := h.OrgQ.GetOrganizationByID(ctx, orgID); err == nil {
orgNames[orgID] = o.Name
}
}
for personID := range personNames {
if p, err := h.IdentityQ.GetPersonByID(ctx, personID); err == nil {
personNames[personID] = p.DisplayName
}
}
for i := range events {
events[i].OrgName = orgNames[events[i].OrgID]
events[i].PersonName = personNames[events[i].PersonID]
}
return events
}
// legacyTabRedirects maps retired ?tab=<id> bookmarks to their replacement
// MPA route. Scheduled for removal in M8 — see openspec change
// operator-mpa-conversion task 6.2.
var legacyTabRedirects = map[string]string{
"orgs": "/operator/organizations",
"organizations": "/operator/organizations",
"grants": "/operator/grants",
"billing": "/operator/billing/accounts",
"org-types": "/operator/org-types",
"products": "/operator/products",
"entitlement-sets": "/operator/entitlement-sets",
"plan-ladders": "/operator/plan-ladders",
// Persons has no browse route per design D4. tab=people bookmarks
// land on the operator landing surface, where the lookup affordance
// will route them to the per-person detail page.
"people": "/operator",
// "sites" (the legacy FedWiki sites tab) is intentionally absent: its
// destination is now an integration-registered route (design.md
// Decision 8; see internal/integrations/fedwiki/web), and core must
// not hardcode an integration-specific path here to redirect to it
// (openspec/changes/integration-extraction task 2.7). A ?tab=sites
// bookmark now falls through to the landing surface below rather than
// resolving to a specific redirect — acceptable since this whole map
// is already slated for removal (operator-mpa-conversion task 6.2).
}