diff --git a/backend/internal/handler/super_admin.go b/backend/internal/handler/super_admin.go index 4e92dbef..c446d134 100644 --- a/backend/internal/handler/super_admin.go +++ b/backend/internal/handler/super_admin.go @@ -100,6 +100,7 @@ func (h *Handler) UpdateTenantSubscription(c echo.Context) error { 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 { @@ -107,6 +108,9 @@ func (h *Handler) UpdateTenantSubscription(c echo.Context) error { "error": "Invalid request", }) } + if strings.TrimSpace(req.Reason) == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "reason is required"}) + } // Get existing subscription; create one if the tenant has none (upsert). sub, err := h.Store.GetSubscriptionByTenantID(c.Request().Context(), tenantID) @@ -175,9 +179,10 @@ func (h *Handler) UpdateTenantSubscription(c echo.Context) error { return writeErr(c, err) } adminID := uuid.MustParse(claims.UserID) - if err := h.Store.LogAdminAction(c.Request().Context(), adminID, action, "subscription", sub.ID, map[string]interface{}{ - "old": oldSub, - "new": sub, + 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) } @@ -341,6 +346,11 @@ func (h *Handler) GetAuditLog(c echo.Context) error { 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 + } + } logs, total, err := h.Store.GetAuditLog(c.Request().Context(), filters, limit, offset) if err != nil { @@ -450,8 +460,12 @@ func (h *Handler) ImpersonateTenant(c echo.Context) error { var body struct { Reason string `json:"reason"` } - //nolint:errcheck - _ = c.Bind(&body) + if err := c.Bind(&body); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) + } + if strings.TrimSpace(body.Reason) == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "reason is required"}) + } status, err := h.Store.GetTenantStatus(c.Request().Context(), tenantID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load tenant"}) diff --git a/backend/internal/handler/super_admin_impersonation_test.go b/backend/internal/handler/super_admin_impersonation_test.go index 893ad1c9..b4991174 100644 --- a/backend/internal/handler/super_admin_impersonation_test.go +++ b/backend/internal/handler/super_admin_impersonation_test.go @@ -25,7 +25,7 @@ func TestImpersonateActiveTenant(t *testing.T) { }, } h := &Handler{Store: fs} - c, rec := newAuthedContext(e, http.MethodPost, "/x", "", uuid.New().String(), "admin") + c, rec := newAuthedContext(e, http.MethodPost, "/x", `{"reason":"test impersonation"}`, uuid.New().String(), "admin") c.SetParamNames("id") c.SetParamValues(target.String()) @@ -62,7 +62,7 @@ func TestImpersonateNonActiveTenantIs409(t *testing.T) { e := echo.New() fs := &fakeStore{getTenantStatus: func(id uuid.UUID) (string, error) { return "suspended", nil }} h := &Handler{Store: fs} - c, rec := newAuthedContext(e, http.MethodPost, "/x", "", uuid.New().String(), "admin") + c, rec := newAuthedContext(e, http.MethodPost, "/x", `{"reason":"test impersonation"}`, uuid.New().String(), "admin") c.SetParamNames("id") c.SetParamValues(uuid.New().String()) if err := h.ImpersonateTenant(c); err != nil { diff --git a/backend/internal/handler/super_admin_subscription_test.go b/backend/internal/handler/super_admin_subscription_test.go index 20e135dc..0b306dca 100644 --- a/backend/internal/handler/super_admin_subscription_test.go +++ b/backend/internal/handler/super_admin_subscription_test.go @@ -28,7 +28,7 @@ func TestUpdateTenantSubscriptionCreatesWhenMissing(t *testing.T) { } h := &Handler{Store: fs} - body := `{"plan_id":"` + planID.String() + `","status":"active"}` + body := `{"plan_id":"` + planID.String() + `","status":"active","reason":"initial plan assignment"}` c, rec := newAuthedContext(e, http.MethodPatch, "/api/super-admin/tenants/"+tenantID.String()+"/subscription", body, uuid.New().String(), "admin") c.SetParamNames("id") c.SetParamValues(tenantID.String()) @@ -54,7 +54,7 @@ func TestUpdateTenantSubscriptionRequiresPlanWhenMissing(t *testing.T) { } h := &Handler{Store: fs} - c, rec := newAuthedContext(e, http.MethodPatch, "/x", `{"status":"active"}`, uuid.New().String(), "admin") + c, rec := newAuthedContext(e, http.MethodPatch, "/x", `{"status":"active","reason":"testing"}`, uuid.New().String(), "admin") c.SetParamNames("id") c.SetParamValues(uuid.New().String()) diff --git a/backend/internal/handler/super_admin_test.go b/backend/internal/handler/super_admin_test.go index 7b8ceae7..cb6d9615 100644 --- a/backend/internal/handler/super_admin_test.go +++ b/backend/internal/handler/super_admin_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "idento/backend/internal/models" @@ -161,3 +162,197 @@ func TestImpersonateTenant_ReasonPersistedToAuditChanges(t *testing.T) { t.Fatalf("expected reason in audit changes, got %#v", capturedChanges) } } + +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) + } +} + +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) + } +} + +func TestImpersonateTenant_ReasonRequired(t *testing.T) { + t.Setenv("JWT_SECRET", "test-secret") + e := echo.New() + tenantID := uuid.New() + + fs := &fakeStore{ + getTenantStatus: func(id uuid.UUID) (string, error) { return "active", nil }, + logAdminAction: func(audID uuid.UUID, action, targetType string, targetID uuid.UUID, changes interface{}, ip, userAgent string) error { + return 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()) + } +} + +func TestImpersonateTenant_MalformedBodyReturnsInvalidRequest(t *testing.T) { + t.Setenv("JWT_SECRET", "test-secret") + e := echo.New() + tenantID := uuid.New() + + fs := &fakeStore{} + h := &Handler{Store: fs} + + req := httptest.NewRequest(http.MethodPost, "/api/super-admin/tenants/"+tenantID.String()+"/impersonate", bytes.NewReader([]byte(`{not valid json`))) + 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 for malformed JSON body, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Invalid request") { + t.Fatalf("expected 'Invalid request' error for malformed body, got: %s", rec.Body.String()) + } +} diff --git a/backend/internal/store/pg_store.go b/backend/internal/store/pg_store.go index 166ddb48..e9ca6091 100644 --- a/backend/internal/store/pg_store.go +++ b/backend/internal/store/pg_store.go @@ -1611,19 +1611,30 @@ func (s *PGStore) LogAdminAction(ctx context.Context, adminID uuid.UUID, action } func (s *PGStore) GetAuditLog(ctx context.Context, filters map[string]interface{}, limit int, offset int) ([]*models.AdminAuditLog, int, error) { - where := "" - args := []interface{}{} + var conditions []string + var args []interface{} + if action, ok := filters["action"].(string); ok && action != "" { - where = "WHERE action = $1" 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 + return nil, 0, fmt.Errorf("query audit log: %w", err) } defer rows.Close() @@ -1637,7 +1648,7 @@ func (s *PGStore) GetAuditLog(ctx context.Context, filters map[string]interface{ &changesJSON, &auditLog.IPAddress, &auditLog.UserAgent, &auditLog.CreatedAt, ) if err != nil { - return nil, 0, err + return nil, 0, fmt.Errorf("scan audit log: %w", err) } if len(changesJSON) > 0 { @@ -1648,8 +1659,10 @@ func (s *PGStore) GetAuditLog(ctx context.Context, filters map[string]interface{ logs = append(logs, &auditLog) } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("iterate audit log: %w", err) + } - // Get total count countQuery := "SELECT COUNT(*) FROM admin_audit_log " + where var total int if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { 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)} /> + +
+ +
+ +