Task — engineering-spec@1
"TEN tenant-admin SPA — seats + billing + audit + residency + retention dashboard for ROOT-CFO tenant administration"
draftTASK-TEN-107
§1 — Description (BCP-14 normative)
The TEN service + portal-web frontend MUST ship admin SPA at services/portal-web/src/ten/admin/ with 6 sections + confirm dialog + presentation-only writes, 4 memory audit kinds.
- MUST validate
admin_sectionagainst closed enum per DEC-2391.
- MUST render 6 sections per DEC-2390:
- seats: list members, manage active seats (read from TASK-TEN-101)
- billing: subscription status, invoices, payment method (TASK-TEN-003)
- audit: memory audit log filter view (read-only)
- residency: current + change residency (TASK-TEN-103)
- retention: per-module retention policies (TASK-TEN-106)
- danger_zone: cancel subscription, delete tenant (TASK-TEN-106 attestation)
- MUST show ConfirmDialog per DEC-2392 for any write action — explicit type-tenant-name confirmation.
- MUST delegate writes to existing service endpoints per DEC-2393:
- SeatsSection → TASK-TEN-101 endpoints
- BillingSection → TASK-TEN-003
- ResidencySection → TASK-TEN-103
- RetentionSection → TASK-TEN-106
- MUST restrict SPA access to ROOT-CFO role via TASK-AUTH-101.
- MUST expose backend endpoint for audit-section read: ``
text GET /v1/ten/admin/audit-events?since=...&kind=... (paginated memory read view)``
- MUST emit 4 memory audit kinds per DEC-2394. PII per TASK-MEMORY-111: section enum (public) ok; action-specific data hashed.
- MUST thread trace_id from UI → backend → audit.
- MUST NOT allow write without confirm per DEC-2392.
- MUST NOT introduce new business logic per DEC-2393 (presentation only).
§2 — Why this design
Why unified SPA (DEC-2390)? Scattered admin tools = error-prone. Single panel = single source of truth for tenant state.
Why confirm dialog (DEC-2392)? Destructive actions (delete tenant) need friction; type-to-confirm is industry standard.
Why presentation-only (DEC-2393)? Business logic in one place (the service); SPA is view. Prevents drift.
§3 — API contract
GET /v1/ten/admin/audit-events?since=2026-05-01&limit=50&kind=ten.subscription_cancelled
§4 — Acceptance criteria
- admin_section enum cardinality 6. 2. 6 sections rendered. 3. ROOT-CFO-only access. 4. Confirm dialog on write. 5. Type-to-confirm for destructive. 6. 4 memory audit kinds emitted. 7. PII scrubbed (action data SHA256). 8. RLS denies cross-tenant. 9. Trace_id preserved. 10. Writes delegate to existing endpoints. 11. No new business logic in SPA. 12. Audit section paginated. 13. section view audit on navigation. 14. Non-CFO access blocked + audit sev-2. 15. Append-only audit-events log (read-only here). 16. Danger zone separated visually. 17. Mobile-responsive (CFO on phone). 18. Loading states for async. 19. Error states UI. 20. Keyboard navigation.
§5 — Verification
test('seats section shows active members', async ({page}) => {
await page.goto('/admin');
await page.click('text=Seats');
await expect(page.locator('[data-testid=seat-list]')).toBeVisible();
});
test('danger zone requires confirm', async ({page}) => {
await page.goto('/admin/danger-zone');
await page.click('text=Delete Tenant');
await expect(page.locator('[data-testid=confirm-dialog]')).toBeVisible();
await page.click('text=Confirm'); // disabled
await expect(page.locator('text=Confirm')).toBeDisabled();
await page.fill('[data-testid=tenant-name-input]', 'my-tenant');
await expect(page.locator('text=Confirm')).toBeEnabled();
});
#[tokio::test]
async fn non_cfo_blocked() {
let ctx = TestContext::with_am_user().await;
let r = ctx.try_fetch_admin_audit_events_as(ctx.am_user).await;
assert_eq!(r.status_code, 403);
}
§7 — Dependencies
Upstream: TASK-TEN-101. Cross-module: TASK-TEN-003, TASK-TEN-103, TASK-TEN-106, TASK-AUTH-101, TASK-MEMORY-111.
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Non-CFO access | role check | 403 + sev-2 audit | inherent |
| Confirm bypass attempt | guard | reject | inherent |
| Cross-tenant view | RLS | 0 rows | inherent |
| Audit pagination N/A page | inherent | empty | inherent |
| Destructive action mid-fail | rollback | retry | inherent |
| Concurrent admin sessions | inherent | each isolated | inherent |
| memory audit query slow | indexed | tune | inherent |
| SPA crash | error boundary | reload | inherent |
| Backend timeout | UI shows error | retry | inherent |
| Mobile UI break | responsive tests | inherent | inherent |
§11 — Implementation notes
- §11.1 SPA uses React + Tailwind; shadcn/ui components.
- §11.2 Danger zone: red border + slow-typing confirm field (anti-muscle-memory).
- §11.3 Audit section reads memory via SQL through TASK-MEMORY-108 query API; not direct table access.
- §11.4 memory audit body: section enum, action_kind; specific data SHA256.
- §11.5 Mobile responsive via Tailwind breakpoints.
End of TASK-TEN-107 spec.