diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 634e60db0c5..a0367bec142 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -20,6 +20,45 @@ "riskInsights": { "message": "Risk Insights" }, + "riskOverTime": { + "message": "Risk over time" + }, + "applicationsAtRiskOverTime": { + "message": "Applications at-risk over time" + }, + "itemsAtRiskOverTime": { + "message": "Items at-risk over time" + }, + "membersAtRiskOverTime": { + "message": "Members at-risk over time" + }, + "currentPeriod": { + "message": "Current year" + }, + "previousPeriod": { + "message": "Previous year" + }, + "last3Months": { + "message": "Last 3 months" + }, + "last6Months": { + "message": "Last 6 months" + }, + "last12Months": { + "message": "Last 12 months" + }, + "loadingRiskData": { + "message": "Loading risk data..." + }, + "downloadChart": { + "message": "Download chart as SVG" + }, + "downloadAsSVG": { + "message": "Download as SVG" + }, + "errorLoadingData": { + "message": "Error loading data" + }, "passwordRisk": { "message": "Password Risk" }, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html index 8cdb927ab65..836e5099942 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html @@ -1,67 +1,79 @@ @if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) { } @else { - + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index 150c66ad2d4..0e34ec1ba8f 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -16,6 +16,7 @@ import { getById } from "@bitwarden/common/platform/misc"; import { DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { RiskOverTimeComponent } from "../risk-over-time/risk-over-time.component"; import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component"; import { ActivityCardComponent } from "./activity-card.component"; @@ -31,6 +32,7 @@ import { NewApplicationsDialogComponent } from "./new-applications-dialog.compon SharedModule, ActivityCardComponent, PasswordChangeMetricComponent, + RiskOverTimeComponent, ], templateUrl: "./all-activity.component.html", }) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time-data.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time-data.service.ts new file mode 100644 index 00000000000..8fb8f3c735e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time-data.service.ts @@ -0,0 +1,118 @@ +import { Injectable } from "@angular/core"; +import { Observable, of } from "rxjs"; + +import { RiskMetricType, TimePeriod, RiskOverTimeData } from "./risk-over-time.models"; + +/** + * Service for managing risk over time data + * This service provides data for the risk over time chart component + */ +@Injectable() +export class RiskOverTimeDataService { + /** + * Get risk over time data for a specific metric and time period + * TODO: Replace with actual API call once backend endpoint is ready + */ + getRiskOverTimeData(metric: RiskMetricType, period: TimePeriod): Observable { + // Mock data for demonstration + // In production, this should call an API endpoint + return of(this.getMockData(metric, period)); + } + + /** + * Generate mock data for development/testing + * This should be replaced with actual API calls once the backend is ready + */ + private getMockData(metric: RiskMetricType, period: TimePeriod): RiskOverTimeData { + const data: RiskOverTimeData = { + labels: [], + currentPeriod: [], + previousPeriod: [], + metricType: metric, + timePeriod: period, + }; + + switch (period) { + case TimePeriod.ThreeMonths: + data.labels = ["Sep", "Oct", "Nov"]; + break; + case TimePeriod.SixMonths: + data.labels = ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + break; + case TimePeriod.TwelveMonths: + data.labels = ["Jan", "Mar", "May", "Jul", "Sep", "Nov"]; + break; + } + + // Generate mock data points based on metric type + const dataPointCount = data.labels.length; + + switch (metric) { + case RiskMetricType.Applications: + // Mock data showing improvement (downward trend) + data.currentPeriod = this.generateTrendData(dataPointCount, 45, 30, true); + data.previousPeriod = this.generateTrendData(dataPointCount, 50, 35, true); + break; + + case RiskMetricType.Items: + // Mock data showing worsening (upward trend) + data.currentPeriod = this.generateTrendData(dataPointCount, 30, 50, false); + data.previousPeriod = this.generateTrendData(dataPointCount, 25, 40, false); + break; + + case RiskMetricType.Members: + // Mock data showing slight improvement + data.currentPeriod = this.generateTrendData(dataPointCount, 60, 50, true); + data.previousPeriod = this.generateTrendData(dataPointCount, 65, 55, true); + break; + } + + return data; + } + + /** + * Generate trend data with some variation + * @param count Number of data points + * @param start Starting value + * @param end Ending value + * @param improving Whether trend is improving (downward) or worsening (upward) + */ + private generateTrendData( + count: number, + start: number, + end: number, + improving: boolean, + ): number[] { + const data: number[] = []; + const step = (end - start) / (count - 1); + + for (let i = 0; i < count; i++) { + // Add some random variation to make it look more realistic + const baseValue = start + step * i; + const variation = (Math.random() - 0.5) * 5; // +/- 2.5 + data.push(Math.max(0, Math.round(baseValue + variation))); + } + + return data; + } + + /** + * Calculate if a trend is improving based on first and last values + */ + isImproving(data: number[]): boolean { + if (data.length < 2) { + return true; + } + return data[data.length - 1] < data[0]; + } + + /** + * Get the percentage change between two values + */ + getPercentageChange(oldValue: number, newValue: number): number { + if (oldValue === 0) { + return 0; + } + return Math.round(((newValue - oldValue) / oldValue) * 100); + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time.component.html new file mode 100644 index 00000000000..0fdfc84377f --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time.component.html @@ -0,0 +1,258 @@ +
+ +
+ +
+
{{ "riskOverTime" | i18n }}
+ +
+ + +
+
{{ chartTitle() }}
+ + +
+ + +
+
+
+ + +
+ + + + {{ "application" | i18n }} + + + + {{ "item" | i18n }} + + + + {{ "member" | i18n }} + + + + +
+
+
+ {{ "currentPeriod" | i18n }} +
+
+
+ {{ "previousPeriod" | i18n }} +
+
+
+ + +
+ + + + + + + + + + + + + + + + + {{ label.value }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ label }} + + + + + +
+
{{ tooltip.label }}
+
+
+
+ {{ "currentPeriod" | i18n }}: + {{ tooltip.currentValue }} +
+
+
+ {{ "previousPeriod" | i18n }}: + {{ tooltip.previousValue }} +
+
+
+
+ + +
+ +
+ + +
+ + {{ "errorLoadingData" | i18n }} +
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time.component.ts new file mode 100644 index 00000000000..c84ce6732dd --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time.component.ts @@ -0,0 +1,381 @@ +import { CommonModule } from "@angular/common"; +import { + Component, + OnInit, + DestroyRef, + inject, + ElementRef, + ChangeDetectionStrategy, + signal, + computed, + viewChild, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + IconButtonModule, + SelectModule, + TypographyModule, + MenuModule, + BadgeModule, + ToggleGroupModule, +} from "@bitwarden/components"; + +import { RiskOverTimeDataService } from "./risk-over-time-data.service"; +import { RiskMetricType, TimePeriod, RiskOverTimeData } from "./risk-over-time.models"; + +@Component({ + selector: "dirt-risk-over-time", + templateUrl: "./risk-over-time.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + JslibModule, + ButtonModule, + IconButtonModule, + SelectModule, + TypographyModule, + MenuModule, + BadgeModule, + ToggleGroupModule, + ], + providers: [RiskOverTimeDataService], +}) +export class RiskOverTimeComponent implements OnInit { + private destroyRef = inject(DestroyRef); + protected readonly chartSvgRef = viewChild>("chartSvg"); + + // Data properties using signals + protected readonly chartData = signal(null); + protected readonly isLoading = signal(true); + protected readonly hasError = signal(false); + protected readonly selectedMetric = signal(RiskMetricType.Applications); + protected readonly selectedPeriod = signal(TimePeriod.ThreeMonths); + protected readonly hoveredDataPoint = signal<{ index: number; x: number } | null>(null); + + // Chart dimensions + chartWidth = 700; + chartHeight = 280; + chartPadding = { top: 20, right: 20, bottom: 35, left: 20 }; // Minimal left padding + + // Dynamic Y-axis position based on label widths + private maxYLabelWidth = 0; + + protected get yAxisX(): number { + // Y-axis line position: left padding + max label width + gap + return this.chartPadding.left + this.maxYLabelWidth + 10; + } + + // Computed values + protected readonly chartTitle = computed(() => { + switch (this.selectedMetric()) { + case RiskMetricType.Applications: + return this.i18nService.t("applicationsAtRiskOverTime"); + case RiskMetricType.Items: + return this.i18nService.t("itemsAtRiskOverTime"); + case RiskMetricType.Members: + return this.i18nService.t("membersAtRiskOverTime"); + default: + return this.i18nService.t("riskOverTime"); + } + }); + + protected readonly isImproving = computed(() => { + const data = this.chartData(); + if (!data) { + return true; + } + const firstValue = data.currentPeriod[0]; + const lastValue = data.currentPeriod[data.currentPeriod.length - 1]; + return lastValue < firstValue; + }); + + protected readonly currentPeriodStrokeColor = computed(() => { + return this.isImproving() ? "#175DDC" : "#C83B3B"; + }); + + protected readonly currentPeriodColor = computed(() => { + return this.isImproving() ? "tw-text-primary-600" : "tw-text-danger"; + }); + + // Enums for template + readonly RiskMetricType = RiskMetricType; + readonly TimePeriod = TimePeriod; + + // Period options for dropdown + periodOptions: Array<{ value: TimePeriod; label: string }> = []; + + constructor( + private dataService: RiskOverTimeDataService, + private i18nService: I18nService, + ) { + // Initialize period options with i18n strings + this.periodOptions = [ + { value: TimePeriod.ThreeMonths, label: this.i18nService.t("last3Months") }, + { value: TimePeriod.SixMonths, label: this.i18nService.t("last6Months") }, + { value: TimePeriod.TwelveMonths, label: this.i18nService.t("last12Months") }, + ]; + } + + ngOnInit(): void { + this.loadData(); + } + + private loadData(): void { + this.isLoading.set(true); + this.hasError.set(false); + + this.dataService + .getRiskOverTimeData(this.selectedMetric(), this.selectedPeriod()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (data) => { + this.chartData.set(data); + this.isLoading.set(false); + // Calculate max Y-axis label width after data loads + this.calculateMaxYLabelWidth(); + }, + error: (_error: unknown) => { + this.hasError.set(true); + this.isLoading.set(false); + }, + }); + } + + private calculateMaxYLabelWidth(): void { + const data = this.chartData(); + if (!data) { + this.maxYLabelWidth = 30; // Default width + return; + } + + // Get all Y-axis label values + const labels = this.yAxisLabels.map((l) => l.value); + + // Create a temporary canvas to measure text width + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) { + this.maxYLabelWidth = 30; + return; + } + + // Set font to match the SVG text element (12px) + context.font = "12px system-ui, -apple-system, sans-serif"; + + // Find the widest label + this.maxYLabelWidth = Math.max(...labels.map((label) => context.measureText(label).width)); + } + + protected onMetricChange(metric: RiskMetricType | string): void { + this.selectedMetric.set(metric as RiskMetricType); + this.loadData(); + } + + protected onPeriodChange(event: Event): void { + const target = event.target as HTMLSelectElement; + this.selectedPeriod.set(target.value as TimePeriod); + this.loadData(); + } + + protected get yAxisLabels(): Array<{ value: string; y: number }> { + const data = this.chartData(); + if (!data) { + return [ + { value: "100%", y: this.chartPadding.top }, + { + value: "75%", + y: + this.chartPadding.top + + (this.chartHeight - this.chartPadding.top - this.chartPadding.bottom) * 0.25, + }, + { + value: "50%", + y: + this.chartPadding.top + + (this.chartHeight - this.chartPadding.top - this.chartPadding.bottom) * 0.5, + }, + { + value: "25%", + y: + this.chartPadding.top + + (this.chartHeight - this.chartPadding.top - this.chartPadding.bottom) * 0.75, + }, + { value: "0", y: this.chartHeight - this.chartPadding.bottom }, + ]; + } + const maxValue = Math.max(...data.currentPeriod, ...data.previousPeriod); + const step = Math.ceil(maxValue / 4); + const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom; + return [ + { value: (step * 4).toString(), y: this.chartPadding.top }, + { value: (step * 3).toString(), y: this.chartPadding.top + chartAreaHeight * 0.25 }, + { value: (step * 2).toString(), y: this.chartPadding.top + chartAreaHeight * 0.5 }, + { value: step.toString(), y: this.chartPadding.top + chartAreaHeight * 0.75 }, + { value: "0", y: this.chartHeight - this.chartPadding.bottom }, + ]; + } + + protected get xAxisLabels(): string[] { + return this.chartData()?.labels || []; + } + + // Calculate SVG path for area chart + protected getAreaPath(data: number[], isPreviousPeriod = false): string { + const chartData = this.chartData(); + if (!chartData || data.length === 0) { + return ""; + } + + const maxValue = Math.max(...chartData.currentPeriod, ...chartData.previousPeriod); + const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right; + const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom; + const step = chartAreaWidth / (data.length - 1); + + // Create path + let path = `M ${this.yAxisX},${this.chartHeight - this.chartPadding.bottom}`; + + // Add points + data.forEach((value, index) => { + const x = this.yAxisX + index * step; + const y = this.chartPadding.top + chartAreaHeight - (value / maxValue) * chartAreaHeight; + + path += ` L ${x},${y}`; + }); + + // Close the path + path += ` L ${this.yAxisX + (data.length - 1) * step},${this.chartHeight - this.chartPadding.bottom}`; + path += " Z"; + + return path; + } + + // Calculate SVG path for line + protected getLinePath(data: number[]): string { + const chartData = this.chartData(); + if (!chartData || data.length === 0) { + return ""; + } + + const maxValue = Math.max(...chartData.currentPeriod, ...chartData.previousPeriod); + const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right; + const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom; + const step = chartAreaWidth / (data.length - 1); + + // Create path + let path = ""; + + data.forEach((value, index) => { + const x = this.yAxisX + index * step; + const y = this.chartPadding.top + chartAreaHeight - (value / maxValue) * chartAreaHeight; + + if (index === 0) { + path += `M ${x},${y}`; + } else { + path += ` L ${x},${y}`; + } + }); + + return path; + } + + // Get points for hover circles + protected getDataPoints(data: number[]): Array<{ x: number; y: number; value: number }> { + const chartData = this.chartData(); + if (!chartData || data.length === 0) { + return []; + } + + const maxValue = Math.max(...chartData.currentPeriod, ...chartData.previousPeriod); + const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right; + const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom; + const step = chartAreaWidth / (data.length - 1); + + return data.map((value, index) => ({ + x: this.yAxisX + index * step, + y: this.chartPadding.top + chartAreaHeight - (value / maxValue) * chartAreaHeight, + value, + })); + } + + protected onChartMouseMove(event: MouseEvent): void { + const data = this.chartData(); + if (!data) { + return; + } + + const svg = event.currentTarget as SVGSVGElement; + const rect = svg.getBoundingClientRect(); + const x = event.clientX - rect.left; + + // Find closest data point + const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right; + const step = chartAreaWidth / (data.currentPeriod.length - 1); + const relativeX = x - this.yAxisX; + const index = Math.round(relativeX / step); + + if (index >= 0 && index < data.currentPeriod.length) { + this.hoveredDataPoint.set({ + index, + x: this.yAxisX + index * step, + }); + } + } + + protected onChartMouseLeave(): void { + this.hoveredDataPoint.set(null); + } + + protected getTooltipPosition(): { x: number; y: number } | null { + const hovered = this.hoveredDataPoint(); + if (!hovered) { + return null; + } + return { + x: hovered.x, + y: this.chartPadding.top + 10, + }; + } + + protected getTooltipData(): { + label: string; + currentValue: number; + previousValue: number; + } | null { + const hovered = this.hoveredDataPoint(); + const data = this.chartData(); + if (!hovered || !data) { + return null; + } + return { + label: data.labels[hovered.index], + currentValue: data.currentPeriod[hovered.index], + previousValue: data.previousPeriod[hovered.index], + }; + } + + protected downloadChartAsSVG(): void { + const svgRef = this.chartSvgRef(); + if (!svgRef) { + return; + } + + try { + const svgElement = svgRef.nativeElement; + const serializer = new XMLSerializer(); + const svgString = serializer.serializeToString(svgElement); + const blob = new Blob([svgString], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${this.chartTitle().replace(/\s+/g, "-").toLowerCase()}.svg`; + link.click(); + URL.revokeObjectURL(url); + } catch { + // Error downloading chart - silently fail + } + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time.models.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time.models.ts new file mode 100644 index 00000000000..837c42e5781 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-over-time/risk-over-time.models.ts @@ -0,0 +1,50 @@ +/** + * Risk metric types + */ +export const RiskMetricType = { + Applications: "applications", + Items: "items", + Members: "members", +} as const; + +export type RiskMetricType = (typeof RiskMetricType)[keyof typeof RiskMetricType]; + +/** + * Time period selection + */ +export const TimePeriod = { + ThreeMonths: "3months", + SixMonths: "6months", + TwelveMonths: "12months", +} as const; + +export type TimePeriod = (typeof TimePeriod)[keyof typeof TimePeriod]; + +/** + * Interface for risk over time data + */ +export interface RiskOverTimeData { + /** Labels for x-axis (e.g., month names) */ + labels: string[]; + + /** Data points for current period */ + currentPeriod: number[]; + + /** Data points for previous period (last year) */ + previousPeriod: number[]; + + /** The metric type this data represents */ + metricType: RiskMetricType; + + /** The time period this data represents */ + timePeriod: TimePeriod; +} + +/** + * Interface for API response containing risk over time data + */ +export interface RiskOverTimeApiResponse { + applications?: RiskOverTimeData; + items?: RiskOverTimeData; + members?: RiskOverTimeData; +}