Move sub-surfaces into their sections: billing views get a pill row, org types a header button. Replace inline IdP handoff copy with an SVG icon and tooltip, add help icons to dense form rows, and delete the registry-driven sidebar nav plumbing. Update specs and tests.
353 lines
12 KiB
Go
353 lines
12 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"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"
|
|
dcclient "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/client"
|
|
dcmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/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"
|
|
)
|
|
|
|
// groupMappingConstraints translates this form's plausible constraint
|
|
// violations (docs/operator-ux-conventions.md §6 — names live next to the
|
|
// form they belong to).
|
|
var groupMappingConstraints = web.ConstraintMessages{
|
|
"uq_group_mappings_group_name": {Field: "group_name", Message: "That group is already managed by another mapping."},
|
|
"fk_group_mappings_resource_key": {Field: "resource_key", Message: "Unknown resource key."},
|
|
}
|
|
|
|
// MappingViewModel is one entitlement→group mapping row.
|
|
type MappingViewModel struct {
|
|
MappingID string
|
|
ResourceKey string
|
|
// DisplayName is the resource key's friendly name, from the same
|
|
// resource-key catalog the mapping-form picker uses, so the mappings
|
|
// table leads with it instead of the raw key (ui-vocabulary 4.3).
|
|
DisplayName string
|
|
GroupName string
|
|
GroupID int64
|
|
CreatedAt string
|
|
DeleteURL string
|
|
}
|
|
|
|
// UnlinkedPersonViewModel is one entitled-but-unlinked person.
|
|
type UnlinkedPersonViewModel struct {
|
|
DisplayName string
|
|
Email string
|
|
}
|
|
|
|
// ConflictViewModel is one conflicted link.
|
|
type ConflictViewModel struct {
|
|
DiscourseUsername string
|
|
PersonID string
|
|
}
|
|
|
|
// ResourceKeyOption is a select option for the mapping form.
|
|
type ResourceKeyOption struct {
|
|
Key string
|
|
DisplayName string
|
|
}
|
|
|
|
// OperatorPageData is the discourse_operator.html body payload.
|
|
type OperatorPageData struct {
|
|
Configured bool
|
|
|
|
// Sync health (latest sweep execution, from Temporal).
|
|
SweepStatus string
|
|
SweepTime string
|
|
|
|
Mappings []MappingViewModel
|
|
ResourceKeys []ResourceKeyOption
|
|
Unlinked []UnlinkedPersonViewModel
|
|
Conflicts []ConflictViewModel
|
|
|
|
// Add-mapping form state (422 re-render path).
|
|
FieldErrors web.FieldErrors
|
|
FormValues map[string]string
|
|
|
|
Error string
|
|
}
|
|
|
|
// DiscourseOperatorHandler serves the Discourse integration's operator
|
|
// surface: entitlement→group mapping management and link/sync visibility
|
|
// (spec: discourse-operator-surface).
|
|
type DiscourseOperatorHandler struct {
|
|
db *sql.DB
|
|
logger *slog.Logger
|
|
authConfig *auth.Config
|
|
apiClient *dcclient.Client
|
|
temporal temporalsdk.Client
|
|
configured bool
|
|
sweepScheduleID string
|
|
templates *server.SafeTemplates
|
|
}
|
|
|
|
// DiscourseOperatorHandlerConfig configures the handler.
|
|
type DiscourseOperatorHandlerConfig struct {
|
|
DB *sql.DB
|
|
Logger *slog.Logger
|
|
AuthConfig *auth.Config
|
|
// Client is nil when the integration is unconfigured (the page then
|
|
// renders only the unconfigured notice; mutation routes are unmounted).
|
|
Client *dcclient.Client
|
|
Temporal temporalsdk.Client
|
|
Configured bool
|
|
// TemplatesFS is the integration's own template tree, injected by the
|
|
// adapter (same cycle argument as FedWiki's operator handler).
|
|
TemplatesFS fs.FS
|
|
// SweepScheduleID names the sweep SCHEDULE to report health from
|
|
// (this integration's workflows.SyncScheduleID; injected so this
|
|
// package needs no import of its own workflows package). Runs are
|
|
// resolved through the schedule because schedule-spawned executions
|
|
// get timestamp-suffixed workflow IDs — see
|
|
// workflows.ScheduleRunStatus.
|
|
SweepScheduleID string
|
|
}
|
|
|
|
// NewDiscourseOperatorHandler composes the operator shell with the
|
|
// integration's body partial.
|
|
func NewDiscourseOperatorHandler(cfg DiscourseOperatorHandlerConfig) (*DiscourseOperatorHandler, error) {
|
|
shellFS, err := fs.Sub(embeds.Templates, "templates")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var tmpl *template.Template
|
|
tmpl = template.New("discourse-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 landing branch references operator_lookup_result.html;
|
|
// html/template's escape analysis requires every referenced template to
|
|
// be in the set even though BodyTemplate pages never execute that
|
|
// branch — parse it alongside the shell (core's operator.go does the
|
|
// same; FedWiki's operator handler omits it and 500s — findings.md #8).
|
|
tmpl, err = tmpl.ParseFS(shellFS, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tmpl, err = tmpl.ParseFS(cfg.TemplatesFS, "discourse_operator.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &DiscourseOperatorHandler{
|
|
db: cfg.DB,
|
|
logger: cfg.Logger,
|
|
authConfig: cfg.AuthConfig,
|
|
apiClient: cfg.Client,
|
|
temporal: cfg.Temporal,
|
|
configured: cfg.Configured,
|
|
sweepScheduleID: cfg.SweepScheduleID,
|
|
templates: server.NewSafeTemplates(tmpl, cfg.Logger),
|
|
}, nil
|
|
}
|
|
|
|
// buildPageData assembles the body payload. API/Temporal-dependent sections
|
|
// are only populated when configured (spec: the unconfigured page shows no
|
|
// error-state sync data).
|
|
func (h *DiscourseOperatorHandler) buildPageData(r *http.Request) OperatorPageData {
|
|
data := OperatorPageData{Configured: h.configured, FormValues: map[string]string{}}
|
|
if !h.configured {
|
|
return data
|
|
}
|
|
ctx := r.Context()
|
|
q := dcmod.New(h.db)
|
|
|
|
// Fetched before the mappings loop so the same catalog backs both the
|
|
// picker's friendly names and each mapping row's leading display name
|
|
// (ui-vocabulary 4.3) — one lookup, two consumers.
|
|
keys, err := q.ListOwnedResourceKeys(ctx)
|
|
if err != nil {
|
|
h.logger.Error("discourse operator: resource keys query failed", slog.Any("error", err))
|
|
}
|
|
displayNames := make(map[string]string, len(keys))
|
|
for _, k := range keys {
|
|
displayNames[k.ResourceKey] = k.DisplayName
|
|
data.ResourceKeys = append(data.ResourceKeys, ResourceKeyOption{Key: k.ResourceKey, DisplayName: k.DisplayName})
|
|
}
|
|
|
|
mappings, err := q.ListGroupMappings(ctx)
|
|
if err != nil {
|
|
h.logger.Error("discourse operator: list mappings failed", slog.Any("error", err))
|
|
data.Error = "Failed to load group mappings. Details are in the server logs."
|
|
return data
|
|
}
|
|
seenKeys := map[string]bool{}
|
|
seenPersons := map[string]bool{}
|
|
for _, m := range mappings {
|
|
data.Mappings = append(data.Mappings, MappingViewModel{
|
|
MappingID: m.MappingID,
|
|
ResourceKey: m.ResourceKey,
|
|
DisplayName: displayNames[m.ResourceKey],
|
|
GroupName: m.GroupName,
|
|
GroupID: m.DiscourseGroupID,
|
|
CreatedAt: m.CreatedAt.Format("Jan 2, 2006"),
|
|
DeleteURL: "/partials/operator/discourse/mappings/" + m.MappingID,
|
|
})
|
|
if seenKeys[m.ResourceKey] {
|
|
continue
|
|
}
|
|
seenKeys[m.ResourceKey] = true
|
|
unlinked, uErr := q.ListEntitledUnlinkedPersons(ctx, m.ResourceKey)
|
|
if uErr != nil {
|
|
h.logger.Error("discourse operator: unlinked query failed", slog.Any("error", uErr))
|
|
continue
|
|
}
|
|
for _, p := range unlinked {
|
|
if seenPersons[p.PersonID] {
|
|
continue
|
|
}
|
|
seenPersons[p.PersonID] = true
|
|
data.Unlinked = append(data.Unlinked, UnlinkedPersonViewModel{
|
|
DisplayName: p.DisplayName, Email: p.PrimaryEmail,
|
|
})
|
|
}
|
|
}
|
|
|
|
conflicts, err := q.ListConflictedUserLinks(ctx)
|
|
if err != nil {
|
|
h.logger.Error("discourse operator: conflicts query failed", slog.Any("error", err))
|
|
}
|
|
for _, c := range conflicts {
|
|
data.Conflicts = append(data.Conflicts, ConflictViewModel{
|
|
DiscourseUsername: c.DiscourseUsername, PersonID: c.PersonID,
|
|
})
|
|
}
|
|
|
|
data.SweepStatus, data.SweepTime = h.sweepHealth(r)
|
|
return data
|
|
}
|
|
|
|
// sweepHealth reports the most recent schedule-spawned sweep's status and
|
|
// time; "no sweep yet" when the schedule hasn't fired or Temporal is absent.
|
|
func (h *DiscourseOperatorHandler) sweepHealth(r *http.Request) (string, string) {
|
|
status, when := workflows.ScheduleRunStatus(r.Context(), h.temporal, h.sweepScheduleID)
|
|
if status == "" {
|
|
return "No sweep has run yet", ""
|
|
}
|
|
return status, when
|
|
}
|
|
|
|
// GetPage handles GET <OperatorSurfacePath>.
|
|
func (h *DiscourseOperatorHandler) GetPage(w http.ResponseWriter, r *http.Request) {
|
|
page := server.BuildOperatorPageData(r, h.authConfig, h.db, h.logger)
|
|
// Second-level page of the Integrations section: the section's sidebar
|
|
// entry lights up via ActiveCapability (the page is reached from the
|
|
// Integrations home, not the sidebar); ia-position nests under the landing.
|
|
page.IAPosition = "integration:integrations:discourse"
|
|
page.ActiveCapability = "integrations"
|
|
page.BodyTemplate = "discourse_operator.html"
|
|
page.BodyData = h.buildPageData(r)
|
|
h.templates.Render(w, "operator.html", page)
|
|
}
|
|
|
|
// renderBody renders the body partial alone (HTMX swap responses).
|
|
func (h *DiscourseOperatorHandler) renderBody(w http.ResponseWriter, status int, data OperatorPageData) {
|
|
w.WriteHeader(status)
|
|
h.templates.Render(w, "discourse_operator.html", data)
|
|
}
|
|
|
|
// CreateMapping handles POST /partials/operator/discourse/mappings: validate
|
|
// against the forum (group exists, not automatic), then insert (spec:
|
|
// "Entitlement-to-group mapping management").
|
|
func (h *DiscourseOperatorHandler) CreateMapping(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
resourceKey := strings.TrimSpace(r.PostFormValue("resource_key"))
|
|
groupName := strings.TrimSpace(r.PostFormValue("group_name"))
|
|
|
|
fail := func(field, msg string) {
|
|
data := h.buildPageData(r)
|
|
data.FieldErrors = web.FieldErrors{field: msg}
|
|
data.FormValues = map[string]string{"resource_key": resourceKey, "group_name": groupName}
|
|
h.renderBody(w, http.StatusUnprocessableEntity, data)
|
|
}
|
|
|
|
if resourceKey == "" {
|
|
fail("resource_key", "Choose the entitlement to deliver.")
|
|
return
|
|
}
|
|
if groupName == "" {
|
|
fail("group_name", "Enter the Discourse group name.")
|
|
return
|
|
}
|
|
|
|
group, err := h.apiClient.GetGroup(r.Context(), groupName)
|
|
if err != nil {
|
|
h.logger.Error("discourse operator: group validation failed", slog.Any("error", err))
|
|
fail("group_name", "Could not reach the forum to validate the group. Try again.")
|
|
return
|
|
}
|
|
if group == nil {
|
|
fail("group_name", "No group with that name exists on the forum.")
|
|
return
|
|
}
|
|
if group.Automatic {
|
|
fail("group_name", "That is an automatic Discourse group; its membership cannot be managed externally. Choose a manually-created group.")
|
|
return
|
|
}
|
|
|
|
if _, err := dcmod.New(h.db).CreateGroupMapping(r.Context(), dcmod.CreateGroupMappingParams{
|
|
ResourceKey: resourceKey, GroupName: group.Name, DiscourseGroupID: group.ID,
|
|
}); err != nil {
|
|
if fe, ok := web.FieldErrorsFromDB(err, groupMappingConstraints); ok {
|
|
data := h.buildPageData(r)
|
|
data.FieldErrors = fe
|
|
data.FormValues = map[string]string{"resource_key": resourceKey, "group_name": groupName}
|
|
h.renderBody(w, http.StatusUnprocessableEntity, data)
|
|
return
|
|
}
|
|
h.logger.Error("discourse operator: create mapping failed", slog.Any("error", err))
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.fireToast(w, "Group mapping created")
|
|
h.renderBody(w, http.StatusOK, h.buildPageData(r))
|
|
}
|
|
|
|
// DeleteMapping handles DELETE /partials/operator/discourse/mappings/{mappingID}.
|
|
// Unmapping stops management; forum-side membership is left as-is (spec:
|
|
// "Unmapping a group").
|
|
func (h *DiscourseOperatorHandler) DeleteMapping(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := dcmod.New(h.db).DeleteGroupMapping(r.Context(), r.PathValue("mappingID"))
|
|
if err != nil {
|
|
h.logger.Error("discourse operator: delete mapping failed", slog.Any("error", err))
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if rows == 0 {
|
|
http.Error(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
h.fireToast(w, "Group mapping deleted")
|
|
h.renderBody(w, http.StatusOK, h.buildPageData(r))
|
|
}
|
|
|
|
func (h *DiscourseOperatorHandler) fireToast(w http.ResponseWriter, message string) {
|
|
if payload, err := json.Marshal(map[string]string{"showSuccessToast": message}); err == nil {
|
|
w.Header().Set("HX-Trigger", string(payload))
|
|
}
|
|
}
|