Task — engineering-spec@1

"TEN tenant-admin SPA — seats + billing + audit + residency + retention dashboard for ROOT-CFO tenant administration"

draftTASK-TEN-107
module ten · class product · priority p1 · created 2026-05-17 · shipped null
depends on TASK-TEN-101 · blocks none

§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.

  1. MUST validate admin_section against closed enum per DEC-2391.
  1. MUST render 6 sections per DEC-2390:
  1. MUST show ConfirmDialog per DEC-2392 for any write action — explicit type-tenant-name confirmation.
  1. MUST delegate writes to existing service endpoints per DEC-2393:
  1. MUST restrict SPA access to ROOT-CFO role via TASK-AUTH-101.
  1. MUST expose backend endpoint for audit-section read: ``text GET /v1/ten/admin/audit-events?since=...&kind=... (paginated memory read view) ``
  1. MUST emit 4 memory audit kinds per DEC-2394. PII per TASK-MEMORY-111: section enum (public) ok; action-specific data hashed.
  1. MUST thread trace_id from UI → backend → audit.
  1. MUST NOT allow write without confirm per DEC-2392.
  1. 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

  1. 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

FailureDetectionOutcomeRecovery
Non-CFO accessrole check403 + sev-2 auditinherent
Confirm bypass attemptguardrejectinherent
Cross-tenant viewRLS0 rowsinherent
Audit pagination N/A pageinherentemptyinherent
Destructive action mid-failrollbackretryinherent
Concurrent admin sessionsinherenteach isolatedinherent
memory audit query slowindexedtuneinherent
SPA crasherror boundaryreloadinherent
Backend timeoutUI shows errorretryinherent
Mobile UI breakresponsive testsinherentinherent

§11 — Implementation notes


End of TASK-TEN-107 spec.