From b66964174315117af069806cc1572f4deead8962 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 11 Jul 2026 10:46:54 +0300 Subject: [PATCH 01/20] Add design spec for Console Redesign Batch 2 (Tenant Detail workbench) Scopes Batch 2 to the Tenant Detail page, Suspend/Archive dialogs, and Impersonation ceremony; defers Audit Log and Plans editor reskins to Batch 3. Co-Authored-By: Claude Sonnet 5 --- ...26-07-11-console-redesign-batch2-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-console-redesign-batch2-design.md diff --git a/docs/superpowers/specs/2026-07-11-console-redesign-batch2-design.md b/docs/superpowers/specs/2026-07-11-console-redesign-batch2-design.md new file mode 100644 index 00000000..5bf22d5a --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-console-redesign-batch2-design.md @@ -0,0 +1,110 @@ +# Platform Console Redesign — Batch 2 (Tenant Detail Workbench) — Design + +- **Date:** 2026-07-11 +- **Status:** approved by user, pending implementation plan +- **Depends on:** Batch 1 (merged, PR #32, commit `fff116d`) — dark-chrome shell, `StatusBadge`, `meterTone`/`meterToneClass`, `Sheet` primitive (unused until now), design tokens, `reason` field already wired into `setTenantStatus` (suspend/reactivate/archive) and `ImpersonateTenant`. +- **Source design brief:** `docs/design-briefs/saas-tenant-admin.md` (deliverables #3 Tenant detail workbench, #4 Impersonation ceremony). +- **Source visual reference:** Claude Design project `165a9ba5-4bb1-4ede-9048-546ccb1742af`, file `Idento Console.dc.html` (same canonical source Batch 1 used). + +## Goal + +Replace the plain-Card `OrganizationDetail.tsx` with the design brief's "workbench": a persistent tenant identity header, five stacked sections navigable via a sticky anchor rail, a lifecycle state timeline instead of a status dropdown, and a ceremonial (not incidental) impersonation entry/exit flow. Audit Log and Plans editor reskins are explicitly **out of scope** — they are independent screens with no code overlap with this work and are deferred to Batch 3. + +## Scope decomposition (why this slice) + +The full "Batch 2" scope named in prior planning notes covered five items: Tenant Detail, Suspend/Archive dialogs, Impersonation ceremony, Audit Log reskin, Plans editor reskin. These split into two groups: + +- **The workbench trio** (this spec): Tenant Detail, Suspend/Archive dialogs, Impersonation ceremony. All three live on or are entered from the same page, share the `reason`-field pattern, and share the new tenant-scoped audit feed described below. +- **Independent screens** (Batch 3, separate spec): Audit Log reskin, Plans editor reskin. No shared code with the trio beyond already-shipped primitives (`StatusBadge`, design tokens). + +## Key decisions (confirmed with user) + +1. **Live consequence copy in Suspend/Archive dialogs** uses only existing aggregate counts (`users_count`, `events_count`, `attendees_count` from `GetTenantStats`) — e.g. "This affects 4 users and 2 events." No new backend query for "event X is running today"; that literal brief example is not built. (Consistent with Batch 1's own "no fabricated data" constraint.) +2. **Users tab has no "last login" column.** No login-timestamp is tracked anywhere in the backend. The tab ships with name/email/role/joined-date only; last-login is documented as a known backend gap in the batch summary, same treatment Batch 1 gave the `isOverLimit` scope-mismatch limitation. +3. **Tenant-scoped audit filtering is added server-side** (`target_id` query param on `GetAuditLog`) rather than fetching the full log and filtering client-side in the browser, because the brief explicitly requires designing for an unboundedly-growing audit log. +4. **Impersonation exit summary shows duration + action count** ("You were in Acme Corp for 12 min, made 4 changes"), derived from the tenant-scoped audit feed between the `impersonate_tenant` mint and now, with a link into that filtered view — not a bare confirmation toast. +5. **`reason` field mandatory for:** impersonation entry, subscription changes (brief says both explicitly "not optional"). **Optional for:** suspend/reactivate/archive, where the per-checkbox acknowledgment carries the guardrail instead. This is an extension of Batch 1's existing optional-`reason` backend fields, not a change to them — suspend/archive/reactivate stay optional; only the new subscription-change `reason` is mandatory, and impersonation's existing optional field becomes mandatory at the frontend+backend validation level. + +## Design + +### Backend changes + +**1. `UpdateTenantSubscription` (`backend/internal/handler/super_admin.go`) — two changes:** +- Accept an optional `reason` field on the request body; when absent/empty, return `400` (mandatory, unlike the lifecycle endpoints). +- Re-target its `LogAdminAction` call from `target_type="subscription", target_id=` to `target_type="tenant", target_id=` (matching the convention already used by `setTenantStatus`/`ImpersonateTenant`), keeping the existing `old`/`new` diff plus the new `reason` in the `changes` payload. This is what makes subscription changes visible to the tenant-scoped audit filter below — without it, the Activity tab and Subscription change-feed would silently miss every subscription edit. + +**2. `ImpersonateTenant` — `reason` becomes mandatory** (`400` if empty), tightening the field Batch 1 already added as optional. + +**3. `GetAuditLog` — add a `target_id` query param filter**, alongside the existing `action` filter (both remain optional and combinable). Reused by: + - Tenant Detail's Activity section (`target_id=`, no action filter → everything that happened to this tenant). + - The Subscription tab's change feed (`target_id=&action=update_subscription`). + - The impersonation exit summary's action count (`target_id=` rows between mint time and now, filtered client-side to this admin's `admin_user_id`). + +No new tables, no new migration — this is filter/target-scoping surface on top of the existing `audit_log` table and `LogAdminAction` call sites. + +### Frontend architecture + +**Tenant Detail layout.** Single-page long-scroll, not tabs and not nested routes: a sticky left anchor rail with five links (Summary / Subscription & Limits / Lifecycle / Users / Activity), active section tracked via `IntersectionObserver` and reflected in the URL hash so a section is directly linkable (e.g. from an audit log entry, in Batch 3). Chosen over nested routes because this is one workbench, not five separate views, and the brief calls for a persistent tenant identity header spanning all of them — routing per section would fragment that. Chosen over tabs because the brief explicitly asks for "stacked sections," and a long-scroll keeps adjacent context (e.g. current plan while reading Lifecycle) visible without a click. + +**Persistent tenant identity header.** Name + `StatusBadge` + plan badge, pinned above the rail, present on every section — reuses Batch 1's `StatusBadge` and design tokens, no new primitive. + +**Lifecycle timeline.** A small horizontal state indicator (active → suspended → archived) replacing the current bare action buttons, plus the transition actions themselves: +- **Suspend** → `Dialog` (modal): per-checkbox acknowledgment list (e.g. "I understand this blocks all API access for this tenant's users"), live-consequence line from `GetTenantStats` counts, optional reason textarea, typed-confirm-tenant-name gate (reusing `ConfirmActionDialog`'s existing fail-closed logic — not modified, only composed). +- **Archive** → `Sheet` (right side-sheet, Batch 1's unused primitive) — same acknowledgment-checkbox + typed-confirm pattern as Suspend, given more visual room specifically to explain the retention countdown archive starts, which needs more than a modal's usual copy budget. +- **Reactivate** stays a simple confirm (no checkboxes, non-destructive), matching current behavior. + +**Subscription & Limits tab.** Keeps the existing plan/status/custom-limits/admin-notes form fields, adds the new mandatory reason field on save, and appends a compact append-only change-feed list below the form — sourced from `GET /audit-log?target_id=&action=update_subscription,create_subscription`, rendered as human-readable diffs ("Plan: Starter → Professional; reason: invoice #1042"). + +**Users tab.** Table from the existing `GetAllUsersSuper(tenant_id=)` endpoint (already supports this filter, unused by any UI today) — name, email, role, joined date. + +**Activity tab.** Tenant-scoped audit feed, day-grouped, action-badged, human-readable diffs. Built as a standalone `AuditEntryList` component (props: `entries`, no fetching of its own) specifically so Batch 3's Audit Log page can reuse it for the global view rather than duplicating rendering logic — this batch only wires it into the tenant-scoped fetch. + +**Impersonation ceremony.** +- Entry: replace the current bare `ConfirmActionDialog` usage with a dialog carrying a mandatory reason textarea (submit disabled until non-empty) — everything else about the dialog (typed-confirm, busy state) is unchanged. +- Banner (`ImpersonationBanner.tsx`): no changes — its amber tone and countdown already match the brief. +- Exit: when `endImpersonation` fires, show a summary (toast or small panel) computed from `GET /audit-log?target_id=` rows between the impersonation's mint timestamp and now, filtered to `action=impersonated_request` for this admin — "You were in {tenant} for {duration}, made {count} changes," linking to that filtered Activity view. + +### Data flow summary + +``` +Tenant Detail page load + → GET /tenants/:id/stats (existing, unchanged) + → GET /plans (existing, unchanged — for the plan selector) + → GET /users?tenant_id=:id (existing, unused-until-now filter) + → GET /audit-log?target_id=:id (new filter — feeds both Activity tab and Subscription change-feed) + +Suspend/Archive/Reactivate → POST /tenants/:id/{suspend,archive,reactivate} (existing, reason optional, unchanged) +Subscription save → PATCH /tenants/:id/subscription (reason now required; audit re-targeted to tenant) +Impersonation entry → POST /tenants/:id/impersonate (reason now required) +Impersonation exit → GET /audit-log?target_id=:id (client-side, for the summary only — no new endpoint) +``` + +### Error handling + +No new error classes. Mandatory-field validation (reason on subscription/impersonation) is enforced both client-side (submit button disabled) and server-side (`400` on empty), matching the existing pattern for tenant-name-required checks elsewhere in this codebase. `GetAuditLog`'s new `target_id` param follows the same "invalid → ignored, not 400" tolerance the existing `action` param has, since it's a UI-controlled value, not user-typed. + +## Testing + +- **Backend (Go):** table-driven tests for the retargeted subscription audit logging (asserts `target_type="tenant"`, `target_id=`, `changes["reason"]` present and required), `ImpersonateTenant` reason-required `400` path, and `GetAuditLog`'s new `target_id` filter (combinable with `action`, tolerant of invalid/absent values) — following `super_admin_test.go`'s existing `fakeStore` convention. +- **Frontend (Vitest + RTL):** anchor-rail `IntersectionObserver` section tracking, Suspend/Archive checkbox-gated confirm buttons (mirroring `ConfirmActionDialog`'s existing fail-closed tests — not re-testing that component, only the new composition), mandatory-reason blocking submit on impersonation entry and subscription save, `AuditEntryList` diff-rendering and day-grouping. +- **Manual/live-browser click-through** (this session's established pattern): full Tenant Detail scroll-spy, Suspend → Archive → Reactivate cycle, impersonate → act → exit-with-summary, in both light/dark and EN/RU, before merge. + +## Out of scope + +- Audit Log page reskin, Plans editor reskin (Batch 3). +- Last-login tracking (backend gap, documented not fixed). +- Literal "named event running today" live-consequence text (backend gap, documented not fixed — counts-only instead). +- Any change to `ConfirmActionDialog`'s fail-closed typed-confirm logic — composed, not modified (same hard rule Batch 1 used). +- Phase 2 on-prem packaging — unrelated track, not part of this initiative's console-redesign line. + +## Risks + +| Risk | Mitigation | +|---|---| +| Re-targeting subscription audit logging from `target_type=subscription` to `tenant` could break something reading the old shape | Grep confirmed no other code queries audit log by `target_type=subscription`; the `AllUsers`/analytics pages don't touch this table. Verify in the implementation plan before landing. | +| Anchor-rail `IntersectionObserver` scroll-spy is a new interaction pattern for this codebase | Keep it isolated to a small hook (`useScrollSpy` or similar), unit-testable in jsdom via a mocked `IntersectionObserver`, matching how Batch 1 tested its own new utilities. | +| Mandatory-reason UX friction on impersonation (support staff in a hurry) | Explicit product tradeoff from the design brief itself ("mandatory reason, not optional") — not a design flaw to fix here. | + +## Next steps + +Write the task-by-task implementation plan (`writing-plans` skill) under `docs/superpowers/plans/2026-07-11-console-redesign-batch2.md`, following Batch 1's plan structure (Global Constraints block, per-task Files/Interfaces/Steps), then execute via `subagent-driven-development`. From eaeef9a82256caa18dc301925a80331c166beca6 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 11 Jul 2026 11:17:26 +0300 Subject: [PATCH 02/20] Add implementation plan for Console Redesign Batch 2 (Tenant Detail workbench) 12 tasks: backend audit-scoping + mandatory-reason changes (1-3), reusable frontend infra (4-6), Suspend/Archive/Impersonation dialogs (7-9), and the Tenant Detail page assembled section-by-section (10-12). Co-Authored-By: Claude Sonnet 5 --- .../2026-07-11-console-redesign-batch2.md | 2924 +++++++++++++++++ 1 file changed, 2924 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-console-redesign-batch2.md diff --git a/docs/superpowers/plans/2026-07-11-console-redesign-batch2.md b/docs/superpowers/plans/2026-07-11-console-redesign-batch2.md new file mode 100644 index 00000000..c3aa32ff --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-console-redesign-batch2.md @@ -0,0 +1,2924 @@ +# Platform Console Redesign — Batch 2 (Tenant Detail Workbench) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the plain-Card `OrganizationDetail.tsx` with the design brief's tenant workbench (persistent identity header, five stacked sections behind a sticky anchor rail, a lifecycle state timeline, Suspend modal + Archive side-sheet with per-checkbox acknowledgment, and a ceremonial impersonation entry/exit flow), per `docs/superpowers/specs/2026-07-11-console-redesign-batch2-design.md`. + +**Architecture:** Three small backend additions (tenant-scoped audit filter, mandatory reason on subscription changes + impersonation, subscription audit re-targeted from `target_type=subscription` to `target_type=tenant`) unlock a fully data-backed frontend rebuild. Frontend work splits into reusable infra (scroll-spy hook, audit diff/day-grouping utilities + list component, typed-confirm gate hook, identity header) built first, then three new dialog/sheet components, then the page itself assembled section-by-section in the last three tasks. Audit Log page reskin and Plans editor reskin are explicitly out of scope (Batch 3). + +**Tech Stack:** Go 1.x / Echo v4 / pgx v5 (backend, Tasks 1–3); React 18.3.1 + Vite + TypeScript + Tailwind v4 + shadcn/radix primitives + react-i18next + react-router-dom v7 + vitest/@testing-library/react (frontend, Tasks 4–12). No new npm/Go dependencies. + +## Global Constraints + +- **HARD RULE (repeat verbatim in every task's implementer brief):** never modify `web/src/components/ConfirmActionDialog.tsx`. Its fail-closed typed-confirm logic (`requireText = confirmText !== undefined; locked = requireText && (confirmText === '' || typed !== confirmText)`) is reused by extraction into a new shared hook (Task 6), not by editing that file. `ConfirmActionDialog` itself keeps serving Reactivate and any other unmodified caller exactly as today. +- **Reason field policy:** mandatory (empty string rejected client-side with a disabled submit button, and server-side with `400`) for impersonation entry (Task 3) and subscription changes (Task 2). Optional, unchanged, for suspend/reactivate/archive — the per-checkbox acknowledgment is that flow's guardrail instead. +- **No fabricated data.** Live-consequence copy in Suspend/Archive dialogs uses only `users_count`/`events_count`/`attendees_count` already returned by `GET /tenants/:id/stats`. Do not add a "which event is running today" query — that is a documented, deliberate scope cut (see spec's Out of Scope). +- **No last-login column.** The Users tab (Task 11) renders name/email/role/joined-date only. Do not add a `last_login` field anywhere — it does not exist on `models.User` and adding it is out of scope. +- **i18n convention:** flat camelCase keys, added to both `en` and `ru` blocks in `web/src/i18n.ts` in the same relative position, keeping the two blocks in parallel key order. Reuse existing prefix families `tenantStatus_`, `lifecycle__` where applicable; new prefix families introduced by this plan: `td_
_` for new Tenant Detail workbench copy, `auditAction_` for the human-readable audit action badge labels. +- **Backend gate:** every backend task ends with `cd backend && go build ./... && go vet ./... && go test ./internal/handler/... ./internal/store/...` passing. +- **Frontend gate:** every frontend task ends with `cd web && npx tsc -b --noEmit && npx eslint && npx vitest run` passing. +- **Scroll container fact (verified in `SuperAdminLayout.tsx:122`):** the page's scroll container is `
`, not `window`. Task 4's `useScrollSpy` hook must resolve its `IntersectionObserver` `root` from the nearest ancestor `
` element at runtime (`element.closest('main')`), not assume `window`/viewport scrolling — a `root: null` (viewport) observer would fire incorrectly against a page that itself doesn't scroll the window. + +--- + +### Task 1: Backend — `target_id` filter on `GetAuditLog` + +**Files:** +- Modify: `backend/internal/store/pg_store.go:1613-1652` (`GetAuditLog`) +- Modify: `backend/internal/handler/super_admin.go:320-358` (`GetAuditLog` handler) +- Test: `backend/internal/handler/super_admin_test.go` (create if missing — package already has `fakeStore` in `testsupport_test.go`) + +**Interfaces:** +- Consumes: existing `store.Store.GetAuditLog(ctx context.Context, filters map[string]interface{}, limit int, offset int) ([]*models.AdminAuditLog, int, error)` — **signature unchanged**, only the SQL builder inside `PGStore.GetAuditLog` and the set of keys the handler puts into `filters` change. +- Produces: `GET /api/super-admin/audit-log` now accepts an optional `?target_id=` query param, combinable with the existing `?action=`. Invalid/absent `target_id` is silently ignored (same tolerance as the existing `action` param) — never a `400`, since this is a UI-controlled value. + +- [ ] **Step 1: Write the failing handler test** + +Append to `backend/internal/handler/super_admin_test.go` (create the file with `package handler` + the imports shown if it doesn't exist yet): + +```go +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" +) + +func TestGetAuditLog_TargetIDFilterPassedToStore(t *testing.T) { + e := echo.New() + var capturedFilters map[string]interface{} + + fs := &fakeStore{ + getAuditLog: func(filters map[string]interface{}, limit, offset int) ([]*models.AdminAuditLog, int, error) { + capturedFilters = filters + return nil, 0, nil + }, + } + h := &Handler{Store: fs} + + targetID := uuid.New() + req := httptest.NewRequest(http.MethodGet, "/api/super-admin/audit-log?target_id="+targetID.String()+"&action=suspend_tenant", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := h.GetAuditLog(c); err != nil { + t.Fatalf("GetAuditLog returned error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if capturedFilters["target_id"] != targetID { + t.Fatalf("expected target_id filter %v, got %#v", targetID, capturedFilters["target_id"]) + } + if capturedFilters["action"] != "suspend_tenant" { + t.Fatalf("expected action filter preserved, got %#v", capturedFilters["action"]) + } +} + +func TestGetAuditLog_InvalidTargetIDIgnoredNot400(t *testing.T) { + e := echo.New() + var capturedFilters map[string]interface{} + + fs := &fakeStore{ + getAuditLog: func(filters map[string]interface{}, limit, offset int) ([]*models.AdminAuditLog, int, error) { + capturedFilters = filters + return nil, 0, nil + }, + } + h := &Handler{Store: fs} + + req := httptest.NewRequest(http.MethodGet, "/api/super-admin/audit-log?target_id=not-a-uuid", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := h.GetAuditLog(c); err != nil { + t.Fatalf("GetAuditLog returned error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 (invalid target_id must be ignored, not rejected), got %d", rec.Code) + } + if _, ok := capturedFilters["target_id"]; ok { + t.Fatalf("expected no target_id key when param is invalid, got %#v", capturedFilters) + } +} +``` + +Add the missing imports the two tests need (`idento/backend/internal/models`, `github.com/google/uuid`) to the file's `import` block. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && go test ./internal/handler/... -run TestGetAuditLog -v` +Expected: FAIL — `capturedFilters["target_id"]` is nil/missing because the handler doesn't read the param yet. + +- [ ] **Step 3: Add the query param to the handler** + +In `backend/internal/handler/super_admin.go`, inside `GetAuditLog` (currently lines 340-343), add after the existing `action` block: + +```go + filters := make(map[string]interface{}) + if action := c.QueryParam("action"); action != "" { + filters["action"] = action + } + if targetIDStr := c.QueryParam("target_id"); targetIDStr != "" { + if targetID, err := uuid.Parse(targetIDStr); err == nil { + filters["target_id"] = targetID + } + } +``` + +- [ ] **Step 4: Add the SQL clause in the store** + +In `backend/internal/store/pg_store.go`, replace `GetAuditLog`'s WHERE-building (currently only handling `action`) with a generic AND-list builder: + +```go +func (s *PGStore) GetAuditLog(ctx context.Context, filters map[string]interface{}, limit int, offset int) ([]*models.AdminAuditLog, int, error) { + var conditions []string + var args []interface{} + + if action, ok := filters["action"].(string); ok && action != "" { + args = append(args, action) + conditions = append(conditions, fmt.Sprintf("action = $%d", len(args))) + } + if targetID, ok := filters["target_id"].(uuid.UUID); ok { + args = append(args, targetID) + conditions = append(conditions, fmt.Sprintf("target_id = $%d", len(args))) + } + + where := "" + if len(conditions) > 0 { + where = "WHERE " + strings.Join(conditions, " AND ") + } + + query := fmt.Sprintf(`SELECT id, admin_user_id, action, target_type, target_id, changes, ip_address::text, user_agent, created_at + FROM admin_audit_log %s + ORDER BY created_at DESC + LIMIT $%d OFFSET $%d`, where, len(args)+1, len(args)+2) + rows, err := s.db.Query(ctx, query, append(args, limit, offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var logs []*models.AdminAuditLog + for rows.Next() { + var auditLog models.AdminAuditLog + var changesJSON []byte + + err := rows.Scan( + &auditLog.ID, &auditLog.AdminUserID, &auditLog.Action, &auditLog.TargetType, &auditLog.TargetID, + &changesJSON, &auditLog.IPAddress, &auditLog.UserAgent, &auditLog.CreatedAt, + ) + if err != nil { + return nil, 0, err + } + + if len(changesJSON) > 0 { + if err := json.Unmarshal(changesJSON, &auditLog.Changes); err != nil { + log.Printf("Failed to unmarshal changes: %v", err) + } + } + + logs = append(logs, &auditLog) + } + + countQuery := "SELECT COUNT(*) FROM admin_audit_log " + where + var total int + if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("get audit log total count: %w", err) + } + + return logs, total, nil +} +``` + +Add `"strings"` to `pg_store.go`'s import block if not already present (check with `grep -n '"strings"' backend/internal/store/pg_store.go` first — the file is large and may already import it for other functions). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/handler/... -run TestGetAuditLog -v` +Expected: PASS, 2 tests. + +- [ ] **Step 6: Full backend gate and commit** + +Run: `cd backend && go build ./... && go vet ./... && go test ./internal/handler/... ./internal/store/...` +Expected: all pass (store package has no live-DB test for this function — none exists today, none added here; SQL correctness is covered by the handler test's filter-passthrough assertion plus manual verification in Task 12's live click-through). + +```bash +git add backend/internal/handler/super_admin.go backend/internal/handler/super_admin_test.go backend/internal/store/pg_store.go +git commit -m "feat(backend): add target_id filter to GetAuditLog, combinable with action" +``` + +--- + +### Task 2: Backend — mandatory `reason` + tenant-targeted audit logging for subscription updates + +**Files:** +- Modify: `backend/internal/handler/super_admin.go:87-186` (`UpdateTenantSubscription`) +- Test: `backend/internal/handler/super_admin_test.go` (append) + +**Interfaces:** +- Consumes: existing `h.Store.LogAdminAction(ctx, adminID, action, targetType string, targetID uuid.UUID, changes map[string]interface{}, ip, userAgent string) error`; existing `claimsFromContext(c)`; existing `h.Store.GetSubscriptionByTenantID`/`UpsertSubscription`/`UpdateSubscription`. +- Produces: `PATCH /tenants/:id/subscription` now requires a non-empty `"reason"` string in the request body (`400` `{"error": "reason is required"}` if absent/empty); on success, `LogAdminAction` is called with `target_type="tenant"` (previously `"subscription"`) and `target_id=tenantID` (previously `sub.ID`), with `changes["reason"]` set alongside the existing `changes["old"]`/`changes["new"]`. Response body/status codes for the happy path are unchanged. + +- [ ] **Step 1: Write the failing tests** + +Append to `backend/internal/handler/super_admin_test.go`: + +```go +func TestUpdateTenantSubscription_ReasonRequired(t *testing.T) { + e := echo.New() + tenantID := uuid.New() + subID := uuid.New() + + fs := &fakeStore{ + getSubscriptionByTenantID: func(id uuid.UUID) (*models.Subscription, error) { + return &models.Subscription{ID: subID, TenantID: tenantID, Status: "active"}, nil + }, + } + h := &Handler{Store: fs} + + body, _ := json.Marshal(map[string]string{"status": "active"}) + req := httptest.NewRequest(http.MethodPatch, "/api/super-admin/tenants/"+tenantID.String()+"/subscription", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues(tenantID.String()) + + if err := h.UpdateTenantSubscription(c); err != nil { + t.Fatalf("UpdateTenantSubscription returned error: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when reason is missing, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestUpdateTenantSubscription_LogsTenantTargetedWithReason(t *testing.T) { + e := echo.New() + tenantID := uuid.New() + adminID := uuid.New() + subID := uuid.New() + var capturedTargetType string + var capturedTargetID uuid.UUID + var capturedChanges map[string]interface{} + + fs := &fakeStore{ + getSubscriptionByTenantID: func(id uuid.UUID) (*models.Subscription, error) { + return &models.Subscription{ID: subID, TenantID: tenantID, Status: "trial"}, nil + }, + updateSubscription: func(sub *models.Subscription) error { return nil }, + logAdminAction: func(_ uuid.UUID, _ string, targetType string, targetID uuid.UUID, changes interface{}, _, _ string) error { + capturedTargetType = targetType + capturedTargetID = targetID + capturedChanges = changes.(map[string]interface{}) + return nil + }, + } + h := &Handler{Store: fs} + + body, _ := json.Marshal(map[string]string{"status": "active", "reason": "invoice #1042 paid"}) + req := httptest.NewRequest(http.MethodPatch, "/api/super-admin/tenants/"+tenantID.String()+"/subscription", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues(tenantID.String()) + c.Set("user", &models.JWTCustomClaims{UserID: adminID.String(), TenantID: uuid.New().String(), Role: "admin"}) + + if err := h.UpdateTenantSubscription(c); err != nil { + t.Fatalf("UpdateTenantSubscription returned error: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if capturedTargetType != "tenant" { + t.Fatalf("expected target_type=tenant, got %q", capturedTargetType) + } + if capturedTargetID != tenantID { + t.Fatalf("expected target_id=%v (tenant), got %v", tenantID, capturedTargetID) + } + if capturedChanges["reason"] != "invoice #1042 paid" { + t.Fatalf("expected reason in audit changes, got %#v", capturedChanges) + } + if capturedChanges["old"] == nil || capturedChanges["new"] == nil { + t.Fatalf("expected old/new diff preserved alongside reason, got %#v", capturedChanges) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && go test ./internal/handler/... -run TestUpdateTenantSubscription -v` +Expected: FAIL — no `reason` validation exists yet, and `target_type` is still `"subscription"`. + +- [ ] **Step 3: Implement** + +In `backend/internal/handler/super_admin.go`, modify `UpdateTenantSubscription`'s request struct and add validation right after `c.Bind`: + +```go + var req struct { + PlanID *string `json:"plan_id"` + Status *string `json:"status"` + EndDate *time.Time `json:"end_date"` + CustomLimits *map[string]interface{} `json:"custom_limits"` + CustomFeatures *map[string]interface{} `json:"custom_features"` + AdminNotes *string `json:"admin_notes"` + Reason string `json:"reason"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request", + }) + } + if strings.TrimSpace(req.Reason) == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "reason is required"}) + } +``` + +Add `"strings"` to the import block if not already present (it likely already is — `CreateTenantSuper` in the same file uses `strings.TrimSpace`). + +Then change the `LogAdminAction` call at the end of the function (currently target_type=`"subscription"`, target_id=`sub.ID`): + +```go + adminID := uuid.MustParse(claims.UserID) + if err := h.Store.LogAdminAction(c.Request().Context(), adminID, action, "tenant", tenantID, map[string]interface{}{ + "old": oldSub, + "new": sub, + "reason": req.Reason, + }, c.RealIP(), c.Request().UserAgent()); err != nil { + log.Printf("Failed to log admin action: %v", err) + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/handler/... -run TestUpdateTenantSubscription -v` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Full backend gate and commit** + +Run: `cd backend && go build ./... && go vet ./... && go test ./internal/handler/... ./internal/store/...` +Expected: all pass. Also grep-verify no other code reads audit rows by `target_type="subscription"` before committing: `grep -rn 'target_type.*subscription\|"subscription".*target' backend/internal --include="*.go"` should show only the line you just changed away from (confirms nothing downstream depends on the old shape). + +```bash +git add backend/internal/handler/super_admin.go backend/internal/handler/super_admin_test.go +git commit -m "feat(backend): require reason on subscription updates, log audit as tenant-targeted" +``` + +--- + +### Task 3: Backend — mandatory `reason` for `ImpersonateTenant` + +**Files:** +- Modify: `backend/internal/handler/super_admin.go:436-486` (`ImpersonateTenant`) +- Test: `backend/internal/handler/super_admin_test.go` (append) + +**Interfaces:** +- Consumes: existing `generateImpersonationToken(userID, tenantID string)`, existing `claimsFromContext`. +- Produces: `POST /tenants/:id/impersonate` now requires a non-empty `"reason"` (`400` `{"error": "reason is required"}` if absent/empty). Success response shape unchanged. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/internal/handler/super_admin_test.go`: + +```go +func TestImpersonateTenant_ReasonRequired(t *testing.T) { + e := echo.New() + tenantID := uuid.New() + + fs := &fakeStore{ + getTenantStatus: func(id uuid.UUID) (string, error) { return "active", nil }, + } + h := &Handler{Store: fs} + + req := httptest.NewRequest(http.MethodPost, "/api/super-admin/tenants/"+tenantID.String()+"/impersonate", bytes.NewReader([]byte(`{}`))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues(tenantID.String()) + c.Set("user", &models.JWTCustomClaims{UserID: uuid.New().String(), TenantID: uuid.New().String(), Role: "admin"}) + + if err := h.ImpersonateTenant(c); err != nil { + t.Fatalf("ImpersonateTenant returned error: %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when reason is missing, got %d: %s", rec.Code, rec.Body.String()) + } +} +``` + +(`TestImpersonateTenant_ReasonPersistedToAuditChanges`, testing the success path with a reason present, already exists from Batch 1 — confirm with `grep -n "TestImpersonateTenant_ReasonPersisted" backend/internal/handler/super_admin_test.go` before writing a duplicate.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/handler/... -run TestImpersonateTenant_ReasonRequired -v` +Expected: FAIL — currently a request with `{}` body succeeds (200), since `reason` is optional today. + +- [ ] **Step 3: Implement** + +In `backend/internal/handler/super_admin.go`, inside `ImpersonateTenant`, after the existing `c.Bind(&body)` (currently line ~454), add: + +```go + if strings.TrimSpace(body.Reason) == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "reason is required"}) + } +``` + +Leave the rest of the function (status check, token mint, `LogAdminAction` call with `changes["reason"] = body.Reason`) unchanged — it already handles a present reason correctly from Batch 1. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && go test ./internal/handler/... -run TestImpersonateTenant -v` +Expected: PASS, all `TestImpersonateTenant_*` tests including the pre-existing one. + +- [ ] **Step 5: Full backend gate and commit** + +Run: `cd backend && go build ./... && go vet ./... && go test ./internal/handler/... ./internal/store/...` +Expected: all pass. + +```bash +git add backend/internal/handler/super_admin.go backend/internal/handler/super_admin_test.go +git commit -m "feat(backend): require reason on impersonation entry" +``` + +--- + +### Task 4: Frontend infra — `useScrollSpy` hook + +**Files:** +- Create: `web/src/hooks/useScrollSpy.ts` +- Test: `web/src/hooks/__tests__/useScrollSpy.test.ts` + +**Interfaces:** +- Consumes: nothing project-specific — plain DOM `IntersectionObserver`. +- Produces: `useScrollSpy(sectionIds: string[]): string` — returns the `id` of the currently most-visible section, defaulting to `sectionIds[0]`. Consumed by Task 10 (the anchor rail). + +- [ ] **Step 1: Write the failing test** + +Create `web/src/hooks/__tests__/useScrollSpy.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useScrollSpy } from '../useScrollSpy'; + +class MockIntersectionObserver { + callback: IntersectionObserverCallback; + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + } + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); +} + +describe('useScrollSpy', () => { + let observerInstance: MockIntersectionObserver; + + beforeEach(() => { + document.body.innerHTML = ` +
+
+
+
+ `; + vi.stubGlobal( + 'IntersectionObserver', + vi.fn((cb: IntersectionObserverCallback) => { + observerInstance = new MockIntersectionObserver(cb); + return observerInstance; + }) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('defaults to the first section id', () => { + const { result } = renderHook(() => useScrollSpy(['summary', 'lifecycle'])); + expect(result.current).toBe('summary'); + }); + + it('updates to the section reported as intersecting', () => { + const { result } = renderHook(() => useScrollSpy(['summary', 'lifecycle'])); + const lifecycleEl = document.getElementById('lifecycle')!; + act(() => { + observerInstance.callback( + [{ isIntersecting: true, target: lifecycleEl } as IntersectionObserverEntry], + observerInstance as unknown as IntersectionObserver + ); + }); + expect(result.current).toBe('lifecycle'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && npx vitest run src/hooks/__tests__/useScrollSpy.test.ts` +Expected: FAIL — `Cannot find module '../useScrollSpy'`. + +- [ ] **Step 3: Implement** + +Create `web/src/hooks/useScrollSpy.ts`: + +```ts +import { useEffect, useState } from 'react'; + +/** + * Tracks which of the given section element IDs is currently most visible, + * for driving an anchor rail's active-link highlight. Resolves its + * IntersectionObserver root from the nearest scrolling
ancestor + * (this app's page scroll container is
, not + * window) rather than assuming viewport scrolling. + */ +export function useScrollSpy(sectionIds: string[]): string { + const [activeId, setActiveId] = useState(sectionIds[0] ?? ''); + + useEffect(() => { + const elements = sectionIds + .map((id) => document.getElementById(id)) + .filter((el): el is HTMLElement => el !== null); + if (elements.length === 0) return; + + const root = elements[0].closest('main'); + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries.filter((e) => e.isIntersecting); + if (visible.length > 0) { + setActiveId(visible[0].target.id); + } + }, + { root, rootMargin: '-10% 0px -70% 0px', threshold: 0 } + ); + + elements.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- sectionIds is a stable literal array from the caller + }, [sectionIds.join(',')]); + + return activeId; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && npx vitest run src/hooks/__tests__/useScrollSpy.test.ts` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Full frontend gate and commit** + +Run: `cd web && npx tsc -b --noEmit && npx eslint src/hooks/useScrollSpy.ts src/hooks/__tests__/useScrollSpy.test.ts && npx vitest run` +Expected: all pass. + +```bash +git add web/src/hooks/useScrollSpy.ts web/src/hooks/__tests__/useScrollSpy.test.ts +git commit -m "feat(web): add useScrollSpy hook for the tenant detail anchor rail" +``` + +--- + +### Task 5: Frontend infra — audit diff/day-grouping utilities + `AuditEntryList` component + +**Files:** +- Create: `web/src/lib/auditFormat.ts` +- Create: `web/src/components/AuditEntryList.tsx` +- Test: `web/src/lib/__tests__/auditFormat.test.ts` +- Test: `web/src/components/__tests__/AuditEntryList.test.tsx` + +**Interfaces:** +- Consumes: nothing — pure functions/presentational component over plain data. +- Produces: `AuditLogEntry` type, `groupAuditLogByDay(entries: AuditLogEntry[]): AuditDayGroup[]`, `formatAuditDiff(entry: AuditLogEntry, planNames?: Record): string`, and `} emptyLabel={string} />`. Consumed by Task 10's Subscription change-feed and Task 12's Activity section — and intended for reuse, unmodified, by Batch 3's global Audit Log page. + +- [ ] **Step 1: Write the failing utility tests** + +Create `web/src/lib/__tests__/auditFormat.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { groupAuditLogByDay, formatAuditDiff, type AuditLogEntry } from '../auditFormat'; + +function entry(overrides: Partial): AuditLogEntry { + return { + id: '1', + admin_user_id: 'admin-1', + action: 'suspend_tenant', + target_type: 'tenant', + target_id: 'tenant-1', + changes: {}, + ip_address: null, + user_agent: null, + created_at: '2026-07-11T10:00:00Z', + ...overrides, + }; +} + +describe('groupAuditLogByDay', () => { + it('groups entries by their created_at date, preserving order within a day', () => { + const entries = [ + entry({ id: '1', created_at: '2026-07-11T10:00:00Z' }), + entry({ id: '2', created_at: '2026-07-11T09:00:00Z' }), + entry({ id: '3', created_at: '2026-07-10T10:00:00Z' }), + ]; + const groups = groupAuditLogByDay(entries); + expect(groups).toHaveLength(2); + expect(groups[0]).toEqual({ day: '2026-07-11', entries: [entries[0], entries[1]] }); + expect(groups[1]).toEqual({ day: '2026-07-10', entries: [entries[2]] }); + }); +}); + +describe('formatAuditDiff', () => { + it('renders lifecycle transitions with reason', () => { + const line = formatAuditDiff(entry({ action: 'suspend_tenant', changes: { from: 'active', to: 'suspended', reason: 'nonpayment' } })); + expect(line).toBe('Status: active → suspended — reason: nonpayment'); + }); + + it('renders lifecycle transitions without reason', () => { + const line = formatAuditDiff(entry({ action: 'archive_tenant', changes: { from: 'suspended', to: 'archived' } })); + expect(line).toBe('Status: suspended → archived'); + }); + + it('renders impersonated_request as method + path', () => { + const line = formatAuditDiff(entry({ action: 'impersonated_request', changes: { method: 'PATCH', path: '/api/events/123' } })); + expect(line).toBe('PATCH /api/events/123'); + }); + + it('renders subscription plan changes using the planNames lookup', () => { + const line = formatAuditDiff( + entry({ + action: 'update_subscription', + changes: { + old: { plan_id: 'plan-starter', status: 'trial' }, + new: { plan_id: 'plan-pro', status: 'active' }, + reason: 'invoice #1042', + }, + }), + { 'plan-starter': 'Starter', 'plan-pro': 'Professional' } + ); + expect(line).toBe('Plan: Starter → Professional; Status: trial → active — reason: invoice #1042'); + }); + + it('falls back to a generic label when nothing in the subscription diff changed', () => { + const line = formatAuditDiff( + entry({ action: 'update_subscription', changes: { old: { status: 'active' }, new: { status: 'active' }, reason: 'note only' } }) + ); + expect(line).toBe('Subscription updated — reason: note only'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npx vitest run src/lib/__tests__/auditFormat.test.ts` +Expected: FAIL — `Cannot find module '../auditFormat'`. + +- [ ] **Step 3: Implement the utilities** + +Create `web/src/lib/auditFormat.ts`: + +```ts +export type AuditLogEntry = { + id: string; + admin_user_id: string; + action: string; + target_type: string; + target_id: string | null; + changes: Record | null; + ip_address: string | null; + user_agent: string | null; + created_at: string; +}; + +export type AuditDayGroup = { day: string; entries: AuditLogEntry[] }; + +/** Groups by the entry's created_at calendar date (UTC, YYYY-MM-DD), preserving API order (newest-first) within each day. */ +export function groupAuditLogByDay(entries: AuditLogEntry[]): AuditDayGroup[] { + const order: string[] = []; + const groups = new Map(); + for (const entry of entries) { + const day = entry.created_at.slice(0, 10); + const bucket = groups.get(day); + if (bucket) { + bucket.push(entry); + } else { + groups.set(day, [entry]); + order.push(day); + } + } + return order.map((day) => ({ day, entries: groups.get(day)! })); +} + +function shortId(id: unknown): string { + return typeof id === 'string' && id.length > 0 ? id.slice(0, 8) : 'none'; +} + +/** Human-readable one-line description of a single audit entry's diff. */ +export function formatAuditDiff(entry: AuditLogEntry, planNames?: Record): string { + const c = entry.changes ?? {}; + const reasonSuffix = typeof c.reason === 'string' && c.reason ? ` — reason: ${c.reason}` : ''; + + switch (entry.action) { + case 'suspend_tenant': + case 'reactivate_tenant': + case 'archive_tenant': { + const from = typeof c.from === 'string' ? c.from : '?'; + const to = typeof c.to === 'string' ? c.to : '?'; + return `Status: ${from} → ${to}${reasonSuffix}`; + } + case 'impersonate_tenant': + return `Support session started${reasonSuffix}`; + case 'impersonated_request': { + const method = typeof c.method === 'string' ? c.method : ''; + const path = typeof c.path === 'string' ? c.path : ''; + return `${method} ${path}`.trim(); + } + case 'update_subscription': + case 'create_subscription': { + const oldSub = (c.old ?? {}) as Record; + const newSub = (c.new ?? {}) as Record; + const parts: string[] = []; + const resolvePlan = (id: unknown) => (planNames?.[id as string] ?? shortId(id)); + if (oldSub.plan_id !== newSub.plan_id) { + parts.push(`Plan: ${resolvePlan(oldSub.plan_id)} → ${resolvePlan(newSub.plan_id)}`); + } + if (oldSub.status !== newSub.status) { + parts.push(`Status: ${oldSub.status ?? '?'} → ${newSub.status ?? '?'}`); + } + if (JSON.stringify(oldSub.custom_limits ?? {}) !== JSON.stringify(newSub.custom_limits ?? {})) { + parts.push('Custom limits updated'); + } + if (parts.length === 0) parts.push('Subscription updated'); + return parts.join('; ') + reasonSuffix; + } + case 'create_tenant': + return 'Tenant created'; + default: + return entry.action.replace(/_/g, ' '); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd web && npx vitest run src/lib/__tests__/auditFormat.test.ts` +Expected: PASS, 5 tests. + +- [ ] **Step 5: Write the failing component test** + +Create `web/src/components/__tests__/AuditEntryList.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AuditEntryList } from '../AuditEntryList'; +import type { AuditLogEntry } from '@/lib/auditFormat'; +import '../../i18n'; + +const entries: AuditLogEntry[] = [ + { + id: '1', + admin_user_id: 'admin-1', + action: 'suspend_tenant', + target_type: 'tenant', + target_id: 'tenant-1', + changes: { from: 'active', to: 'suspended' }, + ip_address: null, + user_agent: null, + created_at: '2026-07-11T10:00:00Z', + }, +]; + +describe('AuditEntryList', () => { + it('renders a day-group heading and the formatted diff line', () => { + render(); + expect(screen.getByText(/Status: active → suspended/)).toBeInTheDocument(); + }); + + it('renders the empty label when there are no entries', () => { + render(); + expect(screen.getByText('No activity yet')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `cd web && npx vitest run src/components/__tests__/AuditEntryList.test.tsx` +Expected: FAIL — `Cannot find module '../AuditEntryList'`. + +- [ ] **Step 7: Implement the component** + +Create `web/src/components/AuditEntryList.tsx`: + +```tsx +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { groupAuditLogByDay, formatAuditDiff, type AuditLogEntry } from '@/lib/auditFormat'; + +const ACTION_BADGE_CLASS: Record = { + suspend_tenant: 'bg-amber-500 text-black', + archive_tenant: 'bg-muted text-muted-foreground', + reactivate_tenant: 'bg-primary text-primary-foreground', + impersonate_tenant: 'border-transparent bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300', + impersonated_request: 'border-transparent bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300', +}; + +type Props = { + entries: AuditLogEntry[]; + planNames?: Record; + emptyLabel: string; +}; + +export function AuditEntryList({ entries, planNames, emptyLabel }: Props) { + const { i18n } = useTranslation(); + + if (entries.length === 0) { + return

{emptyLabel}

; + } + + const groups = groupAuditLogByDay(entries); + return ( +
+ {groups.map((group) => ( +
+

+ {new Date(group.day).toLocaleDateString(i18n.language, { year: 'numeric', month: 'long', day: 'numeric' })} +

+
    + {group.entries.map((entry) => ( +
  • + + {entry.action.replace(/_/g, ' ')} + +
    +

    {formatAuditDiff(entry, planNames)}

    +

    + {new Date(entry.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })} +

    +
    +
  • + ))} +
+
+ ))} +
+ ); +} +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `cd web && npx vitest run src/components/__tests__/AuditEntryList.test.tsx` +Expected: PASS, 2 tests. + +- [ ] **Step 9: Full frontend gate and commit** + +Run: `cd web && npx tsc -b --noEmit && npx eslint src/lib/auditFormat.ts src/components/AuditEntryList.tsx src/lib/__tests__/auditFormat.test.ts src/components/__tests__/AuditEntryList.test.tsx && npx vitest run` +Expected: all pass. + +```bash +git add web/src/lib/auditFormat.ts web/src/components/AuditEntryList.tsx web/src/lib/__tests__/auditFormat.test.ts web/src/components/__tests__/AuditEntryList.test.tsx +git commit -m "feat(web): add audit diff/day-grouping utilities and AuditEntryList component" +``` + +--- + +### Task 6: Frontend infra — `TenantIdentityHeader` component + `useTypedConfirmGate` hook + +**Files:** +- Create: `web/src/components/TenantIdentityHeader.tsx` +- Create: `web/src/hooks/useTypedConfirmGate.ts` +- Test: `web/src/components/__tests__/TenantIdentityHeader.test.tsx` +- Test: `web/src/hooks/__tests__/useTypedConfirmGate.test.ts` + +**Interfaces:** +- Consumes: existing `StatusBadge` (`web/src/components/StatusBadge.tsx`), existing `Badge` (`web/src/components/ui/badge.tsx`). +- Produces: `` — consumed by Task 10 (page assembly). `useTypedConfirmGate(open: boolean, confirmText: string | undefined): { typed: string; setTyped: (v: string) => void; locked: boolean; requireText: boolean }` — the same fail-closed semantics as `ConfirmActionDialog` (extracted, not imported from it — see Global Constraints hard rule), consumed by Task 7 (`LifecycleActionDialog`) and Task 8 (`ArchiveSheet`). + +- [ ] **Step 1: Write the failing hook test** + +Create `web/src/hooks/__tests__/useTypedConfirmGate.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTypedConfirmGate } from '../useTypedConfirmGate'; + +describe('useTypedConfirmGate', () => { + it('is unlocked when confirmText is undefined (no typed-confirm required)', () => { + const { result } = renderHook(() => useTypedConfirmGate(true, undefined)); + expect(result.current.locked).toBe(false); + expect(result.current.requireText).toBe(false); + }); + + it('locks when confirmText is an empty string (fail closed, does not bypass)', () => { + const { result } = renderHook(() => useTypedConfirmGate(true, '')); + expect(result.current.locked).toBe(true); + }); + + it('unlocks only once typed matches confirmText exactly', () => { + const { result } = renderHook(() => useTypedConfirmGate(true, 'Acme Corp')); + expect(result.current.locked).toBe(true); + act(() => result.current.setTyped('Acme Cor')); + expect(result.current.locked).toBe(true); + act(() => result.current.setTyped('Acme Corp')); + expect(result.current.locked).toBe(false); + }); + + it('resets typed text when open transitions to true', () => { + const { result, rerender } = renderHook(({ open }) => useTypedConfirmGate(open, 'X'), { + initialProps: { open: false }, + }); + act(() => result.current.setTyped('X')); + rerender({ open: true }); + expect(result.current.typed).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && npx vitest run src/hooks/__tests__/useTypedConfirmGate.test.ts` +Expected: FAIL — `Cannot find module '../useTypedConfirmGate'`. + +- [ ] **Step 3: Implement the hook** + +Create `web/src/hooks/useTypedConfirmGate.ts`: + +```ts +import { useEffect, useState } from 'react'; + +/** + * Typed-confirm gating logic shared by LifecycleActionDialog and + * ArchiveSheet. Mirrors ConfirmActionDialog's fail-closed semantics + * (extracted, not imported — ConfirmActionDialog itself is never modified): + * an empty-string confirmText LOCKS the gate rather than bypassing it. + */ +export function useTypedConfirmGate(open: boolean, confirmText: string | undefined) { + const [typed, setTyped] = useState(''); + const requireText = confirmText !== undefined; + const locked = requireText && (confirmText === '' || typed !== confirmText); + + useEffect(() => { + if (open) setTyped(''); + }, [open]); + + return { typed, setTyped, locked, requireText }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && npx vitest run src/hooks/__tests__/useTypedConfirmGate.test.ts` +Expected: PASS, 4 tests. + +- [ ] **Step 5: Write the failing component test** + +Create `web/src/components/__tests__/TenantIdentityHeader.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { TenantIdentityHeader } from '../TenantIdentityHeader'; +import '../../i18n'; + +describe('TenantIdentityHeader', () => { + it('renders the tenant name, status badge, and plan badge', () => { + render(); + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + expect(screen.getByText('Suspended')).toBeInTheDocument(); + expect(screen.getByText('Professional')).toBeInTheDocument(); + }); + + it('omits the plan badge when planName is not given', () => { + render(); + expect(screen.queryByText('Professional')).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `cd web && npx vitest run src/components/__tests__/TenantIdentityHeader.test.tsx` +Expected: FAIL — `Cannot find module '../TenantIdentityHeader'`. + +- [ ] **Step 7: Implement the component** + +Create `web/src/components/TenantIdentityHeader.tsx`: + +```tsx +import { StatusBadge } from '@/components/StatusBadge'; +import { Badge } from '@/components/ui/badge'; + +type Props = { + name: string; + status?: string; + planName?: string; +}; + +/** + * Persistent tenant-identity strip pinned above every Tenant Detail + * section, so the operator always knows whose account they're touching + * (design brief's "wrong-tenant safety" requirement). + */ +export function TenantIdentityHeader({ name, status, planName }: Props) { + return ( +
+

