From 0af80e3c0a726348e2034b436e18275302aef87a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 2 Jul 2026 09:20:38 -0600 Subject: [PATCH 1/8] fix[frontend](compilance): added controls visualization inside framework --- frontend/src/app/routes/index.tsx | 2 + .../compliance/pages/CompliancePage.tsx | 74 ++-------------- .../compliance/pages/FrameworkReportPage.tsx | 87 +++++++++++++++++++ frontend/src/shared/i18n/locales/en.json | 1 + 4 files changed, 95 insertions(+), 69 deletions(-) create mode 100644 frontend/src/features/compliance/pages/FrameworkReportPage.tsx diff --git a/frontend/src/app/routes/index.tsx b/frontend/src/app/routes/index.tsx index 6412f0e88..4813f49fc 100644 --- a/frontend/src/app/routes/index.tsx +++ b/frontend/src/app/routes/index.tsx @@ -24,6 +24,7 @@ import { LogExplorerPage } from '@/features/log-explorer/pages/LogExplorerPage' import { UserAuditorPage } from '@/features/user-auditor/pages/UserAuditorPage' import { ThreatIntelPage } from '@/features/threat-intel/pages/ThreatIntelPage' import { CompliancePage } from '@/features/compliance/pages/CompliancePage' +import { FrameworkReportPage } from '@/features/compliance/pages/FrameworkReportPage' import { DataSourcesPage } from '@/features/datasources/pages/DataSourcesPage' import { IntegrationsPage } from '@/features/integrations/pages/IntegrationsPage' import { AlertingRulesPage } from '@/features/alerting-rules/pages/AlertingRulesPage' @@ -121,6 +122,7 @@ export function AppRoutes() { {/* Compliance */} } /> + } /> {/* Legacy redirects */} } /> } /> diff --git a/frontend/src/features/compliance/pages/CompliancePage.tsx b/frontend/src/features/compliance/pages/CompliancePage.tsx index 59b83789e..56422700f 100644 --- a/frontend/src/features/compliance/pages/CompliancePage.tsx +++ b/frontend/src/features/compliance/pages/CompliancePage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { AlertTriangle, @@ -18,10 +19,9 @@ import { toast } from 'sonner' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' -import { complianceService, ComplianceHttpError } from '../services/compliance-http.service' -import type { Framework, Report } from '../types/compliance.types' +import { complianceService } from '../services/compliance-http.service' +import type { Framework } from '../types/compliance.types' import { scoreTone } from '../components/ReportView' -import { ReportDocument } from '../components/ReportDocument' import { ReportsTab } from '../components/ReportsTab' import { ScheduleTab } from '../components/ScheduleTab' import { ControlsTab } from '../components/ControlsTab' @@ -72,12 +72,12 @@ function controlCount(f: Framework): number { function FrameworksTab() { const { t } = useTranslation() + const navigate = useNavigate() const [frameworks, setFrameworks] = useState([]) const [scores, setScores] = useState>({}) const [search, setSearch] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState(false) - const [open, setOpen] = useState(null) const [editing, setEditing] = useState<{ framework?: Framework; creating: boolean } | null>(null) const load = useCallback(() => { @@ -152,13 +152,12 @@ function FrameworksTab() { ) : (
{shown.map((f) => ( - setOpen(f)} onEdit={() => setEditing({ framework: f, creating: false })} onToggle={() => toggle(f)} t={t} /> + navigate(`/compliance/frameworks/${encodeURIComponent(f.key)}`)} onEdit={() => setEditing({ framework: f, creating: false })} onToggle={() => toggle(f)} t={t} /> ))}
)} - {open && setOpen(null)} t={t} />} {editing && ( void; t: ReturnType['t'] }) { - const [report, setReport] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - const [running, setRunning] = useState(false) - - const load = useCallback(() => { - setLoading(true) - setError(false) - complianceService - .liveReport(framework.key) - .then(setReport) - .catch(() => setError(true)) - .finally(() => setLoading(false)) - }, [framework.key]) - useEffect(() => { - load() - }, [load]) - - const runReport = async () => { - setRunning(true) - try { - const r = await complianceService.generateReport(framework.key) - setReport(r) - toast.success(t('compliance.toast.reportGenerated')) - } catch (e) { - toast.error(e instanceof ComplianceHttpError ? e.message : t('compliance.toast.reportError')) - } finally { - setRunning(false) - } - } - - if (loading) { - return ( -
-
{t('compliance.evaluating')}
-
- ) - } - if (error || !report) { - return ( -
-
e.stopPropagation()}> - {t('compliance.reportError')} - - -
-
- ) - } - return ( - void runReport()} - running={running} - onDownloadPdf={() => complianceService.downloadFrameworkPdf(framework.key)} - /> - ) -} - function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) { return ( +
+

{report?.frameworkName || key}

+
+ + + +
+ {loading ? ( +
+ {t('compliance.evaluating')} +
+ ) : error || !report ? ( +
+ {t('compliance.reportError')} + +
+ ) : ( + + )} +
+ + ) +} diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index 205fc4b57..2c3a76441 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -4758,6 +4758,7 @@ "controlsCount": "{{n}} controls", "enabledLabel": "Enabled", "runReport": "Run report", + "backToFrameworks": "Back to frameworks", "evaluating": "Evaluating framework…", "complianceScore": "Compliance score", "generatedAt": "Generated {{date}}", From 61f0c77ca434da5afad3a54d17e475139cecbeb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 2 Jul 2026 09:24:42 -0600 Subject: [PATCH 2/8] fix[frontend](compilance): added controls details visualization --- .../components/ControlDetailDrawer.tsx | 136 ++++++++++++++++++ .../compliance/components/ReportView.tsx | 13 +- .../compliance/pages/FrameworkReportPage.tsx | 8 +- 3 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/compliance/components/ControlDetailDrawer.tsx diff --git a/frontend/src/features/compliance/components/ControlDetailDrawer.tsx b/frontend/src/features/compliance/components/ControlDetailDrawer.tsx new file mode 100644 index 000000000..c373ca18d --- /dev/null +++ b/frontend/src/features/compliance/components/ControlDetailDrawer.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AlertTriangle, Loader2, X } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { complianceService } from '../services/compliance-http.service' +import type { Control, ReportControlRow } from '../types/compliance.types' +import { STATUS_TONE } from './ReportView' + +/** Right-side drawer showing full details of a report control row + its library Control. */ +export function ControlDetailDrawer({ row, onClose }: { row: ReportControlRow; onClose: () => void }) { + const { t } = useTranslation() + const [control, setControl] = useState(null) + const [loading, setLoading] = useState(true) + const [notFound, setNotFound] = useState(false) + + useEffect(() => { + setLoading(true) + setNotFound(false) + complianceService + .getControl(row.controlId) + .then(setControl) + .catch(() => setNotFound(true)) + .finally(() => setLoading(false)) + }, [row.controlId]) + + return ( +
+ +
+ ) +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function Block({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+ {children} +
+ ) +} diff --git a/frontend/src/features/compliance/components/ReportView.tsx b/frontend/src/features/compliance/components/ReportView.tsx index 15d868eaf..74a528ec3 100644 --- a/frontend/src/features/compliance/components/ReportView.tsx +++ b/frontend/src/features/compliance/components/ReportView.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' import { cn } from '@/shared/lib/utils' import { useDateFormat } from '@/shared/lib/datetime' -import type { ControlStatus, Report } from '../types/compliance.types' +import type { ControlStatus, Report, ReportControlRow } from '../types/compliance.types' export function scoreTone(score: number): string { if (score >= 80) return 'text-emerald-500' @@ -25,7 +25,7 @@ export const STATUS_TONE: Record = { } /** Renders a compliance report: summary score + per-section control breakdown. */ -export function ReportView({ report }: { report: Report }) { +export function ReportView({ report, onControlClick }: { report: Report; onControlClick?: (row: ReportControlRow) => void }) { const { t } = useTranslation() const df = useDateFormat() const s = report.summary ?? { compliantPct: 0, total: 0, compliant: 0, nonCompliant: 0, atRisk: 0, notCovered: 0, pending: 0, outOfScope: 0 } @@ -57,7 +57,14 @@ export function ReportView({ report }: { report: Report }) {
{sec.name}
{(sec.controls ?? []).map((c) => ( -
+
onControlClick(c) : undefined} + className={cn( + 'flex items-start gap-3 border-b border-border px-4 py-2.5 last:border-0', + onControlClick && 'cursor-pointer transition-colors hover:bg-muted/50', + )} + > {t(`compliance.status.${c.status}`)} diff --git a/frontend/src/features/compliance/pages/FrameworkReportPage.tsx b/frontend/src/features/compliance/pages/FrameworkReportPage.tsx index f681f1588..2659d1da9 100644 --- a/frontend/src/features/compliance/pages/FrameworkReportPage.tsx +++ b/frontend/src/features/compliance/pages/FrameworkReportPage.tsx @@ -5,8 +5,9 @@ import { AlertTriangle, ArrowLeft, Download, Loader2 } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/shared/components/ui/button' import { complianceService } from '../services/compliance-http.service' -import type { Report } from '../types/compliance.types' +import type { Report, ReportControlRow } from '../types/compliance.types' import { ReportView } from '../components/ReportView' +import { ControlDetailDrawer } from '../components/ControlDetailDrawer' export function FrameworkReportPage() { const { key = '' } = useParams<{ key: string }>() @@ -16,6 +17,7 @@ export function FrameworkReportPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(false) const [downloading, setDownloading] = useState(false) + const [openRow, setOpenRow] = useState(null) const load = useCallback(() => { if (!key) return @@ -79,9 +81,11 @@ export function FrameworkReportPage() {
) : ( - + )}
+ + {openRow && setOpenRow(null)} />}
) } From 3ea52204e4eb55f1c9e717c764dae897f78dddde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 2 Jul 2026 09:44:23 -0600 Subject: [PATCH 3/8] fix[backend](compilance): added manual state changes on framework-controls --- .../compliance/connectors/repository.go | 9 +++ .../modules/compliance/connectors/usecase.go | 6 ++ .../domain/control_status_override.go | 26 +++++++++ backend/modules/compliance/domain/errors.go | 1 + backend/modules/compliance/domain/report.go | 16 +++--- backend/modules/compliance/handler/common.go | 2 +- backend/modules/compliance/handler/report.go | 56 +++++++++++++++++++ backend/modules/compliance/module.go | 3 +- .../repository/control_status_override_pg.go | 45 +++++++++++++++ backend/modules/compliance/routes.go | 3 + .../modules/compliance/usecase/evaluator.go | 53 +++++++++++++++++- 11 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 backend/modules/compliance/domain/control_status_override.go create mode 100644 backend/modules/compliance/repository/control_status_override_pg.go diff --git a/backend/modules/compliance/connectors/repository.go b/backend/modules/compliance/connectors/repository.go index bb8b0997f..4dd621bac 100644 --- a/backend/modules/compliance/connectors/repository.go +++ b/backend/modules/compliance/connectors/repository.go @@ -30,3 +30,12 @@ type ReportStore interface { Get(ctx context.Context, id string) (*domain.ReportSnapshot, error) Delete(ctx context.Context, id string) error } + +// ControlStatusOverrideRepository stores manual (framework, control) → status +// overrides. Upsert on the unique (framework_key, control_id) pair; ListByFramework +// returns a controlID → status map for the evaluator to consume. +type ControlStatusOverrideRepository interface { + Upsert(ctx context.Context, o *domain.UtmComplianceControlStatusOverride) error + Delete(ctx context.Context, frameworkKey, controlID string) error + ListByFramework(ctx context.Context, frameworkKey string) (map[string]string, error) +} diff --git a/backend/modules/compliance/connectors/usecase.go b/backend/modules/compliance/connectors/usecase.go index efa3e7fdb..49def1517 100644 --- a/backend/modules/compliance/connectors/usecase.go +++ b/backend/modules/compliance/connectors/usecase.go @@ -44,6 +44,12 @@ type EvaluatorUsecase interface { DeleteReport(ctx context.Context, id string) error FrameworkReportPDF(ctx context.Context, frameworkKey string) ([]byte, string, error) // live eval → PDF + framework name SnapshotPDF(ctx context.Context, id string) ([]byte, string, error) // stored snapshot → PDF + framework name + + // Manual status overrides — applied on live evaluations only (historical + // snapshots are frozen). SetStatusOverride upserts; ClearStatusOverride + // removes the override so the row falls back to the computed status. + SetStatusOverride(ctx context.Context, frameworkKey, controlID, status, reason string) error + ClearStatusOverride(ctx context.Context, frameworkKey, controlID string) error } type ScheduleUsecase interface { diff --git a/backend/modules/compliance/domain/control_status_override.go b/backend/modules/compliance/domain/control_status_override.go new file mode 100644 index 000000000..9c8028f57 --- /dev/null +++ b/backend/modules/compliance/domain/control_status_override.go @@ -0,0 +1,26 @@ +package domain + +import "time" + +// UtmComplianceControlStatusOverride is a manual status assignment for a +// (framework, control) pair. Applied on top of the evaluator's computed status +type UtmComplianceControlStatusOverride struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + FrameworkKey string `gorm:"column:framework_key;size:100;not null;uniqueIndex:ux_ovr_fw_ctl,priority:1"` + ControlID string `gorm:"column:control_id;size:100;not null;uniqueIndex:ux_ovr_fw_ctl,priority:2"` + Status string `gorm:"column:status;size:32;not null"` + Reason string `gorm:"column:reason;size:500"` + UpdatedAt time.Time `gorm:"column:updated_at;not null"` +} + +func (UtmComplianceControlStatusOverride) TableName() string { + return "utm_compliance_control_status_override" +} + +func ValidStatus(s string) bool { + switch s { + case StatusCompliant, StatusNonCompliant, StatusAtRisk, StatusNotCovered, StatusOutOfScope, StatusPending: + return true + } + return false +} diff --git a/backend/modules/compliance/domain/errors.go b/backend/modules/compliance/domain/errors.go index 0b385bc09..551019aa0 100644 --- a/backend/modules/compliance/domain/errors.go +++ b/backend/modules/compliance/domain/errors.go @@ -14,4 +14,5 @@ var ( ErrInvalidID = errors.New("invalid id/key (must be non-empty and contain no path separators)") ErrFrameworkLocked = errors.New("this framework requires an Enterprise license") ErrControlLocked = errors.New("this control requires an Enterprise license") + ErrInvalidStatus = errors.New("invalid control status") ) diff --git a/backend/modules/compliance/domain/report.go b/backend/modules/compliance/domain/report.go index 91faa2d24..899b3082d 100644 --- a/backend/modules/compliance/domain/report.go +++ b/backend/modules/compliance/domain/report.go @@ -34,9 +34,6 @@ type ReportSection struct { Controls []ReportControlRow `json:"controls"` } -// ReportSnapshot is a stored, point-in-time compliance report (in OpenSearch). -// The report is DATA: the frontend renders it, the PDF path renders it, and the -// snapshot is the durable history — no binary PDF is stored. type ReportSnapshot struct { ID string `json:"id"` FrameworkKey string `json:"frameworkKey"` @@ -56,10 +53,11 @@ type ReportSnapshotMeta struct { } type ReportControlRow struct { - ControlID string `json:"controlId"` - Name string `json:"name"` - Status string `json:"status"` // COMPLIANT | NON_COMPLIANT | AT_RISK | NOT_COVERED | OUT_OF_SCOPE | PENDING - Evidence string `json:"evidence"` - Coverage int `json:"coverage"` // # enabled correlation rules covering this control - Activity int `json:"activity"` // # alerts from those rules in the window + ControlID string `json:"controlId"` + Name string `json:"name"` + Status string `json:"status"` // COMPLIANT | NON_COMPLIANT | AT_RISK | NOT_COVERED | OUT_OF_SCOPE | PENDING + Evidence string `json:"evidence"` + Coverage int `json:"coverage"` // # enabled correlation rules covering this control + Activity int `json:"activity"` // # alerts from those rules in the window + Overridden bool `json:"overridden,omitempty"` // true when status came from a manual override } diff --git a/backend/modules/compliance/handler/common.go b/backend/modules/compliance/handler/common.go index b8e3736d3..c607fbca4 100644 --- a/backend/modules/compliance/handler/common.go +++ b/backend/modules/compliance/handler/common.go @@ -22,7 +22,7 @@ func writeError(c *gin.Context, err error) { c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) case errors.Is(err, domain.ErrControlExists), errors.Is(err, domain.ErrFrameworkExists): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) - case errors.Is(err, domain.ErrInvalidCron), errors.Is(err, domain.ErrInvalidID): + case errors.Is(err, domain.ErrInvalidCron), errors.Is(err, domain.ErrInvalidID), errors.Is(err, domain.ErrInvalidStatus): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/backend/modules/compliance/handler/report.go b/backend/modules/compliance/handler/report.go index 27f58ef0c..0d6f5c902 100644 --- a/backend/modules/compliance/handler/report.go +++ b/backend/modules/compliance/handler/report.go @@ -159,6 +159,62 @@ func (h *ReportHandler) GetFrameworkReportPDF(c *gin.Context) { writePDF(c, pdf, name) } +// SetStatusOverride godoc +// +// @Summary Set a manual status override for a control +// @Description Overrides the evaluator's computed status for a (framework, control) pair. Applied on live evaluations only; historical snapshots are unchanged. +// @Tags Compliance Reports +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param key path string true "Framework key" +// @Param id path string true "Control id" +// @Param body body object true "New status (COMPLIANT | NON_COMPLIANT | AT_RISK | NOT_COVERED | OUT_OF_SCOPE | PENDING) and optional reason" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /compliance/frameworks/{key}/controls/{id}/status [put] +func (h *ReportHandler) SetStatusOverride(c *gin.Context) { + var body struct { + Status string `json:"status"` + Reason string `json:"reason"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + err := h.uc.SetStatusOverride(c.Request.Context(), c.Param("key"), c.Param("id"), body.Status, body.Reason) + audit.Record(c, audit_connectors.Event{Action: "compliance.control.status.override", ResourceType: "compliance_control", ResourceID: c.Param("id")}, + audit_domain.COMPLIANCE_CONTROL_UPDATE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_UPDATE_SUCCESS, err) + if err != nil { + writeError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +// ClearStatusOverride godoc +// +// @Summary Clear a manual status override +// @Description Removes the manual override so the control status falls back to the evaluator's computed value. +// @Tags Compliance Reports +// @Security BearerAuth +// @Param key path string true "Framework key" +// @Param id path string true "Control id" +// @Success 204 +// @Failure 404 {object} map[string]string +// @Router /compliance/frameworks/{key}/controls/{id}/status [delete] +func (h *ReportHandler) ClearStatusOverride(c *gin.Context) { + err := h.uc.ClearStatusOverride(c.Request.Context(), c.Param("key"), c.Param("id")) + audit.Record(c, audit_connectors.Event{Action: "compliance.control.status.override.clear", ResourceType: "compliance_control", ResourceID: c.Param("id")}, + audit_domain.COMPLIANCE_CONTROL_UPDATE_ATTEMPT, audit_domain.COMPLIANCE_CONTROL_UPDATE_SUCCESS, err) + if err != nil { + writeError(c, err) + return + } + c.Status(http.StatusNoContent) +} + // @Summary Download a stored report snapshot as PDF // @Tags Compliance Reports // @Security BearerAuth diff --git a/backend/modules/compliance/module.go b/backend/modules/compliance/module.go index 9da647eff..f5284c93e 100644 --- a/backend/modules/compliance/module.go +++ b/backend/modules/compliance/module.go @@ -36,6 +36,7 @@ func (m *Module) GetScheduleUsecase() connectors.ScheduleUsecase { return m.sc func NewModule(db *gorm.DB, mailSvc mail_connectors.MailService, brand connectors.BrandingProvider, isEnterprise func() bool) *Module { scheduleRepo := repository.NewScheduleRepository(db) + overrideRepo := repository.NewControlStatusOverrideRepository(db) root := env.String("COMPLIANCE_DIR", "/workdir/compliance", false) src := env.String("COMPLIANCE_SRC_DIR", "/utmstack/compliance", false) @@ -62,7 +63,7 @@ func NewModule(db *gorm.DB, mailSvc mail_connectors.MailService, brand connector entitlement := usecase.NewEntitlement(isEnterprise) frameworkUC := usecase.NewFrameworkUsecase(controlStore, frameworkStore, entitlement) - evaluatorUC := usecase.NewEvaluator(controlStore, frameworkStore, repository.NewOpenSearchSQL(), coverageIdx, repository.NewOpenSearchAlerts(), repository.NewReportStore(), brand, entitlement) + evaluatorUC := usecase.NewEvaluator(controlStore, frameworkStore, repository.NewOpenSearchSQL(), coverageIdx, repository.NewOpenSearchAlerts(), repository.NewReportStore(), overrideRepo, brand, entitlement) scheduleUC := usecase.NewScheduleUsecase(scheduleRepo, frameworkStore, entitlement) mailSender := &mailSender{svc: mailSvc} diff --git a/backend/modules/compliance/repository/control_status_override_pg.go b/backend/modules/compliance/repository/control_status_override_pg.go new file mode 100644 index 000000000..6378a7837 --- /dev/null +++ b/backend/modules/compliance/repository/control_status_override_pg.go @@ -0,0 +1,45 @@ +package repository + +import ( + "context" + "time" + + "github.com/utmstack/utmstack/backend/modules/compliance/connectors" + "github.com/utmstack/utmstack/backend/modules/compliance/domain" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type pgOverrideRepo struct{ db *gorm.DB } + +func NewControlStatusOverrideRepository(db *gorm.DB) connectors.ControlStatusOverrideRepository { + return &pgOverrideRepo{db: db} +} + +func (r *pgOverrideRepo) Upsert(ctx context.Context, o *domain.UtmComplianceControlStatusOverride) error { + o.UpdatedAt = time.Now().UTC() + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "framework_key"}, {Name: "control_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"status", "reason", "updated_at"}), + }).Create(o).Error +} + +func (r *pgOverrideRepo) Delete(ctx context.Context, frameworkKey, controlID string) error { + return r.db.WithContext(ctx). + Where("framework_key = ? AND control_id = ?", frameworkKey, controlID). + Delete(&domain.UtmComplianceControlStatusOverride{}).Error +} + +func (r *pgOverrideRepo) ListByFramework(ctx context.Context, frameworkKey string) (map[string]string, error) { + var rows []domain.UtmComplianceControlStatusOverride + if err := r.db.WithContext(ctx). + Where("framework_key = ?", frameworkKey). + Find(&rows).Error; err != nil { + return nil, err + } + out := make(map[string]string, len(rows)) + for _, o := range rows { + out[o.ControlID] = o.Status + } + return out, nil +} diff --git a/backend/modules/compliance/routes.go b/backend/modules/compliance/routes.go index 126948861..d9602eac8 100644 --- a/backend/modules/compliance/routes.go +++ b/backend/modules/compliance/routes.go @@ -31,6 +31,9 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { fw.PUT("/frameworks/:key", write, m.frameworkH.UpdateFramework) fw.DELETE("/frameworks/:key", write, m.frameworkH.DeleteFramework) fw.PUT("/frameworks/:key/enabled", write, m.frameworkH.SetFrameworkEnabled) + + fw.PUT("/frameworks/:key/controls/:id/status", write, m.reportH.SetStatusOverride) + fw.DELETE("/frameworks/:key/controls/:id/status", write, m.reportH.ClearStatusOverride) fw.POST("/entitlement", middleware.RequireInternal(), m.frameworkH.ApplyEntitlement) sched := api.Group("/compliance-report-schedules", userAuth) diff --git a/backend/modules/compliance/usecase/evaluator.go b/backend/modules/compliance/usecase/evaluator.go index 19894dd76..45f8e8b65 100644 --- a/backend/modules/compliance/usecase/evaluator.go +++ b/backend/modules/compliance/usecase/evaluator.go @@ -18,12 +18,13 @@ type evaluator struct { coverage *CoverageIndex alerts connectors.OpenSearchAlerts store connectors.ReportStore - brand connectors.BrandingProvider // optional; nil → default UTMStack branding + overrides connectors.ControlStatusOverrideRepository // optional; nil → no manual overrides + brand connectors.BrandingProvider // optional; nil → default UTMStack branding ent *Entitlement } -func NewEvaluator(controls *ControlStore, frameworks *FrameworkStore, sql connectors.OpenSearchSQL, coverage *CoverageIndex, alerts connectors.OpenSearchAlerts, store connectors.ReportStore, brand connectors.BrandingProvider, ent *Entitlement) connectors.EvaluatorUsecase { - return &evaluator{controls: controls, frameworks: frameworks, sql: sql, coverage: coverage, alerts: alerts, store: store, brand: brand, ent: ent} +func NewEvaluator(controls *ControlStore, frameworks *FrameworkStore, sql connectors.OpenSearchSQL, coverage *CoverageIndex, alerts connectors.OpenSearchAlerts, store connectors.ReportStore, overrides connectors.ControlStatusOverrideRepository, brand connectors.BrandingProvider, ent *Entitlement) connectors.EvaluatorUsecase { + return &evaluator{controls: controls, frameworks: frameworks, sql: sql, coverage: coverage, alerts: alerts, store: store, overrides: overrides, brand: brand, ent: ent} } func (e *evaluator) reportBrand(ctx context.Context) connectors.ReportBrand { @@ -111,6 +112,37 @@ func (e *evaluator) SnapshotPDF(ctx context.Context, id string) ([]byte, string, return pdf, snap.FrameworkName, err } +func (e *evaluator) SetStatusOverride(ctx context.Context, frameworkKey, controlID, status, reason string) error { + if e.overrides == nil { + return fmt.Errorf("status overrides not configured") + } + if frameworkKey == "" || controlID == "" { + return domain.ErrInvalidID + } + if _, ok := e.frameworks.Get(frameworkKey); !ok { + return domain.ErrFrameworkNotFound + } + if !domain.ValidStatus(status) { + return domain.ErrInvalidStatus + } + return e.overrides.Upsert(ctx, &domain.UtmComplianceControlStatusOverride{ + FrameworkKey: frameworkKey, + ControlID: controlID, + Status: status, + Reason: reason, + }) +} + +func (e *evaluator) ClearStatusOverride(ctx context.Context, frameworkKey, controlID string) error { + if e.overrides == nil { + return nil + } + if frameworkKey == "" || controlID == "" { + return domain.ErrInvalidID + } + return e.overrides.Delete(ctx, frameworkKey, controlID) +} + // activityWindow is how far back the activity dimension counts alerts. const activityWindow = -30 * 24 * time.Hour @@ -134,6 +166,16 @@ func (e *evaluator) EvaluateFramework(ctx context.Context, key string) (*domain. since := now.Add(activityWindow).Format(time.RFC3339) cache := map[string]domain.ReportControlRow{} + // Load manual overrides once per evaluation; missing / errored → treat as no overrides. + var overrides map[string]string + if e.overrides != nil { + if m, err := e.overrides.ListByFramework(ctx, fw.Key); err == nil { + overrides = m + } else { + _ = catcher.Error("compliance: loading status overrides failed", err, map[string]any{"framework": fw.Key}) + } + } + for _, sec := range fw.Sections { rs := domain.ReportSection{Name: sec.Name} seen := map[string]bool{} @@ -146,6 +188,11 @@ func (e *evaluator) EvaluateFramework(ctx context.Context, key string) (*domain. row, cached := cache[cid] if !cached { row = e.evalControl(ctx, cid, since) + if s, ok := overrides[cid]; ok && domain.ValidStatus(s) && s != row.Status { + row.Evidence = fmt.Sprintf("manual override (was %s)", row.Status) + row.Status = s + row.Overridden = true + } cache[cid] = row } rs.Controls = append(rs.Controls, row) From a8f87469821301d9d0702e9bab2eae46ae61a6ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 2 Jul 2026 09:59:50 -0600 Subject: [PATCH 4/8] fix[frontend](compilance): added controls status change button --- .../components/ControlDetailDrawer.tsx | 20 ++++-- .../components/ControlStatusBadge.tsx | 69 +++++++++++++++++++ .../compliance/components/ReportView.tsx | 20 ++++-- .../compliance/pages/FrameworkReportPage.tsx | 21 +++++- .../services/compliance-http.service.ts | 9 +++ .../compliance/types/compliance.types.ts | 1 + frontend/src/shared/i18n/locales/de.json | 11 ++- frontend/src/shared/i18n/locales/en.json | 10 ++- frontend/src/shared/i18n/locales/es.json | 11 ++- frontend/src/shared/i18n/locales/fr.json | 11 ++- frontend/src/shared/i18n/locales/it.json | 11 ++- frontend/src/shared/i18n/locales/pt.json | 11 ++- frontend/src/shared/i18n/locales/ru.json | 11 ++- 13 files changed, 196 insertions(+), 20 deletions(-) create mode 100644 frontend/src/features/compliance/components/ControlStatusBadge.tsx diff --git a/frontend/src/features/compliance/components/ControlDetailDrawer.tsx b/frontend/src/features/compliance/components/ControlDetailDrawer.tsx index c373ca18d..a7001543f 100644 --- a/frontend/src/features/compliance/components/ControlDetailDrawer.tsx +++ b/frontend/src/features/compliance/components/ControlDetailDrawer.tsx @@ -1,13 +1,22 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { AlertTriangle, Loader2, X } from 'lucide-react' -import { cn } from '@/shared/lib/utils' import { complianceService } from '../services/compliance-http.service' import type { Control, ReportControlRow } from '../types/compliance.types' -import { STATUS_TONE } from './ReportView' +import { ControlStatusBadge } from './ControlStatusBadge' /** Right-side drawer showing full details of a report control row + its library Control. */ -export function ControlDetailDrawer({ row, onClose }: { row: ReportControlRow; onClose: () => void }) { +export function ControlDetailDrawer({ + frameworkKey, + row, + onClose, + onStatusChanged, +}: { + frameworkKey: string + row: ReportControlRow + onClose: () => void + onStatusChanged?: () => void +}) { const { t } = useTranslation() const [control, setControl] = useState(null) const [loading, setLoading] = useState(true) @@ -33,9 +42,7 @@ export function ControlDetailDrawer({ row, onClose }: { row: ReportControlRow; o
{row.controlId} - - {t(`compliance.status.${row.status}`)} - +

