// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "html/template" "strconv" "git.coopcloud.tech/wiki-cafe/member-console/internal/forms" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" ) // The organization composite's two grant forms (spec // plan-enrollment-administration "The composite's grant forms use // imperative copy and help icons"; spec form-library, form-conventions; // design D3, D8, D10). Both are dense sub-record forms on the organization // composite: Issue grant opens from the Plan and grants card's header, one // per page; Extend opens from a pool's delivery row, once per grant-backed // delivery, so its render carries a Binding.Instance and a per-delivery // WayOut (findings FA-28, FA-29, FA-32, FA-36). // // This file is the forms' home; the registry is only the index (design // D1). It sits beside operator_enrollment.go, which parses through both // declarations and never through r.FormValue. const ( issueGrantFormName = "operator.enrollment.grant.issue" extendGrantFormName = "operator.enrollment.grant.extend" ) // issueGrantForm is the declaration for Issue grant: any published product, // a quantity, an optional valid_until, a reason from the operator domain, // and a free-text description (plan-enrollment-administration "Operator // grant issuance"). Registered at package init so the registry is complete // for the invariants test, the route test and the registered-forms table // whether or not a handler has been built (the pattern operator_product_ // forms.go set). var issueGrantForm = forms.Register(forms.FormSpec{ Name: issueGrantFormName, Kind: forms.KindSubRecord, Family: forms.Dense, Method: "POST", Path: "/partials/operator/organizations/{orgID}/grant/create", // The whole composite re-renders: issuing a grant can change the // pools' delivery listing, the ledger and the tier-changes history as // well as the form, and a refusal must swap into the same target the // success does (design D9, lesson L§17). Single instance per page, so // no Binding.Target override is needed. Target: "#operator-body", Swap: "innerHTML", Fields: []forms.Field{ issueGrantProductField(), { Name: "quantity", Label: "Quantity", Control: forms.Number, Optional: true, Min: "1", Max: strconv.Itoa(maxGrantQuantity), Help: forms.Help("Quantity", "Seats, for per-seat products."), // A ghost example rather than a pre-filled value: the handler // defaults a blank submission to 1 (plan-enrollment-administration, // "a positive integer, defaulting to 1"), so leaving the field // empty is itself a valid, common choice. Placeholder: "1", }, forms.GrantValidUntil(), { Name: "reason", Label: "Reason", Control: forms.Select, Options: issueGrantReasonOptions(), // An enum of one or two words: a select's third is wider than // any option needs. Width: forms.WidthNarrower, }, forms.GrantNote(), }, Commit: "Issue grant", WayOut: forms.ClosePanel("Cancel", "issueGrantPanel"), }) // extendGrantForm is the declaration for Extend: the shared description and // valid_until fields the issuance form carries, named and labelled alike // (finding FA-28: Extend keeps no "reason" field; the handler records // grant_reason "manual"; finding FA-32: one "Valid until" label on both // forms). provision_id is hidden, because the route names only the // organization and its pool, not the specific delivery the button rides // (design D2's Hidden control: a parent id the route cannot itself carry). // This declaration renders once per grant-backed delivery, so every render // site supplies Binding.Instance and a per-delivery WayOut; the WayOut // below is the placeholder invariants need at registration and is never // what actually renders. var extendGrantForm = forms.Register(forms.FormSpec{ Name: extendGrantFormName, Kind: forms.KindSubRecord, Family: forms.Dense, Method: "POST", Path: "/partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend", Target: "#operator-body", Swap: "innerHTML", Fields: []forms.Field{ forms.GrantNote(), forms.GrantValidUntil(), { Name: "provision_id", Label: "Delivery", Control: forms.Hidden, Optional: true, }, }, Commit: "Extend tier", WayOut: forms.ClosePanel("Cancel", "extendGrantPanelTemplate"), }) // issueGrantReasonOptions is the grant-reason enum select's options: a // disabled "Choose a reason" placeholder (spec form-conventions "A select // that needs a choice opens with a placeholder option"), then the // operator-selectable domain, never including 'default' (system-authored // only) or 'trial' (superseded by 'evaluation' plus valid_until; // plan-enrollment-administration "Operator cannot select grant_reason // 'default'", "Time-boxed grant uses 'evaluation', never 'trial'"). func issueGrantReasonOptions() []forms.Option { opts := make([]forms.Option, 0, len(grantReasonDomain)+1) opts = append(opts, forms.ChooseOption("a reason")) for _, v := range grantReasonDomain { opts = append(opts, forms.Option{Value: v, Label: v}) } return opts } // issuanceProductOptions builds the Product select's runtime options: a // disabled "Choose a product" placeholder, then every issuable product, // carrying forward the internal and supersession markers the composite // already showed (finding FA-33 is unaddressed here; the marker's text // equivalent is a tracked, not this lane's, fix). func issuanceProductOptions(products []IssuanceProductOption) []forms.Option { opts := make([]forms.Option, 0, len(products)+1) opts = append(opts, forms.ChooseOption("a product")) for _, p := range products { label := p.Name if p.IsInternal { label += " [internal]" } if p.SupersedesSubscription { label += " ⚠" } opts = append(opts, forms.Option{Value: p.ProductID, Label: label}) } return opts } // issueGrantProductField is the grant's product picker plus the rule-less // warning's live region (design.md M1; plan-enrollment-administration "Issue // grant names a product that provides nothing"), on the duplicate-name // warning's precedent. The picker stays unfiltered and unpartitioned and the // commit stays enabled: a set with no active rule is a state an operator may // legitimately grant from. func issueGrantProductField() forms.Field { return forms.Field{ Name: "product_id", Label: "Product", Control: forms.Select, RuntimeOptions: true, Slot: "warning", Attrs: map[string]string{ "hx-get": "/partials/operator/products/rule-check", "hx-trigger": "change", "hx-target": `#form-operator\.enrollment\.grant\.issue-product_id-warning`, "hx-swap": "innerHTML", }, } } // issueGrantFormOptions is the option map both the render and the parse // read, so what the control offered and what the handler accepts are one // list (the pattern operator_product_forms.go set for entitlement_set_id). func issueGrantFormOptions(products []IssuanceProductOption) map[string][]forms.Option { return map[string][]forms.Option{"product_id": issuanceProductOptions(products)} } // issueGrantFormView renders the Issue grant panel: mode is ModeRecord at // rest (bound to empty values, so a fresh open shows nothing chosen and no // error) and ModeSubmission on a refused submission, with every submitted // value carried back (design D9). func issueGrantFormView(orgID string, products []IssuanceProductOption, mode forms.Mode, values forms.Values, errs *forms.Errors, warning template.HTML) forms.FormView { action, err := web.RouteURL(issueGrantForm.Path, orgID) if err != nil { action = issueGrantForm.Path } var slots map[string]template.HTML if warning != "" { slots = map[string]template.HTML{"product_id": warning} } return forms.Render(issueGrantForm, forms.Binding{ Mode: mode, Action: action, Values: values, Errors: errs, Options: issueGrantFormOptions(products), Slots: slots, }) } // extendGrantFormView renders one delivery's Extend panel. Instance is the // provision id, so a page with several open panels carries no duplicate DOM // id; the way out closes exactly the panel the button that opened it named // (design D10; findings FA-19, FA-28, FA-29, FA-32). func extendGrantFormView(orgID, poolID, provisionID string, mode forms.Mode, values forms.Values, errs *forms.Errors) forms.FormView { action, err := web.RouteURL(extendGrantForm.Path, orgID, poolID) if err != nil { action = extendGrantForm.Path } return forms.Render(extendGrantForm, forms.Binding{ Mode: mode, Action: action, Instance: provisionID, WayOut: &forms.WayOut{Kind: forms.WayOutClosePanel, Label: "Cancel", PanelID: "extendPanel-" + provisionID}, Values: values, Errors: errs, }) } // extendGrantRestValues binds an at-rest Extend panel's one carried value: // the delivery it extends, so the hidden field always names the right // provision even before any submission (design D2's Hidden control). func extendGrantRestValues(provisionID string) forms.Values { values := forms.NewValues() values.Set("provision_id", provisionID) return values }