{name}

+ + {planName && {planName}} +
+ ); +} +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `cd web && npx vitest run src/components/__tests__/TenantIdentityHeader.test.tsx` +Expected: PASS, 2 tests. + +- [ ] **Step 9: Full frontend gate and commit** + +Run: `cd web && npx tsc -b --noEmit && npx eslint src/components/TenantIdentityHeader.tsx src/hooks/useTypedConfirmGate.ts src/components/__tests__/TenantIdentityHeader.test.tsx src/hooks/__tests__/useTypedConfirmGate.test.ts && npx vitest run` +Expected: all pass. + +```bash +git add web/src/components/TenantIdentityHeader.tsx web/src/hooks/useTypedConfirmGate.ts web/src/components/__tests__/TenantIdentityHeader.test.tsx web/src/hooks/__tests__/useTypedConfirmGate.test.ts +git commit -m "feat(web): add TenantIdentityHeader and useTypedConfirmGate" +``` + +--- + +### Task 7: `SuspendTenantDialog` component (modal, checkbox acknowledgment + typed confirm) + +**Files:** +- Create: `web/src/components/SuspendTenantDialog.tsx` +- Modify: `web/src/i18n.ts` (add keys — see Step 3) +- Test: `web/src/components/__tests__/SuspendTenantDialog.test.tsx` + +**Interfaces:** +- Consumes: `useTypedConfirmGate` (Task 6), `Dialog`/`DialogContent`/`DialogDescription`/`DialogFooter`/`DialogHeader`/`DialogTitle` (existing `web/src/components/ui/dialog.tsx`), `Checkbox` (existing `web/src/components/ui/checkbox.tsx`). +- Produces: ` void|Promise} busy onOpenChange />`. Consumed by Task 11 (Lifecycle section). + +- [ ] **Step 1: Write the failing test** + +Create `web/src/components/__tests__/SuspendTenantDialog.test.tsx`: + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SuspendTenantDialog } from '../SuspendTenantDialog'; +import '../../i18n'; + +describe('SuspendTenantDialog', () => { + it('keeps confirm disabled until BOTH the checkbox is checked AND the tenant name is typed', () => { + render( + {}} + tenantName="Acme Corp" + usersCount={4} + eventsCount={2} + onConfirm={() => {}} + busy={false} + /> + ); + const confirmButton = screen.getByRole('button', { name: /suspend/i }); + expect(confirmButton).toBeDisabled(); + + fireEvent.click(screen.getByRole('checkbox')); + expect(confirmButton).toBeDisabled(); // checkbox alone is not enough + + fireEvent.change(screen.getByPlaceholderText('Acme Corp'), { target: { value: 'Acme Corp' } }); + expect(confirmButton).not.toBeDisabled(); + }); + + it('calls onConfirm with the typed reason', () => { + const onConfirm = vi.fn(); + render( + {}} + tenantName="Acme Corp" + usersCount={4} + eventsCount={2} + onConfirm={onConfirm} + busy={false} + /> + ); + fireEvent.click(screen.getByRole('checkbox')); + fireEvent.change(screen.getByPlaceholderText('Acme Corp'), { target: { value: 'Acme Corp' } }); + const [reasonBox] = screen.getAllByRole('textbox').filter((el) => el.tagName === 'TEXTAREA'); + fireEvent.change(reasonBox, { target: { value: 'nonpayment' } }); + fireEvent.click(screen.getByRole('button', { name: /suspend/i })); + expect(onConfirm).toHaveBeenCalledWith('nonpayment'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && npx vitest run src/components/__tests__/SuspendTenantDialog.test.tsx` +Expected: FAIL — `Cannot find module '../SuspendTenantDialog'`. + +- [ ] **Step 3: Add i18n keys** + +In `web/src/i18n.ts`, in the `en.translation` block, immediately after the existing `lifecycle_archive_done: "Organization archived",` line, add: + +```ts + td_suspend_title: "Suspend organization?", + td_suspend_consequence: "This affects {{users}} users and {{events}} events for “{{tenant}}”. All API access will be blocked within ~2 minutes.", + td_suspend_acknowledge: "I understand this blocks access for this organization's users immediately.", + td_reasonOptionalLabel: "Reason (optional, visible in the audit log)", +``` + +In the `ru.translation` block, find the matching `lifecycle_archive_done:` line (same relative position) and add immediately after it: + +```ts + td_suspend_title: "Приостановить организацию?", + td_suspend_consequence: "Это затронет {{users}} пользователей и {{events}} мероприятий «{{tenant}}». Доступ к API будет заблокирован в течение ~2 минут.", + td_suspend_acknowledge: "Я понимаю, что это немедленно заблокирует доступ для пользователей этой организации.", + td_reasonOptionalLabel: "Причина (необязательно, отображается в журнале аудита)", +``` + +(Locate the `ru` block's `lifecycle_archive_done` line first with `grep -n "lifecycle_archive_done" web/src/i18n.ts` — it must appear twice, once per language block; add after the second occurrence.) + +- [ ] **Step 4: Implement the component** + +Create `web/src/components/SuspendTenantDialog.tsx`: + +```tsx +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { useTypedConfirmGate } from '@/hooks/useTypedConfirmGate'; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + tenantName: string; + usersCount: number; + eventsCount: number; + onConfirm: (reason: string) => void | Promise; + busy: boolean; +}; + +export function SuspendTenantDialog({ open, onOpenChange, tenantName, usersCount, eventsCount, onConfirm, busy }: Props) { + const { t } = useTranslation(); + const { typed, setTyped, locked } = useTypedConfirmGate(open, tenantName); + const [acknowledged, setAcknowledged] = useState(false); + const [reason, setReason] = useState(''); + + const close = (o: boolean) => { + if (!o) { + setAcknowledged(false); + setReason(''); + } + onOpenChange(o); + }; + + return ( + + + + {t('td_suspend_title')} + + {t('td_suspend_consequence', { tenant: tenantName, users: usersCount, events: eventsCount })} + + + +
+ setAcknowledged(v === true)} /> + +
+ +
+ +