Files
member-console/internal/embeds/templates/operator.html
T
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.

People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.

Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.

Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.

Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
2026-08-24 03:58:18 -05:00

436 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<!-- Deployment identity: the configured deployment-name (default
"Member Console") renders identically here and on the member
mastheads, so the console tells one story about who it is. The
default is itself brand-neutral, which keeps a stranger's first
screenshot of this general-purpose OSS project deployment-neutral
too. -->
<title>Operator overview - {{ deploymentName }}</title>
<meta name="description" content="Operator console for membership, entitlement and billing administration">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="ia-position" content="{{ .IAPosition }}">
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
<link href="/static/bootstrap.css" rel="stylesheet">
<script defer src="/static/bootstrap.bundle.js"></script>
<script defer src="/static/htmx.min.js"></script>
<script defer src="/static/error-handler.js"></script>
<script defer src="/static/success-toast.js"></script>
<script defer src="/static/grant-toggle.js"></script>
<script defer src="/static/grant-valid-until-tz.js"></script>
<script defer src="/static/confirm-action-modal.js"></script>
<script defer src="/static/sortable.min.js"></script>
<script defer src="/static/sortable-reorder.js"></script>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<link href="/static/app.css" rel="stylesheet">
</head>
<body class="d-flex flex-column vh-100"
hx-boost="true"
hx-headers='{"X-CSRF-Token": "{{ .CSRFToken }}"}'>
<!-- Top navbar — cross-app navigation; visible on all breakpoints so the
sidebar can be repurposed for operator IA. -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">{{ deploymentName }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="/">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/products">Products</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/operator" aria-current="page">Operator</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ .KeycloakAccountURL }}" target="_blank" hx-boost="false" title="Manage your account and sign-in. Opens in a new tab.">Identity and <span class="text-nowrap">Access<svg class="external-link-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span></a>
</li>
<li class="nav-item">
<a class="nav-link text-danger" href="/logout" hx-boost="false">Logout</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="d-flex flex-column flex-lg-row flex-grow-1">
<!-- Operator IA sidebar — one flat list of eight top-level task
entries, nothing else (maintainer 2026-08-23: no children, no
groups; the sidebar names the sections, the sections navigate
themselves; People joined as the eighth entry with the
directory, ux-operator-scale). Order: Overview, People,
Organizations, Grants, Billing, Products, Domains,
Integrations. Second-level surfaces are reached in-page:
person detail from the People directory's rows, Org Types from
the Organizations page header, the four billing views from the
Billing wrapper's tab row, Entitlement Sets and Plan Ladders
from the Products page header, and provider surfaces from the
Integrations home's provider cards. Their IA-position
declarations still carry the Catalog conceptual group
(docs/operator-ia.md). A second-level page marks its section's
entry active. Every entry points at a real
/operator/<capability> route under hx-boost. -->
<aside class="app-sidebar operator-ia-sidebar bg-light d-none d-lg-flex flex-column flex-shrink-0 h-lg-100 border-end">
<ul class="nav flex-column p-3">
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "" }}active{{ end }}" href="/operator">Overview</a></li>
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "persons" }}active{{ end }}" href="/operator/persons">People</a></li>
<li class="nav-item"><a class="nav-link {{ if or (eq .ActiveCapability "organizations") (eq .ActiveCapability "org-types") }}active{{ end }}" href="/operator/organizations">Organizations</a></li>
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "grants" }}active{{ end }}" href="/operator/grants">Grants</a></li>
<li class="nav-item"><a class="nav-link {{ if or (eq .ActiveCapability "billing-accounts") (eq .ActiveCapability "billing-subscriptions") (eq .ActiveCapability "billing-invoices") (eq .ActiveCapability "billing-payments") }}active{{ end }}" href="/operator/billing/accounts">Billing</a></li>
<li class="nav-item"><a class="nav-link {{ if or (eq .ActiveCapability "products") (eq .ActiveCapability "entitlement-sets") (eq .ActiveCapability "plan-ladders") }}active{{ end }}" href="/operator/products">Products</a></li>
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "domains" }}active{{ end }}" href="/operator/domains">Domains</a></li>
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "integrations" }}active{{ end }}" href="/operator/integrations">Integrations</a></li>
</ul>
</aside>
<!-- Main content — single swap target for hx-boost navigations. The
toast container and confirm modal are siblings (outside this region)
so they survive boosts per docs/design-system.md §3. The inner
container-xxl caps content width at ~1320px so wide screens don't
stretch text to the viewport edge. -->
<main id="operator-main" class="flex-fill p-4">
<div class="container-xxl px-0">
<!-- #operator-body is the HTMX swap target for every mutation
form in the operator panel. Forms hx-post to /partials/...
endpoints that respond with the bare page partial; HTMX
swaps that fragment into #operator-body, preserving the
#operator-main + container-xxl wrappers. See
docs/operator-ux-conventions.md §2 (HTMX request shape). -->
<div id="operator-body">
{{ if .BodyTemplate }}
<!-- MPA page handler set a body partial; dispatch via the
renderBody template func (html/template's built-in template
action requires a string literal, not a dynamic name). -->
{{ renderBody .BodyTemplate .BodyData }}
{{ else }}
<!-- ============================================================
Curated landing surface — the operator's entry point.
Answers "what is this deployment doing right now?"
attention-first: search, then setup progress (while
required), then the counts an operator checks daily, then
system status, then the event timeline (maintainer
2026-08-23: numbers outrank the system cards).
Attention-shaped content (an unconfigured integration, a
stuck delivery queue) is never the last region on the page.
Heading contract: one H1 for the page, an H2 per region,
H3 for panels nested inside a region. No level is skipped.
(The older operator pages run H1→H6; that is not a pattern
to copy here.)
Per operator-composite-expansion D4, "Recent activity" is
one unified timeline (grants + transitions + invoices +
payments interleaved by timestamp) rather than two
domain-partitioned sections. Override the default 20-row
limit with ?limit=N (clamped to [1,100]). Per task 7.4,
each entry links to its own subject's operator surface
where one exists.
============================================================ -->
<header class="mb-4">
<h1 class="h2 mb-1">Operator overview</h1>
<p class="text-muted mb-0">
Membership, delivery, catalog and integration state for this deployment.
</p>
</header>
<!-- Region 1 — Lookup. Single typed input resolves to person
detail (email-exact or name-substring), org composite
(slug-exact), a disambiguation list (multi-person), or a
no-match notice. Uses HTMX (hx-post) so the request goes
through the same X-CSRF-Token header path every other
operator form uses (native form POST hit a brittle
Origin-check path in gorilla CSRF). Unique matches return
HX-Redirect; multi/no-match return the result partial
swapped into #lookup-result.
The heading is visually hidden: the input's own placeholder
and button already label the region on screen, and a visible
"Find" heading above a search box is redundant. Screen
readers still get the landmark. -->
<section class="mb-5" aria-labelledby="overview-lookup-heading">
<h2 class="visually-hidden" id="overview-lookup-heading">Find a person or organization</h2>
<form autocomplete="off" hx-post="/operator/lookup" hx-target="#lookup-result" hx-swap="innerHTML">
<div class="input-group input-group-lg">
<input autocomplete="off" type="search" name="term" class="form-control form-control-lg"
aria-labelledby="overview-lookup-heading"
placeholder="Find a person or organization (email, name, or org slug)"
value="{{ .LookupTerm }}" autofocus>
<button type="submit" class="btn btn-primary px-4">Look up</button>
</div>
</form>
<div id="lookup-result">
{{ template "operator_lookup_result.html" . }}
</div>
</section>
<!-- Setup checklist region (ux-first-run). Renders ahead of the
metric tiles ONLY while a REQUIRED step is incomplete, and
stops once every required step is complete even if
conditional steps remain — RequiredIncomplete is the single
predicate that decides both this and /operator/setup's own
content, so the two can never disagree. -->
{{ if .Setup.RequiredIncomplete }}
{{ template "operator_setup.html" .Setup }}
{{ end }}
<!-- Region 2 — At a glance. One tile per capability; each tile
is a link into that capability's section, so this row doubles
as the primary navigation for operators who arrived here
without a specific destination in mind. A tile whose count
could not be loaded renders an em dash, never a zero. -->
<section class="mb-5" aria-labelledby="overview-glance-heading">
<h2 class="h6 text-uppercase text-body-secondary mb-3" id="overview-glance-heading">At a glance</h2>
{{ if .Overview.Stats }}
<div class="row row-cols-1 row-cols-sm-2 row-cols-xl-3 g-3">
{{ range .Overview.Stats }}
<div class="col">
{{ if .Href }}
<a class="card h-100 text-decoration-none text-body" href="{{ .Href }}">
{{ else }}
<div class="card h-100">
{{ end }}
<div class="card-body">
<p class="text-uppercase text-body-secondary small fw-semibold mb-1">{{ .Label }}</p>
<p class="display-6 fw-semibold lh-1 mb-2">
{{ if .Available }}{{ .Value }}{{ else }}<span class="text-body-secondary" title="This count could not be loaded">&mdash;</span>{{ end }}
</p>
<p class="small text-body-secondary mb-0">
{{ if .Available }}{{ .Caption }}{{ else }}Count unavailable{{ end }}
</p>
</div>
{{ if .Href }}
</a>
{{ else }}
</div>
{{ end }}
</div>
{{ end }}
</div>
{{ else }}
<div class="card">
<div class="card-body text-center py-4">
<p class="text-body-secondary mb-0">Counts are unavailable.</p>
</div>
</div>
{{ end }}
</section>
<!-- Region 3 — System status. Registry-driven: every registered
integration is listed with its operational status, and the
outbox panel reports whether queued integration work is
draining. Renders after the metric tiles (maintainer
2026-08-23: the counts are what an operator checks first)
but ahead of the activity feed, so attention-shaped content
is still never the last region on the page. -->
<section class="mb-5" aria-labelledby="overview-system-heading">
<h2 class="h6 text-uppercase text-body-secondary mb-3" id="overview-system-heading">System</h2>
<div class="row row-cols-1 row-cols-md-2 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body">
<h3 class="h6 mb-3">Integrations</h3>
{{ if .Overview.Providers }}
<ul class="list-unstyled mb-0">
{{ range .Overview.Providers }}
<li class="d-flex justify-content-between align-items-center gap-2 py-2 border-bottom">
<span class="text-truncate">
{{ if .SurfacePath }}<a href="{{ .SurfacePath }}">{{ .DisplayName }}</a>
{{ else }}{{ .DisplayName }}{{ end }}
<span class="d-block small text-body-secondary">{{ .Kind }}</span>
</span>
<span class="d-flex flex-column align-items-end gap-1 flex-shrink-0">
<span class="badge {{ if .Healthy }}text-bg-success{{ else }}text-bg-warning{{ end }}"
title="Registry status: an operator-managed record, not a live health check">{{ .Status }}</span>
{{ if not .Configured }}
<span class="badge text-bg-warning" {{ if .MissingKeysText }}title="Missing: {{ .MissingKeysText }}"{{ end }}>Not configured</span>
{{ end }}
</span>
</li>
{{ end }}
</ul>
{{ else }}
<p class="small text-body-secondary mb-0">No integrations registered.</p>
{{ end }}
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body">
<h3 class="h6 mb-3">Delivery queue</h3>
<p class="small text-body-secondary mb-3">
Tracks Stripe's queued outbound work only; other integrations dispatch directly and are not reflected here, so an empty or draining queue says nothing about their state.
</p>
{{ if .Overview.Queue.Available }}
<!-- Term before description, matching the
label-then-value rhythm of the stat tiles
below and keeping the list well-formed. -->
<dl class="row row-cols-3 g-0 mb-0 text-center">
<div class="col">
<dt class="small fw-normal text-body-secondary">Pending</dt>
<dd class="fs-4 fw-semibold mb-0">{{ .Overview.Queue.Pending }}</dd>
</div>
<div class="col">
<dt class="small fw-normal text-body-secondary">Retrying</dt>
<dd class="fs-4 fw-semibold mb-0">{{ .Overview.Queue.Retrying }}</dd>
</div>
<div class="col">
<dt class="small fw-normal text-body-secondary">Dead-letter</dt>
<dd class="fs-4 fw-semibold mb-0 {{ if .Overview.Queue.NeedsAttention }}text-danger{{ end }}">{{ .Overview.Queue.DeadLetter }}</dd>
</div>
</dl>
{{ if .Overview.Queue.NeedsAttention }}
<p class="small text-danger mb-0 mt-3">
Dead-lettered work has exhausted its retries and needs an operator.
</p>
{{ else }}
<p class="small text-body-secondary mb-0 mt-3">
Queued integration work is draining normally.
</p>
{{ end }}
{{ else }}
<p class="small text-body-secondary mb-0">Queue health is unavailable.</p>
{{ end }}
</div>
</div>
</div>
</div>
</section>
<!-- Region 4 — Recent activity. Each entry's Organization column
always names and links the org it belongs to when one is
known; the Summary text additionally links to the entry's
own subject surface (task 7.4 / chrome-conventions D8: "feed
entries link to their subjects") — the org detail page for a
grant or transition, the billing view for an invoice or
payment. An entry with no addressable surface (.Href empty)
renders its summary as plain text, never a dead link. -->
<section aria-labelledby="overview-activity-heading">
<h2 class="h6 text-uppercase text-body-secondary mb-3" id="overview-activity-heading">Recent activity</h2>
<div class="card">
{{ if eq (len .Activity) 0 }}
<div class="card-body text-center py-5">
<p class="mb-1">No activity recorded yet.</p>
<p class="small text-body-secondary mb-0">
Grants, plan transitions, invoices and payments appear here as they happen.
</p>
</div>
{{ else }}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr class="text-uppercase small text-body-secondary">
<th scope="col">When</th>
<th scope="col">Event</th>
<th scope="col">Organization</th>
<th scope="col">Actor</th>
<th scope="col">Summary</th>
</tr>
</thead>
<tbody>
{{ range .Activity }}
<tr>
<td><small class="text-nowrap text-body-secondary">{{ .Timestamp }}</small></td>
<td>
{{ if eq .EventType "grant_issued" }}<span class="badge text-bg-primary">grant</span>
{{ else if eq .EventType "transition" }}<span class="badge text-bg-info">transition</span>
{{ else if eq .EventType "invoice_created" }}<span class="badge text-bg-secondary">invoice</span>
{{ else if eq .EventType "payment_received" }}<span class="badge text-bg-success">payment</span>
{{ else }}<span class="badge text-bg-light">{{ .EventType }}</span>{{ end }}
</td>
<td>
{{ if .OrgID }}<a href="/operator/organizations/{{ .OrgID }}">{{ if .OrgName }}{{ .OrgName }}{{ else }}<code>{{ .OrgID }}</code>{{ end }}</a>{{ end }}
</td>
<td>
{{ if .PersonID }}<a href="/operator/persons/{{ .PersonID }}">{{ if .PersonName }}{{ .PersonName }}{{ else }}<code>{{ .PersonID }}</code>{{ end }}</a>
{{ else }}<small class="text-body-secondary">&mdash;</small>{{ end }}
</td>
<td><small>{{ if .Href }}<a href="{{ .Href }}">{{ .Summary }}</a>{{ else }}{{ .Summary }}{{ end }}</small></td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}
</div>
</section>
{{ end }}
</div>
</div>
</main>
</div>
<!-- Toast Container — sibling of <main> so toasts survive hx-boost
navigations and partial swaps (see docs/design-system.md §3). -->
<div class="toast-container">
<div id="errorToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive"
aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="errorToastBody">
An error occurred. Please try again.
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
</div>
<div id="successToast" class="toast align-items-center text-bg-success border-0" role="status"
aria-live="polite" aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="successToastBody">
Done.
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
</div>
</div>
<!-- Shared confirmation modal (driven by data-action-* attrs on triggers).
Sibling of <main> so the modal markup survives boosted navigations. -->
<div class="modal fade" id="confirmActionModal" tabindex="-1" aria-labelledby="confirmActionModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title h5" id="confirmActionModalLabel">Confirm</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form autocomplete="off" id="confirmActionForm"
hx-swap="innerHTML"
hx-disabled-elt="find button[type=submit]"
hx-indicator="find .htmx-indicator">
<div class="modal-body">
<p id="confirmActionBody" class="mb-0"></p>
<div id="confirmActionHiddenFields"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger" id="confirmActionSubmit">
<span class="htmx-indicator spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span id="confirmActionLabel">Confirm</span>
</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>