Files
member-console/internal/embeds/templates/partials/operator_enrollment.html
T
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- Add deployment-name branding to titles, mastheads, and OG tags
- Share one grant delivery-state query with lineage across grants
  surfaces
- Show pool status/usage, org owners, and config readiness
- Make billing views projection-aware with recency and sync vocabulary
- Guard FedWiki creation without domains and render route-aware 404s
2026-08-23 01:45:52 -05:00

470 lines
24 KiB
HTML

<!-- Organization enrollment detail partial for operator -->
{{ if .Error }}
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> {{ .Error }}
</div>
{{ end }}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0 h6">Enrollment: {{ .OrgName }}</h1>
<small class="text-muted"><code>{{ .OrgSlug }}</code></small>
<small class="text-muted d-block">
Owner:
{{ if .OrgOwnerPersonID }}
<a href="/operator/persons/{{ .OrgOwnerPersonID }}">{{ if .OrgOwnerName }}{{ .OrgOwnerName }}{{ else }}{{ .OrgOwnerPersonID }}{{ end }}</a>
{{ else }}
<em>none</em>
{{ end }}
</small>
</div>
<a class="btn btn-outline-secondary btn-sm" href="/operator/organizations">
← Back to organizations
</a>
</div>
<!-- Billing Summary -->
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h2 class="card-title mb-0 h6">Billing summary</h2>
{{ if .BillingSummary.HasAccount }}
<a class="btn btn-outline-secondary btn-sm" href="/operator/billing/accounts">Full billing →</a>
{{ end }}
</div>
{{ if not .BillingSummary.HasAccount }}
<p class="text-muted small mb-0">No billing account exists for this organization yet.</p>
{{ else }}
<div class="row g-3">
<div class="col-md-4">
<div class="text-muted small">Subscription</div>
{{ if .BillingSummary.HasSubscription }}
<div>
<span class="badge bg-secondary">{{ .BillingSummary.SubscriptionStatus }}</span>
{{ if .BillingSummary.SubscriptionPeriodEnd }}
<small class="text-muted">· renews {{ .BillingSummary.SubscriptionPeriodEnd }}</small>
{{ end }}
{{ if .BillingSummary.CancelAtPeriodEnd }}
<small class="text-warning">· cancels at period end</small>
{{ end }}
</div>
{{ else }}
<div class="text-muted small">No subscription</div>
{{ end }}
</div>
<div class="col-md-4">
<div class="text-muted small">Latest invoice</div>
{{ if .BillingSummary.HasInvoice }}
<div>
{{ if .BillingSummary.LatestInvoicePaid }}
<span class="badge text-bg-success">paid</span>
{{ else }}
<span class="badge text-bg-warning">unpaid</span>
{{ end }}
<strong>{{ .BillingSummary.LatestInvoiceAmountDue }}</strong>
<small class="text-muted">· {{ .BillingSummary.LatestInvoiceDate }}</small>
</div>
{{ else }}
<div class="text-muted small">No invoices issued</div>
{{ end }}
</div>
<div class="col-md-4">
<div class="text-muted small">Outstanding balance</div>
<div>
{{ if gt .BillingSummary.OutstandingBalance 0 }}
<strong class="text-warning">{{ .BillingSummary.OutstandingBalanceFormatted }}</strong>
<small class="text-muted">across open invoices</small>
{{ else }}
<strong class="text-success">0</strong>
<small class="text-muted">no open invoices</small>
{{ end }}
</div>
</div>
</div>
{{ end }}
</div>
</div>
<!-- Pool Enrollment State -->
<h2 class="mt-4 h6">Resource Pools</h2>
{{ if .PoolMissing }}
<!-- Every organization gets exactly one default pool at provisioning; one
with none is broken, not merely empty (ux-honest-surfaces: "a pool-less
organization is presented as broken, not empty"). The Issue Grant
section below renders the same reason instead of a submittable form. -->
<div class="alert alert-danger" role="alert">
<strong>Missing default resource pool.</strong> This organization has no
resource pool. Every organization should have exactly one; without it,
grant delivery is impossible until the pool is repaired.
</div>
{{ else if eq (len .Pools) 0 }}
<div class="text-center py-3">
<p class="text-muted mb-0">No pools for this organization.</p>
</div>
{{ else }}
{{ range .Pools }}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h3 class="card-title mb-1 h6">
{{ .PoolName }} <span class="badge bg-secondary">{{ .PoolType }}</span>
{{ if eq .Status "active" }}
<span class="badge text-bg-success">{{ .Status }}</span>
{{ else }}
<span class="badge text-bg-danger">{{ .Status }}</span>
{{ end }}
</h3>
{{ if ne .Status "active" }}
<p class="text-danger small mb-1">This pool is not active; it is not delivering entitlements.</p>
{{ end }}
{{ if .HasAttachment }}
<p class="mb-0">
<span class="badge bg-primary">{{ .LadderKey }}</span>
<strong>{{ .TierName }}</strong> at rank {{ .Rank }}
<small class="text-muted">· since {{ .ActivatedAt }}</small>
</p>
{{ else }}
<p class="text-muted mb-0">No active plan</p>
{{ end }}
</div>
</div>
{{ if gt (len .Usage) 0 }}
<div class="table-responsive mt-2">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Resource</th>
<th>Used</th>
<th>Limit</th>
</tr>
</thead>
<tbody>
{{ range .Usage }}
<tr{{ if ge .Used .Limit }} class="text-danger"{{ end }}>
<td><code>{{ .ResourceKey }}</code></td>
<td>{{ .Used }}</td>
<td>{{ .Limit }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
<!-- Issuance — ONE form over all published products (Doc 41 dissolved the
plan/non-plan form split; the conferral primitive derives position
behavior from the product's shape). Internal wrap products carry an
"internal" badge; supersession advisories are server-rendered from the
pre-check flags on IssuanceProducts (no client JS — CSP). -->
{{ if .PoolMissing }}
<!-- ux-honest-surfaces: "the Issue Grant form SHALL be blocked with that
reason". Same reason stated in the Resource Pools warning above, and
matched by IssueGrant's own defense-in-depth refusal (poolMissingMessage). -->
<div class="alert alert-danger mb-3" role="alert">
<strong>Issue grant unavailable.</strong> This organization is missing
its default resource pool, so grant delivery is impossible until the
pool is repaired. See the Resource Pools section above.
</div>
{{ else if .ProductsError }}
<div class="alert alert-danger" role="alert">{{ .ProductsError }}</div>
{{ else if eq (len .IssuanceProducts) 0 }}
<div class="text-muted small mb-3">No published products yet — publish a product to enable issuance.</div>
{{ else }}
<div class="card mb-3">
<div class="card-body">
<h2 class="card-title h6">Issue grant</h2>
{{ $issueActive := and (eq $.FormName "issue") (eq $.FormPoolID "") }}
<p class="text-muted small mb-2">
Records the grant and updates this organization's entitlements
right away. If the product occupies a plan-ladder position, the
pool moves to it; leave <em>Valid until</em> blank for an
open-ended grant.
</p>
{{ $formErr := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "" }}
{{ with $formErr }}
<div class="alert alert-danger" role="alert">{{ . }}</div>
{{ end }}
<form autocomplete="off" hx-post="{{ routeURL "/partials/operator/organizations/{orgID}/grant/create" .OrgID }}"
hx-target="#operator-body" hx-swap="innerHTML"
hx-disabled-elt="find button[type=submit]">
<div class="row g-2">
<div class="col-md-6 mb-2">
<label class="form-label form-label-sm" for="issue-product">Product</label>
{{ $err := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "product_id" }}
<select id="issue-product" name="product_id" class="form-select form-select-sm {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}" required>
<option value="">Select product…</option>
{{ range $.IssuanceProducts }}
<option value="{{ .ProductID }}" {{ if and $issueActive (eq .ProductID (index $.FormValues "product_id")) }}selected{{ end }}>{{ .Name }}{{ if .IsInternal }} [internal]{{ end }}{{ if .SupersedesSubscription }} ⚠{{ end }}</option>
{{ end }}
</select>
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
<div class="col-md-3 mb-2">
<label class="form-label form-label-sm" for="issue-quantity">Quantity</label>
{{ $err := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "quantity" }}
<input autocomplete="off" id="issue-quantity" type="number" name="quantity" min="1" max="1000000" value="{{ if $issueActive }}{{ index $.FormValues "quantity" }}{{ else }}1{{ end }}" class="form-control form-control-sm {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}">
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
<div class="form-text">Above 1 only for per-seat products — most products deliver one position.</div>
</div>
<div class="col-md-3 mb-2">
<label class="form-label form-label-sm" for="issue-valid-until">Valid until <span class="text-muted">(optional)</span></label>
{{ $err := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "valid_until" }}
<input id="issue-valid-until" type="datetime-local" name="valid_until" value="{{ if $issueActive }}{{ index $.FormValues "valid_until" }}{{ end }}" class="form-control form-control-sm {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}">
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
</div>
<div class="row g-2">
<div class="col-md-4 mb-2">
<label class="form-label form-label-sm" for="issue-reason">Reason</label>
{{ $err := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "reason" }}
<select id="issue-reason" name="reason" class="form-select form-select-sm {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}" required>
<option value="">Select reason…</option>
{{ range $.GrantReasons }}
<option value="{{ . }}" {{ if and $issueActive (eq . (index $.FormValues "reason")) }}selected{{ end }}>{{ . }}</option>
{{ end }}
</select>
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
<div class="col-md-8 mb-2">
<label class="form-label form-label-sm" for="issue-description">Description <span class="text-muted">(optional)</span></label>
{{ $err := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "description" }}
<textarea id="issue-description" name="description" rows="1" maxlength="500" class="form-control form-control-sm {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}" placeholder="Audit note — why this grant?">{{ if $issueActive }}{{ index $.FormValues "description" }}{{ end }}</textarea>
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
</div>
{{ $superseders := false }}
{{ range $.IssuanceProducts }}{{ if .SupersedesSubscription }}{{ $superseders = true }}{{ end }}{{ end }}
{{ if $superseders }}
<div class="alert alert-warning py-1 px-2 small mb-2" role="alert">
Products marked ⚠ would supersede a position currently held by a subscription —
the subscription keeps billing until it is changed in the billing view.
</div>
{{ end }}
<p class="text-muted small mb-2">
Issuing records the grant and updates entitlements now.
Connected services (for example forum or site access) apply
the change in the background and can retry or fail; the
<a href="/operator">overview</a>'s system panel shows queued
work for services that use the delivery queue.
</p>
<button type="submit" class="btn btn-primary btn-sm">Issue grant</button>
</form>
</div>
</div>
{{ end }}
<!-- Extend Current Tier -->
{{ range $pool := .Pools }}
{{ if $pool.HasAttachment }}
<div class="card mb-3">
<div class="card-body">
<h2 class="card-title h6">Extend current tier</h2>
<p class="text-muted small mb-2">
Issue a new grant on the current tier (<strong>{{ $pool.TierName }}</strong>) without changing position.
Set <em>Expires at</em> to extend a trial; leave it empty for an open-ended comp or gift period.
</p>
{{ if not $pool.HasGrantDelivery }}
<!-- Extend targets the pool's active grant-backed provision; without
one the handler can only reject, so render the precondition
instead of a form that always fails. -->
<p class="text-muted small mb-0">
Nothing to extend right now — this pool's current delivery is not
grant-backed (it may be subscription-billed or ended). Use
<em>Issue grant</em> to grant a position instead.
</p>
{{ else }}
{{ $extendActive := and (eq $.FormName "extend") (eq $.FormPoolID $pool.PoolID) }}
<form autocomplete="off" hx-post="{{ routeURL "/partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend" $.OrgID $pool.PoolID }}"
hx-target="#operator-body" hx-swap="innerHTML"
hx-disabled-elt="find button[type=submit]"
hx-indicator="find .htmx-indicator">
<div class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label" for="extend-reason-{{ $pool.PoolID }}">Reason</label>
{{ $err := fieldErr $.FormName "extend" $.FormPoolID $pool.PoolID $.FieldErrors "reason" }}
<input autocomplete="off" type="text" id="extend-reason-{{ $pool.PoolID }}" name="reason" value="{{ if $extendActive }}{{ index $.FormValues "reason" }}{{ end }}"
class="form-control {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}" placeholder="Why extend?" required>
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
<div class="col-md-4">
<label class="form-label" for="extend-valid-until-{{ $pool.PoolID }}">Expires at <span class="text-muted">(optional)</span></label>
{{ $err := fieldErr $.FormName "extend" $.FormPoolID $pool.PoolID $.FieldErrors "valid_until" }}
<input type="datetime-local" id="extend-valid-until-{{ $pool.PoolID }}" name="valid_until" value="{{ if $extendActive }}{{ index $.FormValues "valid_until" }}{{ end }}"
class="form-control {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}">
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-outline-primary w-100">
<span class="htmx-indicator spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
Extend tier
</button>
</div>
</div>
</form>
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
<!-- Grants — every grant for the org, annotated with delivery state.
The grants table is intentionally a ledger (design/entitlements/model.md
treats grants as audit records). The Delivery column collapses the
join to pool_provisions / pool_provision_ladders so operators can see
at a glance which row is actually backing the pool right now vs. which
are historical. Destructive actions render only on Live rows; system
defaults always show "System-managed" since they're owned by org-type
config, not per-grant operator action. Same vocabulary and badge
treatment as /operator/grants (design decision 1: one shared query, one
rendering). -->
{{ if .GrantsError }}
<div class="alert alert-danger" role="alert">{{ .GrantsError }}</div>
{{ end }}
{{ if gt (len .Grants) 0 }}
<div class="card mb-3">
<div class="card-body">
<h2 class="card-title h6">Grants</h2>
<p class="text-muted small mb-2">
One row per grant ever issued — this is the issuance ledger, not
the delivery record. Delivery state is derived from the pool
provision each grant created: at most one grant per ladder
position is <span class="badge text-bg-success">Live</span> at a
time; the rest are history.
</p>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Product</th>
<th>Reason</th>
<th>Delivery</th>
<th>Qty</th>
<th>Activated</th>
<th>Ended</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .Grants }}
<tr{{ if ne .DeliveryState "live" }} class="text-muted"{{ end }}>
<td>{{ if .ProductName }}{{ .ProductName }}{{ else }}—{{ end }}</td>
<td><span class="badge bg-secondary">{{ .GrantReason }}</span></td>
<td>
{{ if eq .DeliveryState "live" }}
<span class="badge text-bg-success">Live</span>
{{ if .ExtendsLabel }}<br><small class="text-muted">extends {{ .ExtendsLabel }}</small>{{ end }}
{{ else if eq .DeliveryState "superseded" }}
<span class="badge text-bg-secondary">Superseded</span>
{{ if .ReplacedByLabel }}<br><small class="text-muted">replaced by {{ .ReplacedByLabel }}</small>{{ end }}
{{ else }}
<span class="badge text-bg-light border">Inactive</span>
{{ end }}
</td>
<td>{{ .Quantity }}</td>
<td><small>{{ if .ActivatedAt }}{{ .ActivatedAt }}{{ else }}—{{ end }}</small></td>
<td><small>{{ if .EndedAt }}{{ .EndedAt }}{{ else }}—{{ end }}</small></td>
<td>
{{ if eq .DeliveryState "live" }}
{{ if eq .GrantReason "default" }}
<span class="text-muted small" title="System-managed default. Change the org type's default ladder to remove.">System-managed</span>
{{ else }}
<button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="{{ routeURL "/partials/operator/grants/{grantID}/revoke-and-transition" .GrantID }}"
data-action-method="post"
data-action-target="#operator-body"
data-action-title="Revoke grant"
data-action-body="{{ if .ProductName }}Revoke {{ .ProductName }} for this organization?{{ else }}Revoke this grant for this organization?{{ end }} The pool returns to its org-type default and a downgrade or end transition is recorded."
data-action-confirm-label="Revoke grant"
data-action-fields='{"org_id":"{{ $.OrgID }}"}'>Revoke</button>
{{ end }}
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
<!-- Members -->
<h2 class="mt-4 h6">Members</h2>
{{ if .MembersError }}
<div class="alert alert-danger" role="alert">{{ .MembersError }}</div>
{{ end }}
{{ if eq (len .Members) 0 }}
<div class="text-muted small py-2">No members enrolled.</div>
{{ else }}
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Joined</th>
</tr>
</thead>
<tbody>
{{ range .Members }}
<tr>
<td><a href="/operator/persons/{{ .PersonID }}">{{ .DisplayName }}</a></td>
<td><small class="text-muted">{{ .Email }}</small></td>
<td><span class="badge bg-secondary">{{ .RoleName }}</span></td>
<td><small>{{ .JoinedAt }}</small></td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}
<!-- Transition History -->
<h2 class="mt-4 h6">Transition History</h2>
{{ if .TransitionsError }}
<div class="alert alert-danger" role="alert">{{ .TransitionsError }}</div>
{{ end }}
{{ if eq (len .Transitions) 0 }}
<div class="text-center py-3">
<p class="text-muted mb-0">No transitions recorded.</p>
</div>
{{ else }}
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>Type</th>
<th>From</th>
<th>To</th>
<th>Actor</th>
<th>Reason</th>
<th>Effective</th>
</tr>
</thead>
<tbody>
{{ range .Transitions }}
<tr>
<td><span class="badge text-bg-info">{{ .TransitionType }}</span></td>
<td>{{ .FromRank }}</td>
<td>{{ .ToRank }}</td>
<td>
{{ if .ActorName }}{{ .ActorName }}{{ else }}<em class="text-muted">{{ .ActorType }}</em>{{ end }}
</td>
<td><small>{{ .Reason }}</small></td>
<td><small>{{ .EffectiveAt }}</small></td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}