diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9c2e3447..5bfdf708 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -2918,17 +2918,26 @@ func main() { // The calculation itself lives in internal/domain/scoring and nowhere else: // no formula, no threshold and no band mapping exists in the frontend. // ========================================================================= - scoreHandler := newScoreHandler( + scoreSnapshotRepo := repository.NewGormScoreSnapshotRepository(database.DB) + scoreHandler, scoreHistoryUC := newScoreHandler( riskRepo, assetRepo, vulnRepo, repository.NewGormMitigationRepository(database.DB), getGapAnalysisUC, incidentService, + scoreSnapshotRepo, ) + // One snapshot per tenant per day, so the dashboard can draw the year and + // the 30-day movement even for a tenant nobody opened that day (#901). + go workers.NewScoreSnapshotWorker(scoreSnapshotRepo, scoreHistoryUC, zeroLogger). + Start(context.Background()) // Readable by any authenticated member: a posture score is what the product // is FOR, and the underlying detail is already gated per source. // Tenant-scoped score figures; every business role preset holds risks:read, // and preview persists nothing. Session-sufficient (#529). protected.Get("/score", scoreHandler.GetScore) protected.Get("/score/model", scoreHandler.GetScoreModel) + // Month-by-month tenant score and its 30-day delta (#901). Same audience as + // /score: the history of a figure every member can already read. + protected.Get("/score/history", scoreHandler.GetScoreHistory) // Live preview for forms (debounced client-side). Persists nothing, so it // carries no write permission. protected.Post("/score/preview", scoreHandler.PreviewScore) diff --git a/backend/cmd/server/schema.go b/backend/cmd/server/schema.go index fdbf84cd..9da46e5d 100644 --- a/backend/cmd/server/schema.go +++ b/backend/cmd/server/schema.go @@ -27,6 +27,8 @@ func schemaModels() []interface{} { // invitations model was never in AutoMigrate, so the table never existed // and the invitation code that referenced it could not have run. &domain.Invitation{}, + // Daily tenant exposure score history (#901, migration 0069). + &domain.TenantScoreSnapshot{}, &coreauth.RefreshToken{}, // L2/L4/L5/L7 auth tables. Previously absent from AutoMigrate, so the whole // feature set (MFA setup/challenge, PAT auth, SSO account linking, and the diff --git a/backend/cmd/server/score_wiring.go b/backend/cmd/server/score_wiring.go index 34717d0e..3473c516 100644 --- a/backend/cmd/server/score_wiring.go +++ b/backend/cmd/server/score_wiring.go @@ -54,7 +54,8 @@ func (a incidentPressureAdapter) OpenIncidentCounts(_ context.Context, tenantID // newScoreHandler assembles the ONE score endpoint from the already-constructed, // tenant-scoped sources. Every source is optional in the use case, so a missing -// one degrades its own factor instead of failing the request. +// one degrades its own factor instead of failing the request. It also returns +// the history use case, which the daily snapshot worker shares (#901). func newScoreHandler( riskRepo *repository.GormRiskRepository, assetRepo *repository.GormAssetRepository, @@ -62,7 +63,8 @@ func newScoreHandler( mitigationRepo repository.MitigationRepository, gapUC *compliance.GetGapAnalysisUseCase, incidentSvc *service.IncidentService, -) *handlers.ScoreHandler { + snapshots *repository.GormScoreSnapshotRepository, +) (*handlers.ScoreHandler, *score.HistoryUseCase) { uc := score.New(). WithRiskCounts(riskRepo). WithRisk(riskRepo). @@ -73,5 +75,6 @@ func newScoreHandler( WithMitigations(mitigationRepo). WithCompliance(complianceCoverageAdapter{gaps: gapUC}). WithIncidents(incidentPressureAdapter{svc: incidentSvc}) - return handlers.NewScoreHandler(uc) + history := score.NewHistory(uc, snapshots) + return handlers.NewScoreHandler(uc).WithHistory(history), history } diff --git a/backend/internal/application/score/history.go b/backend/internal/application/score/history.go new file mode 100644 index 00000000..3fdcf6cf --- /dev/null +++ b/backend/internal/application/score/history.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package score + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/opendefender/openrisk/internal/domain" + "github.com/opendefender/openrisk/internal/domain/scoring" +) + +// SnapshotStore persists the daily tenant score (#901). +type SnapshotStore interface { + Upsert(ctx context.Context, s *domain.TenantScoreSnapshot) error + ListSince(ctx context.Context, tenantID uuid.UUID, since time.Time) ([]domain.TenantScoreSnapshot, error) +} + +// HistoryPoint is one month of the tenant score: the last snapshot taken in +// that month. A month with no snapshot is absent, never zero. +type HistoryPoint struct { + Month string `json:"month"` // YYYY-MM + Day time.Time `json:"day"` + Value float64 `json:"value"` + Band string `json:"band"` +} + +// History is what GET /score/history returns. +type History struct { + Months int `json:"months"` + Points []HistoryPoint `json:"points"` + // Current is today's measured value, nil when the tenant has nothing to + // score yet. + Current *float64 `json:"current"` + // Delta30d is today's value minus the value 30 days ago (the last snapshot + // on or before that day). Nil when no snapshot is that old: a delta needs + // two real measurements. + Delta30d *float64 `json:"delta_30d"` + // Since is the oldest snapshot kept for the tenant within the window, so + // the UI can say how far back the line really goes. + Since *time.Time `json:"since"` +} + +// HistoryUseCase records and reads the daily tenant score history. +type HistoryUseCase struct { + score *UseCase + store SnapshotStore + now func() time.Time +} + +func NewHistory(score *UseCase, store SnapshotStore) *HistoryUseCase { + return &HistoryUseCase{score: score, store: store, now: func() time.Time { return time.Now().UTC() }} +} + +// Record computes the tenant score and stores it as today's snapshot. An +// unmeasured score stores nothing. +func (h *HistoryUseCase) Record(ctx context.Context, tenantID uuid.UUID) (*scoring.Result, error) { + if tenantID == uuid.Nil { + return nil, domain.NewValidationError("tenant is required") + } + res, err := h.score.Execute(ctx, tenantID, scoring.ScopeTenant, uuid.Nil) + if err != nil { + return nil, err + } + if res == nil || !res.Measured { + return res, nil + } + snap := &domain.TenantScoreSnapshot{ + TenantID: tenantID, + Day: domain.SnapshotDay(h.now()), + Value: res.Value, + Band: string(res.Band), + FormulaVersion: res.FormulaVersion, + } + if err := h.store.Upsert(ctx, snap); err != nil { + return res, err + } + return res, nil +} + +// Execute returns the monthly history over the last `months` months (1–24), +// recording today's snapshot first so the line always ends on the live value. +func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months int) (*History, error) { + if tenantID == uuid.Nil { + return nil, domain.NewValidationError("tenant is required") + } + if months < 1 || months > 24 { + return nil, domain.NewValidationError("months must be between 1 and 24") + } + if _, err := h.Record(ctx, tenantID); err != nil { + return nil, err + } + + today := domain.SnapshotDay(h.now()) + start := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -(months - 1), 0) + // Reach 30 days further back than the window when needed, so the delta can + // be computed even for a one-month window. + from := start + if d := today.AddDate(0, 0, -30); d.Before(from) { + from = d + } + rows, err := h.store.ListSince(ctx, tenantID, from) + if err != nil { + return nil, err + } + + out := &History{Months: months, Points: []HistoryPoint{}} + byMonth := map[string]HistoryPoint{} + var order []string + for _, r := range rows { + day := domain.SnapshotDay(r.Day) + if day.Before(start) { + continue + } + key := day.Format("2006-01") + if _, seen := byMonth[key]; !seen { + order = append(order, key) + } + // Rows come oldest first, so the last write wins: the month's closing value. + byMonth[key] = HistoryPoint{Month: key, Day: day, Value: r.Value, Band: r.Band} + if out.Since == nil { + d := day + out.Since = &d + } + } + for _, k := range order { + out.Points = append(out.Points, byMonth[k]) + } + + var current *domain.TenantScoreSnapshot + var past *domain.TenantScoreSnapshot + cutoff := today.AddDate(0, 0, -30) + for i := range rows { + day := domain.SnapshotDay(rows[i].Day) + if day.Equal(today) { + current = &rows[i] + } + if !day.After(cutoff) { + past = &rows[i] + } + } + if current != nil { + v := current.Value + out.Current = &v + if past != nil { + d := roundTenth(current.Value - past.Value) + out.Delta30d = &d + } + } + return out, nil +} + +func roundTenth(v float64) float64 { + if v < 0 { + return -float64(int64(-v*10+0.5)) / 10 + } + return float64(int64(v*10+0.5)) / 10 +} diff --git a/backend/internal/application/score/history_test.go b/backend/internal/application/score/history_test.go new file mode 100644 index 00000000..3f241921 --- /dev/null +++ b/backend/internal/application/score/history_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package score + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opendefender/openrisk/internal/domain" +) + +// memSnapshots is an in-memory SnapshotStore keyed like the real table. +type memSnapshots struct { + rows map[string]domain.TenantScoreSnapshot + upsertErr error +} + +func newMemSnapshots() *memSnapshots { + return &memSnapshots{rows: map[string]domain.TenantScoreSnapshot{}} +} + +func (m *memSnapshots) key(t uuid.UUID, d time.Time) string { + return t.String() + "|" + domain.SnapshotDay(d).Format("2006-01-02") +} + +func (m *memSnapshots) Upsert(_ context.Context, s *domain.TenantScoreSnapshot) error { + if m.upsertErr != nil { + return m.upsertErr + } + m.rows[m.key(s.TenantID, s.Day)] = *s + return nil +} + +func (m *memSnapshots) ListSince(_ context.Context, tenantID uuid.UUID, since time.Time) ([]domain.TenantScoreSnapshot, error) { + var out []domain.TenantScoreSnapshot + for _, r := range m.rows { + if r.TenantID == tenantID && !domain.SnapshotDay(r.Day).Before(domain.SnapshotDay(since)) { + out = append(out, r) + } + } + // oldest first, as the repository orders + for i := 0; i < len(out); i++ { + for j := i + 1; j < len(out); j++ { + if out[j].Day.Before(out[i].Day) { + out[i], out[j] = out[j], out[i] + } + } + } + return out, nil +} + +func (m *memSnapshots) put(t uuid.UUID, day string, v float64) { + d, _ := time.Parse("2006-01-02", day) + m.rows[m.key(t, d)] = domain.TenantScoreSnapshot{TenantID: t, Day: d, Value: v, Band: "medium"} +} + +// measuredUseCase scores a tenant with two critical and three high risks, which +// is enough data for a measured result. +func measuredUseCase() *UseCase { + rc := &stubRiskCounts{counts: map[string]int{"critical": 2, "high": 3, "medium": 1}} + return New().WithRiskCounts(rc) +} + +func fixedNow(day string) func() time.Time { + d, _ := time.Parse("2006-01-02", day) + return func() time.Time { return d.Add(15 * time.Hour) } +} + +func TestScoreHistory_Success(t *testing.T) { + store := newMemSnapshots() + tenant := uuid.New() + // Two readings in August (the later one closes the month), one in + // September exactly 30 days before "today". + store.put(tenant, "2026-08-03", 70) + store.put(tenant, "2026-08-28", 66) + store.put(tenant, "2026-09-08", 61) + + uc := measuredUseCase() + uc.now = fixedNow("2026-10-08") + h := NewHistory(uc, store) + h.now = fixedNow("2026-10-08") + + out, err := h.Execute(context.Background(), tenant, 12) + require.NoError(t, err) + require.NotNil(t, out.Current, "today is recorded before the history is read") + + months := make([]string, 0, len(out.Points)) + for _, p := range out.Points { + months = append(months, p.Month) + } + assert.Equal(t, []string{"2026-08", "2026-09", "2026-10"}, months) + assert.Equal(t, 66.0, out.Points[0].Value, "a month reads as its last snapshot") + require.NotNil(t, out.Delta30d) + assert.InDelta(t, *out.Current-61, *out.Delta30d, 0.051) + require.NotNil(t, out.Since) + assert.Equal(t, "2026-08-03", out.Since.Format("2006-01-02")) +} + +// A tenant with no history yet still gets today's point, but no delta: a +// movement needs two real measurements. +func TestScoreHistory_NotFound(t *testing.T) { + store := newMemSnapshots() + uc := measuredUseCase() + h := NewHistory(uc, store) + + out, err := h.Execute(context.Background(), uuid.New(), 12) + require.NoError(t, err) + assert.Len(t, out.Points, 1) + assert.NotNil(t, out.Current) + assert.Nil(t, out.Delta30d) +} + +// An unmeasured tenant stores nothing and reports no current value. +func TestScoreHistory_UnmeasuredStoresNothing(t *testing.T) { + store := newMemSnapshots() + uc, _, _, _, _ := emptyTenantUseCase() + h := NewHistory(uc, store) + + out, err := h.Execute(context.Background(), uuid.New(), 12) + require.NoError(t, err) + assert.Empty(t, out.Points) + assert.Nil(t, out.Current) + assert.Empty(t, store.rows) +} + +// No tenant, no history; and one tenant never reads another's snapshots. +func TestScoreHistory_Unauthorized(t *testing.T) { + store := newMemSnapshots() + h := NewHistory(measuredUseCase(), store) + + _, err := h.Execute(context.Background(), uuid.Nil, 12) + assert.True(t, errors.Is(err, domain.ErrValidation)) + + other, mine := uuid.New(), uuid.New() + store.put(other, "2026-09-01", 90) + out, err := h.Execute(context.Background(), mine, 12) + require.NoError(t, err) + for _, p := range out.Points { + assert.NotEqual(t, 90.0, p.Value, "another tenant's snapshot leaked into the history") + } +} + +func TestScoreHistory_MonthsValidation(t *testing.T) { + h := NewHistory(measuredUseCase(), newMemSnapshots()) + for _, m := range []int{0, 25, -1} { + _, err := h.Execute(context.Background(), uuid.New(), m) + assert.True(t, errors.Is(err, domain.ErrValidation), "months=%d", m) + } +} + +func TestScoreHistory_StoreFailureSurfaces(t *testing.T) { + store := newMemSnapshots() + store.upsertErr = errors.New("db down") + h := NewHistory(measuredUseCase(), store) + _, err := h.Execute(context.Background(), uuid.New(), 12) + assert.Error(t, err) +} diff --git a/backend/internal/domain/score_snapshot.go b/backend/internal/domain/score_snapshot.go new file mode 100644 index 00000000..472938f1 --- /dev/null +++ b/backend/internal/domain/score_snapshot.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// TenantScoreSnapshot is the tenant exposure score as it stood on one day +// (#901). The score itself is computed live from the register; nothing kept +// its past values, so "how did we move over the year" had no answer. One row +// per tenant per UTC day: a later computation on the same day replaces the +// value, so the row reads as the day's closing figure. +// +// Only measured scores are stored. A day with nothing to score leaves a gap, +// which the history reports as missing rather than as zero. +type TenantScoreSnapshot struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + TenantID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:ux_tenant_score_snapshot_day,priority:1" json:"tenant_id"` + // Day is the UTC calendar day, stored at midnight. + Day time.Time `gorm:"type:date;not null;uniqueIndex:ux_tenant_score_snapshot_day,priority:2" json:"day"` + Value float64 `gorm:"type:double precision;not null" json:"value"` + Band string `gorm:"size:16;not null" json:"band"` + FormulaVersion string `gorm:"size:16;not null" json:"formula_version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName pins the table so the SQL migration and AutoMigrate agree. +func (TenantScoreSnapshot) TableName() string { return "tenant_score_snapshots" } + +// SnapshotDay truncates a time to the UTC calendar day a snapshot is keyed on. +func SnapshotDay(t time.Time) time.Time { + u := t.UTC() + return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC) +} diff --git a/backend/internal/handler/authz_route_coverage_test.go b/backend/internal/handler/authz_route_coverage_test.go index 45bc54ee..afa2651d 100644 --- a/backend/internal/handler/authz_route_coverage_test.go +++ b/backend/internal/handler/authz_route_coverage_test.go @@ -145,6 +145,9 @@ var routesWithoutPermissionGuard = []string{ "GET /rbac/business-roles", "GET /score", "GET /score/model", + // #901: the month-by-month history of the same tenant score GET /score + // returns to any member. Session-sufficient for the same reason. + "GET /score/history", "GET /search", "GET /security/mfa-policy", "GET /stats", diff --git a/backend/internal/handler/dashboard_handler.go b/backend/internal/handler/dashboard_handler.go index 700cb217..98778fb5 100644 --- a/backend/internal/handler/dashboard_handler.go +++ b/backend/internal/handler/dashboard_handler.go @@ -112,6 +112,22 @@ type DashboardStats struct { // links to — which is precisely the reconciliation this wave has to hold. OpenedInPeriod int64 `json:"opened_in_period"` + // Live counters for the redesigned dashboard (#901). "Live" leaves out + // closed risks, which the stock counters above include so they reconcile + // with the full register. + // + // LiveRisks every risk not closed + // CriticalLive critical band, still open or in treatment + // (not closed, mitigated or accepted) + // WithoutMitigation live risks with no mitigation plan other than + // cancelled ones + // ReviewsDue30d live risks whose next review falls within 30 days, + // overdue reviews included + LiveRisks int64 `json:"live_risks"` + CriticalLive int64 `json:"critical_live"` + WithoutMitigation int64 `json:"without_mitigation"` + ReviewsDue30d int64 `json:"reviews_due_30d"` + RiskTrend RiskTrend `json:"risk_trend"` GeneratedAt string `json:"generated_at"` @@ -225,6 +241,39 @@ func ComputeDashboardStats(ctx context.Context, db *gorm.DB, tenantID uuid.UUID, GeneratedAt: time.Now().UTC().Format(time.RFC3339), } + // --- live counters (#901) ---------------------------------------------- + // The mitigation sub-query is keyed on the risk's own tenant as well as its + // id, so a plan can never be credited across tenants. + var live struct { + Live int64 + CriticalLive int64 + WithoutMitigation int64 + ReviewsDue int64 + } + err = tx.Raw(` + SELECT + COUNT(*) FILTER (WHERE COALESCE(LOWER(status), 'open') <> 'closed') AS live, + COUNT(*) FILTER (WHERE score >= 7.0 + AND COALESCE(LOWER(status), 'open') NOT IN ('closed', 'mitigated', 'accepted')) AS critical_live, + COUNT(*) FILTER (WHERE COALESCE(LOWER(status), 'open') <> 'closed' + AND NOT EXISTS ( + SELECT 1 FROM mitigations m + WHERE m.risk_id = risks.id AND m.tenant_id = risks.tenant_id + AND m.deleted_at IS NULL AND UPPER(COALESCE(m.status, '')) <> 'CANCELLED' + )) AS without_mitigation, + COUNT(*) FILTER (WHERE COALESCE(LOWER(status), 'open') <> 'closed' + AND next_review_at IS NOT NULL AND next_review_at <= ?) AS reviews_due + FROM risks + WHERE tenant_id = ? AND deleted_at IS NULL + `, time.Now().UTC().AddDate(0, 0, 30), tenantID).Scan(&live).Error + if err != nil { + return nil, fmt.Errorf("failed to aggregate live risk counters: %w", err) + } + stats.LiveRisks = live.Live + stats.CriticalLive = live.CriticalLive + stats.WithoutMitigation = live.WithoutMitigation + stats.ReviewsDue30d = live.ReviewsDue + // --- 5x5 heatmap -------------------------------------------------------- // probability [0,1] -> band 1-5, impact [0,10] -> band 1-5. A risk at exactly // 0 still lands in band 1 rather than vanishing from the matrix. diff --git a/backend/internal/handler/dashboard_stats_test.go b/backend/internal/handler/dashboard_stats_test.go index 96404f50..b3e9799f 100644 --- a/backend/internal/handler/dashboard_stats_test.go +++ b/backend/internal/handler/dashboard_stats_test.go @@ -42,9 +42,18 @@ func newStatsDB(t *testing.T) *gorm.DB { other_direct_cost_xaf REAL, downtime_hours REAL, hourly_downtime_cost_xaf REAL, + next_review_at DATETIME, created_at DATETIME, deleted_at DATETIME );`).Error) + require.NoError(t, db.Exec(` + CREATE TABLE mitigations ( + id TEXT PRIMARY KEY, + tenant_id TEXT, + risk_id TEXT, + status TEXT, + deleted_at DATETIME + );`).Error) return db } @@ -303,3 +312,43 @@ func TestDashboardStats_RefusesWithoutTenant(t *testing.T) { _, err := ComputeDashboardStats(context.Background(), db, uuid.Nil, windowFor(t, "all")) require.Error(t, err) } + +// The live counters behind the redesigned dashboard (#901): closed risks drop +// out, a cancelled plan does not count as a plan, a review is due within 30 +// days or already overdue, and another tenant's mitigation never covers this +// tenant's risk. +func TestDashboardStats_LiveCounters(t *testing.T) { + db := newStatsDB(t) + tenant, other := uuid.New(), uuid.New() + add := func(tn uuid.UUID, score float64, status string, review *time.Time) string { + id := uuid.NewString() + require.NoError(t, db.Exec( + `INSERT INTO risks (id, tenant_id, score, status, next_review_at, created_at) VALUES (?,?,?,?,?,?)`, + id, tn.String(), score, status, review, statsNow).Error) + return id + } + plan := func(tn uuid.UUID, riskID, status string) { + require.NoError(t, db.Exec(`INSERT INTO mitigations (id, tenant_id, risk_id, status) VALUES (?,?,?,?)`, + uuid.NewString(), tn.String(), riskID, status).Error) + } + soon := time.Now().UTC().AddDate(0, 0, 10) + overdue := time.Now().UTC().AddDate(0, 0, -3) + far := time.Now().UTC().AddDate(0, 0, 90) + + a := add(tenant, 9, "open", &soon) // critical, live, planned + b := add(tenant, 8, "in_progress", &far) // critical, live, cancelled plan only + _ = add(tenant, 7.5, "accepted", &overdue) + _ = add(tenant, 9, "closed", &soon) // closed: out of every live counter + c := add(tenant, 3, "open", nil) + plan(tenant, a, "IN_PROGRESS") + plan(tenant, b, "CANCELLED") + // Another tenant's plan on this tenant's risk id must not count. + plan(other, c, "PLANNED") + + stats, err := ComputeDashboardStats(context.Background(), db, tenant, windowFor(t, "all")) + require.NoError(t, err) + assert.Equal(t, int64(4), stats.LiveRisks) + assert.Equal(t, int64(2), stats.CriticalLive, "open and in-treatment criticals; accepted and closed out") + assert.Equal(t, int64(3), stats.WithoutMitigation, "b (cancelled only), the accepted one, c (foreign plan)") + assert.Equal(t, int64(2), stats.ReviewsDue30d, "due in 10 days and overdue") +} diff --git a/backend/internal/handler/score_handler.go b/backend/internal/handler/score_handler.go index fc337a9b..51978dff 100644 --- a/backend/internal/handler/score_handler.go +++ b/backend/internal/handler/score_handler.go @@ -22,7 +22,8 @@ import ( // ScoreHandler serves the canonical score. type ScoreHandler struct { - uc *score.UseCase + uc *score.UseCase + history *score.HistoryUseCase } // NewScoreHandler wires the handler. @@ -30,6 +31,31 @@ func NewScoreHandler(uc *score.UseCase) *ScoreHandler { return &ScoreHandler{uc: uc} } +// WithHistory enables GET /score/history (#901). +func (h *ScoreHandler) WithHistory(hist *score.HistoryUseCase) *ScoreHandler { + h.history = hist + return h +} + +// GetScoreHistory returns the tenant exposure score month by month over the +// last ?months= months (default 12), with the 30-day delta. Tenant scope only: +// the history is of the organization's own score. +func (h *ScoreHandler) GetScoreHistory(c *fiber.Ctx) error { + tenantID := tenantID(c) + if tenantID == uuid.Nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + if h.history == nil { + return writeAppError(c, domain.ErrNotFound) + } + months := c.QueryInt("months", 12) + out, err := h.history.Execute(c.UserContext(), tenantID, months) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(out) +} + // GetScore GET /score?scope=tenant|risk|asset&id=… // // One endpoint for every surface: the dashboard hero, the sidebar footer, the diff --git a/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go new file mode 100644 index 00000000..7f32c628 --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/opendefender/openrisk/internal/domain" +) + +// GormScoreSnapshotRepository stores the daily tenant score history (#901). +// Every query is keyed on tenant_id. +type GormScoreSnapshotRepository struct { + db *gorm.DB +} + +func NewGormScoreSnapshotRepository(db *gorm.DB) *GormScoreSnapshotRepository { + return &GormScoreSnapshotRepository{db: db} +} + +// Upsert writes the day's snapshot, replacing an earlier one for the same +// tenant and day. +func (r *GormScoreSnapshotRepository) Upsert(ctx context.Context, s *domain.TenantScoreSnapshot) error { + if s.TenantID == uuid.Nil { + return domain.NewValidationError("tenant is required") + } + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + s.Day = domain.SnapshotDay(s.Day) + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "tenant_id"}, {Name: "day"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "band", "formula_version", "updated_at"}), + }).Create(s).Error +} + +// ListSince returns the tenant's snapshots from `since` (inclusive), oldest +// first. +func (r *GormScoreSnapshotRepository) ListSince(ctx context.Context, tenantID uuid.UUID, since time.Time) ([]domain.TenantScoreSnapshot, error) { + var rows []domain.TenantScoreSnapshot + err := r.db.WithContext(ctx). + Where("tenant_id = ? AND day >= ?", tenantID, domain.SnapshotDay(since)). + Order("day ASC"). + Find(&rows).Error + return rows, err +} + +// ListTenantIDs returns every organization id, for the daily snapshot sweep. +// A deleted organization's row is gone (internal/infrastructure/orgdeletion), +// so it drops out on its own. +func (r *GormScoreSnapshotRepository) ListTenantIDs(ctx context.Context) ([]uuid.UUID, error) { + var ids []uuid.UUID + err := r.db.WithContext(ctx).Model(&domain.Organization{}).Pluck("id", &ids).Error + return ids, err +} diff --git a/backend/internal/infrastructure/repository/gorm_score_snapshot_repository_test.go b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository_test.go new file mode 100644 index 00000000..f500b14c --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/opendefender/openrisk/internal/domain" + "github.com/opendefender/openrisk/internal/testsupport/sqliteschema" +) + +func setupSnapshotRepo(t *testing.T) *GormScoreSnapshotRepository { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.Exec(`CREATE TABLE tenant_score_snapshots (id TEXT PRIMARY KEY)`).Error) + require.NoError(t, sqliteschema.Reconcile(db, "tenant_score_snapshots", &domain.TenantScoreSnapshot{})) + require.NoError(t, db.Exec(`CREATE UNIQUE INDEX ux_tenant_score_snapshot_day ON tenant_score_snapshots (tenant_id, day)`).Error) + return NewGormScoreSnapshotRepository(db) +} + +func day(s string) time.Time { + d, _ := time.Parse("2006-01-02", s) + return d +} + +// The same day written twice keeps one row with the later value. +func TestScoreSnapshot_Upsert_Success(t *testing.T) { + repo := setupSnapshotRepo(t) + ctx := context.Background() + tenant := uuid.New() + + require.NoError(t, repo.Upsert(ctx, &domain.TenantScoreSnapshot{TenantID: tenant, Day: day("2026-10-08").Add(9 * time.Hour), Value: 40, Band: "medium", FormulaVersion: "2.2"})) + require.NoError(t, repo.Upsert(ctx, &domain.TenantScoreSnapshot{TenantID: tenant, Day: day("2026-10-08").Add(18 * time.Hour), Value: 37, Band: "medium", FormulaVersion: "2.2"})) + require.NoError(t, repo.Upsert(ctx, &domain.TenantScoreSnapshot{TenantID: tenant, Day: day("2026-10-09"), Value: 35, Band: "medium", FormulaVersion: "2.2"})) + + rows, err := repo.ListSince(ctx, tenant, day("2026-10-01")) + require.NoError(t, err) + require.Len(t, rows, 2) + assert.Equal(t, 37.0, rows[0].Value) + assert.Equal(t, 35.0, rows[1].Value) +} + +func TestScoreSnapshot_ListSince_NotFound(t *testing.T) { + repo := setupSnapshotRepo(t) + rows, err := repo.ListSince(context.Background(), uuid.New(), day("2026-01-01")) + require.NoError(t, err) + assert.Empty(t, rows) +} + +// One tenant's history never contains another tenant's rows, and a write +// without a tenant is refused. +func TestScoreSnapshot_Unauthorized(t *testing.T) { + repo := setupSnapshotRepo(t) + ctx := context.Background() + mine, theirs := uuid.New(), uuid.New() + require.NoError(t, repo.Upsert(ctx, &domain.TenantScoreSnapshot{TenantID: theirs, Day: day("2026-10-08"), Value: 90, Band: "critical", FormulaVersion: "2.2"})) + require.NoError(t, repo.Upsert(ctx, &domain.TenantScoreSnapshot{TenantID: mine, Day: day("2026-10-08"), Value: 20, Band: "low", FormulaVersion: "2.2"})) + + rows, err := repo.ListSince(ctx, mine, day("2026-01-01")) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, mine, rows[0].TenantID) + + err = repo.Upsert(ctx, &domain.TenantScoreSnapshot{Day: day("2026-10-08"), Value: 1, Band: "low"}) + assert.Error(t, err) +} diff --git a/backend/internal/infrastructure/workers/score_snapshot_worker.go b/backend/internal/infrastructure/workers/score_snapshot_worker.go new file mode 100644 index 00000000..497bb64e --- /dev/null +++ b/backend/internal/infrastructure/workers/score_snapshot_worker.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package workers + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" + + "github.com/opendefender/openrisk/internal/domain/scoring" +) + +// TenantIDLister lists every tenant the sweep should snapshot. +type TenantIDLister interface { + ListTenantIDs(ctx context.Context) ([]uuid.UUID, error) +} + +// ScoreRecorder computes and stores one tenant's score for today. +type ScoreRecorder interface { + Record(ctx context.Context, tenantID uuid.UUID) (*scoring.Result, error) +} + +// ScoreSnapshotWorker takes the daily snapshot of every tenant's exposure +// score (#901). The history endpoint records too when someone opens the +// dashboard; this sweep is what keeps a quiet tenant's line continuous. +// Re-running on the same day replaces that day's row, so the interval only +// decides how fresh the day's closing value is. +type ScoreSnapshotWorker struct { + tenants TenantIDLister + recorder ScoreRecorder + logger zerolog.Logger + interval time.Duration +} + +func NewScoreSnapshotWorker(tenants TenantIDLister, recorder ScoreRecorder, logger zerolog.Logger) *ScoreSnapshotWorker { + return &ScoreSnapshotWorker{tenants: tenants, recorder: recorder, logger: logger, interval: 6 * time.Hour} +} + +// WithInterval overrides the sweep interval (tests). +func (w *ScoreSnapshotWorker) WithInterval(d time.Duration) *ScoreSnapshotWorker { + if d > 0 { + w.interval = d + } + return w +} + +func (w *ScoreSnapshotWorker) Start(ctx context.Context) { + t := time.NewTicker(w.interval) + defer t.Stop() + w.Sweep(ctx) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + w.Sweep(ctx) + } + } +} + +// Sweep records every tenant once. One tenant failing never stops the others. +func (w *ScoreSnapshotWorker) Sweep(ctx context.Context) int { + ids, err := w.tenants.ListTenantIDs(ctx) + if err != nil { + w.logger.Warn().Err(err).Msg("score snapshots: could not list tenants") + return 0 + } + recorded := 0 + for _, id := range ids { + if ctx.Err() != nil { + return recorded + } + if _, err := w.recorder.Record(ctx, id); err != nil { + w.logger.Warn().Err(err).Str("tenant_id", id.String()).Msg("score snapshots: record failed") + continue + } + recorded++ + } + return recorded +} diff --git a/backend/internal/infrastructure/workers/score_snapshot_worker_test.go b/backend/internal/infrastructure/workers/score_snapshot_worker_test.go new file mode 100644 index 00000000..b7c55b2e --- /dev/null +++ b/backend/internal/infrastructure/workers/score_snapshot_worker_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package workers + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + + "github.com/opendefender/openrisk/internal/domain/scoring" +) + +type listTenants struct { + ids []uuid.UUID + err error +} + +func (l listTenants) ListTenantIDs(context.Context) ([]uuid.UUID, error) { return l.ids, l.err } + +type recordSpy struct { + seen []uuid.UUID + fail uuid.UUID +} + +func (r *recordSpy) Record(_ context.Context, id uuid.UUID) (*scoring.Result, error) { + r.seen = append(r.seen, id) + if id == r.fail { + return nil, errors.New("boom") + } + return &scoring.Result{Measured: true}, nil +} + +// Every tenant is recorded once, each under its own id. +func TestScoreSnapshotSweep_Success(t *testing.T) { + a, b := uuid.New(), uuid.New() + spy := &recordSpy{} + n := NewScoreSnapshotWorker(listTenants{ids: []uuid.UUID{a, b}}, spy, zerolog.Nop()).Sweep(context.Background()) + assert.Equal(t, 2, n) + assert.Equal(t, []uuid.UUID{a, b}, spy.seen) +} + +// One tenant failing does not stop the sweep for the others. +func TestScoreSnapshotSweep_OneFailure(t *testing.T) { + a, b, c := uuid.New(), uuid.New(), uuid.New() + spy := &recordSpy{fail: b} + n := NewScoreSnapshotWorker(listTenants{ids: []uuid.UUID{a, b, c}}, spy, zerolog.Nop()).Sweep(context.Background()) + assert.Equal(t, 2, n) + assert.Len(t, spy.seen, 3) +} + +// No tenants listed (or the listing failed): nothing is recorded. +func TestScoreSnapshotSweep_NotFound(t *testing.T) { + spy := &recordSpy{} + assert.Equal(t, 0, NewScoreSnapshotWorker(listTenants{err: errors.New("db")}, spy, zerolog.Nop()).Sweep(context.Background())) + assert.Empty(t, spy.seen) +} diff --git a/backend/internal/security/isolation/registry.go b/backend/internal/security/isolation/registry.go index d2d51065..a47d21de 100644 --- a/backend/internal/security/isolation/registry.go +++ b/backend/internal/security/isolation/registry.go @@ -530,6 +530,8 @@ var decisions = []Decision{ "assessed, not pinned: the handler passes mwCtx.OrganizationID (uuid.Nil when absent) into GetRiskWeights. Unresolved: whether the weights store falls back to a shared default row on a miss, which would be correct behaviour but must not be reachable by passing a zero tenant. Settled by a two-tenant test that also asserts what uuid.Nil returns"}, {"/api/v1/score", Pending, "assessed, not pinned: fails CLOSED with 401 on uuid.Nil before anything is read, and the optional `id` query parameter is scoped by the `scope` vocabulary (tenant|risk|asset). Unresolved: for scope=risk and scope=asset the id names a record, so this route has a parameterised route's IDOR surface in a query string — the shape this gate does not model. Settled by a test that asks, as tenant A with scope=risk, for a risk id belonging to tenant B and asserts not-found"}, + {"/api/v1/score/history", Covered, + "#901: the tenant comes from the token (401 on uuid.Nil) and takes no id. GormScoreSnapshotRepository.ListSince keys on tenant_id; repository TestScoreSnapshot_Unauthorized seeds two tenants and asserts each reads only its own rows, and application/score TestScoreHistory_Unauthorized asserts another tenant's snapshot never enters the history"}, {"/api/v1/score/model", PublicByDesign, "returns scoring.Describe() — the frozen formula, its bands and its factor names. Requires a session (401 on a zero tenant) but reads no table, so there is nothing tenant-owned in the response"}, diff --git a/backend/pkg/crq/montecarlo.go b/backend/pkg/crq/montecarlo.go index 9d25f871..cf023ce3 100644 --- a/backend/pkg/crq/montecarlo.go +++ b/backend/pkg/crq/montecarlo.go @@ -80,9 +80,11 @@ type SimulationInput struct { // LossDistribution is the Monte Carlo output for one risk (all XAF). The // percentiles are the headline; the run metadata makes the figure explainable. type LossDistribution struct { - P10 float64 `json:"p10"` - P50 float64 `json:"p50"` // median — the figure to lead with - P90 float64 `json:"p90"` + P10 float64 `json:"p10"` + P50 float64 `json:"p50"` // median — the figure to lead with + P90 float64 `json:"p90"` + // P95 is the "worst plausible year" the dashboard quotes beside the ALE. + P95 float64 `json:"p95"` Mean float64 `json:"mean"` Min float64 `json:"min"` Max float64 `json:"max"` @@ -116,7 +118,7 @@ func Simulate(in SimulationInput) LossDistribution { // Degenerate magnitude (no spread) → a deterministic point at LEF × mode. if lm.Max <= lm.Min { v := round2(lef * lm.Mode) - dist.P10, dist.P50, dist.P90, dist.Mean, dist.Min, dist.Max = v, v, v, v, v, v + dist.P10, dist.P50, dist.P90, dist.P95, dist.Mean, dist.Min, dist.Max = v, v, v, v, v, v, v return dist } @@ -137,6 +139,7 @@ func Simulate(in SimulationInput) LossDistribution { dist.P10 = round2(percentile(samples, 10)) dist.P50 = round2(percentile(samples, 50)) dist.P90 = round2(percentile(samples, 90)) + dist.P95 = round2(percentile(samples, 95)) dist.Mean = round2(sum / float64(iters)) dist.Min = round2(samples[0]) dist.Max = round2(samples[len(samples)-1]) @@ -202,6 +205,7 @@ func SimulatePortfolio(inputs []SimulationInput, iterations int, seed int64) Los dist.P10 = round2(percentile(samples, 10)) dist.P50 = round2(percentile(samples, 50)) dist.P90 = round2(percentile(samples, 90)) + dist.P95 = round2(percentile(samples, 95)) dist.Mean = round2(sum / float64(iterations)) dist.Min = round2(samples[0]) dist.Max = round2(samples[len(samples)-1]) @@ -325,6 +329,7 @@ type DistributionAmounts struct { P10 Amount `json:"p10"` P50 Amount `json:"p50"` P90 Amount `json:"p90"` + P95 Amount `json:"p95"` Mean Amount `json:"mean"` Iterations int `json:"iterations"` @@ -339,6 +344,7 @@ func (p Presenter) Present(d LossDistribution) DistributionAmounts { P10: p.Amount(d.P10), P50: p.Amount(d.P50), P90: p.Amount(d.P90), + P95: p.Amount(d.P95), Mean: p.Amount(d.Mean), Iterations: d.Iterations, Seed: d.Seed, diff --git a/backend/pkg/crq/montecarlo_test.go b/backend/pkg/crq/montecarlo_test.go index 70bec7ca..5383c505 100644 --- a/backend/pkg/crq/montecarlo_test.go +++ b/backend/pkg/crq/montecarlo_test.go @@ -53,6 +53,10 @@ func TestSimulate_Bounds(t *testing.T) { if d.P10 < d.Min-1 || d.P90 > d.Max+1 { t.Fatalf("percentiles outside observed range") } + // P95 sits between P90 and the worst observed year. + if !(d.P90 <= d.P95 && d.P95 <= d.Max) { + t.Fatalf("P95 out of [P90, max]: P90=%.0f P95=%.0f max=%.0f", d.P90, d.P95, d.Max) + } if d.FormulaVersion != FormulaVersion { t.Fatalf("formula version not stamped: %q", d.FormulaVersion) } @@ -119,7 +123,7 @@ func TestSimulatePortfolio(t *testing.T) { if a != b { t.Fatalf("portfolio not deterministic") } - if !(a.P10 <= a.P50 && a.P50 <= a.P90) { + if !(a.P10 <= a.P50 && a.P50 <= a.P90 && a.P90 <= a.P95) { t.Fatalf("portfolio percentiles not ordered: %+v", a) } var want float64 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 27e117d2..5e237801 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -110,6 +110,25 @@ so the boot should say clearly which variable is missing. ## Resolved +### D-067 — Console redesign: four calls made at kick-off · decided 2026-10-08 +**Decided** — For the October 2026 redesign (#899), the owner chose: +1. **Exposure score keeps its direction.** The design draws a security score + where higher is better; ours is an exposure score where 0 is no exposure. + It stays as it is and is labelled "Score d'exposition" everywhere. No + formula change, no ADR. +2. **Automatic vulnerability scanning** goes through the agent's package + inventory (osquery) matched against OSV/NVD in the backend (#910). No paid + service, no new scanner embedded in the agent. +3. **Blocks with no data source**: built when the backend work is small; when a + new source is needed (log ingestion for IOC matches) the block keeps the + design's layout with an honest empty state and a follow-up issue. +4. **Default theme** follows the OS, as before. +**Also** — the dashboard's view follows the account's business role; the +design's role switch is not built. The 3D tilt of D-063 does not survive the +redesign: the new score card has none, and the owner asked for the design as +drawn. +**Unblocked** — #900 to #924. + ### D-063 — 3D tilt on the overall score card: ship it · decided 2026-10-01 **Decided (owner)** — Option A: ship a restrained tilt now. The owner's answer was "livre maintenant la phase 5", read as "ship the tilt", so the agents' recommendation (B, diff --git a/frontend/.lint-ceiling.json b/frontend/.lint-ceiling.json index 05c13880..b42c6d36 100644 --- a/frontend/.lint-ceiling.json +++ b/frontend/.lint-ceiling.json @@ -1,11 +1,11 @@ { - "total": 97, + "total": 96, "rules": { "@typescript-eslint/no-unused-vars": 27, "@typescript-eslint/no-explicit-any": 21, "react-hooks/preserve-manual-memoization": 12, "react-hooks/set-state-in-effect": 12, - "react-refresh/only-export-components": 9, + "react-refresh/only-export-components": 8, "react-hooks/static-components": 6, "react-hooks/purity": 3, "prefer-const": 2, diff --git a/frontend/src/features/action-center/ActionCenterPanel.tsx b/frontend/src/features/action-center/ActionCenterPanel.tsx deleted file mode 100644 index 311549e8..00000000 --- a/frontend/src/features/action-center/ActionCenterPanel.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// The Action Center panel (#430). -// -// One list answering one question: what is mine to act on right now, in order, -// and how do I get straight to it. Every row is a real anchor to a real route — -// there is no row here that does not go anywhere (CLAUDE.md rule 12). -// -// This is NOT the notification bell. The bell is chronological, "what happened", -// and is read then dismissed. This is a work queue: an item appears because a -// record is outstanding and disappears when that work is done. There is no -// dismiss and no read state, on purpose — see the epic's non-goals. -// -// Two things it deliberately does not do: -// -// - it does not sort. The server has already applied category rank, then each -// category's own secondary key, then id, and it pages on that same order. -// Re-sorting here would agree with the server on page one and contradict it -// on page two. -// - it does not invent an empty state's meaning. An empty Action Center is the -// GOOD outcome — nothing is outstanding — so it reads as an all-clear rather -// than as "no data found". - -import { useMemo } from 'react'; -import { Link } from 'react-router'; -import { CircleCheck } from 'lucide-react'; - -import { useI18n, interpolate } from '../../hooks/useI18n'; -import { EmptyState, ErrorState, Skeleton } from '../../shared/ui'; -import { useActionItems, PANEL_LIMIT } from './useActionItems'; -import { linkableItems } from './actionLinks'; -import { ActionItemRow } from './ActionItemRow'; - -const SKELETON_ROWS = 4; - -export function ActionCenterPanel({ limit = PANEL_LIMIT }: { limit?: number }) { - const { t } = useI18n(); - const { items, total, isLoading, isError, refetch } = useActionItems({ limit }); - - // Filtering, not sorting: an item whose deep_link does not resolve to a real - // route is dropped (and logged) rather than rendered as a dead row. Order is - // the server's throughout. - const rows = useMemo(() => linkableItems(items), [items]); - - return ( -
-
-
-

- {t('actionCenter.title')} -

-

{t('actionCenter.subtitle')}

-
- {!isLoading && !isError && total > 0 && ( - - {interpolate(t('actionCenter.showing'), { shown: rows.length, total })} - - )} -
- - {isLoading && ( -
- {t('actionCenter.loading')} - {Array.from({ length: SKELETON_ROWS }).map((_, index) => ( - - ))} -
- )} - - {!isLoading && isError && ( -
- -
- )} - - {!isLoading && !isError && rows.length === 0 && ( -
- -
- )} - - {!isLoading && !isError && rows.length > 0 && ( - <> - - {total > rows.length && ( -
- {/* #433 — this was a dead line of text saying how much was not on - screen, because the page it wanted to link to did not exist - yet. It does now, so the count is a real link rather than a - statement the user cannot act on. */} - - {interpolate(t('actionCenter.viewAll'), { count: total - rows.length })} - -
- )} - - )} -
- ); -} - -export default ActionCenterPanel; diff --git a/frontend/src/features/action-center/__tests__/actionCenter.test.tsx b/frontend/src/features/action-center/__tests__/actionCenter.test.tsx deleted file mode 100644 index cf865621..00000000 --- a/frontend/src/features/action-center/__tests__/actionCenter.test.tsx +++ /dev/null @@ -1,468 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// The Action Center's rendering contract (#430, acceptance criteria 1-6 and 9). -// -// What is asserted here is that the panel tells the truth about the server: -// -// - it renders the server's order, and does not impose one of its own; -// - loading, error and empty are three different screens, and empty reads as -// an all-clear rather than as a failure; -// - every one of the six categories produces the exact deep link the #429 -// contract documents, resolving to a route that exists in routeModel; -// - an item whose link goes nowhere is dropped and logged, never drawn as a -// dead row; -// - the panel has no serious or critical axe violations. -// -// The server is mocked at the service boundary — the only place a fixture is -// allowed to exist. Nothing built here ships in a production route. - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import axe from 'axe-core'; - -import { useUIStore } from '../../../store/uiStore'; -import { ActionCenterPanel } from '../ActionCenterPanel'; -import { safeDeepLink } from '../actionLinks'; -import type { ActionCenterResponse, ActionItem } from '../actionCenterService'; - -vi.mock('../actionCenterService', async () => { - const actual = - await vi.importActual('../actionCenterService'); - return { ...actual, actionCenterService: { list: vi.fn() } }; -}); - -import { actionCenterService } from '../actionCenterService'; - -const list = vi.mocked(actionCenterService.list); - -const TENANT = '3f1b0f9e-5a4c-4c7e-9a1d-8b2c6d5e4f30'; - -function item(overrides: Partial & Pick): ActionItem { - return { - title: 'Untitled', - subject_resource_type: 'risk', - subject_resource_id: '8f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0', - deep_link: '/risks', - due_at: null, - category_rank: 1, - tenant_id: TENANT, - ...overrides, - }; -} - -function page(items: ActionItem[], total = items.length): ActionCenterResponse { - return { - data: items, - generated_at: '2026-08-31T09:00:00Z', - limit: 20, - offset: 0, - total, - }; -} - -/** - * The six categories with the deep links the backend contract commits to - * (#429 issue comment, and docs/openapi.yaml). Read as a table on purpose: if - * one of these ever stops resolving, the test names which one. - */ -const CONTRACT_LINKS: Array<{ type: ActionItem['type']; rank: number; deepLink: string }> = [ - { - type: 'overdue_mitigation', - rank: 1, - deepLink: '/risks/mitigations/8f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0', - }, - { - type: 'critical_risk', - rank: 2, - deepLink: '/risks?drawer=risk&entity=1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f', - }, - { type: 'pending_approval', rank: 3, deepLink: '/governance' }, - { type: 'open_incident', rank: 4, deepLink: '/incidents/412/war-room' }, - { - type: 'expiring_evidence', - rank: 5, - deepLink: '/compliance/evidence?drawer=evidence&entity=aa11bb22-cc33-dd44-ee55-ff6677889900', - }, - { - type: 'overdue_remediation', - rank: 6, - deepLink: '/compliance/remediation/9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d', - }, -]; - -function LocationProbe() { - const location = useLocation(); - return
{`${location.pathname}${location.search}`}
; -} - -function renderPanel(initialPath = '/') { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false, refetchInterval: false } }, - }); - return render( - - - - - - - - } - /> - - - , - ); -} - -beforeEach(() => { - vi.clearAllMocks(); - useUIStore.setState({ lang: 'en' }); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('ActionCenterPanel', () => { - /* --- AC1: the server's order is the rendered order ---------------------- */ - - it('renders items in the exact order the API returned, across categories', async () => { - // Deliberately NOT in category_rank order: the server owns the ordering and - // may page on a secondary key the client cannot see, so a client that - // "helpfully" re-sorts by rank would disagree with the server on page two. - // Rendering this array unchanged is what proves nothing re-sorts. - const returned = [ - item({ - id: 'incident:412', - type: 'open_incident', - title: 'Ransomware on the payroll server', - deep_link: '/incidents/412/war-room', - category_rank: 4, - }), - item({ - id: 'mitigation:1', - type: 'overdue_mitigation', - title: 'Patch the perimeter VPN', - deep_link: '/risks/mitigations/8f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0', - category_rank: 1, - }), - item({ - id: 'approval:2', - type: 'pending_approval', - title: 'Risk acceptance for legacy SFTP', - deep_link: '/governance', - category_rank: 3, - }), - ]; - list.mockResolvedValue(page(returned)); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-list')).toBeInTheDocument()); - - const rendered = screen.getAllByTestId('action-center-item'); - expect(rendered).toHaveLength(3); - expect(rendered.map((node) => node.getAttribute('href'))).toEqual( - returned.map((entry) => entry.deep_link), - ); - expect(rendered.map((node) => node.getAttribute('data-action-type'))).toEqual([ - 'open_incident', - 'overdue_mitigation', - 'pending_approval', - ]); - }); - - /* --- AC2: empty is an all-clear, not a blank div ------------------------ */ - - it('renders an explicit empty state, not a blank div or a hanging skeleton', async () => { - list.mockResolvedValue(page([])); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-empty')).toBeInTheDocument()); - expect(screen.getByText('You are all clear')).toBeInTheDocument(); - expect(screen.queryByTestId('action-center-skeleton')).not.toBeInTheDocument(); - expect(screen.queryByTestId('action-center-list')).not.toBeInTheDocument(); - }); - - /* --- AC3: a skeleton while in flight, never a full-page spinner ---------- */ - - it('renders a skeleton before the query resolves', async () => { - let resolve: ((value: ActionCenterResponse) => void) | undefined; - list.mockReturnValue( - new Promise((r) => { - resolve = r; - }), - ); - - renderPanel(); - - expect(screen.getByTestId('action-center-skeleton')).toBeInTheDocument(); - expect(screen.queryByTestId('action-center-list')).not.toBeInTheDocument(); - expect(screen.queryByTestId('action-center-empty')).not.toBeInTheDocument(); - - resolve?.( - page([ - item({ - id: 'approval:2', - type: 'pending_approval', - title: 'Risk acceptance', - deep_link: '/governance', - category_rank: 3, - }), - ]), - ); - - await waitFor(() => expect(screen.getByTestId('action-center-list')).toBeInTheDocument()); - expect(screen.queryByTestId('action-center-skeleton')).not.toBeInTheDocument(); - }); - - /* --- AC4: a failure shows a failure, and fabricates nothing -------------- */ - - it('renders an error state with no items when the request fails', async () => { - list.mockRejectedValue(new Error('Network Error')); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-error')).toBeInTheDocument()); - expect(screen.queryByTestId('action-center-item')).not.toBeInTheDocument(); - expect(screen.queryByTestId('action-center-empty')).not.toBeInTheDocument(); - expect(screen.getByText('The action list could not be loaded')).toBeInTheDocument(); - }); - - /* --- AC5: the six deep links, and real navigation ------------------------ */ - - it('renders the documented deep link for each of the six item types', async () => { - list.mockResolvedValue( - page( - CONTRACT_LINKS.map(({ type, rank, deepLink }) => - item({ - id: `${type}:x`, - type, - title: `A ${type}`, - deep_link: deepLink, - category_rank: rank, - }), - ), - ), - ); - - renderPanel(); - - await waitFor(() => expect(screen.getAllByTestId('action-center-item')).toHaveLength(6)); - - const byType = new Map( - screen - .getAllByTestId('action-center-item') - .map((node) => [node.getAttribute('data-action-type'), node.getAttribute('href')]), - ); - for (const { type, deepLink } of CONTRACT_LINKS) { - expect(byType.get(type)).toBe(deepLink); - } - }); - - it('every documented deep link resolves to a route that exists in routeModel', () => { - // The links above are only worth asserting because the route tree says they - // are reachable. This is the half of criterion 5 that a rendered href alone - // cannot prove. - for (const { type, deepLink } of CONTRACT_LINKS) { - expect(safeDeepLink(deepLink), `${type} -> ${deepLink}`).toBe(deepLink); - } - }); - - it('navigates to the deep link on click and on Enter and Space from the keyboard', async () => { - list.mockResolvedValue( - page([ - item({ - id: 'incident:412', - type: 'open_incident', - title: 'Ransomware on the payroll server', - deep_link: '/incidents/412/war-room', - category_rank: 4, - }), - ]), - ); - - const { unmount } = renderPanel(); - await waitFor(() => expect(screen.getByTestId('action-center-item')).toBeInTheDocument()); - - fireEvent.click(screen.getByTestId('action-center-item')); - await waitFor(() => - expect(screen.getByTestId('location')).toHaveTextContent('/incidents/412/war-room'), - ); - unmount(); - - // Enter — the anchor's own default activation. - renderPanel(); - await waitFor(() => expect(screen.getByTestId('action-center-item')).toBeInTheDocument()); - const link = screen.getByTestId('action-center-item'); - link.focus(); - fireEvent.keyDown(link, { key: 'Enter' }); - fireEvent.click(link); - await waitFor(() => - expect(screen.getByTestId('location')).toHaveTextContent('/incidents/412/war-room'), - ); - screen.getByTestId('location'); - }); - - it('activates a row with the Space key', async () => { - list.mockResolvedValue( - page([ - item({ - id: 'remediation:9', - type: 'overdue_remediation', - title: 'Close the access-review gap', - deep_link: '/compliance/remediation/9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d', - category_rank: 6, - }), - ]), - ); - - renderPanel(); - await waitFor(() => expect(screen.getByTestId('action-center-item')).toBeInTheDocument()); - - fireEvent.keyDown(screen.getByTestId('action-center-item'), { key: ' ' }); - await waitFor(() => - expect(screen.getByTestId('location')).toHaveTextContent( - '/compliance/remediation/9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d', - ), - ); - }); - - /* --- AC6: no row ever goes nowhere -------------------------------------- */ - - it('drops an item whose deep link does not resolve, and logs why', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - list.mockResolvedValue( - page( - [ - item({ - id: 'approval:2', - type: 'pending_approval', - title: 'Risk acceptance', - deep_link: '/governance', - category_rank: 3, - }), - item({ - id: 'ghost:1', - type: 'critical_risk', - title: 'Points at a route that does not exist', - deep_link: '/risks/00000000-0000-0000-0000-000000000000', - category_rank: 2, - }), - ], - 2, - ), - ); - - renderPanel(); - - await waitFor(() => expect(screen.getAllByTestId('action-center-item')).toHaveLength(1)); - expect(screen.getByTestId('action-center-item').getAttribute('href')).toBe('/governance'); - expect(screen.queryByText('Points at a route that does not exist')).not.toBeInTheDocument(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('ghost:1')); - }); - - it('refuses a deep link that leaves the origin', () => { - // deep_link is server data that ends up in an href, so it is checked like - // any other untrusted string. None of these may become a link. - expect(safeDeepLink('https://evil.example/risks')).toBeNull(); - expect(safeDeepLink('//evil.example/risks')).toBeNull(); - expect(safeDeepLink('javascript:alert(1)')).toBeNull(); - expect(safeDeepLink('risks')).toBeNull(); - expect(safeDeepLink('')).toBeNull(); - expect(safeDeepLink(undefined)).toBeNull(); - }); - - it('renders an unrecognised item type under a generic label rather than hiding real work', async () => { - // A frontend can be one release behind the API. An unknown category is not a - // reason to hide work the user has to do — but it still may not produce a - // dead link, which is why its deep_link goes through the same check. - list.mockResolvedValue( - page([ - item({ - id: 'newthing:1', - // Cast: the point of the test is a value outside the generated union, - // which is exactly what a newer server can send. - type: 'audit_finding' as ActionItem['type'], - title: 'Something this build has never heard of', - deep_link: '/governance', - category_rank: 7, - }), - ]), - ); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-item')).toBeInTheDocument()); - expect(screen.getByText('Something this build has never heard of')).toBeInTheDocument(); - expect(screen.getByTestId('action-center-item').getAttribute('href')).toBe('/governance'); - expect(screen.getByText(/Action required/)).toBeInTheDocument(); - }); - - /* --- i18n: no hardcoded literal survives a language switch --------------- */ - - it('renders its own strings in French when the language is French', async () => { - useUIStore.setState({ lang: 'fr' }); - list.mockResolvedValue(page([])); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-empty')).toBeInTheDocument()); - expect(screen.getByText("Centre d'actions")).toBeInTheDocument(); - expect(screen.getByText('Vous êtes à jour')).toBeInTheDocument(); - }); - - /* --- AC9: axe ------------------------------------------------------------ */ - - it('has no serious or critical axe violations with items rendered', async () => { - list.mockResolvedValue( - page( - CONTRACT_LINKS.map(({ type, rank, deepLink }) => - item({ - id: `${type}:x`, - type, - title: `A ${type.replace(/_/g, ' ')}`, - deep_link: deepLink, - category_rank: rank, - due_at: rank % 2 === 0 ? null : '2026-07-01T00:00:00Z', - }), - ), - ), - ); - - const { container } = renderPanel(); - await waitFor(() => expect(screen.getAllByTestId('action-center-item')).toHaveLength(6)); - - const results = await axe.run(container, { - // jsdom has no layout engine, so colour contrast cannot be evaluated here. - // It is asserted for real against the running app by check-contrast.mjs and - // the Playwright axe pass. - rules: { 'color-contrast': { enabled: false } }, - }); - const blocking = results.violations.filter( - (violation) => violation.impact === 'serious' || violation.impact === 'critical', - ); - expect(blocking.map((violation) => `${violation.id}: ${violation.help}`)).toEqual([]); - }); - - it('has no serious or critical axe violations in the empty state', async () => { - list.mockResolvedValue(page([])); - - const { container } = renderPanel(); - await waitFor(() => expect(screen.getByTestId('action-center-empty')).toBeInTheDocument()); - - const results = await axe.run(container, { rules: { 'color-contrast': { enabled: false } } }); - const blocking = results.violations.filter( - (violation) => violation.impact === 'serious' || violation.impact === 'critical', - ); - expect(blocking.map((violation) => `${violation.id}: ${violation.help}`)).toEqual([]); - }); -}); diff --git a/frontend/src/features/action-center/__tests__/actionCenterPage.test.tsx b/frontend/src/features/action-center/__tests__/actionCenterPage.test.tsx index badbc8c3..104b26a0 100644 --- a/frontend/src/features/action-center/__tests__/actionCenterPage.test.tsx +++ b/frontend/src/features/action-center/__tests__/actionCenterPage.test.tsx @@ -27,7 +27,6 @@ import { ROUTES, resolveRoute, parentHref } from '../../../shared/routeModel'; import { NAV_GROUPS } from '../../../shared/navModel'; import { ActionCenterPage } from '../ActionCenterPage'; import { pageFromParam } from '../paging'; -import { ActionCenterPanel } from '../ActionCenterPanel'; import { PAGE_LIMIT } from '../useActionItems'; import type { ActionCenterResponse, ActionItem } from '../actionCenterService'; @@ -413,59 +412,3 @@ describe('ActionCenterPage', () => { }); }); -/* --- AC2: the dashboard panel's way in ------------------------------------ */ - -describe('the dashboard panel', () => { - function renderPanel() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false, refetchInterval: false } }, - }); - return render( - - - - - - - - } - /> - - - , - ); - } - - it('offers a real link to the full page when more is outstanding than it shows', async () => { - list.mockResolvedValue(envelope(approvals(1, 8), 25, 0)); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-view-all')).toBeInTheDocument()); - expect(screen.getByTestId('action-center-view-all').getAttribute('href')).toBe( - '/action-center', - ); - }); - - it('does not offer it when the panel already shows everything', async () => { - list.mockResolvedValue(envelope(approvals(1, 3), 3, 0)); - - renderPanel(); - - await waitFor(() => expect(screen.getAllByTestId('action-center-item')).toHaveLength(3)); - expect(screen.queryByTestId('action-center-view-all')).not.toBeInTheDocument(); - }); - - it('navigates to the full page when the link is activated', async () => { - list.mockResolvedValue(envelope(approvals(1, 8), 25, 0)); - - renderPanel(); - await waitFor(() => expect(screen.getByTestId('action-center-view-all')).toBeInTheDocument()); - - fireEvent.click(screen.getByTestId('action-center-view-all')); - await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/action-center')); - }); -}); diff --git a/frontend/src/features/dashboard/AnalystDashboard.tsx b/frontend/src/features/dashboard/AnalystDashboard.tsx deleted file mode 100644 index b6d4d282..00000000 --- a/frontend/src/features/dashboard/AnalystDashboard.tsx +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// Security-analyst dashboard persona (UX-2). Leads with vulnerabilities: the -// prioritised KPI band (open / P1 / KEV / exploitable) and the top-priority queue, -// each row deep-linking into the vulnerability drawer. Real data from the same -// endpoints the Vulnerabilities screen uses. -// -// W0-06: the KPI band used to render `stats?.total ?? 0` and friends, so a failed -// /vulnerabilities/stats produced a tidy row of zeroes — an analyst's dashboard -// reporting no open vulnerabilities and no KEV, which is the most reassuring -// thing this screen can say and the least verifiable. It now renders an error -// state. Each tile also carries its own filter into the register: clicking -// "P1 — 4" opens those four, not the whole list. - -import { useNavigate } from 'react-router'; -import { Bug, ShieldAlert, Flame, Zap } from 'lucide-react'; -import { useUIStore } from '../../store/uiStore'; -import { useVulnerabilities, useVulnStats } from '../vulnerabilities/useVulnerabilities'; -import { deepLink } from './deepLinks'; -import { WidgetState } from './WidgetState'; -import { DashboardShell, PersonaHeader, KpiRow, Card, type KpiSpec } from './shared'; - -const SEV_COLOR: Record = { - critical: 'var(--critical)', - high: 'var(--high)', - medium: 'var(--medium)', - low: 'var(--low)', - info: 'var(--fg-muted)', -}; -const TIER_COLOR: Record = { - P1: 'var(--critical)', - P2: 'var(--high)', - P3: 'var(--medium)', - P4: 'var(--low)', -}; -const soft = (c: string, p = 15) => `color-mix(in srgb, ${c} ${p}%, transparent)`; - -export function AnalystDashboard() { - const navigate = useNavigate(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - - const statsQuery = useVulnStats(); - const stats = statsQuery.data; - const listQuery = useVulnerabilities({ limit: 6, sort_by: 'priority_score', sort_dir: 'desc' }); - const { isLoading } = listQuery; - const top = listQuery.data?.items ?? []; - - // The queue is sorted by priority, so every link carries that sort: the rows - // the tile counted are the rows at the top of the page it opens. - const sort = { key: 'priority_score', dir: 'desc' } as const; - // Every tile here reads the same query, so they share one "did this - // genuinely fetch during this mount" signal. - const fresh = statsQuery.isFetchedAfterMount; - const kpis: KpiSpec[] = [ - { - label: tr('Vulnérabilités', 'Vulnerabilities'), - val: stats?.total ?? 0, - icon: Bug, - col: 'var(--accent)', - onClick: () => navigate(deepLink('vulnerabilities', { sort })), - fresh, - }, - { - label: tr('Ouvertes', 'Open'), - val: stats?.open ?? 0, - icon: ShieldAlert, - col: 'var(--high)', - onClick: () => navigate(deepLink('vulnerabilities', { filters: { status: 'open' }, sort })), - fresh, - }, - { - label: tr('Priorité P1', 'Priority P1'), - val: stats?.by_tier?.P1 ?? 0, - icon: Flame, - col: 'var(--critical)', - onClick: () => navigate(deepLink('vulnerabilities', { filters: { tier: 'P1' }, sort })), - fresh, - }, - { - label: 'KEV', - val: stats?.kev_count ?? 0, - icon: Zap, - col: 'var(--critical)', - onClick: () => navigate(deepLink('vulnerabilities', { filters: { kev: 'true' }, sort })), - fresh, - }, - ]; - - const sevCounts = stats?.by_severity ?? {}; - const sevOrder = ['critical', 'high', 'medium', 'low', 'info']; - const sevMax = Math.max(1, ...sevOrder.map((s) => sevCounts[s] ?? 0)); - - return ( - - navigate('/vulnerabilities')} - /> - -
- void statsQuery.refetch()} - > - - -
- -
- {/* top-priority queue */} - -
-
- {tr('File de priorité', 'Priority queue')} -
- -
- void listQuery.refetch()} - isEmpty={top.length === 0} - emptyIcon={Bug} - emptyTitle={tr('Aucune vulnérabilité', 'No vulnerabilities')} - emptyDescription={tr( - 'Importez un scan ou branchez un connecteur : les vulnérabilités priorisées apparaîtront ici, P1 et KEV en tête.', - 'Import a scan or connect a source: prioritised vulnerabilities appear here, P1 and KEV first.', - )} - emptyAction={ - - } - > - <> - {top.map((v) => { - const tierCol = TIER_COLOR[v.priority_tier] ?? 'var(--fg-muted)'; - return ( - - ); - })} - - -
- - {/* severity distribution */} - -
- {tr('Par sévérité', 'By severity')} -
- void statsQuery.refetch()} - > - <> -
- {sevOrder.map((s) => { - const c = sevCounts[s] ?? 0; - const col = SEV_COLOR[s]; - return ( - - ); - })} -
-
- {tr('Exploitables', 'Exploitable')} - {stats?.exploit_count ?? 0} -
- -
-
-
-
- ); -} diff --git a/frontend/src/features/dashboard/AuditDashboard.tsx b/frontend/src/features/dashboard/AuditDashboard.tsx deleted file mode 100644 index ee92d7a6..00000000 --- a/frontend/src/features/dashboard/AuditDashboard.tsx +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// Auditor / compliance dashboard persona (UX-2). Leads with controls: coverage per -// framework, the gap count, and shortcuts to audits & gap analysis. Real data from -// the same compliance overview the Compliance screen uses. -// -// W0-06: a failed compliance fetch rendered "No framework — import one to start", -// with all four KPIs at zero. That is not a milder version of the truth, it is a -// different statement: it tells an auditor their organisation has imported -// nothing, and points them at a task they have already done. An unreadable -// posture and an empty one need opposite responses, so they now render -// differently. - -import { useNavigate } from 'react-router'; -import { ClipboardCheck, Layers, AlertTriangle, ListChecks } from 'lucide-react'; -import { useUIStore } from '../../store/uiStore'; -import { useComplianceOverview, frameworkColorFor } from '../compliance/complianceOverview'; -import { WidgetState } from './WidgetState'; -import { DashboardShell, PersonaHeader, KpiRow, Card, type KpiSpec } from './shared'; - -export function AuditDashboard() { - const navigate = useNavigate(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const query = useComplianceOverview(); - const { data, isLoading } = query; - const frameworks = data ?? []; - - const totalControls = frameworks.reduce((s, f) => s + f.total, 0); - const passed = frameworks.reduce((s, f) => s + f.passed, 0); - const gaps = Math.max(0, totalControls - passed); - const avg = frameworks.length - ? Math.round(frameworks.reduce((s, f) => s + f.pct, 0) / frameworks.length) - : 0; - - // All four tiles read the same query, so they share one "did this - // genuinely fetch during this mount" signal. - const fresh = query.isFetchedAfterMount; - const kpis: KpiSpec[] = [ - { - label: tr('Référentiels', 'Frameworks'), - val: frameworks.length, - icon: Layers, - col: 'var(--accent)', - onClick: () => navigate('/compliance'), - fresh, - }, - { - label: tr('Couverture moy.', 'Avg. coverage'), - val: avg, - icon: ClipboardCheck, - col: 'var(--low)', - suffix: '%', - onClick: () => navigate('/compliance'), - fresh, - }, - { - label: tr('Contrôles', 'Controls'), - val: totalControls, - icon: ListChecks, - col: 'var(--high)', - onClick: () => navigate('/compliance'), - fresh, - }, - { - label: tr('Écarts', 'Gaps'), - val: gaps, - icon: AlertTriangle, - col: 'var(--critical)', - onClick: () => navigate('/compliance/gaps'), - fresh, - }, - ]; - - return ( - - navigate('/compliance/audits')} - /> - -
- void query.refetch()} - > - - -
- -
- -
-
- {tr('Couverture par référentiel', 'Coverage by framework')} -
- -
- void query.refetch()} - isEmpty={frameworks.length === 0} - emptyIcon={Layers} - emptyTitle={tr('Aucun référentiel importé', 'No framework imported')} - emptyDescription={tr( - 'Importez ISO 27001, NIST CSF, PCI DSS ou l’un des autres catalogues : la couverture par référentiel se calcule ensuite automatiquement.', - 'Import ISO 27001, NIST CSF, PCI DSS or one of the other catalogues: coverage is computed from there automatically.', - )} - emptyAction={ - - } - > -
- {frameworks.map((f, i) => { - const col = frameworkColorFor(f.name, i); - return ( - - ); - })} -
-
-
- - -
{tr('Actions', 'Actions')}
-
- {[ - { - label: tr('Analyse des écarts', 'Gap analysis'), - sub: tr('Ce qui reste à implémenter', 'What is left to implement'), - to: '/compliance/gaps', - icon: AlertTriangle, - col: 'var(--critical)', - }, - { - label: tr('Audits', 'Audits'), - sub: tr('Planifier & suivre', 'Plan & track'), - to: '/compliance/audits', - icon: ClipboardCheck, - col: 'var(--accent)', - }, - { - label: tr('Remédiations', 'Remediations'), - sub: tr('Plans en cours', 'Open plans'), - to: '/compliance/remediation', - icon: ListChecks, - col: 'var(--high)', - }, - ].map((a) => { - const Icon = a.icon; - return ( - - ); - })} -
-
-
-
- ); -} diff --git a/frontend/src/features/dashboard/ConsoleDashboard.tsx b/frontend/src/features/dashboard/ConsoleDashboard.tsx new file mode 100644 index 00000000..e0027863 --- /dev/null +++ b/frontend/src/features/dashboard/ConsoleDashboard.tsx @@ -0,0 +1,80 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// +// The dashboard of the October 2026 redesign (#901). +// +// One layout for every member, read top to bottom: the incident that needs +// attention now, the exposure score, the money at stake and four counters, +// the year of the score next to the risk matrix, then what to do next and +// what just happened. The member's business role picks which four counters +// and which priorities they see (dashboardVariant.ts); there is no role switch +// on screen. +// +// Every block fetches its own tenant-scoped source and renders its own +// loading, error and empty state, so one failing source never blanks the page. + +import { useAuthStore } from '../../hooks/useAuthStore'; +import { useI18n } from '../../hooks/useI18n'; +import { formatDate, formatTime } from '../../i18n/format'; +import { PageFrame, PageHeader } from '../../shared/ui'; +import { MFAEnrollmentBanner } from '../auth/MFAEnrollmentBanner'; +import { MFAPostAhaPrompt } from '../auth/MFAPostAhaPrompt'; +import { OnboardingChecklist } from '../onboarding/OnboardingChecklist'; +import { useDashboardStats } from './useCommandCenter'; +import { variantFor } from './dashboardVariant'; +import { IncidentBanner } from './console/IncidentBanner'; +import { ScoreCard } from './console/ScoreCard'; +import { ExposureCard } from './console/ExposureCard'; +import { CounterList } from './console/CounterList'; +import { ScoreTrendCard } from './console/ScoreTrendCard'; +import { RiskMatrixCard } from './console/RiskMatrixCard'; +import { PriorityList } from './console/PriorityList'; +import { RecentActivity } from './console/RecentActivity'; + +const ALL_TIME = { kind: 'preset', preset: 'all' } as const; + +export function ConsoleDashboard() { + const { t, locale } = useI18n(); + const variant = variantFor(useAuthStore((s) => s.user?.business_role)); + // The register aggregate is the page's reference read: its timestamp is the + // "données à" of the eyebrow. + const stats = useDashboardStats(ALL_TIME); + const asOf = stats.dataUpdatedAt ? new Date(stats.dataUpdatedAt) : new Date(); + const day = formatDate(locale, new Date(), { dateStyle: 'full' }); + + return ( + + + + + {/* Account and onboarding prompts sit under the incident line: an attack + in progress outranks a setup reminder. */} + + + + +
+ + + +
+ +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx index 22e9281b..b2d848f2 100644 --- a/frontend/src/features/dashboard/DashboardPage.tsx +++ b/frontend/src/features/dashboard/DashboardPage.tsx @@ -1,995 +1,19 @@ // Copyright (c) 2026 OpenDefender Contributors // SPDX-License-Identifier: AGPL-3.0-only // -// The Security Command Center. +// The landing page. // -// Every number on this page comes from a tenant-scoped server aggregate, and -// every tile links to the register rows that produced it, carrying the filter -// the tile expresses. There are no fixtures, no fallbacks and no client-side -// arithmetic over a page of results: a widget that cannot read its data says so -// rather than showing a plausible zero. -// -// See docs/W0-06_SECURITY_COMMAND_CENTER.md for the contract inventory. +// Every member gets the redesigned dashboard (#901); their business role picks +// its counters and priorities. ?view=executive switches to the consolidated +// executive view, which answers the same "how are we doing" question for a +// committee. /analytics still redirects there, so old links resolve. -import { localeTag } from '../../i18n/locales'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router'; -import { - ShieldAlert, - AlertTriangle, - ShieldCheck, - CheckCircle2, - FileText, - Zap, - Plus, - Grid3x3, - TrendingUp, - type LucideIcon, -} from 'lucide-react'; -import { useRiskStore } from '../../hooks/useRiskStore'; -import { useUIStore } from '../../store/uiStore'; -import { useAuthStore } from '../../hooks/useAuthStore'; -import { useUIStrings } from '../../shared/uiStrings'; -import { critColor, frameworkColor, softFill, type Criticality } from '../../shared/riskColors'; -import { - useDashboardStats, - type DashboardStats, - type MatrixCell, - type RiskTrend, -} from './useCommandCenter'; -import { useDashboardPeriod, periodLabel, type PeriodSelection } from './period'; -import { deepLink } from './deepLinks'; -import { PeriodControl } from './PeriodControl'; -import { WidgetState } from './WidgetState'; -import { SlotReel } from '../../shared/ds/SlotReel'; -import { useScore } from '../../hooks/useScore'; -import { ScoreGauge } from '../../shared/ScoreGauge'; -import { useScoreCardTilt } from './useScoreCardTilt'; -import { EmptyState } from '../../shared/EmptyState'; -import { Btn } from '../../shared/ui'; -import { useIncidents } from '../incidents/useIncidents'; -import { OnboardingChecklist } from '../onboarding/OnboardingChecklist'; -import { MFAEnrollmentBanner } from '../auth/MFAEnrollmentBanner'; -import { MFAPostAhaPrompt } from '../auth/MFAPostAhaPrompt'; -import { ActionCenterPanel } from '../action-center/ActionCenterPanel'; -import { personaFor } from './dashboardPersona'; -import { AnalystDashboard } from './AnalystDashboard'; -import { ExecDashboard } from './ExecDashboard'; -import { AuditDashboard } from './AuditDashboard'; -import { EstateDashboard } from './EstateDashboard'; -import { ViewerDashboard } from './ViewerDashboard'; +import { useSearchParams } from 'react-router'; import { ExecutiveDashboard } from '../analytics/ExecutiveDashboard'; -import type { LocaleCode } from '../../i18n/locales'; - -const Card = ({ - children, - className = '', - style, -}: { - children: React.ReactNode; - className?: string; - style?: React.CSSProperties; -}) => ( -
- {children} -
-); - -interface RecentRisk { - id: string; - rawId: string; - name: string; - crit: Criticality; - score: number; - meta: string; - fw: string; -} +import { ConsoleDashboard } from './ConsoleDashboard'; -/* ---------------- persona dispatcher ---------------- */ - -// The dashboard adapts to the member's GRC role (UX-2). Each persona renders its -// own real-data view; admins and unmapped roles get the full posture dashboard. -// -// ?view=executive overrides the persona with the consolidated executive view. -// That view used to live at /analytics, filed under Reports — which is why -// people went looking for it beside the PDFs. It answers "how are we doing", -// which is the dashboard's own question, so it is a display mode here rather -// than a destination of its own. /analytics still redirects to ?view=executive -// so existing links and bookmarks resolve. export const DashboardPage = () => { - const businessRole = useAuthStore((s) => s.user?.business_role); - const persona = personaFor(businessRole); const [params] = useSearchParams(); if (params.get('view') === 'executive') return ; - switch (persona) { - case 'analyst': - return ; - case 'exec': - return ; - case 'audit': - return ; - case 'estate': - return ; - case 'viewer': - return ; - default: - return ; - } + return ; }; - -/* ---------------- posture persona (default: RSSI / risk roles / admin) ---------------- */ - -function PostureDashboard() { - const navigate = useNavigate(); - const L = useUIStrings(); - const lang = useUIStore((s) => s.lang); - const risks = useRiskStore((s) => s.risks); - const fetchRisks = useRiskStore((s) => s.fetchRisks); - - useEffect(() => { - fetchRisks?.().catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const critOf = (r: (typeof risks)[number]): Criticality => - // The server's criticality band (derived from the score by the ScoreWorker). - // `level` is a legacy field that is not reliably populated — it rendered - // every risk as medium. Prefer criticality; deriving a band from the number - // here is the mapping that drifted away from the number itself. - ((r.criticality ?? r.level)?.toLowerCase() as Criticality) || 'low'; - - // The period lives in the URL, so it survives a reload and can be pasted. - const { selection, setSelection } = useDashboardPeriod(); - const statsQuery = useDashboardStats(selection); - const stats = statsQuery.data; - - // The canonical tenant score — the same query key the sidebar and /score use, - // so the three render one object from one fetch and cannot disagree. - const { - data: tenantScore, - isLoading: scoreLoading, - isError: scoreError, - isFetchedAfterMount: scoreFresh, - } = useScore('tenant'); - const scoreCardRef = useRef(null); - useScoreCardTilt(scoreCardRef); - const user = useAuthStore((s) => s.user); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const firstName = (user?.full_name || '').trim().split(/\s+/)[0] || user?.username || ''; - const greeting = `${tr('Bonjour', 'Hello')}${firstName ? `, ${firstName}` : ''}`; - const sev = stats?.risks_by_severity ?? {}; - - // Every KPI is read straight off the server aggregate. These used to fall back - // to risks.filter(...).length over the risk store, which holds ONE PAGE of the - // register — so "12 en cours" meant "12 on the page you happen to be on" and - // silently disagreed with the register itself. There is no client-side count - // here on purpose, and no `?? 0` either: when the aggregate has not arrived the - // tiles render a loading state, and when it FAILED they render an error state. - // A zero that was never read is the most reassuring thing this page can print, - // and the least true. - const kpis = useMemo( - () => ({ - total: stats?.total_risks ?? 0, - critical: sev.CRITICAL ?? sev.critical ?? 0, - mitig: stats?.in_progress_risks ?? 0, - resolved: stats?.mitigated_risks ?? 0, - // eslint-disable-next-line react-hooks/exhaustive-deps - }), - [stats], - ); - - const recent: RecentRisk[] = useMemo(() => { - return risks.slice(0, 5).map((r) => ({ - rawId: r.id, - id: r.id.length > 10 ? `#${r.id.slice(0, 8)}` : r.id, - name: r.title, - crit: critOf(r), - score: r.score, - meta: r.assets?.[0]?.name ?? '—', - fw: r.frameworks?.[0] ?? r.tags?.[0] ?? '—', - })); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [risks]); - - const fmt = (n: number) => Math.round(n).toLocaleString(localeTag(lang)); - - return ( -
-
- {/* header */} -
-
-

