Archives openspec change slice3-walk-fixes and syncs its five delta specs (fedwiki-sites, entitlements, operator-panel-navigation, operator-list-scale, ui-quality-gate). - FedWiki site usage is read from active site rows in both quota readers; the reservation counter converges on the rows: raise-only after farm sync and inside the create quota check, exact at boot. The understated production counters repair on the first boot. - The People tile caption excludes the reserved system person through the same query parameter the directory uses. - The operator Domains live-claims list is a governed list: pages of 50, true total, search over root name and organization, a pending/active facet. - New lint rule table-without-list-controls refuses an unpaged page-body table unless it carries a list-scale exempt marker with a reason; six curated or detail tables carry one. Its first run caught the operator FedWiki sites list, which is now governed the same way. - Entitlement-set rule copy: "Per unit", "Multiplied by the quantity purchased or granted."
425 lines
17 KiB
Go
425 lines
17 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
|
|
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows"
|
|
temporalsdk "go.temporal.io/sdk/client"
|
|
)
|
|
|
|
// FedWikiSiteViewModel represents a site for the operator template
|
|
// rendering. Renamed from the core package's OperatorSiteViewModel
|
|
// (openspec/changes/integration-extraction task 2.3): that name suggested
|
|
// a generic "sites" concept, but it was 100% FedWiki-specific — its only
|
|
// consumer was this page.
|
|
type FedWikiSiteViewModel struct {
|
|
ID string
|
|
Domain string
|
|
URL string // composed with the configured fedwiki-site-scheme; see GetSitesPage
|
|
OwnerOrgName string
|
|
IsCustomDomain bool
|
|
CreatedAt string
|
|
Status string // active | readonly | archived
|
|
}
|
|
|
|
// FedWikiSitesData holds data for the fedwiki_operator_sites.html partial.
|
|
// Renamed from the core package's OperatorSitesData (see
|
|
// FedWikiSiteViewModel's doc comment for why).
|
|
type FedWikiSitesData struct {
|
|
// Configured reports whether FedWiki's required configuration group
|
|
// (fedwiki-farm-api-url, fedwiki-admin-token) resolves. false renders
|
|
// the same blocked not-configured shape the Discourse operator page
|
|
// renders instead of an empty sites listing (ACC-2; fedwiki-sites spec
|
|
// "Operator panel site listing").
|
|
Configured bool
|
|
// Description, when the provider manifest declares one, overrides the
|
|
// page header's default lead (operator-panel-navigation: "A provider
|
|
// page leads with the integration's description").
|
|
Description string
|
|
Sites []FedWikiSiteViewModel
|
|
Error string
|
|
// Sync health (latest schedule-spawned sync execution, from Temporal)
|
|
// — same panel the Discourse operator page leads with; the shared
|
|
// anatomy is documented in docs/building-an-integration.md.
|
|
SyncStatus string
|
|
SyncTime string
|
|
// Nav governs the sites table (operator-list-scale): search over the
|
|
// domain and the holder's name, the status facet over fedwiki.sites'
|
|
// own vocabulary, and true-total pagination. The section count above
|
|
// the table is that same true total.
|
|
Nav server.ListNav
|
|
}
|
|
|
|
// fedWikiSiteFacets is the status vocabulary fedwiki.sites carries
|
|
// (sites_status_valid, 00001_init), so the filter offers exactly the
|
|
// values a row can hold and no more.
|
|
var fedWikiSiteFacets = []server.FacetOption{
|
|
{Value: "active", Label: "Active"},
|
|
{Value: "readonly", Label: "Read only"},
|
|
{Value: "archived", Label: "Archived"},
|
|
}
|
|
|
|
// FedWikiOperatorHandler serves the read-only operator-tier view of every
|
|
// FedWiki site in the system, at the path the FedWiki provider manifest
|
|
// declares (OperatorSurfacePath). Moved from the core OperatorPartialsHandler
|
|
// (GetFedWikiSitesPage, internal/server/operator_pages.go:100-152 pre-move)
|
|
// per openspec/changes/integration-extraction task 2.3: unlike the
|
|
// member-facing API/partials handlers, this page was previously grafted
|
|
// onto the shared operator handler rather than owning its own handler
|
|
// type — it now does, mirroring FedWikiHandler/FedWikiPartialsHandler.
|
|
type FedWikiOperatorHandler struct {
|
|
SiteQ fwmod.Querier
|
|
Database *sql.DB
|
|
Logger *slog.Logger
|
|
AuthConfig *auth.Config
|
|
FedWikiSiteScheme string // http or https; mirrors FedWikiHandler/FedWikiPartialsHandler's field
|
|
Templates *server.SafeTemplates
|
|
Temporal temporalsdk.Client
|
|
SyncScheduleID string
|
|
// Configured mirrors DiscourseOperatorHandler's field: whether
|
|
// FedWiki's required config group resolves, computed once by the
|
|
// adapter (fedwiki.go's own viper concern) and threaded through.
|
|
Configured bool
|
|
// Description: see FedWikiSitesData.Description.
|
|
Description string
|
|
}
|
|
|
|
// FedWikiOperatorHandlerConfig holds configuration for FedWikiOperatorHandler.
|
|
type FedWikiOperatorHandlerConfig struct {
|
|
SiteQ fwmod.Querier
|
|
Database *sql.DB
|
|
Logger *slog.Logger
|
|
AuthConfig *auth.Config
|
|
FedWikiSiteScheme string
|
|
// TemplatesFS is FedWiki's own template directory, supplied by the
|
|
// Adapter from its embedded templates tree — see
|
|
// FedWikiPartialsConfig.TemplatesFS's doc comment for why it's injected
|
|
// rather than embedded in this package directly (the same cycle
|
|
// argument applies here). Core's operator.html shell, by contrast, is
|
|
// fetched directly from internal/embeds below — no cycle risk, since
|
|
// embeds is a leaf core package.
|
|
TemplatesFS fs.FS
|
|
// Temporal + SyncScheduleID feed the Sync health panel (nil/"" →
|
|
// "no sync yet"); see workflows.ScheduleRunStatus for why health is
|
|
// resolved through the schedule rather than a workflow ID.
|
|
Temporal temporalsdk.Client
|
|
SyncScheduleID string
|
|
// Configured: see FedWikiOperatorHandler.Configured.
|
|
Configured bool
|
|
// Description: see FedWikiSitesData.Description.
|
|
Description string
|
|
}
|
|
|
|
// NewFedWikiOperatorHandler creates a new FedWikiOperatorHandler.
|
|
func NewFedWikiOperatorHandler(cfg FedWikiOperatorHandlerConfig) (*FedWikiOperatorHandler, error) {
|
|
shellFS, err := fs.Sub(embeds.Templates, "templates")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// operator.html dispatches to a named body partial via the `renderBody`
|
|
// template func (html/template's `{{ template "literal" }}` action
|
|
// requires a compile-time-literal name, hence the indirection). This
|
|
// handler only ever renders its own one body partial
|
|
// (fedwiki_operator_sites.html), so, unlike OperatorPartialsHandler's
|
|
// shared set, renderBody here is a closure over that single template
|
|
// rather than a dynamic dispatcher over many.
|
|
var tmpl *template.Template
|
|
tmpl = template.New("fedwiki-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,
|
|
// The shell's <title> names the page (server.PageTitle).
|
|
"pageTitle": server.PageTitle,
|
|
})
|
|
// The shell's landing branch references operator_lookup_result.html;
|
|
// html/template's escape analysis requires every referenced template in
|
|
// the set even on branches this page never takes, so parse the shell's
|
|
// partial dependency alongside it or every render fails.
|
|
tmpl, err = tmpl.ParseFS(shellFS, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tmpl, err = web.ParseUIPartials(tmpl); err != nil {
|
|
return nil, err
|
|
}
|
|
// An integration's operator page is an operator page: its trail is
|
|
// rooted at the operator surface like every other (design D18).
|
|
tmpl = tmpl.Funcs(template.FuncMap{"surfaceRoot": web.OperatorSurfaceRoot})
|
|
tmpl, err = tmpl.ParseFS(cfg.TemplatesFS, "fedwiki_operator_sites.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &FedWikiOperatorHandler{
|
|
SiteQ: cfg.SiteQ,
|
|
Database: cfg.Database,
|
|
Logger: cfg.Logger,
|
|
AuthConfig: cfg.AuthConfig,
|
|
FedWikiSiteScheme: cfg.FedWikiSiteScheme,
|
|
Templates: server.NewSafeTemplates(tmpl, cfg.Logger),
|
|
Temporal: cfg.Temporal,
|
|
SyncScheduleID: cfg.SyncScheduleID,
|
|
Configured: cfg.Configured,
|
|
Description: cfg.Description,
|
|
}, nil
|
|
}
|
|
|
|
// GetSitesPage handles GET <the FedWiki provider manifest's
|
|
// OperatorSurfacePath> — read-only integration-tier view of every FedWiki
|
|
// site in the system. The existing fedwiki_operator_sites.html partial is
|
|
// already read-only (table + external links, no forms), so no template
|
|
// changes were needed for the move.
|
|
func (h *FedWikiOperatorHandler) GetSitesPage(w http.ResponseWriter, r *http.Request) {
|
|
bodyData := FedWikiSitesData{Configured: h.Configured, Description: h.Description}
|
|
if !h.Configured {
|
|
page := server.BuildOperatorPageData(r, h.AuthConfig, h.Database, h.Logger)
|
|
page.IAPosition = "integration:integrations:fedwiki"
|
|
page.ActiveCapability = "integrations"
|
|
page.BodyTemplate = "fedwiki_operator_sites.html"
|
|
page.BodyData = bodyData
|
|
h.Templates.Render(w, "operator.html", page)
|
|
return
|
|
}
|
|
|
|
bodyData.SyncStatus, bodyData.SyncTime = workflows.ScheduleRunStatus(r.Context(), h.Temporal, h.SyncScheduleID)
|
|
if bodyData.SyncStatus == "" {
|
|
bodyData.SyncStatus = "No sync has run yet"
|
|
}
|
|
|
|
// The governed list's state comes off the request (operator-list-scale):
|
|
// search, the status facet, and the page. The owning organization rides
|
|
// along on each row from the query's join, so the search reaches it and
|
|
// the old workspace -> organization lookup cache is gone.
|
|
params := server.ParseListParams(r, "status")
|
|
params.Facet = server.ValidFacet(params.Facet, fedWikiSiteFacets)
|
|
sites, total, err := server.FetchPage(¶ms, func(limit, offset int32) ([]fwmod.ListAllSitesForOperatorPageRow, int64, error) {
|
|
rows, qErr := h.SiteQ.ListAllSitesForOperatorPage(r.Context(), fwmod.ListAllSitesForOperatorPageParams{
|
|
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
|
|
Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""},
|
|
PageLimit: limit,
|
|
PageOffset: offset,
|
|
})
|
|
if qErr != nil || len(rows) == 0 {
|
|
return rows, 0, qErr
|
|
}
|
|
return rows, rows[0].TotalCount, nil
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to list sites", slog.Any("error", err))
|
|
bodyData.Error = "Failed to retrieve sites"
|
|
} else {
|
|
// Same config knob the member-facing FedWikiPartialsHandler.buildSiteURL
|
|
// uses (default https) — audit finding #37: this page used to hardcode
|
|
// https:// regardless, breaking links on http-only dev/test farms.
|
|
scheme := h.FedWikiSiteScheme
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
|
|
siteVMs := make([]FedWikiSiteViewModel, len(sites))
|
|
for i, site := range sites {
|
|
siteVMs[i] = FedWikiSiteViewModel{
|
|
ID: site.SiteID,
|
|
Domain: site.Domain,
|
|
URL: scheme + "://" + site.Domain,
|
|
OwnerOrgName: site.OrgName,
|
|
IsCustomDomain: site.IsCustomDomain,
|
|
CreatedAt: site.CreatedAt.Format("Jan 2, 2006"),
|
|
Status: site.Status,
|
|
}
|
|
}
|
|
bodyData.Sites = siteVMs
|
|
bodyData.Nav = server.ListNav{
|
|
BasePath: fwmod.ProviderSource().ProviderManifest().OperatorSurfacePath,
|
|
SearchPlaceholder: "Search by domain or organization",
|
|
FacetParam: "status",
|
|
FacetOptions: fedWikiSiteFacets,
|
|
Q: params.Q,
|
|
Facet: params.Facet,
|
|
Page: params.Page,
|
|
Total: total,
|
|
}
|
|
}
|
|
|
|
page := server.BuildOperatorPageData(r, h.AuthConfig, h.Database, h.Logger)
|
|
// Second-level page of the Integrations section: the section's sidebar
|
|
// entry lights up via ActiveCapability (the page itself is reached from
|
|
// the Integrations home's provider cards, not the sidebar); the
|
|
// ia-position tuple nests under the section landing.
|
|
page.IAPosition = "integration:integrations:fedwiki"
|
|
page.ActiveCapability = "integrations"
|
|
page.BodyTemplate = "fedwiki_operator_sites.html"
|
|
page.BodyData = bodyData
|
|
|
|
h.Templates.Render(w, "operator.html", page)
|
|
}
|
|
|
|
// ---- Page-anatomy pipeline (anatomy sweep group 9). The shared parts
|
|
// (pageHeader, sectionHeader, statusBadge, emptyState) are parsed into
|
|
// this handler's template set by web.ParseUIPartials; these methods
|
|
// supply their data using the server package's part types, so badge
|
|
// labels and tones stay in the one badge map (design D3).
|
|
|
|
func (d FedWikiSitesData) Header() server.PageHeader {
|
|
lead := "Every wiki site the FedWiki integration manages, with its owning organization and status."
|
|
if d.Description != "" {
|
|
lead = d.Description
|
|
}
|
|
return server.PageHeader{
|
|
Title: "FedWiki sites",
|
|
Lead: lead,
|
|
Crumbs: []server.Link{{Label: "Integrations", URL: "/operator/integrations"}},
|
|
Action: &server.Link{Label: "Settings", URL: "/operator/integrations/fedwiki/settings"},
|
|
}
|
|
}
|
|
|
|
func (FedWikiSitesData) SyncHeader() server.SectionHeader {
|
|
return server.SectionHeader{Title: "Sync health", Help: "The sync is the periodic background job that reconciles this list with the wiki farm."}
|
|
}
|
|
|
|
// SitesHeader counts the whole filtered set, never the page's rows
|
|
// (operator-list-scale).
|
|
func (d FedWikiSitesData) SitesHeader() server.SectionHeader {
|
|
h := server.SectionHeader{Title: "Sites"}
|
|
switch n := d.Nav.Total; {
|
|
case n == 1:
|
|
h.Count = "1 site"
|
|
case n > 1:
|
|
h.Count = fmt.Sprintf("%d sites", n)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (FedWikiSitesData) Empty() server.EmptyStateParams {
|
|
return server.EmptyStateParams{Headline: "No sites yet."}
|
|
}
|
|
|
|
// NotConfigured is the page's blocked state, modelled on Discourse's
|
|
// operator page (ACC-2): the integration is registered but dormant until
|
|
// its required keys are set, and the settings page is where an operator
|
|
// sees which keys those are.
|
|
func (FedWikiSitesData) NotConfigured() server.EmptyStateParams {
|
|
return server.EmptyStateParams{
|
|
Blocked: true,
|
|
BlockerCopy: "FedWiki is not configured. Set fedwiki-farm-api-url and fedwiki-admin-token to enable the integration; until then it is registered but dormant.",
|
|
PrerequisiteURL: "/operator/integrations/fedwiki/settings",
|
|
PrerequisiteLabel: "Open FedWiki settings",
|
|
}
|
|
}
|
|
|
|
func (s FedWikiSiteViewModel) TypeBadge() server.Badge {
|
|
if s.IsCustomDomain {
|
|
return server.StatusBadge("custom")
|
|
}
|
|
return server.StatusBadge("subdomain")
|
|
}
|
|
|
|
// StatusBadge maps the store's status strings ("readonly" has no
|
|
// underscore) onto the shared badge map's states.
|
|
func (s FedWikiSiteViewModel) StatusBadge() server.Badge {
|
|
switch s.Status {
|
|
case "readonly":
|
|
return server.StatusBadge("read_only")
|
|
case "archived":
|
|
return server.StatusBadge("archived")
|
|
default:
|
|
return server.StatusBadge("active")
|
|
}
|
|
}
|
|
|
|
// ---- Member-surface anatomy (sweep group 10): the sites card and the
|
|
// custom-domain dialog render their badges and empty states through the
|
|
// shared parts, with the labels and tones in the one badge map.
|
|
|
|
func (s SiteViewModel) TypeBadge() server.Badge {
|
|
if s.IsCustomDomain {
|
|
return server.StatusBadge("custom")
|
|
}
|
|
return server.StatusBadge("subdomain")
|
|
}
|
|
|
|
func (s SiteViewModel) StatusBadge() server.Badge {
|
|
if s.IsReadonly {
|
|
return server.StatusBadge("read_only")
|
|
}
|
|
return server.StatusBadge("active")
|
|
}
|
|
|
|
// NoDomainsEmpty names the deployment-side blocker: no site domains are
|
|
// configured, which is never the member's fault and never their plan's
|
|
// (ux-first-run task 3.6). No prerequisite link: a member cannot fix it.
|
|
func (SitesData) NoDomainsEmpty() server.EmptyStateParams {
|
|
return server.EmptyStateParams{
|
|
Headline: "Sites can't be created yet.",
|
|
Note: "The service hasn't configured any site domains. This isn't fixable here; check back later.",
|
|
}
|
|
}
|
|
|
|
func (SitesData) NoEntitlementEmpty() server.EmptyStateParams {
|
|
return server.EmptyStateParams{
|
|
Headline: "Your plan doesn't currently include FedWiki site creation.",
|
|
}
|
|
}
|
|
|
|
// NoPublishedPlanEmpty is the entitlement branch's third case: no published
|
|
// product confers site creation at all, so the deployment itself hasn't
|
|
// caught up yet — never the member's plan (fedwiki-sites spec "The sites
|
|
// card distinguishes an empty catalog from an excluded entitlement", ACC-7;
|
|
// mirrors NoDomainsEmpty's "not something you can fix" framing).
|
|
func (SitesData) NoPublishedPlanEmpty() server.EmptyStateParams {
|
|
return server.EmptyStateParams{
|
|
Headline: "Website plans aren't published yet.",
|
|
Note: "This deployment hasn't published a plan that includes FedWiki sites. This isn't fixable here; check back later.",
|
|
}
|
|
}
|
|
|
|
func (SitesData) NoSitesEmpty() server.EmptyStateParams {
|
|
return server.EmptyStateParams{
|
|
Headline: "You don't have any active FedWiki sites yet.",
|
|
Note: "Create a site to get started.",
|
|
}
|
|
}
|
|
|
|
// StateBadge mirrors the core member-domains record mapping
|
|
// (server.MemberDomainRecordView.StateBadge) for the dialog's own record
|
|
// rows.
|
|
func (r CustomDomainRecordView) StateBadge() server.Badge {
|
|
switch r.State {
|
|
case "match":
|
|
return server.StatusBadge("found")
|
|
case "mismatch":
|
|
return server.StatusBadge("found_mismatch")
|
|
case "missing":
|
|
return server.StatusBadge("not_found")
|
|
case "error":
|
|
return server.StatusBadge("check_failed").WithTitle("We couldn't complete the DNS lookup; we'll keep trying.")
|
|
default:
|
|
return server.StatusBadge("not_checked")
|
|
}
|
|
}
|
|
|
|
func (r CustomDomainRecordView) RecordKindBadge() server.Badge {
|
|
return server.Badge{Label: r.Kind, Tone: "light"}
|
|
}
|