Files
member-console/internal/server/operator_pages.go
T
cgalo5758 71818de0bd Add setup checklist and empty-state guidance
Implement the ux-first-run change: a state-derived setup checklist on
/operator/setup with a landing region that recedes once required steps
are done, and empty states that distinguish blocked from empty across
operator and member surfaces. Also add production deployment and
environment reference docs, plus a config-key completeness test.
2026-08-23 03:06:11 -05:00

473 lines
19 KiB
Go

package server
import (
"context"
"database/sql"
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
"github.com/spf13/viper"
)
// buildOperatorPageData populates the auth/CSRF/keycloak fields every
// operator MPA page needs. Page handlers layer IAPosition, ActiveCapability,
// and the BodyTemplate/BodyData pair on top of this base before rendering
// operator.html.
func (h *OperatorPartialsHandler) buildOperatorPageData(r *http.Request) OperatorPageData {
return BuildOperatorPageData(r, h.AuthConfig, h.Database, h.Logger)
}
// BuildOperatorPageData is buildOperatorPageData, exported so an
// integration registering its own operator surface (design.md Decision 8;
// e.g. internal/integrations/fedwiki/web's read-only sites page) can
// assemble the same shell page data — auth/CSRF/keycloak fields plus the
// registry-driven Integration nav — that OperatorPartialsHandler's own MPA
// pages use, instead of reimplementing loadIntegrationProvidersNav's query
// itself. Callers still layer IAPosition, ActiveCapability, and the
// BodyTemplate/BodyData pair on top before rendering "operator.html".
func BuildOperatorPageData(r *http.Request, authConfig *auth.Config, database *sql.DB, logger *slog.Logger) OperatorPageData {
ctx := r.Context()
return OperatorPageData{
Name: authConfig.GetUserName(ctx),
Username: authConfig.GetUsername(ctx),
Email: authConfig.GetUserEmail(ctx),
KeycloakAccountURL: viper.GetString("oidc-idp-issuer-url") + "/account",
CSRFToken: middleware.CSRFToken(r),
CurrentPath: r.URL.Path,
IntegrationProviders: loadIntegrationProvidersNav(ctx, database, logger),
}
}
// loadIntegrationProvidersNav resolves the provisioning providers for the
// operator sidebar's Integration group from the registry. A query failure logs
// and yields an empty group rather than failing the whole page. It is a free
// function so every operator page handler — including the landing surface, whose
// handler is a different type — renders the same registry-driven nav.
func loadIntegrationProvidersNav(ctx context.Context, db *sql.DB, logger *slog.Logger) []IntegrationProviderNav {
providers, err := integration.New(db).ListProvidersByKind(ctx, string(integration.KindProvisioning))
if err != nil {
logger.Error("failed to list provisioning providers for nav", slog.Any("error", err))
return nil
}
nav := make([]IntegrationProviderNav, 0, len(providers))
for _, p := range providers {
if !p.OperatorSurfacePath.Valid || p.OperatorSurfacePath.String == "" {
continue // provisioning provider without an operator surface — skip
}
nav = append(nav, IntegrationProviderNav{
Slug: p.Slug,
DisplayName: p.DisplayName,
SurfacePath: p.OperatorSurfacePath.String,
})
}
return nav
}
func (h *OperatorPartialsHandler) integrationProvidersNav(ctx context.Context) []IntegrationProviderNav {
return loadIntegrationProvidersNav(ctx, h.Database, h.Logger)
}
// GetOrganizationsPage handles GET /operator/organizations — the runtime
// organizations browse page. Reuses operator_organizations.html as the body
// partial inside the operator.html shell; data shape matches the legacy
// partial endpoint so the template renders identically.
func (h *OperatorPartialsHandler) GetOrganizationsPage(w http.ResponseWriter, r *http.Request) {
bodyData := OrganizationsData{}
orgs, err := h.OrgQ.ListOrganizationsWithOwner(r.Context())
if err != nil {
h.Logger.Error("failed to list organizations", slog.Any("error", err))
bodyData.Error = "Failed to retrieve organizations"
} else {
vms := make([]OrganizationViewModel, len(orgs))
for i, org := range orgs {
memberCount := 0
if members, mErr := h.OrgQ.GetOrgMembersByOrgID(r.Context(), org.OrgID); mErr == nil {
memberCount = len(members)
}
ownerName := org.OwnerDisplayName.String
if ownerName == "" {
ownerName = "Unknown"
}
vms[i] = OrganizationViewModel{
OrgID: org.OrgID,
Name: org.Name,
Slug: org.Slug,
OrgType: org.OrgType,
Status: org.Status,
MemberCount: memberCount,
CreatedAt: org.CreatedAt.Format("Jan 2, 2006"),
OwnerName: ownerName,
IsSystemOrg: org.OrgType == systemtenant.OrgType,
}
}
bodyData.Organizations = vms
}
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:organizations"
page.ActiveCapability = "organizations"
page.BodyTemplate = "operator_organizations.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// IntegrationRow is one registered integration on the Integrations landing
// — every provider kind, not just provisioning, so a payments integration
// like Stripe has a row (and its settings link) instead of being half-
// present (2026-07-22 fresh-eyes audit).
type IntegrationRow struct {
Slug string
DisplayName string
Kind string
Status string
SurfacePath string // "" when the provider declares no operator surface
SettingsPath string // "" when the integration declares no configuration
Operations []string
ResourceKeys []string
// Configured reports whether every required configuration key
// (RequiredGroup-tagged) resolves to a value — the SAME check the
// settings page performs (configurationReadiness), never a live probe.
// true for an integration with no required keys at all: there is
// nothing an operator could set. MissingKeysText names the unresolved
// keys (comma-joined) for the row's tooltip when false.
Configured bool
MissingKeysText string
}
// IntegrationsData is the body data for operator_integrations.html.
type IntegrationsData struct {
Integrations []IntegrationRow
// Orphans: override rows whose key matches no installed integration's
// declaration (integration removed or key retired). Harmless at boot
// (the overlay skips them); listed here with a delete affordance.
Orphans []OrphanOverrideRow
Error string
}
// OrphanOverrideRow is one unrecognized override row on the Integrations
// landing.
type OrphanOverrideRow struct {
Key string
Value string
}
// GetIntegrationsPage handles GET /operator/integrations — the Integrations
// section landing. Registry-driven: lists every provisioning provider with its
// status, declared lifecycle operations, owned resource keys, and admin-surface
// link. Read-only; a new conforming provider appears here with no code edit.
func (h *OperatorPartialsHandler) GetIntegrationsPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
bodyData := IntegrationsData{}
// Settings links + orphan classification come from the adapter
// registry (Config.IntegrationConfigs) — the authority on declared
// configuration, independent of provider kind.
settingsBySlug := map[string]string{}
declaredKeys := map[string]bool{}
for _, info := range h.IntegrationConfigs {
if len(info.Keys) == 0 {
continue
}
settingsBySlug[info.Slug] = "/operator/integrations/" + info.Slug + "/settings"
for _, k := range info.Keys {
declaredKeys[k.Name] = true
}
}
iq := integration.New(h.Database)
providers, err := iq.ListProviders(ctx)
if err != nil {
h.Logger.Error("failed to list providers for integrations page", slog.Any("error", err))
bodyData.Error = "Failed to retrieve integrations"
} else {
rows := make([]IntegrationRow, 0, len(providers))
for _, p := range providers {
configured, missing := configurationReadiness(h.IntegrationConfigs, p.Slug)
row := IntegrationRow{
Slug: p.Slug,
DisplayName: p.DisplayName,
Kind: p.ProviderKind,
Status: p.Status,
SettingsPath: settingsBySlug[p.Slug],
ResourceKeys: h.ownedResourceKeys(ctx, p.Slug),
Configured: configured,
MissingKeysText: strings.Join(missing, ", "),
}
delete(settingsBySlug, p.Slug)
if p.OperatorSurfacePath.Valid {
row.SurfacePath = p.OperatorSurfacePath.String
}
if ops, oErr := iq.ListProviderOperations(ctx, p.Slug); oErr == nil {
row.Operations = ops
} else {
h.Logger.Warn("failed to list provider operations", slog.String("provider", p.Slug), slog.Any("error", oErr))
}
rows = append(rows, row)
}
// A configuring adapter with no provider row (none exist today —
// every integration registers a manifest) still gets a minimal row
// so its settings page stays reachable.
for _, info := range h.IntegrationConfigs {
if path, ok := settingsBySlug[info.Slug]; ok && len(info.Keys) > 0 {
configured, missing := configurationReadiness(h.IntegrationConfigs, info.Slug)
rows = append(rows, IntegrationRow{
Slug: info.Slug, DisplayName: info.DisplayName, SettingsPath: path,
Configured: configured, MissingKeysText: strings.Join(missing, ", "),
})
}
}
bodyData.Integrations = rows
}
if overrideRows, oErr := iq.ListConfigOverrides(ctx); oErr != nil {
h.Logger.Error("failed to list config overrides for integrations page", slog.Any("error", oErr))
} else {
for _, row := range overrideRows {
if !declaredKeys[row.Key] {
bodyData.Orphans = append(bodyData.Orphans, OrphanOverrideRow{Key: row.Key, Value: row.Value})
}
}
}
page := h.buildOperatorPageData(r)
page.IAPosition = "integration:integrations"
page.ActiveCapability = "integrations"
page.BodyTemplate = "operator_integrations.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// ownedResourceKeys returns the resource keys a provider owns
// (core.resource_keys.provider = slug). Read-only raw query: the
// entitlements ResourceKey sqlc model doesn't carry the provider column, which
// lives behind the integration module's cross-schema queries. Failures degrade
// to an empty list rather than failing the page.
func (h *OperatorPartialsHandler) ownedResourceKeys(ctx context.Context, slug string) []string {
rows, err := h.Database.QueryContext(ctx,
`SELECT resource_key FROM core.resource_keys WHERE provider = $1 ORDER BY resource_key`, slug)
if err != nil {
h.Logger.Warn("failed to list owned resource keys", slog.String("provider", slug), slog.Any("error", err))
return nil
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
h.Logger.Warn("scan owned resource key", slog.String("provider", slug), slog.Any("error", err))
return keys
}
keys = append(keys, k)
}
return keys
}
// GetGrantsPage handles GET /operator/grants — read-only audit/lookup view
// of every grant in the system. Per operator-mpa-conversion D8, no
// Issue/Extend/Revoke or CreateGrant affordances are exposed here; those
// live on the per-org composite. Reuses operator_grants.html as the body
// partial (which task 3.5 stripped to read-only).
func (h *OperatorPartialsHandler) GetGrantsPage(w http.ResponseWriter, r *http.Request) {
bodyData := h.loadGrantsListData(r, "", "")
// ux-first-run 3.3: the grants list's empty state must distinguish
// "blocked: no product has been published yet" from "empty: issue a
// grant from an organization's detail page". GrantsData (defined in
// operator_partials.go, outside this lane's edit scope) has no field for
// that distinction, so it rides on the existing but otherwise-unused
// Products field here: the legacy create-grant form it originally fed
// was removed from this read-only page in M7e Slice D, so its length is
// safe to repurpose purely as the template's blocked/not-blocked signal.
anyPublished, err := h.BillingQ.AnyPublishedProduct(r.Context())
if err != nil {
h.Logger.Error("failed to check for a published product", slog.Any("error", err))
anyPublished = true // fail open: don't show a false blocked banner on a transient error
}
if anyPublished {
bodyData.Products = []ProductViewModel{{}}
}
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:grants"
page.ActiveCapability = "grants"
page.BodyTemplate = "operator_grants.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// OperatorBillingWrapperData is the dot context for operator_billing.html.
// Each GetBillingXPage handler populates ActiveSection (drives the pill
// active class) plus the inner template name and its data struct; the
// wrapper renders the pill nav and dispatches via renderBody.
type OperatorBillingWrapperData struct {
ActiveSection string
InnerTemplate string
InnerData any
// DataAsOf / NoEventsProcessed carry the webhook-projection recency
// stamp (design.md Decision 3; spec: operator-billing-views) shared by
// all four billing sub-pages: renderBillingPage computes it once here
// so every InnerTemplate shows it without a query of its own.
// DataAsOf is formatted for display and empty when NoEventsProcessed.
DataAsOf string
NoEventsProcessed bool
}
// renderBillingPage is the shared shell for the four billing sub-pages. It
// loads the inner data via the supplied loader, wraps the result in the
// operator_billing.html dispatcher, and renders inside operator.html.
func (h *OperatorPartialsHandler) renderBillingPage(w http.ResponseWriter, r *http.Request, activeSection, innerTemplate, capability string, innerData any) {
wrapper := OperatorBillingWrapperData{
ActiveSection: activeSection,
InnerTemplate: innerTemplate,
InnerData: innerData,
}
if processedAt, ok := h.latestProcessedWebhookEventAt(r.Context()); ok {
wrapper.DataAsOf = processedAt.Format("Jan 2, 2006 15:04 MST")
} else {
wrapper.NoEventsProcessed = true
}
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:billing:" + activeSection
page.ActiveCapability = capability
page.BodyTemplate = "operator_billing.html"
page.BodyData = wrapper
h.Templates.Render(w, "operator.html", page)
}
// GetBillingAccountsPage handles GET /operator/billing/accounts — runtime
// billing tier. Reuses operator_billing_accounts.html via the shared
// operator_billing.html wrapper; data hydration extracted into
// loadBillingAccountsData (shared with the legacy partial endpoint).
func (h *OperatorPartialsHandler) GetBillingAccountsPage(w http.ResponseWriter, r *http.Request) {
h.renderBillingPage(w, r, "accounts", "operator_billing_accounts.html", "billing-accounts", h.loadBillingAccountsData(r))
}
// GetSubscriptionsPage handles GET /operator/billing/subscriptions.
func (h *OperatorPartialsHandler) GetSubscriptionsPage(w http.ResponseWriter, r *http.Request) {
h.renderBillingPage(w, r, "subscriptions", "operator_subscriptions.html", "billing-subscriptions", h.loadSubscriptionsData(r))
}
// GetInvoicesPage handles GET /operator/billing/invoices.
func (h *OperatorPartialsHandler) GetInvoicesPage(w http.ResponseWriter, r *http.Request) {
h.renderBillingPage(w, r, "invoices", "operator_invoices.html", "billing-invoices", h.loadInvoicesData(r))
}
// GetPaymentsPage handles GET /operator/billing/payments.
func (h *OperatorPartialsHandler) GetPaymentsPage(w http.ResponseWriter, r *http.Request) {
h.renderBillingPage(w, r, "payments", "operator_payments.html", "billing-payments", h.loadPaymentsData(r))
}
// GetOrgTypesPage handles GET /operator/org-types — catalog tier. Reuses
// operator_org_types.html as the body partial; data hydration extracted into
// loadOrgTypesPageData (shared with the legacy partial endpoint).
func (h *OperatorPartialsHandler) GetOrgTypesPage(w http.ResponseWriter, r *http.Request) {
bodyData := h.loadOrgTypesPageData(r, OrgTypesData{}, "", "")
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:org-types"
page.ActiveCapability = "org-types"
page.BodyTemplate = "operator_org_types.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// GetProductsPage handles GET /operator/products — catalog tier. Reuses
// operator_products.html as the body partial; data hydration extracted into
// loadProductsPageData (shared with the legacy partial endpoint).
func (h *OperatorPartialsHandler) GetProductsPage(w http.ResponseWriter, r *http.Request) {
bodyData := h.loadProductsPageData(r, "", "")
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:products"
page.ActiveCapability = "products"
page.BodyTemplate = "operator_products.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// GetEntitlementSetsPage handles GET /operator/entitlement-sets — catalog tier.
// Reuses operator_entitlement_sets.html; data hydration extracted into
// loadEntitlementSetsPageData (shared with the legacy partial endpoint).
func (h *OperatorPartialsHandler) GetEntitlementSetsPage(w http.ResponseWriter, r *http.Request) {
bodyData := h.loadEntitlementSetsPageData(r, "", "")
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:entitlement-sets"
page.ActiveCapability = "entitlement-sets"
page.BodyTemplate = "operator_entitlement_sets.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// GetPlanLaddersPage handles GET /operator/plan-ladders — catalog tier.
// Reuses operator_plan_ladders.html; data hydration extracted into
// loadPlanLaddersPageData (shared with the legacy partial endpoint).
func (h *OperatorPartialsHandler) GetPlanLaddersPage(w http.ResponseWriter, r *http.Request) {
bodyData := h.loadPlanLaddersPageData(r, "", "")
page := h.buildOperatorPageData(r)
page.IAPosition = "catalog:plan-ladders"
page.ActiveCapability = "plan-ladders"
page.BodyTemplate = "operator_plan_ladders.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// GetPersonPage handles GET /operator/persons/{personID} — resolved-detail
// view for a single person. Per design D4 there is no flat /operator/persons
// browse route; operators arrive here via the landing-surface lookup
// affordance or by drilling into an org member row.
func (h *OperatorPartialsHandler) GetPersonPage(w http.ResponseWriter, r *http.Request) {
personID := r.PathValue("personID")
bodyData := h.loadPersonDetailData(r, personID)
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:persons:" + personID
// Persons has no sidebar entry (lookup-only per D4). Setting
// ActiveCapability to "persons" prevents the Overview link from
// lighting up on this page — no sidebar link matches, so nothing
// is rendered active, which is correct.
page.ActiveCapability = "persons"
page.BodyTemplate = "operator_person_detail.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// GetOrganizationDetailPage handles GET /operator/organizations/{orgID} — the
// per-org composite. Reuses operator_enrollment.html as the body partial; the
// composite's data hydration lives in loadOrgEnrollmentData (shared with the
// legacy partial endpoint).
//
// 3.4 spec gaps tracked for follow-up commits: a billing summary section and
// the second labeled CreateGrant form for non-plan products. This commit
// lands the route and shell wiring — the existing template already covers
// the enrollment header, grant history, Issue/Extend forms, and Revoke.
func (h *OperatorPartialsHandler) GetOrganizationDetailPage(w http.ResponseWriter, r *http.Request) {
orgID := r.PathValue("orgID")
bodyData := h.loadOrgEnrollmentData(r, orgID, "", "")
page := h.buildOperatorPageData(r)
page.IAPosition = "runtime:organizations:" + orgID
page.ActiveCapability = "organizations"
page.BodyTemplate = "operator_enrollment.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}