{control?.name || row.name}

@@ -49,7 +56,6 @@ export function ControlDetailDrawer({ row, onClose }: { row: ReportControlRow; o
- diff --git a/frontend/src/features/compliance/components/ControlStatusBadge.tsx b/frontend/src/features/compliance/components/ControlStatusBadge.tsx new file mode 100644 index 000000000..d11bbf2a4 --- /dev/null +++ b/frontend/src/features/compliance/components/ControlStatusBadge.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ChevronDown } from 'lucide-react' +import { toast } from 'sonner' +import { cn } from '@/shared/lib/utils' +import { complianceService, ComplianceHttpError } from '../services/compliance-http.service' +import { CONTROL_STATUSES, type ControlStatus, type ReportControlRow } from '../types/compliance.types' +import { STATUS_TONE } from './ReportView' + +/** + * Status pill that doubles as a manual-override selector. Native e.stopPropagation()} + onChange={(e) => void change(e.target.value as ControlStatus | '')} + title={t('compliance.status.label', { defaultValue: 'Status' })} + className={cn( + 'cursor-pointer appearance-none rounded py-0.5 pl-1.5 pr-5 text-[10px] font-semibold outline-none transition-opacity focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-60', + STATUS_TONE[row.status], + )} + > + + {CONTROL_STATUSES.map((s) => ( + + ))} + + + + ) +} diff --git a/frontend/src/features/compliance/components/ReportView.tsx b/frontend/src/features/compliance/components/ReportView.tsx index 74a528ec3..3601e7b93 100644 --- a/frontend/src/features/compliance/components/ReportView.tsx +++ b/frontend/src/features/compliance/components/ReportView.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next' import { cn } from '@/shared/lib/utils' import { useDateFormat } from '@/shared/lib/datetime' import type { ControlStatus, Report, ReportControlRow } from '../types/compliance.types' +import { ControlStatusBadge } from './ControlStatusBadge' export function scoreTone(score: number): string { if (score >= 80) return 'text-emerald-500' @@ -25,7 +26,15 @@ export const STATUS_TONE: Record = { } /** Renders a compliance report: summary score + per-section control breakdown. */ -export function ReportView({ report, onControlClick }: { report: Report; onControlClick?: (row: ReportControlRow) => void }) { +export function ReportView({ + report, + onControlClick, + onStatusChanged, +}: { + report: Report + onControlClick?: (row: ReportControlRow) => void + onStatusChanged?: () => void +}) { const { t } = useTranslation() const df = useDateFormat() const s = report.summary ?? { compliantPct: 0, total: 0, compliant: 0, nonCompliant: 0, atRisk: 0, notCovered: 0, pending: 0, outOfScope: 0 } @@ -65,9 +74,12 @@ export function ReportView({ report, onControlClick }: { report: Report; onContr onControlClick && 'cursor-pointer transition-colors hover:bg-muted/50', )} > - - {t(`compliance.status.${c.status}`)} - +
{c.controlId} diff --git a/frontend/src/features/compliance/pages/FrameworkReportPage.tsx b/frontend/src/features/compliance/pages/FrameworkReportPage.tsx index 2659d1da9..6245451ae 100644 --- a/frontend/src/features/compliance/pages/FrameworkReportPage.tsx +++ b/frontend/src/features/compliance/pages/FrameworkReportPage.tsx @@ -35,6 +35,16 @@ export function FrameworkReportPage() { const back = () => navigate('/compliance') + const reloadAndResyncDrawer = async () => { + const fresh = await complianceService.liveReport(key).catch(() => null) + if (!fresh) return + setReport(fresh) + if (openRow) { + const stillHere = fresh.sections?.flatMap((s) => s.controls ?? []).find((c) => c.controlId === openRow.controlId) + if (stillHere) setOpenRow(stillHere) + } + } + const download = async () => { if (downloading) return setDownloading(true) @@ -81,11 +91,18 @@ export function FrameworkReportPage() {
) : ( - + )}
- {openRow && setOpenRow(null)} />} + {openRow && ( + setOpenRow(null)} + onStatusChanged={reloadAndResyncDrawer} + /> + )}
) } diff --git a/frontend/src/features/compliance/services/compliance-http.service.ts b/frontend/src/features/compliance/services/compliance-http.service.ts index 3a40a9fb1..597e78177 100644 --- a/frontend/src/features/compliance/services/compliance-http.service.ts +++ b/frontend/src/features/compliance/services/compliance-http.service.ts @@ -52,6 +52,15 @@ export const complianceService = { downloadSnapshotPdf: (id: string) => api.get(`/compliance/reports/${encodeURIComponent(id)}/pdf`, { responseType: 'blob' }), + // Manual (framework, control) status overrides — applied on live evaluations. + setControlStatusOverride: (frameworkKey: string, controlId: string, status: string, reason?: string) => + api.put( + `/compliance/frameworks/${encodeURIComponent(frameworkKey)}/controls/${encodeURIComponent(controlId)}/status`, + { status, reason: reason ?? '' }, + ), + clearControlStatusOverride: (frameworkKey: string, controlId: string) => + api.delete(`/compliance/frameworks/${encodeURIComponent(frameworkKey)}/controls/${encodeURIComponent(controlId)}/status`), + // ── Schedules (Postgres) ───────────────────────────────────────────────── listSchedules: (frameworkKey?: string) => { const p = new URLSearchParams() diff --git a/frontend/src/features/compliance/types/compliance.types.ts b/frontend/src/features/compliance/types/compliance.types.ts index 2c2d86421..a77eb65f1 100644 --- a/frontend/src/features/compliance/types/compliance.types.ts +++ b/frontend/src/features/compliance/types/compliance.types.ts @@ -81,6 +81,7 @@ export interface ReportControlRow { evidence: string coverage: number // enabled correlation rules covering this control activity: number // alerts from those rules in the window + overridden?: boolean // true when status came from a manual override } export interface ReportSection { diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index 748ae3cad..caa03ab66 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -4594,6 +4594,12 @@ "controlsCount": "{{n}} Kontrollen", "enabledLabel": "Aktiviert", "runReport": "Bericht ausführen", + "backToFrameworks": "Zurück zu Frameworks", + "statusAuto": "Auto (bewertet)", + "overrideError": "Status konnte nicht aktualisiert werden", + "coverageLabel": "Abdeckung", + "activityLabel": "Aktivität", + "evidenceLabel": "Nachweis", "evaluating": "Framework wird bewertet…", "complianceScore": "Compliance-Score", "generatedAt": "Erstellt {{date}}", @@ -4606,7 +4612,8 @@ "AT_RISK": "Gefährdet", "NOT_COVERED": "Nicht abgedeckt", "OUT_OF_SCOPE": "Außerhalb des Umfangs", - "PENDING": "Ausstehend" + "PENDING": "Ausstehend", + "label": "Status" }, "toast": { "toggleError": "Status des Frameworks konnte nicht geändert werden", @@ -4708,6 +4715,8 @@ "family": "Familie", "familyName": "Familie", "scope": "Umfang", + "source": "Quelle", + "notInLibrary": "Kontrolle nicht in der Bibliothek gefunden", "scopeOpt": { "data": "Daten", "governance": "Governance" diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index 2c3a76441..02bf8f007 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -4759,6 +4759,11 @@ "enabledLabel": "Enabled", "runReport": "Run report", "backToFrameworks": "Back to frameworks", + "statusAuto": "Auto (evaluated)", + "overrideError": "Failed to update status", + "coverageLabel": "Coverage", + "activityLabel": "Activity", + "evidenceLabel": "Evidence", "evaluating": "Evaluating framework…", "complianceScore": "Compliance score", "generatedAt": "Generated {{date}}", @@ -4771,7 +4776,8 @@ "AT_RISK": "At risk", "NOT_COVERED": "Not covered", "OUT_OF_SCOPE": "Out of scope", - "PENDING": "Pending" + "PENDING": "Pending", + "label": "Status" }, "toast": { "toggleError": "Failed to change framework state", @@ -4873,6 +4879,8 @@ "family": "Family", "familyName": "Family", "scope": "Scope", + "source": "Source", + "notInLibrary": "Control not found in library", "scopeOpt": { "data": "Data", "governance": "Governance" diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index 60cd068e5..bbf62c573 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -4682,6 +4682,12 @@ "controlsCount": "{{n}} controles", "enabledLabel": "Activo", "runReport": "Correr reporte", + "backToFrameworks": "Volver a frameworks", + "statusAuto": "Auto (evaluado)", + "overrideError": "No se pudo actualizar el estado", + "coverageLabel": "Cobertura", + "activityLabel": "Actividad", + "evidenceLabel": "Evidencia", "evaluating": "Evaluando framework…", "complianceScore": "Puntaje de compliance", "generatedAt": "Generado {{date}}", @@ -4694,7 +4700,8 @@ "AT_RISK": "En riesgo", "NOT_COVERED": "Sin cobertura", "OUT_OF_SCOPE": "Fuera de alcance", - "PENDING": "Pendiente" + "PENDING": "Pendiente", + "label": "Estado" }, "toast": { "toggleError": "No se pudo cambiar el estado del framework", @@ -4796,6 +4803,8 @@ "family": "Familia", "familyName": "Familia", "scope": "Alcance", + "source": "Fuente", + "notInLibrary": "Control no encontrado en la biblioteca", "scopeOpt": { "data": "Datos", "governance": "Gobernanza" diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index fa8296722..783db8e73 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -4594,6 +4594,12 @@ "controlsCount": "{{n}} contrôles", "enabledLabel": "Activé", "runReport": "Exécuter le rapport", + "backToFrameworks": "Retour aux frameworks", + "statusAuto": "Auto (évalué)", + "overrideError": "Impossible de mettre à jour le statut", + "coverageLabel": "Couverture", + "activityLabel": "Activité", + "evidenceLabel": "Preuve", "evaluating": "Évaluation du framework…", "complianceScore": "Score de conformité", "generatedAt": "Généré {{date}}", @@ -4606,7 +4612,8 @@ "AT_RISK": "À risque", "NOT_COVERED": "Non couvert", "OUT_OF_SCOPE": "Hors périmètre", - "PENDING": "En attente" + "PENDING": "En attente", + "label": "Statut" }, "toast": { "toggleError": "Échec du changement d'état du framework", @@ -4708,6 +4715,8 @@ "family": "Famille", "familyName": "Famille", "scope": "Périmètre", + "source": "Source", + "notInLibrary": "Contrôle introuvable dans la bibliothèque", "scopeOpt": { "data": "Données", "governance": "Gouvernance" diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index 6be5e0ade..48e6c6b39 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -4594,6 +4594,12 @@ "controlsCount": "{{n}} controlli", "enabledLabel": "Attivo", "runReport": "Esegui report", + "backToFrameworks": "Torna ai framework", + "statusAuto": "Auto (valutato)", + "overrideError": "Impossibile aggiornare lo stato", + "coverageLabel": "Copertura", + "activityLabel": "Attività", + "evidenceLabel": "Evidenza", "evaluating": "Valutazione del framework…", "complianceScore": "Punteggio di conformità", "generatedAt": "Generato {{date}}", @@ -4606,7 +4612,8 @@ "AT_RISK": "A rischio", "NOT_COVERED": "Non coperto", "OUT_OF_SCOPE": "Fuori ambito", - "PENDING": "In sospeso" + "PENDING": "In sospeso", + "label": "Stato" }, "toast": { "toggleError": "Impossibile cambiare lo stato del framework", @@ -4708,6 +4715,8 @@ "family": "Famiglia", "familyName": "Famiglia", "scope": "Ambito", + "source": "Origine", + "notInLibrary": "Controllo non trovato in libreria", "scopeOpt": { "data": "Dati", "governance": "Governance" diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index c7b9b26a0..ac0449fb9 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -4682,6 +4682,12 @@ "controlsCount": "{{n}} controles", "enabledLabel": "Ativo", "runReport": "Executar relatório", + "backToFrameworks": "Voltar aos frameworks", + "statusAuto": "Auto (avaliado)", + "overrideError": "Falha ao atualizar o status", + "coverageLabel": "Cobertura", + "activityLabel": "Atividade", + "evidenceLabel": "Evidência", "evaluating": "Avaliando framework…", "complianceScore": "Pontuação de compliance", "generatedAt": "Gerado {{date}}", @@ -4694,7 +4700,8 @@ "AT_RISK": "Em risco", "NOT_COVERED": "Sem cobertura", "OUT_OF_SCOPE": "Fora do escopo", - "PENDING": "Pendente" + "PENDING": "Pendente", + "label": "Status" }, "toast": { "toggleError": "Falha ao mudar o estado do framework", @@ -4796,6 +4803,8 @@ "family": "Família", "familyName": "Família", "scope": "Escopo", + "source": "Fonte", + "notInLibrary": "Controle não encontrado na biblioteca", "scopeOpt": { "data": "Dados", "governance": "Governança" diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index a3ad16ab5..6221bb441 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -4417,6 +4417,12 @@ "controlsCount": "Контролей: {{n}}", "enabledLabel": "Включён", "runReport": "Запустить отчёт", + "backToFrameworks": "К фреймворкам", + "statusAuto": "Авто (оценено)", + "overrideError": "Не удалось обновить статус", + "coverageLabel": "Покрытие", + "activityLabel": "Активность", + "evidenceLabel": "Свидетельство", "evaluating": "Оценка фреймворка…", "complianceScore": "Оценка соответствия", "generatedAt": "Создан {{date}}", @@ -4429,7 +4435,8 @@ "AT_RISK": "Под риском", "NOT_COVERED": "Не покрыт", "OUT_OF_SCOPE": "Вне области", - "PENDING": "Ожидает" + "PENDING": "Ожидает", + "label": "Статус" }, "toast": { "toggleError": "Не удалось изменить состояние фреймворка", @@ -4531,6 +4538,8 @@ "family": "Семейство", "familyName": "Семейство", "scope": "Область", + "source": "Источник", + "notInLibrary": "Контроль не найден в библиотеке", "scopeOpt": { "data": "Данные", "governance": "Управление" From 6e0003a476e3d2358d4688de6fd5b50e8c2eec20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 2 Jul 2026 11:09:59 -0600 Subject: [PATCH 5/8] fix[frontend](compilance): added controls notes --- .../components/ControlDetailDrawer.tsx | 45 +++++- .../components/ControlNoteBubble.tsx | 130 ++++++++++++++++++ .../compliance/components/ReportView.tsx | 9 +- .../compliance/pages/FrameworkReportPage.tsx | 2 +- .../services/compliance-http.service.ts | 9 ++ .../compliance/types/compliance.types.ts | 1 + frontend/src/shared/i18n/locales/de.json | 9 ++ frontend/src/shared/i18n/locales/en.json | 9 ++ frontend/src/shared/i18n/locales/es.json | 9 ++ frontend/src/shared/i18n/locales/fr.json | 9 ++ frontend/src/shared/i18n/locales/it.json | 9 ++ frontend/src/shared/i18n/locales/pt.json | 9 ++ frontend/src/shared/i18n/locales/ru.json | 9 ++ 13 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 frontend/src/features/compliance/components/ControlNoteBubble.tsx diff --git a/frontend/src/features/compliance/components/ControlDetailDrawer.tsx b/frontend/src/features/compliance/components/ControlDetailDrawer.tsx index a7001543f..86b2fc8ec 100644 --- a/frontend/src/features/compliance/components/ControlDetailDrawer.tsx +++ b/frontend/src/features/compliance/components/ControlDetailDrawer.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { AlertTriangle, Loader2, X } from 'lucide-react' +import { cn } from '@/shared/lib/utils' import { complianceService } from '../services/compliance-http.service' import type { Control, ReportControlRow } from '../types/compliance.types' import { ControlStatusBadge } from './ControlStatusBadge' @@ -58,8 +59,8 @@ export function ControlDetailDrawer({
-
+ {loading ? (
@@ -140,3 +141,43 @@ function Block({ label, children }: { label: string; children: React.ReactNode } ) } + +// EvidenceBlock spans the full drawer width, clamps evidence to 2 lines by +// default, and offers a "see more" toggle when the content overflows. +function EvidenceBlock({ text }: { text: string }) { + const { t } = useTranslation() + const [expanded, setExpanded] = useState(false) + const [truncated, setTruncated] = useState(false) + const bodyRef = useRef(null) + + useLayoutEffect(() => { + const el = bodyRef.current + if (!el) return + // Only meaningful while clamped: scrollHeight > clientHeight means content overflows 2 lines. + setTruncated(el.scrollHeight > el.clientHeight + 1) + }, [text]) + + const value = text || '—' + return ( +
+
+ {t('compliance.evidenceLabel', { defaultValue: 'Evidence' })} +
+
+ {value} +
+ {text && (truncated || expanded) && ( + + )} +
+ ) +} diff --git a/frontend/src/features/compliance/components/ControlNoteBubble.tsx b/frontend/src/features/compliance/components/ControlNoteBubble.tsx new file mode 100644 index 000000000..96c998551 --- /dev/null +++ b/frontend/src/features/compliance/components/ControlNoteBubble.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { Loader2, MessageSquare, MessageSquareText } from 'lucide-react' +import { toast } from 'sonner' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import { complianceService, ComplianceHttpError } from '../services/compliance-http.service' +import type { ReportControlRow } from '../types/compliance.types' + +/** + * Notes icon on a control row. Filled when a note exists; click opens a floating + * bubble anchored to the icon with the note text + a textarea to edit or clear it. + * Stops propagation so it doesn't open the row's drawer. + */ +export function ControlNoteBubble({ + frameworkKey, + row, + onSaved, + className, +}: { + frameworkKey: string + row: ReportControlRow + onSaved?: () => void + className?: string +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [anchor, setAnchor] = useState(null) + const [draft, setDraft] = useState('') + const [saving, setSaving] = useState(false) + const btnRef = useRef(null) + + const has = !!row.note + + useEffect(() => { + if (!open) return + const onEsc = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false) + window.addEventListener('keydown', onEsc) + return () => window.removeEventListener('keydown', onEsc) + }, [open]) + + const openBubble = (e: React.MouseEvent) => { + e.stopPropagation() + setAnchor(e.currentTarget.getBoundingClientRect()) + setDraft(row.note ?? '') + setOpen(true) + } + + const save = async () => { + if (saving) return + setSaving(true) + try { + const value = draft.trim() + if (value === '') { + if (has) await complianceService.clearControlNote(frameworkKey, row.controlId) + } else { + await complianceService.setControlNote(frameworkKey, row.controlId, value) + } + onSaved?.() + setOpen(false) + } catch (e) { + toast.error(e instanceof ComplianceHttpError ? e.message : t('compliance.noteError', { defaultValue: 'Failed to save note' })) + } finally { + setSaving(false) + } + } + + return ( + <> + + + {open && anchor && createPortal( +
setOpen(false)} + > +
e.stopPropagation()} + > +
+ {t('compliance.notes.title', { defaultValue: 'Notes' })} — {row.controlId} +
+