Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.
People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.
Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.
Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.
Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
533 lines
22 KiB
Go
533 lines
22 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/organization"
|
|
"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 — the auth/CSRF/keycloak fields — that
|
|
// OperatorPartialsHandler's own MPA pages use. Callers still layer
|
|
// IAPosition, ActiveCapability, and the BodyTemplate/BodyData pair on top
|
|
// before rendering "operator.html". The sidebar itself is a fixed flat list
|
|
// of the seven sections; provider surfaces are reached from the
|
|
// Integrations home, not the sidebar, so no registry query happens here.
|
|
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),
|
|
}
|
|
}
|
|
|
|
// organizationsListNav builds the list-controls view model for the
|
|
// Organizations page: the org-type facet vocabulary is the same
|
|
// core.org_types set the Org Types management page reads (h.OrgQ.ListOrgTypes),
|
|
// so a type only appears as a filter option once it actually exists. An
|
|
// unknown or stale org_type value in the URL is ignored rather than erroring
|
|
// (operator-panel-navigation: "Unknown filter params do not error").
|
|
func (h *OperatorPartialsHandler) organizationsListNav(r *http.Request) (ListNav, string) {
|
|
nav := ListNav{
|
|
BasePath: "/operator/organizations",
|
|
SearchPlaceholder: "Search by name, slug, or owner",
|
|
FacetParam: "org_type",
|
|
}
|
|
orgTypes, err := h.OrgQ.ListOrgTypes(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list org types for organizations facet", slog.Any("error", err))
|
|
} else {
|
|
nav.FacetOptions = make([]FacetOption, len(orgTypes))
|
|
for i, ot := range orgTypes {
|
|
nav.FacetOptions[i] = FacetOption{Value: ot.OrgType, Label: ot.DisplayName}
|
|
}
|
|
}
|
|
params := ParseListParams(r, "org_type")
|
|
facet := ValidFacet(params.Facet, nav.FacetOptions)
|
|
nav.Q, nav.Facet, nav.Page = params.Q, facet, params.Page
|
|
return nav, facet
|
|
}
|
|
|
|
// GetOrganizationsPage handles GET /operator/organizations — the runtime
|
|
// organizations browse page. Reuses operator_organizations.html as the body
|
|
// partial inside the operator.html shell. Server-side search, org-type
|
|
// filter, and pagination with a true total count (operator-list-scale D1/D2).
|
|
func (h *OperatorPartialsHandler) GetOrganizationsPage(w http.ResponseWriter, r *http.Request) {
|
|
bodyData := OrganizationsData{}
|
|
|
|
nav, facet := h.organizationsListNav(r)
|
|
params := ListParams{Q: nav.Q, Facet: facet, Page: nav.Page}
|
|
|
|
orgs, total, err := FetchPage(¶ms, func(limit, offset int32) ([]organization.ListOrganizationsPageRow, int64, error) {
|
|
rows, lErr := h.OrgQ.ListOrganizationsPage(r.Context(), organization.ListOrganizationsPageParams{
|
|
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
|
|
OrgType: sql.NullString{String: facet, Valid: facet != ""},
|
|
PageLimit: limit,
|
|
PageOffset: offset,
|
|
})
|
|
if lErr != nil || len(rows) == 0 {
|
|
return rows, 0, lErr
|
|
}
|
|
return rows, rows[0].TotalCount, nil
|
|
})
|
|
nav.Page = params.Page
|
|
nav.Total = total
|
|
|
|
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
|
|
}
|
|
bodyData.Nav = nav
|
|
|
|
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 []ResourceKeyOption
|
|
// 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. Carries display_name
|
|
// alongside the raw key so the landing table can lead with the friendly name
|
|
// (ui-vocabulary 4.3), the same way a picker would.
|
|
func (h *OperatorPartialsHandler) ownedResourceKeys(ctx context.Context, slug string) []ResourceKeyOption {
|
|
rows, err := h.Database.QueryContext(ctx,
|
|
`SELECT resource_key, display_name 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 []ResourceKeyOption
|
|
for rows.Next() {
|
|
var k ResourceKeyOption
|
|
if err := rows.Scan(&k.ResourceKey, &k.DisplayName); 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
|
|
}
|
|
|
|
// grantsFacetOptions is the grants list's delivery-state filter vocabulary
|
|
// (operator-list-scale: "Status filters exist where a status vocabulary
|
|
// exists" — grants: the derived Live/Superseded/Inactive states, design
|
|
// D4). Values match sqlc.narg(delivery_state) in ListGrantsWithDeliveryPage
|
|
// and the DeliveryState strings buildGrantViewModels renders.
|
|
var grantsFacetOptions = []FacetOption{
|
|
{Value: "live", Label: "Live"},
|
|
{Value: "superseded", Label: "Superseded"},
|
|
{Value: "inactive", Label: "Inactive"},
|
|
}
|
|
|
|
// 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).
|
|
//
|
|
// Governed by operator-list-scale: server-side search (org name or product
|
|
// name), the delivery-state filter, and true-total pagination via
|
|
// ListGrantsWithDeliveryPage (design D4). FetchPage's load closure ignores
|
|
// its own limit/offset arguments and instead reads params.Limit()/Offset()
|
|
// directly — safe because FetchPage always calls load with exactly those
|
|
// values (or, on the past-the-end clamp retry, with p.Page already reset
|
|
// to 1, which makes params.Offset() recompute to the same 0 it passes
|
|
// explicitly), and params is captured by reference so loadGrantsListData
|
|
// always sees FetchPage's current Page.
|
|
func (h *OperatorPartialsHandler) GetGrantsPage(w http.ResponseWriter, r *http.Request) {
|
|
params := ParseListParams(r, "state")
|
|
params.Facet = ValidFacet(params.Facet, grantsFacetOptions)
|
|
|
|
var bodyData GrantsData
|
|
grants, total, _ := FetchPage(¶ms, func(limit, offset int32) ([]GrantViewModel, int64, error) {
|
|
bodyData = h.loadGrantsListData(r, params, "", "")
|
|
return bodyData.Grants, bodyData.Nav.Total, nil
|
|
})
|
|
bodyData.Grants = grants
|
|
bodyData.Nav = ListNav{
|
|
BasePath: "/operator/grants",
|
|
SearchPlaceholder: "Search by organization or product",
|
|
FacetParam: "state",
|
|
FacetOptions: grantsFacetOptions,
|
|
Q: params.Q,
|
|
Facet: params.Facet,
|
|
Page: params.Page,
|
|
Total: total,
|
|
}
|
|
|
|
// 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 was originally no flat
|
|
// /operator/persons browse route; maintainer decision 2026-08-23
|
|
// (operator-people-directory) reversed that: the People directory now
|
|
// exists at /operator/persons, and operators reach this detail page from a
|
|
// directory row, 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
|
|
// ActiveCapability "persons" now lights the People sidebar entry
|
|
// (operator-people-directory: "the existing person detail ... marks
|
|
// the People sidebar entry active as its section's second-level
|
|
// page"), the same way any other detail page marks its section active.
|
|
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)
|
|
}
|