{greeting}

-
{L.dashSub}
-
-
- - -
-
- - {/* Get-started panel. It takes no props on purpose: the steps, their - completion and the celebration all come from GET /activation/state. - Deriving them here from local counts is what used to make it tick - unreliably and fire confetti at random. */} - {/* OR26-03 — the non-blocking MFA prompt. It replaces the enrolment wall - that used to stand between a new account and its first screen: the - server now lets eligible users in and this says what they should do - about it. Enforcement is the server's (MFAPolicyGuard) — dismissing - this changes nothing about who is allowed in. */} - - - - - {/* And the second ask, once the Aha moment has actually been reached. - The trigger is activation.aha_reached_at, recorded server-side from - the user's own data — never a flag this component sets. */} - - - {/* #430 — the Action Center, above the score and the KPIs on purpose: - "what should I do next" outranks "how are we doing" on the screen a - user lands on. DashboardShell mounts the same panel for the other - personas; this dashboard does not use that shell. */} - - - {/* row 1 — score hero + kpis */} -
- {/* One score, one source. This used to read stats.global_risk_score - while the sidebar read analytics/executive → cyber_score.score: - two quantities on two scales pointing in opposite directions, - both labelled "score". Both now read the same query key, and the - competing formula has been removed from /stats entirely. */} - {/* #856 — the tilt lives on this wrapper, never inside ScoreGauge, so - the arc and the figure move with the card as one surface. `grid` - keeps the card stretched to the row height it had as a direct - grid item. */} -
- navigate('/score')} - /> -
- -
- - {/* row 2 — heatmap + trend */} -
- void statsQuery.refetch()} - /> - void statsQuery.refetch()} - selection={selection} - onWidenPeriod={() => setSelection({ kind: 'preset', preset: 'all' })} - /> -
- - {/* row 3 — recent + war room */} -
- - navigate(id ? `/incidents/${id}/war-room` : '/incidents')} /> -
-
-
- ); -} - -/* ---------------- KPI grid ---------------- */ - -interface StatsQuery { - isLoading: boolean; - error: unknown; - refetch: () => unknown; - /** True once this query has genuinely fetched during THIS mount, as - * opposed to serving cached data from an earlier visit — TanStack Query's - * own signal for exactly that distinction. */ - isFetchedAfterMount: boolean; -} - -function KpiGrid({ - values, - fmt, - lang, - query, - openedInPeriod, - selection, -}: { - values: { total: number; critical: number; mitig: number; resolved: number }; - fmt: (n: number) => string; - lang: LocaleCode; - query: StatsQuery; - openedInPeriod: number; - selection: PeriodSelection; -}) { - const L = useUIStrings(); - const navigate = useNavigate(); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - - // Each tile carries the filter it expresses onto the register. Clicking - // "Critical — 3" used to land on an unfiltered list of everything and leave the - // user to rebuild by hand the filter they had just clicked. - // - // The sort travels too: a critical-risks link that opens sorted by score puts - // the three rows the tile counted at the top of the page, which is what the - // user came for. - const sort = { key: 'score', dir: 'desc' } as const; - const data: { - label: string; - val: number; - icon: LucideIcon; - col: string; - to: string; - hint: string; - }[] = [ - { - label: L.kpiTotal, - val: values.total, - icon: ShieldAlert, - col: 'var(--accent)', - to: deepLink('risks', { sort }), - hint: tr('Tous les risques du registre', 'Every risk in the register'), - }, - { - label: L.kpiCrit, - val: values.critical, - icon: AlertTriangle, - col: 'var(--critical)', - to: deepLink('risks', { filters: { criticality: 'critical' }, sort }), - hint: tr( - 'Registre filtré sur criticité = critique', - 'Register filtered to criticality = critical', - ), - }, - { - label: L.kpiMiti, - val: values.mitig, - icon: ShieldCheck, - col: 'var(--high)', - to: deepLink('risks', { filters: { status: 'in_progress' }, sort }), - hint: tr( - 'Registre filtré sur statut = en cours', - 'Register filtered to status = in progress', - ), - }, - { - label: L.kpiResolved, - val: values.resolved, - icon: CheckCircle2, - col: 'var(--low)', - to: deepLink('risks', { filters: { status: 'mitigated' }, sort }), - hint: tr('Registre filtré sur statut = atténué', 'Register filtered to status = mitigated'), - }, - ]; - - return ( -
- query.refetch()} - > - <> - {data.map((d) => ( - navigate(d.to)} - /> - ))} - {/* The one period-scoped counter in this block, labelled with the - window so it cannot be read as a stock. */} -
- - {tr( - `${fmt(openedInPeriod)} ouvert(s) sur la période (${periodLabel(selection, lang)})`, - `${fmt(openedInPeriod)} opened in the selected period (${periodLabel(selection, lang)})`, - )} -
- -
-
- ); -} - -function KpiCard({ - label, - val, - icon: Icon, - col, - fmt, - lang, - onClick, - hint, - fresh, -}: { - label: string; - val: number; - icon: LucideIcon; - col: string; - fmt: (n: number) => string; - lang: LocaleCode; - onClick: () => void; - hint: string; - /** True when `val` was genuinely fetched during THIS mount — see - * `StatsQuery.isFetchedAfterMount`. Rolls the number in from 0 once. */ - fresh?: boolean; -}) { - return ( - - ); -} - -/* ---------------- Heatmap ---------------- */ -// The 25 cell counts come from the /stats risk_matrix aggregate, banded in SQL. -// They used to be a hardcoded literal, which meant a brand-new tenant with an -// empty register was shown a fully populated probability x impact matrix — the -// single most trust-destroying thing the dashboard did. -function HeatmapCard({ - matrix, - isLoading, - error, - retry, -}: { - matrix?: MatrixCell[]; - isLoading: boolean; - error: unknown; - retry: () => void; -}) { - const L = useUIStrings(); - const lang = useUIStore((s) => s.lang); - const navigate = useNavigate(); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const canCreate = useAuthStore((s) => s.hasPermission('risks:create')); - - const counts: Record = {}; - for (const cell of matrix ?? []) counts[`${cell.impact}-${cell.probability}`] = cell.count; - const total = (matrix ?? []).reduce((n, c) => n + c.count, 0); - - // Tints the 5×5 grid's own cells from their COORDINATES (bucket × bucket, - // 1–25) so the map reads as a heat map. No risk's score and no risk's label is - // derived from it — it is a legend for the grid, not a score band. Named and - // commented so it is never mistaken for one (see docs/scoring/SCORE_MODEL.md). - const cellCol = (pBucket: number, iBucket: number) => { - const v = pBucket * iBucket; // 1..25, the grid's own coordinates - return v >= 15 - ? 'var(--critical)' - : v >= 8 - ? 'var(--high)' - : v >= 4 - ? 'var(--medium)' - : 'var(--low)'; - }; - const rows = []; - for (let i = 5; i >= 1; i--) { - const cells = []; - for (let p = 1; p <= 5; p++) { - const c = counts[`${i}-${p}`] ?? 0; - const col = cellCol(p, i); - cells.push( -
- {c ? ( - - {c} - - ) : null} -
, - ); - } - rows.push( -
- {cells} -
, - ); - } - - return ( - -
{L.heatTitle}
- - window.dispatchEvent(new CustomEvent('openrisk:new-risk'))} - /> - navigate('/risks/import')} /> - - ) : undefined - } - > -
-
- - {L.impact} - -
-
-
{rows}
-
- {L.proba} -
-
-
-
-
- ); -} - -/* ---------------- Trend ---------------- */ -// -// The series comes from the server, over the period selected on this page. -// -// It used to be computed here, in the browser, from useRiskStore.risks — which -// holds ONE PAGE of the register. A tenant with 200 risks saw a trend of the 20 -// on page one. It banded them on `level`, a field this same file documents as -// unreliable, and it plotted a cumulative count by created_at, so the line could -// only ever rise regardless of what the team actually did. Its 7/30/90 toggle was -// local component state that changed nothing else on the page. -// -// Two series now, and they are labelled apart because they answer different -// questions: `opened` is a FLOW (risks created in each bucket) and -// `cumulative_total` is a STOCK (risks in existence at each bucket's end). The -// band split uses each risk's CURRENT criticality — the register does not version -// criticality per day, and inventing a per-day band would be a number nobody -// could reconcile. -// Exported for its tests. The decision this component makes — "no risks" versus -// "no risks in THIS window" — was wrong once (#343) and the two states have -// opposite remedies, so it is pinned by tests rather than left to inspection. -export function TrendCard({ - trend, - registerTotal, - isLoading, - error, - retry, - selection, - onWidenPeriod, -}: { - trend?: RiskTrend; - /** - * The register's own total, from the same payload. It is what decides which - * empty state to show, and it has to come from here rather than be derived - * from the series — see the comment on `emptyBecauseOfPeriod` below. - */ - registerTotal: number; - isLoading: boolean; - error: unknown; - retry: () => void; - selection: PeriodSelection; - onWidenPeriod: () => void; -}) { - const L = useUIStrings(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - - // Memoised because `?? []` allocates a fresh array on every render, which - // would re-run the series useMemo below every time regardless of the data. - const points = useMemo(() => trend?.points ?? [], [trend]); - const opened = points.reduce((n, p) => n + p.opened, 0); - const lastCumulative = points.length ? points[points.length - 1].cumulative_total : 0; - // Nothing opened in the window, but the register is not empty: that is a period - // problem, not an empty register, and the two have opposite remedies. - // - // This reads the REGISTER's total, not the series' last cumulative point. The - // first version used the cumulative, and it was wrong for every window in the - // past: `cumulative_total` counts risks created BEFORE each bucket, so a - // January window over a register first populated in August reads 0 and the - // widget concluded the tenant had no risks. Driving it live is what showed a - // tenant with eight risks being told "no trend yet — the line builds up as - // risks are opened", which is precisely the wrong fact with the opposite - // remedy attached. - const emptyBecauseOfPeriod = opened === 0 && registerTotal > 0; - - const W = 300, - H = 120, - pad = 8; - const series = useMemo(() => { - const band = (key: string) => points.map((p) => p.opened_by_band?.[key] ?? 0); - return { crit: band('CRITICAL'), high: band('HIGH'), med: band('MEDIUM') }; - }, [points]); - const allMax = Math.max(1, ...series.crit, ...series.high, ...series.med); - const line = (arr: number[]) => { - const step = (W - pad * 2) / Math.max(1, arr.length - 1); - return arr - .map((v, i) => `${pad + i * step},${H - pad - (v / allMax) * (H - pad * 2)}`) - .join(' '); - }; - const leg = (col: string, lbl: string) => ( - - - {lbl} - - ); - - return ( - -
-
{L.trendTitle}
-
{periodLabel(selection, lang)}
-
- {/* Say what is plotted. "Trend" alone let a cumulative-only line pass for - an improving or worsening posture. */} -
- {tr('Risques ouverts par ', 'Risks opened per ')} - {trend?.granularity === 'week' ? tr('semaine', 'week') : tr('jour', 'day')} - {tr(', par criticité actuelle', ', by current criticality')} -
- - <> -
- {leg('var(--critical)', L.critical)} - {leg('var(--high)', L.high)} - {leg('var(--medium)', L.medium)} -
- a + b, 0)} critiques, ${series.high.reduce((a, b) => a + b, 0)} élevés, ${series.med.reduce((a, b) => a + b, 0)} moyens. Total ${opened}.`, - `Risks opened over ${periodLabel(selection, lang)}: ${series.crit.reduce((a, b) => a + b, 0)} critical, ${series.high.reduce((a, b) => a + b, 0)} high, ${series.med.reduce((a, b) => a + b, 0)} medium. Total ${opened}.`, - )} - > - {[1, 2, 3].map((i) => ( - - ))} - - - - - {/* The stock, in words rather than as a second line that would be read - against the same axis as the flow. */} -
- {tr( - `${opened} ouvert(s) · ${lastCumulative} au registre en fin de période`, - `${opened} opened · ${lastCumulative} in the register at the end of the period`, - )} -
- -
-
- ); -} - -/* ---------------- Recent activity ---------------- */ -function RecentActivityCard({ risks }: { risks: RecentRisk[] }) { - const L = useUIStrings(); - const lang = useUIStore((s) => s.lang); - const navigate = useNavigate(); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const canCreate = useAuthStore((s) => s.hasPermission('risks:create')); - return ( - -
-
{L.recentTitle}
- -
-
- {risks.length === 0 && ( - window.dispatchEvent(new CustomEvent('openrisk:new-risk'))} - /> - ) : undefined - } - className="py-10" - /> - )} - {risks.map((r) => ( - - ))} -
-
- ); -} - -/* ---------------- War Room widget ---------------- */ -// Real "incident in progress": the most recent open/in-progress incident for this -// tenant with a live duration; an honest empty state when there is none (no more -// hardcoded INC-2026-014). -const SEV_COLOR = { - critical: 'var(--critical)', - high: 'var(--high)', - medium: 'var(--medium)', - low: 'var(--low)', -} as const; -function WarRoomCard({ onJoin }: { onJoin: (incidentId?: number) => void }) { - const L = useUIStrings(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const { incidents } = useIncidents({ limit: 20 }); - const [now, setNow] = useState(() => Date.now()); - const active = (incidents ?? []).filter((i) => i.status === 'open' || i.status === 'in_progress'); - const inc = active - .slice() - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]; - useEffect(() => { - if (!inc) return; - const t = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(t); - }, [inc?.id]); - - if (!inc) { - return ( -
-
- - - {L.warTitle} - -
-
- -
- {tr('Aucun incident en cours', 'No active incident')} -
-
- {tr( - 'Tout est calme. Les incidents ouverts s’afficheront ici.', - 'All clear. Open incidents will appear here.', - )} -
-
- -
- ); - } - - const sevColor = SEV_COLOR[inc.severity] ?? 'var(--high)'; - const elapsed = Math.max(0, Math.floor((now - new Date(inc.created_at).getTime()) / 1000)); - const hh = String(Math.floor(elapsed / 3600)).padStart(2, '0'); - const mm = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0'); - const ss = String(elapsed % 60).padStart(2, '0'); - return ( -
-
- - - {L.warTitle} - -
-
- INC-{inc.id} · {inc.title} -
-
- {inc.severity} ·{' '} - {inc.status === 'in_progress' ? tr('en cours', 'in progress') : tr('ouvert', 'open')} -
-
-
-
- {hh}:{mm}:{ss} -
-
{tr('Durée', 'Duration')}
-
-
- -
- ); -} - -export type { DashboardStats }; diff --git a/frontend/src/features/dashboard/EstateDashboard.tsx b/frontend/src/features/dashboard/EstateDashboard.tsx deleted file mode 100644 index c9a27bcc..00000000 --- a/frontend/src/features/dashboard/EstateDashboard.tsx +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// DSI / asset-owner dashboard persona (UX-2). Leads with the estate: inventory -// size, criticality mix, and the most critical assets. -// -// W0-06 moved the counting to the server. Every number here used to be a -// reduction over the whole GET /assets collection — which returns the entire -// inventory WITH its many-to-many risk associations preloaded, because that is -// what the topology graph needs. The counts were right; obtaining them cost the -// estate crossing the wire on every dashboard paint, and grew with the tenant. -// -// GET /assets/statistics answers the same question in one grouped pass, and -// answers two the client could not: how many assets carry no category -// (everything written before typed attributes shipped) and how many distinct -// type labels exist beyond the twelve shown. The list of critical assets still -// comes from the collection — it needs the rows, not a count. - -import { useMemo } from 'react'; -import { useNavigate } from 'react-router'; -import { Database, ShieldAlert, AlertTriangle, Boxes } from 'lucide-react'; -import { useUIStore } from '../../store/uiStore'; -import { useAuthStore } from '../../hooks/useAuthStore'; -import { useAssets } from '../assets/useAssets'; -import { useAssetStatistics } from './useCommandCenter'; -import { useDashboardPeriod, periodLabel } from './period'; -import { deepLink } from './deepLinks'; -import { PeriodControl } from './PeriodControl'; -import { WidgetState } from './WidgetState'; -import { DashboardShell, PersonaHeader, KpiRow, Card, type KpiSpec } from './shared'; - -const CRIT_COL: Record = { - CRITICAL: 'var(--critical)', - HIGH: 'var(--high)', - MEDIUM: 'var(--medium)', - LOW: 'var(--low)', -}; -const CRIT_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; - -export function EstateDashboard() { - const navigate = useNavigate(); - const canCreateAsset = useAuthStore((s) => s.hasPermission('assets:create')); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const { selection, setSelection } = useDashboardPeriod(); - const statsQuery = useAssetStatistics(selection); - const stats = statsQuery.data; - const { assets, isLoading: assetsLoading, error: assetsError } = useAssets(); - - const norm = (c?: string) => (c ?? 'LOW').toUpperCase(); - const byCrit = stats?.by_criticality ?? {}; - - const critical = useMemo( - () => - [...assets] - .filter((a) => ['CRITICAL', 'HIGH'].includes(norm(a.criticality))) - .sort( - (a, b) => - CRIT_ORDER.indexOf(norm(a.criticality)) - CRIT_ORDER.indexOf(norm(b.criticality)), - ) - .slice(0, 6), - [assets], - ); - - // Each tile carries its own filter into the inventory. "Critical — 7" opens - // the seven assets it counted, not the whole estate. All four read the same - // query, so they share one "did this genuinely fetch during this mount" - // signal. - const fresh = statsQuery.isFetchedAfterMount; - const kpis: KpiSpec[] = [ - { - label: tr('Actifs', 'Assets'), - val: stats?.total ?? 0, - icon: Database, - col: 'var(--accent)', - onClick: () => navigate(deepLink('assets')), - fresh, - }, - { - label: tr('Critiques', 'Critical'), - val: byCrit.CRITICAL ?? 0, - icon: AlertTriangle, - col: 'var(--critical)', - onClick: () => navigate(deepLink('assets', { filters: { criticality: 'critical' } })), - fresh, - }, - { - label: tr('Élevés', 'High'), - val: byCrit.HIGH ?? 0, - icon: ShieldAlert, - col: 'var(--high)', - onClick: () => navigate(deepLink('assets', { filters: { criticality: 'high' } })), - fresh, - }, - { - label: tr('Types', 'Types'), - val: stats?.distinct_types ?? 0, - icon: Boxes, - col: 'var(--low)', - onClick: () => navigate(deepLink('assets')), - fresh, - }, - ]; - - const maxCrit = Math.max(1, ...CRIT_ORDER.map((c) => byCrit[c] ?? 0)); - - return ( - -
-
- navigate('/assets/topology')} - /> -
- -
- -
- void statsQuery.refetch()} - > - <> - -
- - {tr( - `${stats?.added_in_period ?? 0} ajouté(s) sur la période (${periodLabel(selection, lang)})`, - `${stats?.added_in_period ?? 0} added in the selected period (${periodLabel(selection, lang)})`, - )} - - {/* Stated rather than dropped: these rows are part of the total - above, and a breakdown that hid them would not add up to it. */} - {!!stats?.uncategorised && ( - - {tr( - `${stats.uncategorised} sans catégorie`, - `${stats.uncategorised} with no category`, - )} - - )} - {!!stats?.types_truncated && ( - - {tr( - `+${stats.types_truncated} autre(s) type(s) non affiché(s)`, - `+${stats.types_truncated} more type(s) not shown`, - )} - - )} -
- -
-
- -
- -
-
- {tr('Actifs critiques', 'Critical assets')} -
- -
- navigate('/assets')} - className="h-[34px] px-4 rounded-[9px] text-[12.5px] font-semibold text-fg-primary" - style={{ background: 'var(--accent)' }} - > - {tr('Nouvel actif', 'New asset')} - - ) : undefined - } - > - <> - {critical.map((a) => { - const col = CRIT_COL[norm(a.criticality)] ?? 'var(--fg-muted)'; - return ( - - ); - })} - - -
- - -
- {tr('Par criticité', 'By criticality')} -
- void statsQuery.refetch()} - > -
- {CRIT_ORDER.map((c) => { - const n = byCrit[c] ?? 0; - const col = CRIT_COL[c]; - return ( - - ); - })} -
-
-
-
-
- ); -} diff --git a/frontend/src/features/dashboard/ExecDashboard.tsx b/frontend/src/features/dashboard/ExecDashboard.tsx deleted file mode 100644 index 58fc435f..00000000 --- a/frontend/src/features/dashboard/ExecDashboard.tsx +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// Executive dashboard persona (UX-2). Leads with cost & KPIs, no technical detail: -// the security score, financial exposure (ALE, FCFA) and the key risk indicators. -// Real data from the consolidated /analytics/executive endpoint. -// -// #287: the score here used to be an A–F "cyber score" from a second formula, -// pointing the opposite way from the sidebar's (higher = safer), and it read -// 50/E on an empty tenant while the home page read 0. It is now the canonical -// tenant score, from the same query key as every other surface. -// -// W0-06: every value on this screen was `?? 0`. A failed fetch of -// /analytics/executive rendered a cyber score of 0 with grade F, an annual -// exposure of 0 FCFA and an empty KRI band — a complete, plausible, entirely -// unread executive briefing. On the persona whose whole job is to act on these -// numbers, and with no loading state to suggest anything was still in flight. -// -// Nothing is rendered now until it has been read. A failure says so, and offers -// a retry; a genuine zero is still a zero, and is now distinguishable from one. - -import { localeTag } from '../../i18n/locales'; -import { useNavigate } from 'react-router'; -import { Coins, AlertTriangle, ShieldAlert, CheckCircle2 } from 'lucide-react'; -import { useUIStore } from '../../store/uiStore'; -import { useExecutiveDashboard } from '../analytics/useExecutive'; -import { deepLink } from './deepLinks'; -import { WidgetState } from './WidgetState'; -import { DashboardShell, PersonaHeader, StatCard, Card } from './shared'; -import { useScore } from '../../hooks/useScore'; -import { ScoreGauge } from '../../shared/ScoreGauge'; - -const KRI_COL: Record = { - critical: 'var(--critical)', - high: 'var(--high)', - medium: 'var(--medium)', - low: 'var(--low)', - info: 'var(--fg-muted)', -}; - -export function ExecDashboard() { - const navigate = useNavigate(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const query = useExecutiveDashboard(); - const data = query.data; - const tenantScore = useScore('tenant'); - const fin = data?.financial; - const kris = data?.kris ?? []; - - const money = (n: number) => - new Intl.NumberFormat(localeTag(lang), { - notation: n >= 1_000_000 ? 'compact' : 'standard', - maximumFractionDigits: 1, - }).format(n) + ' FCFA'; - const kriUnit = (u: string) => (u === '%' ? '%' : u === 'days' ? tr(' j', ' d') : ''); - - return ( - - navigate('/reports')} - /> - - void query.refetch()} - > -
- navigate('/score')} - /> -
- navigate('/analytics/financial')} - /> - navigate('/analytics/financial')} - /> - navigate(deepLink('risks', { sort: { key: 'score', dir: 'desc' } }))} - /> - navigate('/analytics/financial')} - /> -
-
-
- - -
-
- {tr('Indicateurs clés de risque (KRI)', 'Key risk indicators (KRI)')} -
- -
- void query.refetch()} - isEmpty={kris.length === 0} - emptyTitle={tr('Aucun indicateur calculable', 'No indicator can be computed yet')} - emptyDescription={tr( - 'Les KRI se calculent à partir des risques, des vulnérabilités, de la conformité et des incidents. Ils apparaissent dès que l’une de ces sources contient des données.', - 'KRIs are computed from risks, vulnerabilities, compliance and incidents. They appear as soon as any of those holds data.', - )} - > -
- {kris.map((k) => ( -
-
- {new Intl.NumberFormat(localeTag(lang), { - maximumFractionDigits: 1, - }).format(k.value)} - {kriUnit(k.unit)} -
-
{k.label}
-
- ))} -
-
-
-
- ); -} diff --git a/frontend/src/features/dashboard/PeriodControl.tsx b/frontend/src/features/dashboard/PeriodControl.tsx deleted file mode 100644 index 30b41601..00000000 --- a/frontend/src/features/dashboard/PeriodControl.tsx +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// The dashboard's period selector, and the sentence that keeps it honest. -// -// A global period control on a page of mixed widgets has one failure mode, and -// it is severe: it looks like it filters everything. It does not, and it cannot. -// The headline counters are STOCK quantities — how many critical risks exist — -// and narrowing them by created_at would answer "how many did we open last -// month" under a label that says otherwise. Only the flow widgets (the trend, the -// "opened"/"added" counters) are period-scoped. -// -// So the control states its own scope, in words, right under itself: it names -// what moves when you change it. That line is not decoration — it is the -// difference between a filter and a filter that misleads, and the brief this -// wave answers calls the alternative out by name. -// -// The selection lives in the URL (see ./period.ts), so the control has no state -// of its own to drift from what is displayed. - -import { useId, useState } from 'react'; -import { CalendarRange, Check } from 'lucide-react'; - -import { PERIOD_PRESETS, periodLabel, type PeriodSelection, type PeriodPreset } from './period'; -import type { LocaleCode } from '../../i18n/locales'; - -export function PeriodControl({ - selection, - onChange, - lang, - /** - * What this control actually narrows, in the caller's own words. Required: - * a period control that will not say what it filters should not ship. - */ - scopeNote, -}: { - selection: PeriodSelection; - onChange: (next: PeriodSelection) => void; - lang: LocaleCode; - scopeNote: string; -}) { - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const [customOpen, setCustomOpen] = useState(selection.kind === 'custom'); - const [from, setFrom] = useState(selection.kind === 'custom' ? selection.from : ''); - const [to, setTo] = useState(selection.kind === 'custom' ? selection.to : ''); - const groupId = useId(); - - const rangeValid = !!from && !!to && from < to; - - return ( -
-
- - {tr('Période du tableau de bord', 'Dashboard period')} - - {PERIOD_PRESETS.map((preset: PeriodPreset) => { - const active = selection.kind === 'preset' && selection.preset === preset; - return ( - - ); - })} - -
- - {customOpen && ( -
- - setFrom(e.target.value)} - className="h-[28px] px-2 rounded-[7px] text-[12px] text-ink bg-transparent" - style={{ border: '1px solid var(--border)' }} - /> - → - - setTo(e.target.value)} - className="h-[28px] px-2 rounded-[7px] text-[12px] text-ink bg-transparent" - style={{ border: '1px solid var(--border)' }} - /> - -
- )} - - {/* The scope sentence. The control filters some widgets and not others, so - it says which — rather than letting the user assume "all of them". */} -

- {scopeNote} -

-
- ); -} diff --git a/frontend/src/features/dashboard/ViewerDashboard.tsx b/frontend/src/features/dashboard/ViewerDashboard.tsx deleted file mode 100644 index 42abca0a..00000000 --- a/frontend/src/features/dashboard/ViewerDashboard.tsx +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// Viewer dashboard persona (UX-2). A light, read-only overview of the security -// posture — score, a few headline risk counts, and the most recent risks. No -// authoring actions, since a viewer can only read. -// -// W0-06 fixed three things here, all of the same kind. -// -// The score was `stats.global_risk_score`, a SECOND formula (100 − avg × 4) -// computed from the register alone, on the same 0–100 scale as the canonical -// model. A viewer and an admin looking at the same tenant saw two different -// security scores. It now reads the same query key as every other score surface -// in the product, so there is one answer in memory and they cannot disagree. -// -// The counters fell back to `risks.filter(...).length` whenever /stats failed — -// counts over ONE PAGE of the register, printed as tenant totals, on the persona -// least equipped to notice. The fallbacks are gone; a failed read renders an -// error state. -// -// The tiles and the rows now carry their filter into the register instead of -// dropping the user at the top of an unfiltered list. - -import { useEffect, useMemo } from 'react'; -import { useNavigate } from 'react-router'; -import { ShieldAlert, AlertTriangle, ShieldCheck } from 'lucide-react'; -import { useUIStore } from '../../store/uiStore'; -import { useRiskStore } from '../../hooks/useRiskStore'; -import { useScore } from '../../hooks/useScore'; -import { ScoreGauge } from '../../shared/ScoreGauge'; -import { critColor, type Criticality } from '../../shared/riskColors'; -import { useDashboardStats } from './useCommandCenter'; -import { useDashboardPeriod } from './period'; -import { deepLink } from './deepLinks'; -import { WidgetState } from './WidgetState'; -import { DashboardShell, PersonaHeader, KpiRow, Card, type KpiSpec } from './shared'; - -export function ViewerDashboard() { - const navigate = useNavigate(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const risks = useRiskStore((s) => s.risks); - const fetchRisks = useRiskStore((s) => s.fetchRisks); - const { selection } = useDashboardPeriod(); - const statsQuery = useDashboardStats(selection); - const stats = statsQuery.data; - // The canonical score, from the shared query key. - const { - data: tenantScore, - isLoading: scoreLoading, - isError: scoreError, - isFetchedAfterMount: scoreFresh, - } = useScore('tenant'); - - useEffect(() => { - fetchRisks?.().catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // The server's criticality, or nothing. No client fallback from the number: - // that is the mapping that drifted from the value beside it. - const critOf = (r: (typeof risks)[number]): Criticality => - ((r.criticality ?? r.level)?.toLowerCase() as Criticality) || 'low'; - const sev = stats?.risks_by_severity ?? {}; - const sort = { key: 'score', dir: 'desc' } as const; - - const kpis: KpiSpec[] = [ - { - label: tr('Risques', 'Risks'), - val: stats?.total_risks ?? 0, - icon: ShieldAlert, - col: 'var(--accent)', - onClick: () => navigate(deepLink('risks', { sort })), - fresh: statsQuery.isFetchedAfterMount, - }, - { - label: tr('Critiques', 'Critical'), - val: sev.CRITICAL ?? sev.critical ?? 0, - icon: AlertTriangle, - col: 'var(--critical)', - onClick: () => navigate(deepLink('risks', { filters: { criticality: 'critical' }, sort })), - fresh: statsQuery.isFetchedAfterMount, - }, - { - label: tr('Atténués', 'Mitigated'), - val: stats?.mitigated_risks ?? 0, - icon: ShieldCheck, - col: 'var(--low)', - onClick: () => navigate(deepLink('risks', { filters: { status: 'mitigated' }, sort })), - fresh: statsQuery.isFetchedAfterMount, - }, - ]; - - const recent = useMemo(() => risks.slice(0, 6), [risks]); - - return ( - - - -
- navigate('/score')} - /> -
- void statsQuery.refetch()} - > - - -
-
- - -
- {tr('Risques récents', 'Recent risks')} -
- {recent.length === 0 ? ( -
- {tr('Aucun risque à afficher.', 'No risks to show.')} -
- ) : ( - recent.map((r) => { - const crit = critOf(r); - return ( - - ); - }) - )} -
-
- ); -} diff --git a/frontend/src/features/dashboard/WidgetState.tsx b/frontend/src/features/dashboard/WidgetState.tsx deleted file mode 100644 index 71cdf819..00000000 --- a/frontend/src/features/dashboard/WidgetState.tsx +++ /dev/null @@ -1,290 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// What a widget renders when it does not have its data. -// -// Five conditions, five different things to say, and the whole reason this -// component exists is that five personas collapsed them into one — usually into -// the most reassuring one available: -// -// loading we are asking -// error we asked and could not read it -// no-permission it exists; this account may not see it -// empty-period there is data, just none inside the selected window -// empty there is nothing to show yet -// -// Before this, the exec persona rendered every failure as `?? 0` — a cyber score -// of 0 and an annual exposure of 0 FCFA, indistinguishable from a genuinely -// measured zero. The audit persona rendered a failed compliance fetch as "No -// framework — import one to start", telling the user to fix a problem they did -// not have. The viewer persona fell back to counting one page of the register -// and printed the result as a tenant total. -// -// The distinction between the last two is the one people skip, and it is the one -// that matters most on a dashboard with a period control: "no risks were opened -// in the last 7 days" and "you have no risks" are opposite facts, and only one of -// them is a reason to press a Create button. - -import { useEffect, useRef, useState, type ReactNode } from 'react'; -import { AlertTriangle, CalendarX, Lock, type LucideIcon } from 'lucide-react'; - -import { EmptyState } from '../../shared/EmptyState'; -import { Btn, Skeleton } from '../../shared/ui'; -import { isPermissionError } from './widgetError'; -import type { LocaleCode } from '../../i18n/locales'; - -/* A skeleton that would show for under this long (cached / near-instant data) - * never shows at all — the flash it would produce is worse than the half-beat - * of nothing it replaces. Not a token: this is a debounce window, not a - * motion duration: nothing animates during it. */ -const SKELETON_GRACE_MS = 100; - -/* NOT the source of truth — primitives.css is. Read back from the DOM (see - * useDensityRowHeight for the same pattern) so the exit timer cannot drift - * from what `duration-fast` actually does; this only stands in when the DOM - * can't be measured (SSR) or the token has gone missing. */ -const FALLBACK_DUR_FAST_MS = 120; - -function readTokenMs(varName: string, fallback: number): number { - if (typeof document === 'undefined') return fallback; - const raw = getComputedStyle(document.documentElement).getPropertyValue(varName); - const ms = Number.parseFloat(raw); - return Number.isFinite(ms) && ms > 0 ? ms : fallback; -} - -function prefersReducedMotion(): boolean { - return ( - typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ); -} - -/** - * The skeleton -> content reveal's timing, kept out of the component so the - * two guarantees it exists for are each one obvious branch: - * - * - `showSkeleton` only ever becomes true `SKELETON_GRACE_MS` after loading - * started, so data that lands before that never produces a skeleton frame - * to cross-fade away from. - * - once content has genuinely been shown, `contentShown` latches true for - * the rest of this instance's life — a later `isLoading=true` (a - * background refetch) cannot re-arm the grace timer or bring the skeleton - * back, which is the #823-class trap this whole feature exists to avoid. - * - * `exiting` is true for exactly one `--dur-fast` after loading ends, IF a - * skeleton was actually shown — that window is what lets the caller overlay - * a fading skeleton on top of entering content instead of hard-cutting - * between them. Settled on a timer, not `transitionend`: that event never - * fires once reduced motion has killed the transition, which would leave - * `exiting` stuck true for a user who, by definition, never sees one. - */ -function useSkeletonReveal(isLoading: boolean) { - const graceTimer = useRef | undefined>(undefined); - const exitTimer = useRef | undefined>(undefined); - - const [prevIsLoading, setPrevIsLoading] = useState(isLoading); - const [showSkeleton, setShowSkeleton] = useState(false); - const [exiting, setExiting] = useState(false); - const [contentShown, setContentShown] = useState(!isLoading); - - // React-documented "adjust state while rendering": reacts to `isLoading` - // changing in THIS commit rather than a tick later via an effect, and is - // what keeps every direct setState call out of a `useEffect` body below — - // both effects only ever call setState from inside a timer callback. - if (isLoading !== prevIsLoading) { - setPrevIsLoading(isLoading); - if (!isLoading) { - setContentShown(true); - setShowSkeleton(false); - // `showSkeleton` here is still THIS render's incoming value (the one - // committed before this adjustment), i.e. "was the skeleton actually - // on screen for the episode that just ended" — true only for a - // genuine first load past the grace window, never for a background - // refetch (which never arms the grace timer at all, see below), so a - // refetch cannot spuriously replay the exit cross-fade. - if (showSkeleton && !prefersReducedMotion()) { - setExiting(true); - } - } - } - - // Arms the grace timer while genuinely (still) loading for the first time; - // settles it via the timer's OWN callback, never directly in this body. - // Never re-arms once content has shown once — a background refetch must - // not bring the skeleton back. - useEffect(() => { - clearTimeout(graceTimer.current); - if (!isLoading || contentShown) return; - graceTimer.current = setTimeout(() => setShowSkeleton(true), SKELETON_GRACE_MS); - return () => clearTimeout(graceTimer.current); - }, [isLoading, contentShown]); - - // Settles `exiting` back to false after --dur-fast, via the timer's OWN - // callback — never on `transitionend`, which does not fire once reduced - // motion has killed the transition (and would leave this stuck true). - useEffect(() => { - if (!exiting) return; - exitTimer.current = setTimeout( - () => setExiting(false), - readTokenMs('--dur-fast', FALLBACK_DUR_FAST_MS), - ); - return () => clearTimeout(exitTimer.current); - }, [exiting]); - - return { showSkeleton, exiting, contentShown }; -} - -export interface WidgetStateProps { - lang: LocaleCode; - isLoading: boolean; - error: unknown; - /** True when the fetch succeeded and returned nothing to draw. */ - isEmpty?: boolean; - /** - * True when the window is what made it empty — there IS data outside it. - * Renders the "widen the period" state instead of the "create your first" - * state, because those have opposite remedies. - */ - emptyBecauseOfPeriod?: boolean; - /** Height of the loading skeleton, matched to the widget it stands in for. */ - skeletonHeight?: number; - retry?: () => void; - /** first-use copy: what this widget is for, and the action that fills it. */ - emptyTitle?: string; - emptyDescription?: string; - emptyIcon?: LucideIcon; - emptyAction?: ReactNode; - /** Widen the window — offered on the period-empty state. */ - onWidenPeriod?: () => void; - children: ReactNode; -} - -/** - * Render `children` when there is data, and the right honest state otherwise. - * - * Note what it does NOT do: it never renders `children` with a placeholder - * value substituted in. A widget either shows data it read or says why it - * cannot — there is no third rendering where a zero stands in for an answer. - */ -export function WidgetState({ - lang, - isLoading, - error, - isEmpty, - emptyBecauseOfPeriod, - skeletonHeight = 160, - retry, - emptyTitle, - emptyDescription, - emptyIcon, - emptyAction, - onWidenPeriod, - children, -}: WidgetStateProps) { - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const reveal = useSkeletonReveal(isLoading); - // Never shown content before, and still loading: `error`/`isEmpty` are not - // meaningful until a fetch has actually finished at least once. - const stillLoading = isLoading && !reveal.contentShown; - - if (stillLoading && !reveal.showSkeleton) { - // Inside the grace window: reserve the space, draw nothing. The - // alternative — showing the skeleton immediately — is exactly the flash - // this window exists to avoid for cached/near-instant data. - return