Add default_plan_ladder_id with a forward data migration and update the runtime to resolve the ladder's rank-0 tier at use-time. Regenerate sqlc, update auto-provisioning, ReapplyDefaultsForPool, operator UI and tests; add GetTierByLadderRank and pool/provision query helpers. Add a CSP-safe confirm-action modal and wire operator actions to it. Close plan-sole-writer safety gaps and serialize IssueGrant with a FOR UPDATE pool lock to prevent ladder races.
624 lines
23 KiB
Go
624 lines
23 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"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/embeds"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/fedwiki"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
|
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/stripe"
|
|
"github.com/google/uuid"
|
|
"go.temporal.io/sdk/client"
|
|
)
|
|
|
|
// OperatorRole is the OIDC role required to access operator pages
|
|
const OperatorRole = "operator-member"
|
|
|
|
// OperatorPartialsHandler handles HTMX partial requests for operator pages
|
|
type OperatorPartialsHandler struct {
|
|
SiteQ fwmod.Querier
|
|
EntitlementsQ entitlements.Querier
|
|
BillingQ billing.Querier
|
|
StripeQ stripedb.Querier
|
|
Database *sql.DB
|
|
IdentityQ identity.Querier
|
|
OrgQ organization.Querier
|
|
Logger *slog.Logger
|
|
AuthConfig *auth.Config
|
|
Templates *SafeTemplates
|
|
StripeDashboardURL string
|
|
TemporalClient client.Client
|
|
}
|
|
|
|
// OperatorPartialsConfig holds configuration for the operator partials handler
|
|
type OperatorPartialsConfig struct {
|
|
SiteQ fwmod.Querier
|
|
EntitlementsQ entitlements.Querier
|
|
BillingQ billing.Querier
|
|
StripeQ stripedb.Querier
|
|
Database *sql.DB
|
|
IdentityQ identity.Querier
|
|
OrgQ organization.Querier
|
|
Logger *slog.Logger
|
|
AuthConfig *auth.Config
|
|
StripeDashboardURL string
|
|
TemporalClient client.Client
|
|
}
|
|
|
|
// NewOperatorPartialsHandler creates a new OperatorPartialsHandler
|
|
func NewOperatorPartialsHandler(cfg OperatorPartialsConfig) (*OperatorPartialsHandler, error) {
|
|
// Parse partial templates
|
|
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tmpl, err := template.ParseFS(templateSubFS, "operator_*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &OperatorPartialsHandler{
|
|
SiteQ: cfg.SiteQ,
|
|
EntitlementsQ: cfg.EntitlementsQ,
|
|
BillingQ: cfg.BillingQ,
|
|
StripeQ: cfg.StripeQ,
|
|
Database: cfg.Database,
|
|
IdentityQ: cfg.IdentityQ,
|
|
OrgQ: cfg.OrgQ,
|
|
Logger: cfg.Logger,
|
|
AuthConfig: cfg.AuthConfig,
|
|
Templates: NewSafeTemplates(tmpl, cfg.Logger),
|
|
StripeDashboardURL: cfg.StripeDashboardURL,
|
|
TemporalClient: cfg.TemporalClient,
|
|
}, nil
|
|
}
|
|
|
|
// RegisterRoutes registers all operator HTMX partial routes
|
|
func (h *OperatorPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /partials/operator/users", h.requireOperatorRole(h.GetPersons))
|
|
mux.HandleFunc("GET /partials/operator/organizations", h.requireOperatorRole(h.GetOrganizations))
|
|
mux.HandleFunc("GET /partials/operator/sites", h.requireOperatorRole(h.GetSites))
|
|
// Grant management
|
|
mux.HandleFunc("GET /partials/operator/grants", h.requireOperatorRole(h.GetGrants))
|
|
mux.HandleFunc("POST /partials/operator/grants", h.requireOperatorRole(h.CreateGrant))
|
|
mux.HandleFunc("POST /partials/operator/grants/{grantID}/revoke", h.requireOperatorRole(h.RevokeGrant))
|
|
// Product management
|
|
mux.HandleFunc("GET /partials/operator/products", h.requireOperatorRole(h.GetProducts))
|
|
mux.HandleFunc("POST /partials/operator/products", h.requireOperatorRole(h.CreateProduct))
|
|
mux.HandleFunc("GET /partials/operator/products/{productID}/edit", h.requireOperatorRole(h.GetProductEdit))
|
|
mux.HandleFunc("PUT /partials/operator/products/{productID}", h.requireOperatorRole(h.UpdateProduct))
|
|
// Entitlement set management
|
|
mux.HandleFunc("GET /partials/operator/entitlement-sets", h.requireOperatorRole(h.GetEntitlementSets))
|
|
mux.HandleFunc("POST /partials/operator/entitlement-sets", h.requireOperatorRole(h.CreateEntitlementSet))
|
|
mux.HandleFunc("GET /partials/operator/entitlement-sets/{setID}/edit", h.requireOperatorRole(h.GetEntitlementSetEdit))
|
|
mux.HandleFunc("PUT /partials/operator/entitlement-sets/{setID}", h.requireOperatorRole(h.UpdateEntitlementSet))
|
|
mux.HandleFunc("GET /partials/operator/entitlement-sets/{setID}/rules", h.requireOperatorRole(h.GetEntitlementSetRules))
|
|
mux.HandleFunc("POST /partials/operator/entitlement-sets/{setID}/rules", h.requireOperatorRole(h.CreateEntitlementSetRule))
|
|
mux.HandleFunc("DELETE /partials/operator/entitlement-sets/{setID}/rules/{ruleID}", h.requireOperatorRole(h.DeleteEntitlementSetRule))
|
|
// Organization type management
|
|
mux.HandleFunc("GET /partials/operator/org-types", h.requireOperatorRole(h.GetOrgTypes))
|
|
mux.HandleFunc("POST /partials/operator/org-types/{orgType}/default-plan", h.requireOperatorRole(h.UpdateOrgTypeDefaultPlan))
|
|
mux.HandleFunc("POST /partials/operator/org-types/{orgType}/backfill", h.requireOperatorRole(h.BackfillOrgType))
|
|
// Enrollment & transition tools
|
|
mux.HandleFunc("GET /partials/operator/organizations/{orgID}/enrollment", h.requireOperatorRole(h.GetOrgEnrollment))
|
|
mux.HandleFunc("POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant", h.requireOperatorRole(h.IssueGrant))
|
|
mux.HandleFunc("POST /partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend", h.requireOperatorRole(h.ExtendGrant))
|
|
mux.HandleFunc("POST /partials/operator/grants/{grantID}/revoke-and-transition", h.requireOperatorRole(h.RevokeGrantAndTransition))
|
|
// Billing views
|
|
mux.HandleFunc("GET /partials/operator/billing/accounts", h.requireOperatorRole(h.GetBillingAccounts))
|
|
mux.HandleFunc("GET /partials/operator/billing/subscriptions", h.requireOperatorRole(h.GetSubscriptions))
|
|
mux.HandleFunc("GET /partials/operator/billing/invoices", h.requireOperatorRole(h.GetInvoices))
|
|
mux.HandleFunc("GET /partials/operator/billing/payments", h.requireOperatorRole(h.GetPayments))
|
|
// Product prices
|
|
mux.HandleFunc("GET /partials/operator/products/{productID}/prices", h.requireOperatorRole(h.GetProductPrices))
|
|
mux.HandleFunc("POST /partials/operator/products/{productID}/prices", h.requireOperatorRole(h.CreatePrice))
|
|
// Plan ladder management
|
|
mux.HandleFunc("GET /partials/operator/plan-ladders", h.requireOperatorRole(h.GetPlanLadders))
|
|
mux.HandleFunc("POST /partials/operator/plan-ladders", h.requireOperatorRole(h.CreatePlanLadder))
|
|
mux.HandleFunc("GET /partials/operator/plan-ladders/{ladderID}/edit", h.requireOperatorRole(h.GetPlanLadderEdit))
|
|
mux.HandleFunc("PUT /partials/operator/plan-ladders/{ladderID}", h.requireOperatorRole(h.UpdatePlanLadder))
|
|
mux.HandleFunc("DELETE /partials/operator/plan-ladders/{ladderID}", h.requireOperatorRole(h.DeletePlanLadder))
|
|
mux.HandleFunc("GET /partials/operator/plan-ladders/{ladderID}/tiers", h.requireOperatorRole(h.GetPlanLadderTiers))
|
|
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers", h.requireOperatorRole(h.CreatePlanLadderTier))
|
|
mux.HandleFunc("PUT /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/rank", h.requireOperatorRole(h.UpdatePlanLadderTierRank))
|
|
mux.HandleFunc("DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}", h.requireOperatorRole(h.DeletePlanLadderTier))
|
|
mux.HandleFunc("GET /partials/operator/plan-ladders/validation", h.requireOperatorRole(h.GetPlanLadderValidation))
|
|
}
|
|
|
|
// requireOperatorRole is middleware that checks for the operator role
|
|
func (h *OperatorPartialsHandler) requireOperatorRole(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !h.AuthConfig.HasRole(r, OperatorRole) {
|
|
h.Logger.Warn("operator partials 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)
|
|
}
|
|
}
|
|
|
|
// PersonViewModel represents a person for operator template rendering
|
|
type PersonViewModel struct {
|
|
PersonID string
|
|
DisplayName string
|
|
Email string
|
|
Status string
|
|
CreatedAt string
|
|
}
|
|
|
|
// PersonsData holds data for the persons list partial
|
|
type PersonsData struct {
|
|
Persons []PersonViewModel
|
|
Error string
|
|
}
|
|
|
|
// GetPersons handles GET /partials/operator/users — lists all persons
|
|
func (h *OperatorPartialsHandler) GetPersons(w http.ResponseWriter, r *http.Request) {
|
|
persons, err := h.IdentityQ.ListPersons(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list persons", slog.Any("error", err))
|
|
h.renderError(w, "operator_users.html", "Failed to retrieve people")
|
|
return
|
|
}
|
|
|
|
vms := make([]PersonViewModel, len(persons))
|
|
for i, p := range persons {
|
|
vms[i] = PersonViewModel{
|
|
PersonID: p.PersonID,
|
|
DisplayName: p.DisplayName,
|
|
Email: p.PrimaryEmail,
|
|
Status: p.Status,
|
|
CreatedAt: p.CreatedAt.Format("Jan 2, 2006"),
|
|
}
|
|
}
|
|
|
|
data := PersonsData{Persons: vms}
|
|
h.Templates.Render(w, "operator_users.html", data)
|
|
}
|
|
|
|
// OrganizationViewModel represents an organization for operator template rendering
|
|
type OrganizationViewModel struct {
|
|
OrgID string
|
|
Name string
|
|
Slug string
|
|
OrgType string
|
|
Status string
|
|
MemberCount int
|
|
CreatedAt string
|
|
}
|
|
|
|
// OrganizationsData holds data for the organizations list partial
|
|
type OrganizationsData struct {
|
|
Organizations []OrganizationViewModel
|
|
Error string
|
|
}
|
|
|
|
// GetOrganizations handles GET /partials/operator/organizations
|
|
func (h *OperatorPartialsHandler) GetOrganizations(w http.ResponseWriter, r *http.Request) {
|
|
orgs, err := h.OrgQ.ListOrganizations(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list organizations", slog.Any("error", err))
|
|
h.renderError(w, "operator_organizations.html", "Failed to retrieve organizations")
|
|
return
|
|
}
|
|
|
|
vms := make([]OrganizationViewModel, len(orgs))
|
|
for i, org := range orgs {
|
|
memberCount := 0
|
|
members, err := h.OrgQ.GetOrgMembersByOrgID(r.Context(), org.OrgID)
|
|
if err == nil {
|
|
memberCount = len(members)
|
|
}
|
|
|
|
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"),
|
|
}
|
|
}
|
|
|
|
data := OrganizationsData{Organizations: vms}
|
|
h.Templates.Render(w, "operator_organizations.html", data)
|
|
}
|
|
|
|
// OperatorSiteViewModel represents a site for operator template rendering
|
|
type OperatorSiteViewModel struct {
|
|
ID string
|
|
Domain string
|
|
OwnerOrgName string
|
|
IsCustomDomain bool
|
|
CreatedAt string
|
|
}
|
|
|
|
// OperatorSitesData holds data for the sites list partial
|
|
type OperatorSitesData struct {
|
|
Sites []OperatorSiteViewModel
|
|
Error string
|
|
}
|
|
|
|
// GetSites handles GET /partials/operator/sites
|
|
func (h *OperatorPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request) {
|
|
sites, err := h.SiteQ.ListAllSites(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to get all sites", slog.Any("error", err))
|
|
h.renderError(w, "operator_sites.html", "Failed to retrieve sites")
|
|
return
|
|
}
|
|
|
|
// Build a cache of workspace_id → org name to avoid repeated lookups
|
|
orgNameCache := make(map[string]string)
|
|
|
|
siteVMs := make([]OperatorSiteViewModel, len(sites))
|
|
for i, site := range sites {
|
|
orgName := ""
|
|
if cached, ok := orgNameCache[site.WorkspaceID]; ok {
|
|
orgName = cached
|
|
} else {
|
|
ws, err := h.OrgQ.GetWorkspaceByID(r.Context(), site.WorkspaceID)
|
|
if err == nil {
|
|
org, err := h.OrgQ.GetOrganizationByID(r.Context(), ws.OrgID)
|
|
if err == nil {
|
|
orgName = org.Name
|
|
}
|
|
}
|
|
orgNameCache[site.WorkspaceID] = orgName
|
|
}
|
|
|
|
siteVMs[i] = OperatorSiteViewModel{
|
|
ID: site.SiteID,
|
|
Domain: site.Domain,
|
|
OwnerOrgName: orgName,
|
|
IsCustomDomain: site.IsCustomDomain,
|
|
CreatedAt: site.CreatedAt.Format("Jan 2, 2006"),
|
|
}
|
|
}
|
|
|
|
data := OperatorSitesData{Sites: siteVMs}
|
|
h.Templates.Render(w, "operator_sites.html", data)
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func (h *OperatorPartialsHandler) renderError(w http.ResponseWriter, tmplName string, message string) {
|
|
data := struct{ Error string }{Error: message}
|
|
h.Templates.Render(w, tmplName, data)
|
|
}
|
|
|
|
// GrantViewModel represents a grant for operator template rendering.
|
|
type GrantViewModel struct {
|
|
GrantID string
|
|
OrgName string
|
|
ProductName string
|
|
EntitlementSetName string
|
|
Quantity int32
|
|
GrantReason string
|
|
Status string
|
|
CreatedAt string
|
|
}
|
|
|
|
// GrantsData holds data for the grants management partial.
|
|
type GrantsData struct {
|
|
Grants []GrantViewModel
|
|
Organizations []OrganizationViewModel
|
|
Products []ProductViewModel
|
|
EntitlementSets []EntitlementSetOption
|
|
Success string
|
|
Error string
|
|
}
|
|
|
|
// ProductViewModel represents a product for the grant creation form.
|
|
type ProductViewModel struct {
|
|
ProductID string
|
|
Name string
|
|
ProductType string
|
|
}
|
|
|
|
|
|
// GetGrants handles GET /partials/operator/grants — renders the grant management section.
|
|
func (h *OperatorPartialsHandler) GetGrants(w http.ResponseWriter, r *http.Request) {
|
|
h.renderGrantsPage(w, r, "", "")
|
|
}
|
|
|
|
// CreateGrant handles POST /partials/operator/grants — creates a grant and re-renders the page.
|
|
func (h *OperatorPartialsHandler) CreateGrant(w http.ResponseWriter, r *http.Request) {
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
h.renderGrantsPage(w, r, "", "Unauthorized")
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderGrantsPage(w, r, "", "Invalid request")
|
|
return
|
|
}
|
|
|
|
grantPath := r.FormValue("grant_path")
|
|
productID := r.FormValue("product_id")
|
|
entitlementSetID := r.FormValue("entitlement_set_id")
|
|
orgID := r.FormValue("org_id")
|
|
reason := r.FormValue("reason")
|
|
quantityStr := r.FormValue("quantity")
|
|
|
|
// Validate: exactly one path must be selected
|
|
if grantPath != "product" && grantPath != "entitlement_set" {
|
|
h.renderGrantsPage(w, r, "", "Select either Product or Entitlement Set path")
|
|
return
|
|
}
|
|
if grantPath == "product" && productID == "" {
|
|
h.renderGrantsPage(w, r, "", "Product is required for product path")
|
|
return
|
|
}
|
|
if grantPath == "entitlement_set" && entitlementSetID == "" {
|
|
h.renderGrantsPage(w, r, "", "Entitlement set is required for entitlement set path")
|
|
return
|
|
}
|
|
if orgID == "" {
|
|
h.renderGrantsPage(w, r, "", "Organization is required")
|
|
return
|
|
}
|
|
|
|
quantity := int32(1)
|
|
if quantityStr != "" {
|
|
if q, err := strconv.Atoi(quantityStr); err == nil && q > 0 {
|
|
quantity = int32(q)
|
|
}
|
|
}
|
|
|
|
// Safety guard: plan-tier products must go through the Enrollment page
|
|
// so that Transition() is invoked and ladder attachments are created.
|
|
if grantPath == "product" && productID != "" {
|
|
ladders, err := h.BillingQ.ListLaddersByProduct(r.Context(), productID)
|
|
if err == nil && len(ladders) > 0 {
|
|
h.renderGrantsPage(w, r, "", "Plan products are granted through the organization's Enrollment tab; the general Grants tab is for addons and one-time products only.")
|
|
return
|
|
}
|
|
}
|
|
|
|
input := entitlements.CreateGrantInput{
|
|
OrgID: orgID,
|
|
GrantedByPersonID: session.PersonID,
|
|
GrantReason: reason,
|
|
Quantity: quantity,
|
|
}
|
|
if grantPath == "product" {
|
|
input.ProductID = productID
|
|
} else {
|
|
input.EntitlementSetID = entitlementSetID
|
|
}
|
|
|
|
_, err := entitlements.CreateGrantAndMaterialize(r.Context(), h.Database, input)
|
|
if err != nil {
|
|
h.Logger.Error("failed to create grant", slog.Any("error", err))
|
|
h.renderGrantsPage(w, r, "", "Failed to create grant: "+err.Error())
|
|
return
|
|
}
|
|
|
|
h.renderGrantsPage(w, r, "Grant created successfully.", "")
|
|
}
|
|
|
|
// RevokeGrant handles POST /partials/operator/grants/{grantID}/revoke — revokes a grant and re-renders.
|
|
func (h *OperatorPartialsHandler) RevokeGrant(w http.ResponseWriter, r *http.Request) {
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
h.renderGrantsPage(w, r, "", "Unauthorized")
|
|
return
|
|
}
|
|
|
|
grantID := r.PathValue("grantID")
|
|
if grantID == "" {
|
|
h.renderGrantsPage(w, r, "", "Grant ID is required")
|
|
return
|
|
}
|
|
grantUUID, err := uuid.Parse(grantID)
|
|
if err != nil {
|
|
h.renderGrantsPage(w, r, "", "Invalid grant ID")
|
|
return
|
|
}
|
|
|
|
// Pre-tx peek to find the provision's pool, so we know which row to lock.
|
|
// State may change between this read and the lock — we re-resolve the
|
|
// ladder attachment after the lock to make the decision authoritative.
|
|
provision, err := h.EntitlementsQ.GetPoolProvisionByGrantID(r.Context(), uuid.NullUUID{UUID: grantUUID, Valid: true})
|
|
if err != nil || provision.ProvisionID == "" {
|
|
// No provision (revoked grant, or never provisioned). Plain revoke path.
|
|
if err := entitlements.RevokeGrantAndRematerialize(r.Context(), h.Database, grantID, session.PersonID, "operator_revocation"); err != nil {
|
|
h.Logger.Error("failed to revoke grant", slog.Any("error", err))
|
|
h.renderGrantsPage(w, r, "", "Failed to revoke grant: "+err.Error())
|
|
return
|
|
}
|
|
h.renderGrantsPage(w, r, "Grant revoked successfully.", "")
|
|
return
|
|
}
|
|
|
|
tx, err := h.Database.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
h.Logger.Error("failed to begin transaction", slog.Any("error", err))
|
|
h.renderGrantsPage(w, r, "", "Transaction failed")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Lock ordering: pool → grant → provision → ladder.
|
|
if _, err := tx.ExecContext(r.Context(), "SELECT 1 FROM entitlements.resource_pools WHERE pool_id = $1 FOR UPDATE", provision.PoolID); err != nil {
|
|
h.Logger.Error("failed to lock pool row", slog.Any("error", err))
|
|
h.renderGrantsPage(w, r, "", "Pool lock failed")
|
|
return
|
|
}
|
|
|
|
q := entitlements.New(tx)
|
|
|
|
// Authoritative re-read inside the tx, after the lock.
|
|
attachments, err := q.GetActiveAttachmentsByPool(r.Context(), provision.PoolID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to read ladder attachments", slog.Any("error", err))
|
|
h.renderGrantsPage(w, r, "", "Failed to read pool state")
|
|
return
|
|
}
|
|
hasLadderAttachment := false
|
|
for _, att := range attachments {
|
|
if att.ProvisionID == provision.ProvisionID {
|
|
hasLadderAttachment = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if _, err := q.RevokeGrant(r.Context(), entitlements.RevokeGrantParams{
|
|
GrantID: grantID,
|
|
RevokedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
RevocationReason: sql.NullString{String: "operator_revocation", Valid: true},
|
|
}); err != nil {
|
|
h.Logger.Error("failed to revoke grant", slog.Any("error", err))
|
|
h.renderGrantsPage(w, r, "", "Failed to revoke grant: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if hasLadderAttachment {
|
|
// Transition(End) ends the prior provision itself, captures from_rank,
|
|
// and re-applies the org-type default. Do NOT pre-end the provision —
|
|
// that erases the from_rank context Transition needs to classify the
|
|
// transition as a downgrade vs. initiate.
|
|
if _, err := entitlements.Transition(r.Context(), tx, provision.PoolID, entitlements.TransitionTarget{End: true},
|
|
entitlements.TransitionActor{
|
|
ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(session.PersonID), Valid: true},
|
|
Reason: "operator_revocation",
|
|
}); err != nil {
|
|
h.Logger.Error("transition failed after revocation", slog.Any("error", err))
|
|
h.renderGrantsPage(w, r, "", "Transition failed: "+err.Error())
|
|
return
|
|
}
|
|
} else {
|
|
// No ladder attachment — end the provision and rematerialize so the
|
|
// pool reflects the change. No transition row (consistent with
|
|
// non-plan revocations).
|
|
if _, err := q.UpdatePoolProvisionStatus(r.Context(), entitlements.UpdatePoolProvisionStatusParams{
|
|
ProvisionID: provision.ProvisionID,
|
|
Status: "ended",
|
|
}); err != nil {
|
|
h.Logger.Error("failed to end provision", slog.Any("error", err))
|
|
h.renderGrantsPage(w, r, "", "Failed to end provision: "+err.Error())
|
|
return
|
|
}
|
|
if err := entitlements.MaterializePoolEntitlements(r.Context(), q, provision.PoolID); err != nil {
|
|
h.Logger.Error("failed to rematerialize pool", slog.Any("error", err))
|
|
h.renderGrantsPage(w, r, "", "Failed to rematerialize: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
h.Logger.Error("failed to commit transaction", slog.Any("error", err))
|
|
h.renderGrantsPage(w, r, "", "Commit failed")
|
|
return
|
|
}
|
|
|
|
h.renderGrantsPage(w, r, "Grant revoked successfully.", "")
|
|
}
|
|
|
|
// renderGrantsPage loads all data needed for the grants management page and renders it.
|
|
func (h *OperatorPartialsHandler) renderGrantsPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
|
|
data := GrantsData{
|
|
Success: success,
|
|
Error: errMsg,
|
|
}
|
|
|
|
if errMsg == "" {
|
|
// Load organizations for the form
|
|
orgs, err := h.OrgQ.ListOrganizations(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list organizations", slog.Any("error", err))
|
|
data.Error = "Failed to load organizations"
|
|
} else {
|
|
data.Organizations = make([]OrganizationViewModel, len(orgs))
|
|
for i, org := range orgs {
|
|
data.Organizations[i] = OrganizationViewModel{
|
|
OrgID: org.OrgID,
|
|
Name: org.Name,
|
|
Slug: org.Slug,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load products for the form
|
|
products, err := h.BillingQ.ListActiveProducts(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list products", slog.Any("error", err))
|
|
} else {
|
|
data.Products = make([]ProductViewModel, len(products))
|
|
for i, p := range products {
|
|
data.Products[i] = ProductViewModel{
|
|
ProductID: p.ProductID,
|
|
Name: p.Name,
|
|
ProductType: p.ProductType.String,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load active entitlement sets for the form
|
|
data.EntitlementSets = h.loadEntitlementSetOptions(r)
|
|
|
|
// Load all grants
|
|
grants, err := h.EntitlementsQ.ListAllGrants(r.Context())
|
|
if err != nil {
|
|
h.Logger.Error("failed to list grants", slog.Any("error", err))
|
|
} else {
|
|
data.Grants = make([]GrantViewModel, len(grants))
|
|
for i, g := range grants {
|
|
// Look up org name
|
|
orgName := ""
|
|
if g.GrantedToOrgID.Valid {
|
|
org, err := h.OrgQ.GetOrganizationByID(r.Context(), g.GrantedToOrgID.UUID.String())
|
|
if err == nil {
|
|
orgName = org.Name
|
|
}
|
|
}
|
|
|
|
// Look up product name
|
|
productName := ""
|
|
if g.ProductID.Valid {
|
|
if p, err := h.BillingQ.GetProductByID(r.Context(), g.ProductID.UUID.String()); err == nil {
|
|
productName = p.Name
|
|
}
|
|
}
|
|
|
|
// Look up entitlement set name (for direct entitlement set grants)
|
|
entitlementSetName := ""
|
|
if !g.ProductID.Valid && g.EntitlementSetID.Valid {
|
|
if es, err := h.EntitlementsQ.GetEntitlementSetByID(r.Context(), g.EntitlementSetID.UUID.String()); err == nil {
|
|
entitlementSetName = es.Name
|
|
}
|
|
}
|
|
|
|
data.Grants[i] = GrantViewModel{
|
|
GrantID: g.GrantID,
|
|
OrgName: orgName,
|
|
ProductName: productName,
|
|
EntitlementSetName: entitlementSetName,
|
|
Quantity: g.Quantity,
|
|
GrantReason: g.GrantReason,
|
|
Status: g.Status,
|
|
CreatedAt: g.CreatedAt.Format("Jan 2, 2006"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
h.Templates.Render(w, "operator_grants.html", data)
|
|
}
|