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.
533 lines
21 KiB
Go
533 lines
21 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)
|
|
}
|
|
}
|
|
|
|
// IntegrationProviderNav is one entry in the operator sidebar's Integration
|
|
// group, resolved from the provider registry (provisioning providers) rather
|
|
// than hardcoded in the template.
|
|
type IntegrationProviderNav struct {
|
|
Slug string
|
|
DisplayName string
|
|
SurfacePath string
|
|
}
|
|
|
|
// 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
|
|
// IntegrationProviders are the registered provisioning providers rendered
|
|
// in the operator sidebar's Integration group — driven by the provider
|
|
// registry, not hardcoded, so a new provisioning provider needs no template edit.
|
|
IntegrationProviders []IntegrationProviderNav
|
|
// CurrentPath is the request path; the Integration nav item whose
|
|
// SurfacePath equals it renders active.
|
|
CurrentPath 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
|
|
LinkPath string // where the row's primary link points
|
|
}
|
|
|
|
// 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),
|
|
CurrentPath: r.URL.Path,
|
|
IntegrationProviders: loadIntegrationProvidersNav(ctx, h.Database, h.Logger),
|
|
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 + ")",
|
|
LinkPath: "/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 + ")",
|
|
LinkPath: "/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,
|
|
LinkPath: "/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,
|
|
LinkPath: "/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).
|
|
}
|