Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b669641
Add design spec for Console Redesign Batch 2 (Tenant Detail workbench)
Jul 11, 2026
eaeef9a
Add implementation plan for Console Redesign Batch 2 (Tenant Detail w…
Jul 11, 2026
63bbede
feat(backend): add target_id filter to GetAuditLog, combinable with a…
Jul 11, 2026
e42b7f6
feat(backend): require reason on subscription updates, log audit as t…
Jul 11, 2026
152e8d8
fix(backend): restore plan-required coverage in TestUpdateTenantSubsc…
Jul 11, 2026
2c6dbbe
feat(backend): require reason on impersonation entry
Jul 11, 2026
e384851
feat(web): add useScrollSpy hook for the tenant detail anchor rail
Jul 11, 2026
065a036
feat(web): add audit diff/day-grouping utilities and AuditEntryList c…
Jul 11, 2026
9c69171
fix(web): render AuditEntryList day headings in the viewer's local ca…
Jul 11, 2026
a5818bc
test(web): use vi.stubEnv for TZ in AuditEntryList regression test
Jul 11, 2026
01f2adf
feat(web): add TenantIdentityHeader and useTypedConfirmGate
Jul 11, 2026
7e680c2
feat(web): add SuspendTenantDialog with checkbox-gated typed confirm
Jul 11, 2026
3a954d4
feat(web): add ArchiveSheet with dual-checkbox-gated typed confirm
Jul 11, 2026
a3b3fc4
feat(web): mandatory-reason impersonation entry dialog + exit summary
Jul 11, 2026
a3e8d08
feat(web): rebuild Tenant Detail page skeleton — anchor rail, Summary…
Jul 11, 2026
e053ae9
feat(web): wire Lifecycle timeline (Suspend/Archive/Reactivate) and U…
Jul 11, 2026
88841de
feat(web): wire Activity section and impersonation ceremony — Tenant …
Jul 11, 2026
8a6bdfc
fix(web): retry useScrollSpy observer setup until sections mount
Jul 11, 2026
013a3f5
fix(web): retry hash-anchor scroll after Tenant Detail data finishes …
Jul 11, 2026
36d22b1
fix: address PR #35 review findings from CodeRabbit and Codex
Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions backend/internal/handler/super_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,17 @@ 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 {
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"})
}

// Get existing subscription; create one if the tenant has none (upsert).
sub, err := h.Store.GetSubscriptionByTenantID(c.Request().Context(), tenantID)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"})
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/handler/super_admin_impersonation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/handler/super_admin_subscription_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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())

Expand Down
195 changes: 195 additions & 0 deletions backend/internal/handler/super_admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"idento/backend/internal/models"
Expand Down Expand Up @@ -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())
}
}
25 changes: 19 additions & 6 deletions backend/internal/store/pg_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading