ownerlens 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +183 -0
- package/README.md +209 -0
- package/bin/ownerlens.js +92 -0
- package/dist/assets/index-B9aAYpVl.css +1 -0
- package/dist/assets/index-BcwLk2bx.js +10 -0
- package/dist/index.html +13 -0
- package/package.json +73 -0
- package/src/App.tsx +18 -0
- package/src/components/azure/AzureComponent.test.tsx +625 -0
- package/src/components/azure/AzureComponent.tsx +189 -0
- package/src/components/azure/AzureRbacComponent.tsx +104 -0
- package/src/components/azure/ClosableAzureTab.tsx +42 -0
- package/src/components/azure/EntraPermissionsComponent.tsx +194 -0
- package/src/components/azure/ManagedIdentityComponent.test.tsx +324 -0
- package/src/components/azure/ManagedIdentityComponent.tsx +141 -0
- package/src/components/azure/ResourceGroupComponent.tsx +157 -0
- package/src/components/azure/ServicePrincipalComponent.test.tsx +457 -0
- package/src/components/azure/ServicePrincipalComponent.tsx +155 -0
- package/src/components/azure/ServicePrincipalFieldRenderers.tsx +140 -0
- package/src/components/azure/ZtaComponent.test.tsx +267 -0
- package/src/components/azure/ZtaComponent.tsx +276 -0
- package/src/components/azure/ZtaRemediationBadge.tsx +70 -0
- package/src/components/azure/api.ts +216 -0
- package/src/components/azure/azureReportConfig.ts +247 -0
- package/src/core/azure/azureRbac.ts +70 -0
- package/src/core/azure/entra/index.ts +1 -0
- package/src/core/azure/entra/managedIdentity.ts +21 -0
- package/src/core/azure/entra/servicePrincipal.ts +34 -0
- package/src/core/azure/entra/types.ts +56 -0
- package/src/core/azure/identityEnrichment.ts +65 -0
- package/src/core/azure/resources.ts +141 -0
- package/src/core/azure/ztaReport.ts +58 -0
- package/src/core/config.ts +39 -0
- package/src/core/ownership/OwnershipTarget.ts +32 -0
- package/src/core/ownership/resolveOwner.ts +5 -0
- package/src/core/ownership/types.ts +14 -0
- package/src/core/risk/types.ts +1 -0
- package/src/core/runtime/index.ts +1 -0
- package/src/core/runtime/localSnapshotFiles.ts +74 -0
- package/src/core/runtime/rest.ts +61 -0
- package/src/lib/searchFilterUtils.ts +17 -0
- package/src/lib/utils.ts +48 -0
- package/src/main.tsx +10 -0
- package/src/providers/azure/identities/azureIdentityTypes.ts +1 -0
- package/src/providers/azure/identities/buildAzureManagedIdentityAssignmentIndex.test.ts +32 -0
- package/src/providers/azure/identities/buildAzureManagedIdentityAssignmentIndex.ts +35 -0
- package/src/providers/azure/identities/userAssignedIdentityAssignments.ts +52 -0
- package/src/providers/azure/inputTransferObject/entra/EntraAppRoleAssignment.ts +10 -0
- package/src/providers/azure/inputTransferObject/entra/EntraApplication.ts +27 -0
- package/src/providers/azure/inputTransferObject/entra/EntraOAuth2PermissionGrant.ts +8 -0
- package/src/providers/azure/inputTransferObject/entra/EntraServicePrincipal.ts +43 -0
- package/src/providers/azure/inputTransferObject/entra/EntraSnapshot.ts +13 -0
- package/src/providers/azure/inputTransferObject/entra/EntraSnapshotMeta.ts +12 -0
- package/src/providers/azure/inputTransferObject/resources/AzureActivityLog.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureResource.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureResourceGroup.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureRoleAssignment.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureSnapshot.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureSnapshotMeta.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureSubscription.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureUserAssignedManagedIdentity.ts +1 -0
- package/src/providers/azure/ownership/azureActivityOwnershipEvidence.ts +60 -0
- package/src/providers/azure/ownership/azureOwnerReportTypes.ts +13 -0
- package/src/providers/azure/ownership/azureOwnershipConfig.ts +21 -0
- package/src/providers/azure/ownership/azureOwnershipTypes.ts +46 -0
- package/src/providers/azure/ownership/buildAzureOwnershipReport.test.ts +99 -0
- package/src/providers/azure/ownership/buildAzureOwnershipReport.ts +90 -0
- package/src/providers/azure/ownership/buildAzureOwnershipTargets.test.ts +87 -0
- package/src/providers/azure/ownership/buildAzureOwnershipTargets.ts +42 -0
- package/src/providers/azure/ownership/resolveAzureOwner.ts +146 -0
- package/src/providers/azure/runtime/DisabledEvidenceStore.ts +34 -0
- package/src/providers/azure/runtime/EnrichmentService.ts +35 -0
- package/src/providers/azure/runtime/LocalReportRuntime.test.ts +2318 -0
- package/src/providers/azure/runtime/LocalReportRuntime.ts +302 -0
- package/src/providers/azure/runtime/RuntimeHost.ts +60 -0
- package/src/providers/azure/runtime/SnapshotImporter.ts +44 -0
- package/src/providers/azure/runtime/enrichment/azureIdentityEnrichment.ts +523 -0
- package/src/providers/azure/runtime/enrichment/azureScopeClassifier.ts +30 -0
- package/src/providers/azure/runtime/enrichment/evaluateAzureRoleAssignmentRisk.ts +88 -0
- package/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts +307 -0
- package/src/providers/azure/runtime/entra/LocalEntraReportRuntime.ts +227 -0
- package/src/providers/azure/runtime/entra/appRoleAssignmentsTable.ts +52 -0
- package/src/providers/azure/runtime/entra/applicationsTable.ts +175 -0
- package/src/providers/azure/runtime/entra/entraServicePrincipalMapper.ts +63 -0
- package/src/providers/azure/runtime/entra/localReportRuntimeRest.ts +41 -0
- package/src/providers/azure/runtime/entra/oauth2PermissionGrantsTable.ts +48 -0
- package/src/providers/azure/runtime/entra/principalProjection.ts +173 -0
- package/src/providers/azure/runtime/entra/servicePrincipalsTable.ts +149 -0
- package/src/providers/azure/runtime/entra/snapshotMetadataTable.ts +18 -0
- package/src/providers/azure/runtime/entra/snapshotStore.ts +102 -0
- package/src/providers/azure/runtime/localReportCollections.ts +101 -0
- package/src/providers/azure/runtime/localReportRuntimeRest.ts +71 -0
- package/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts +145 -0
- package/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts +114 -0
- package/src/providers/azure/runtime/resources/disabledOwnerEvidenceTable.ts +60 -0
- package/src/providers/azure/runtime/resources/localReportRuntimeRest.ts +81 -0
- package/src/providers/azure/runtime/resources/resourceGroupOwnership.ts +90 -0
- package/src/providers/azure/runtime/resources/snapshotMetadataTable.ts +19 -0
- package/src/providers/azure/runtime/resources/snapshotStore.ts +128 -0
- package/src/providers/azure/runtime/resources/tables.ts +441 -0
- package/src/providers/azure/runtime/runtimeRestQuery.ts +46 -0
- package/src/providers/azure/runtime/runtimeSqlSchema.ts +357 -0
- package/src/providers/azure/runtime/zta/Discovery.ts +141 -0
- package/src/providers/azure/runtime/zta/LocalZeroTrustAssessmentReportRuntime.ts +86 -0
- package/src/providers/azure/runtime/zta/ZeroTrustAssessmentQueryService.ts +124 -0
- package/src/providers/azure/runtime/zta/localReportRuntimeRest.ts +15 -0
- package/src/providers/azure/runtime/zta/snapshotMetadataTable.ts +77 -0
- package/src/providers/azure/runtime/zta/snapshotStore.ts +112 -0
- package/src/providers/azure/runtime/zta/tables.ts +361 -0
- package/src/providers/azure/runtime/zta/types.ts +7 -0
- package/src/providers/azure/runtime/zta/ztaReportMapper.ts +12 -0
- package/src/report/applyCollectionControls.ts +289 -0
- package/src/report/buildCollectionColumns.tsx +38 -0
- package/src/report/components/ConfidenceBadge.tsx +10 -0
- package/src/report/components/EvidenceList.test.ts +25 -0
- package/src/report/components/EvidenceList.tsx +52 -0
- package/src/report/components/GenericTable.tsx +373 -0
- package/src/report/components/PermissionRiskBadge.tsx +19 -0
- package/src/report/components/reportTableControls.test.ts +175 -0
- package/src/report/components/reportTableControls.tsx +483 -0
- package/src/report/components/ui/badge.tsx +35 -0
- package/src/report/components/ui/button.tsx +38 -0
- package/src/report/components/ui/card.tsx +23 -0
- package/src/report/components/ui/input.tsx +15 -0
- package/src/report/components/ui/table.tsx +44 -0
- package/src/report/components/ui/tabs.tsx +29 -0
- package/src/report/export/csv.ts +34 -0
- package/src/report/ownerManualPrecheck.test.ts +137 -0
- package/src/report/ownerManualPrecheck.ts +132 -0
- package/src/report/reportArchitecture.test.ts +125 -0
- package/src/report/reportTypes.ts +54 -0
- package/src/report/reportValueRenderers.tsx +54 -0
- package/src/report/runtimeCollectionQuery.ts +23 -0
- package/src/report/types.ts +14 -0
- package/src/styles.css +43 -0
- package/tools/README.md +108 -0
- package/tools/azure-activity-check.ps1 +164 -0
- package/tools/collect-azure.ps1 +54 -0
- package/tools/collect-entra.ps1 +47 -0
- package/tools/collect-scripts.test.ts +22 -0
- package/tools/prepare-entra-snapshot.ps1 +403 -0
- package/tools/prepare-entra-snapshot.test.ts +14 -0
- package/tools/prepare-resource-snapshot.ps1 +345 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
|
|
4
|
+
import { Button } from "./ui/button";
|
|
5
|
+
import { Input } from "./ui/input";
|
|
6
|
+
import { TableHead } from "./ui/table";
|
|
7
|
+
import type { ReportColumnHelp, ReportFieldDescriptor } from "../reportTypes";
|
|
8
|
+
import {
|
|
9
|
+
applyCollectionControls,
|
|
10
|
+
type ColumnFilter,
|
|
11
|
+
type ColumnFilterOptions,
|
|
12
|
+
type ColumnFilters,
|
|
13
|
+
type SortRule
|
|
14
|
+
} from "../applyCollectionControls";
|
|
15
|
+
|
|
16
|
+
export type { ColumnFilter, ColumnFilterOptions, ColumnFilters, SortRule } from "../applyCollectionControls";
|
|
17
|
+
|
|
18
|
+
export type ReportTableColumn<TRow> = {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
filter?: "auto" | "text" | "multiselect";
|
|
23
|
+
help?: ReportColumnHelp;
|
|
24
|
+
render: (row: TRow) => ReactNode;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const maxMultiselectOptions = 5;
|
|
28
|
+
const tooltipWidth = 320;
|
|
29
|
+
const tooltipGap = 8;
|
|
30
|
+
const dropdownGap = 4;
|
|
31
|
+
const dropdownEstimatedHeight = 272;
|
|
32
|
+
const viewportMargin = 16;
|
|
33
|
+
|
|
34
|
+
export function useReportTableControls<TRow>(rows: TRow[], fields: ReportFieldDescriptor<TRow>[]) {
|
|
35
|
+
const [filters, setFilters] = useState<ColumnFilters>({});
|
|
36
|
+
const [sortRules, setSortRules] = useState<SortRule[]>([]);
|
|
37
|
+
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
const { controlledRows, filterOptions } = useMemo(
|
|
40
|
+
() => applyReportTableControls(rows, fields, filters, sortRules),
|
|
41
|
+
[fields, filters, rows, sortRules]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function setColumnFilter(columnId: string, value: string) {
|
|
45
|
+
setFilters((current) => {
|
|
46
|
+
const next = { ...current };
|
|
47
|
+
|
|
48
|
+
if (value.trim().length === 0) {
|
|
49
|
+
delete next[columnId];
|
|
50
|
+
} else {
|
|
51
|
+
next[columnId] = { type: "text", value };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return next;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function setColumnValuesFilter(columnId: string, values: string[]) {
|
|
59
|
+
setFilters((current) => applyColumnValuesFilter(current, columnId, values));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toggleColumnValueFilter(columnId: string, value: string, checked: boolean) {
|
|
63
|
+
setFilters((current) => applyColumnFilterValueToggle(current, columnId, value, checked));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function setColumnFilterOpen(columnId: string, isOpen: boolean) {
|
|
67
|
+
setOpenFilterColumnId((currentColumnId) => applyColumnFilterOpen(currentColumnId, columnId, isOpen));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toggleColumnSort(columnId: string) {
|
|
71
|
+
setSortRules((current) => {
|
|
72
|
+
const existingRule = current.find((rule) => rule.columnId === columnId);
|
|
73
|
+
|
|
74
|
+
if (!existingRule) {
|
|
75
|
+
return [...current, { columnId, direction: "asc" }];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (existingRule.direction === "asc") {
|
|
79
|
+
return current.map((rule) => (rule.columnId === columnId ? { ...rule, direction: "desc" } : rule));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return current.filter((rule) => rule.columnId !== columnId);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
controlledRows,
|
|
88
|
+
filterOptions,
|
|
89
|
+
filters,
|
|
90
|
+
openFilterColumnId,
|
|
91
|
+
setColumnFilter,
|
|
92
|
+
setColumnFilterOpen,
|
|
93
|
+
setColumnValuesFilter,
|
|
94
|
+
sortRules,
|
|
95
|
+
toggleColumnValueFilter,
|
|
96
|
+
toggleColumnSort
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function applyReportTableControls<TRow>(
|
|
101
|
+
rows: TRow[],
|
|
102
|
+
fields: ReportFieldDescriptor<TRow>[],
|
|
103
|
+
filters: ColumnFilters,
|
|
104
|
+
sortRules: SortRule[] = []
|
|
105
|
+
) {
|
|
106
|
+
return applyCollectionControls(rows, fields, {
|
|
107
|
+
filters,
|
|
108
|
+
sortRules
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function applyColumnValuesFilter(
|
|
113
|
+
currentFilters: ColumnFilters,
|
|
114
|
+
columnId: string,
|
|
115
|
+
values: string[]
|
|
116
|
+
): ColumnFilters {
|
|
117
|
+
const next = { ...currentFilters };
|
|
118
|
+
|
|
119
|
+
if (values.length === 0) {
|
|
120
|
+
delete next[columnId];
|
|
121
|
+
} else {
|
|
122
|
+
next[columnId] = { type: "values", values };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return next;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function applyColumnFilterValueToggle(
|
|
129
|
+
currentFilters: ColumnFilters,
|
|
130
|
+
columnId: string,
|
|
131
|
+
value: string,
|
|
132
|
+
checked: boolean
|
|
133
|
+
): ColumnFilters {
|
|
134
|
+
const currentFilter = currentFilters[columnId];
|
|
135
|
+
const selectedValues = currentFilter?.type === "values" ? currentFilter.values : [];
|
|
136
|
+
|
|
137
|
+
return applyColumnValuesFilter(currentFilters, columnId, applyColumnValueToggle(selectedValues, value, checked));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function applyColumnFilterOpen(
|
|
141
|
+
currentColumnId: string | null,
|
|
142
|
+
columnId: string,
|
|
143
|
+
isOpen: boolean
|
|
144
|
+
): string | null {
|
|
145
|
+
if (isOpen) {
|
|
146
|
+
return columnId;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return currentColumnId === columnId ? null : currentColumnId;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function applyColumnValueToggle(selectedValues: string[], value: string, checked: boolean): string[] {
|
|
153
|
+
if (checked) {
|
|
154
|
+
return selectedValues.includes(value) ? selectedValues : [...selectedValues, value];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return selectedValues.filter((selectedValue) => selectedValue !== value);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function ReportTableHead<TRow>({
|
|
161
|
+
columns,
|
|
162
|
+
filters,
|
|
163
|
+
filterOptions,
|
|
164
|
+
openFilterColumnId,
|
|
165
|
+
sortRules,
|
|
166
|
+
onFilterChange,
|
|
167
|
+
onFilterOpenChange,
|
|
168
|
+
onValueFilterToggle,
|
|
169
|
+
onValuesFilterChange,
|
|
170
|
+
onSortToggle
|
|
171
|
+
}: {
|
|
172
|
+
columns: ReportTableColumn<TRow>[];
|
|
173
|
+
filters: ColumnFilters;
|
|
174
|
+
filterOptions: ColumnFilterOptions;
|
|
175
|
+
openFilterColumnId: string | null;
|
|
176
|
+
sortRules: SortRule[];
|
|
177
|
+
onFilterChange: (columnId: string, value: string) => void;
|
|
178
|
+
onFilterOpenChange: (columnId: string, isOpen: boolean) => void;
|
|
179
|
+
onValueFilterToggle: (columnId: string, value: string, checked: boolean) => void;
|
|
180
|
+
onValuesFilterChange: (columnId: string, values: string[]) => void;
|
|
181
|
+
onSortToggle: (columnId: string) => void;
|
|
182
|
+
}) {
|
|
183
|
+
return (
|
|
184
|
+
<>
|
|
185
|
+
{columns.map((column) => {
|
|
186
|
+
const sortIndex = sortRules.findIndex((rule) => rule.columnId === column.id);
|
|
187
|
+
const sortRule = sortIndex >= 0 ? sortRules[sortIndex] : null;
|
|
188
|
+
const sortMark = sortRule ? (sortRule.direction === "asc" ? "↑" : "↓") : "↕";
|
|
189
|
+
const options = filterOptions[column.id] ?? [];
|
|
190
|
+
const filter = filters[column.id];
|
|
191
|
+
const shouldUseMultiselect =
|
|
192
|
+
(column.filter === "multiselect" && options.length > 0) ||
|
|
193
|
+
(column.filter !== "text" && options.length > 0 && options.length <= maxMultiselectOptions);
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<TableHead key={column.id} className={column.className}>
|
|
197
|
+
<div className="flex min-w-[132px] flex-col gap-1.5">
|
|
198
|
+
<div className="flex items-start justify-between gap-1 py-1">
|
|
199
|
+
<button
|
|
200
|
+
aria-label={`Sort by ${column.label}`}
|
|
201
|
+
className="inline-flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-1 rounded-sm border-0 bg-transparent p-0 text-left text-xs font-semibold text-foreground"
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={() => onSortToggle(column.id)}
|
|
204
|
+
>
|
|
205
|
+
<span className="truncate">{column.label}</span>
|
|
206
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground">
|
|
207
|
+
{sortRule ? <span>{sortIndex + 1}</span> : null}
|
|
208
|
+
<span aria-hidden="true">{sortMark}</span>
|
|
209
|
+
</span>
|
|
210
|
+
</button>
|
|
211
|
+
{column.help ? <ColumnInfo label={column.label} help={column.help} /> : null}
|
|
212
|
+
</div>
|
|
213
|
+
{shouldUseMultiselect ? (
|
|
214
|
+
<ColumnValueFilter
|
|
215
|
+
column={column}
|
|
216
|
+
filter={filter}
|
|
217
|
+
isOpen={openFilterColumnId === column.id}
|
|
218
|
+
options={options}
|
|
219
|
+
onClear={onValuesFilterChange}
|
|
220
|
+
onOpenChange={onFilterOpenChange}
|
|
221
|
+
onValueToggle={onValueFilterToggle}
|
|
222
|
+
/>
|
|
223
|
+
) : (
|
|
224
|
+
<Input
|
|
225
|
+
aria-label={`Filter ${column.label}`}
|
|
226
|
+
className="h-7 min-w-0 bg-card px-1.5 py-1 text-xs shadow-none"
|
|
227
|
+
placeholder="Filter with RegExp"
|
|
228
|
+
value={filter?.type === "text" ? filter.value : ""}
|
|
229
|
+
onChange={(event) => onFilterChange(column.id, event.target.value)}
|
|
230
|
+
/>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
</TableHead>
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
236
|
+
</>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function ColumnInfo({ label, help }: { label: string; help: ReportColumnHelp }) {
|
|
241
|
+
const triggerRef = useRef<HTMLSpanElement>(null);
|
|
242
|
+
const [tooltipPosition, setTooltipPosition] = useState<{ left: number; top: number } | null>(null);
|
|
243
|
+
|
|
244
|
+
function showTooltip() {
|
|
245
|
+
const trigger = triggerRef.current;
|
|
246
|
+
if (!trigger) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const rect = trigger.getBoundingClientRect();
|
|
251
|
+
const maxLeft = window.innerWidth - tooltipWidth - viewportMargin;
|
|
252
|
+
const preferredLeft = rect.right - tooltipWidth;
|
|
253
|
+
const left = Math.max(viewportMargin, Math.min(preferredLeft, maxLeft));
|
|
254
|
+
const preferredTop = rect.bottom + tooltipGap;
|
|
255
|
+
const top =
|
|
256
|
+
preferredTop + tooltipGap > window.innerHeight
|
|
257
|
+
? Math.max(viewportMargin, rect.top - tooltipGap)
|
|
258
|
+
: preferredTop;
|
|
259
|
+
|
|
260
|
+
setTooltipPosition({ left, top });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function hideTooltip() {
|
|
264
|
+
setTooltipPosition(null);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<span className="inline-flex shrink-0" onBlur={hideTooltip} onFocus={showTooltip} onMouseEnter={showTooltip} onMouseLeave={hideTooltip}>
|
|
269
|
+
<span
|
|
270
|
+
ref={triggerRef}
|
|
271
|
+
aria-label={`${label} column information`}
|
|
272
|
+
className="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full border border-input bg-card text-[10px] font-semibold leading-none text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
273
|
+
role="button"
|
|
274
|
+
tabIndex={0}
|
|
275
|
+
>
|
|
276
|
+
i
|
|
277
|
+
</span>
|
|
278
|
+
{tooltipPosition
|
|
279
|
+
? createPortal(
|
|
280
|
+
<ColumnInfoTooltip help={help} label={label} left={tooltipPosition.left} top={tooltipPosition.top} />,
|
|
281
|
+
document.body
|
|
282
|
+
)
|
|
283
|
+
: null}
|
|
284
|
+
</span>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function ColumnInfoTooltip({
|
|
289
|
+
label,
|
|
290
|
+
help,
|
|
291
|
+
left,
|
|
292
|
+
top
|
|
293
|
+
}: {
|
|
294
|
+
label: string;
|
|
295
|
+
help: ReportColumnHelp;
|
|
296
|
+
left: number;
|
|
297
|
+
top: number;
|
|
298
|
+
}) {
|
|
299
|
+
const logic = help.logic ?? [];
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<span
|
|
303
|
+
className="pointer-events-none fixed z-[100] block w-80 max-w-[calc(100vw-2rem)] whitespace-normal rounded-md border border-border bg-card p-3 text-left text-xs font-normal leading-5 text-foreground shadow-lg"
|
|
304
|
+
role="tooltip"
|
|
305
|
+
style={{ left, top }}
|
|
306
|
+
>
|
|
307
|
+
<span className="mb-2 block font-semibold text-foreground">{label}</span>
|
|
308
|
+
<span className="block">
|
|
309
|
+
<span className="font-semibold text-muted-foreground">Source: </span>
|
|
310
|
+
{help.source}
|
|
311
|
+
</span>
|
|
312
|
+
{help.field ? (
|
|
313
|
+
<span className="block">
|
|
314
|
+
<span className="font-semibold text-muted-foreground">Attribute: </span>
|
|
315
|
+
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{help.field}</code>
|
|
316
|
+
</span>
|
|
317
|
+
) : null}
|
|
318
|
+
<span className="mt-2 block font-semibold text-muted-foreground">Logic:</span>
|
|
319
|
+
<ul className="m-0 mt-1 list-disc space-y-1 pl-4">
|
|
320
|
+
{logic.map((line) => (
|
|
321
|
+
<li key={line}>{line}</li>
|
|
322
|
+
))}
|
|
323
|
+
</ul>
|
|
324
|
+
</span>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function ColumnValueFilter<TRow>({
|
|
329
|
+
column,
|
|
330
|
+
filter,
|
|
331
|
+
isOpen,
|
|
332
|
+
options,
|
|
333
|
+
onClear,
|
|
334
|
+
onOpenChange,
|
|
335
|
+
onValueToggle
|
|
336
|
+
}: {
|
|
337
|
+
column: ReportTableColumn<TRow>;
|
|
338
|
+
filter: ColumnFilter | undefined;
|
|
339
|
+
isOpen: boolean;
|
|
340
|
+
options: string[];
|
|
341
|
+
onClear: (columnId: string, values: string[]) => void;
|
|
342
|
+
onOpenChange: (columnId: string, isOpen: boolean) => void;
|
|
343
|
+
onValueToggle: (columnId: string, value: string, checked: boolean) => void;
|
|
344
|
+
}) {
|
|
345
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
346
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
347
|
+
const [menuPosition, setMenuPosition] = useState<{
|
|
348
|
+
left: number;
|
|
349
|
+
top: number;
|
|
350
|
+
minWidth: number;
|
|
351
|
+
maxWidth: number;
|
|
352
|
+
} | null>(null);
|
|
353
|
+
const selectedValues = filter?.type === "values" ? filter.values : [];
|
|
354
|
+
const label =
|
|
355
|
+
selectedValues.length === 0
|
|
356
|
+
? "All"
|
|
357
|
+
: selectedValues.length === 1
|
|
358
|
+
? selectedValues[0]
|
|
359
|
+
: `${selectedValues.length} selected`;
|
|
360
|
+
|
|
361
|
+
function toggleValue(value: string, checked: boolean) {
|
|
362
|
+
onValueToggle(column.id, value, checked);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function updateMenuPosition() {
|
|
366
|
+
const trigger = triggerRef.current;
|
|
367
|
+
if (!trigger) {
|
|
368
|
+
setMenuPosition(null);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const rect = trigger.getBoundingClientRect();
|
|
373
|
+
const maxWidth = window.innerWidth - viewportMargin * 2;
|
|
374
|
+
const minWidth = Math.min(Math.max(rect.width, 160), maxWidth);
|
|
375
|
+
const preferredLeft = rect.left;
|
|
376
|
+
const maxLeft = window.innerWidth - minWidth - viewportMargin;
|
|
377
|
+
const left = Math.max(viewportMargin, Math.min(preferredLeft, maxLeft));
|
|
378
|
+
const preferredTop = rect.bottom + dropdownGap;
|
|
379
|
+
const top =
|
|
380
|
+
preferredTop + dropdownEstimatedHeight > window.innerHeight && rect.top > dropdownEstimatedHeight
|
|
381
|
+
? Math.max(viewportMargin, rect.top - dropdownGap - dropdownEstimatedHeight)
|
|
382
|
+
: preferredTop;
|
|
383
|
+
|
|
384
|
+
setMenuPosition({ left, top, minWidth, maxWidth });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (!isOpen) {
|
|
389
|
+
setMenuPosition(null);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
updateMenuPosition();
|
|
394
|
+
window.addEventListener("resize", updateMenuPosition);
|
|
395
|
+
window.addEventListener("scroll", updateMenuPosition, true);
|
|
396
|
+
|
|
397
|
+
return () => {
|
|
398
|
+
window.removeEventListener("resize", updateMenuPosition);
|
|
399
|
+
window.removeEventListener("scroll", updateMenuPosition, true);
|
|
400
|
+
};
|
|
401
|
+
}, [isOpen]);
|
|
402
|
+
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
if (!isOpen) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function handleDocumentMouseDown(event: MouseEvent) {
|
|
409
|
+
if (!(event.target instanceof Node)) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (triggerRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
onOpenChange(column.id, false);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
document.addEventListener("mousedown", handleDocumentMouseDown);
|
|
421
|
+
|
|
422
|
+
return () => {
|
|
423
|
+
document.removeEventListener("mousedown", handleDocumentMouseDown);
|
|
424
|
+
};
|
|
425
|
+
}, [column.id, isOpen, onOpenChange]);
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
<div className="relative">
|
|
429
|
+
<Button
|
|
430
|
+
ref={triggerRef}
|
|
431
|
+
aria-label={`Filter ${column.label}`}
|
|
432
|
+
className="h-7 w-full cursor-pointer list-none justify-between gap-1 bg-card px-1.5 py-1 font-normal shadow-sm marker:hidden"
|
|
433
|
+
size="sm"
|
|
434
|
+
type="button"
|
|
435
|
+
variant="outline"
|
|
436
|
+
onClick={() => onOpenChange(column.id, !isOpen)}
|
|
437
|
+
>
|
|
438
|
+
<span className="truncate">{label}</span>
|
|
439
|
+
<span aria-hidden="true" className="text-muted-foreground">
|
|
440
|
+
▾
|
|
441
|
+
</span>
|
|
442
|
+
</Button>
|
|
443
|
+
{isOpen && menuPosition && typeof document !== "undefined"
|
|
444
|
+
? createPortal(
|
|
445
|
+
<div
|
|
446
|
+
ref={menuRef}
|
|
447
|
+
className="fixed z-[100] rounded-md border border-border bg-card p-2 text-xs text-foreground shadow-lg"
|
|
448
|
+
style={{
|
|
449
|
+
left: menuPosition.left,
|
|
450
|
+
top: menuPosition.top,
|
|
451
|
+
minWidth: menuPosition.minWidth,
|
|
452
|
+
maxWidth: menuPosition.maxWidth
|
|
453
|
+
}}
|
|
454
|
+
>
|
|
455
|
+
<Button
|
|
456
|
+
className="mb-1 w-full cursor-pointer justify-start px-2 py-1 text-left text-xs text-muted-foreground"
|
|
457
|
+
size="sm"
|
|
458
|
+
type="button"
|
|
459
|
+
variant="ghost"
|
|
460
|
+
onClick={() => onClear(column.id, [])}
|
|
461
|
+
>
|
|
462
|
+
Clear
|
|
463
|
+
</Button>
|
|
464
|
+
<div className="flex max-h-52 flex-col gap-1 overflow-auto">
|
|
465
|
+
{options.map((option) => (
|
|
466
|
+
<label key={option} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-muted">
|
|
467
|
+
<input
|
|
468
|
+
checked={selectedValues.includes(option)}
|
|
469
|
+
className="h-3.5 w-3.5"
|
|
470
|
+
type="checkbox"
|
|
471
|
+
onChange={(event) => toggleValue(option, event.target.checked)}
|
|
472
|
+
/>
|
|
473
|
+
<span className="break-words">{option}</span>
|
|
474
|
+
</label>
|
|
475
|
+
))}
|
|
476
|
+
</div>
|
|
477
|
+
</div>,
|
|
478
|
+
document.body
|
|
479
|
+
)
|
|
480
|
+
: null}
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../../lib/utils";
|
|
5
|
+
|
|
6
|
+
const badgeVariants = cva(
|
|
7
|
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "border-transparent bg-primary text-primary-foreground",
|
|
12
|
+
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
|
13
|
+
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
|
14
|
+
outline: "text-foreground",
|
|
15
|
+
high: "border-transparent bg-emerald-100 text-emerald-800",
|
|
16
|
+
medium: "border-transparent bg-amber-100 text-amber-800",
|
|
17
|
+
low: "border-transparent bg-blue-100 text-blue-800",
|
|
18
|
+
none: "border-transparent bg-muted text-muted-foreground",
|
|
19
|
+
riskHigh: "border-transparent bg-red-100 text-red-800",
|
|
20
|
+
riskMedium: "border-transparent bg-amber-100 text-amber-800",
|
|
21
|
+
riskLow: "border-transparent bg-emerald-100 text-emerald-800",
|
|
22
|
+
riskNone: "border-transparent bg-muted text-muted-foreground"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
variant: "default"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>;
|
|
32
|
+
|
|
33
|
+
export function Badge({ className, variant, ...props }: BadgeProps) {
|
|
34
|
+
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
|
35
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../../lib/utils";
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
12
|
+
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
13
|
+
outline: "border border-input bg-background hover:bg-muted hover:text-foreground",
|
|
14
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
15
|
+
ghost: "hover:bg-muted hover:text-foreground",
|
|
16
|
+
link: "text-primary underline-offset-4 hover:underline"
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
default: "h-10 px-4 py-2",
|
|
20
|
+
sm: "h-8 px-3 text-xs",
|
|
21
|
+
lg: "h-11 px-8",
|
|
22
|
+
icon: "h-10 w-10"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
size: "default",
|
|
27
|
+
variant: "default"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
|
|
33
|
+
|
|
34
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ className, size, variant, ...props }, ref) => (
|
|
35
|
+
<button ref={ref} className={cn(buttonVariants({ size, variant }), className)} {...props} />
|
|
36
|
+
));
|
|
37
|
+
|
|
38
|
+
Button.displayName = "Button";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
|
|
5
|
+
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
6
|
+
return <div className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
10
|
+
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
|
14
|
+
return <h3 className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
|
18
|
+
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
22
|
+
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
|
|
5
|
+
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<input
|
|
8
|
+
className={cn(
|
|
9
|
+
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
10
|
+
className
|
|
11
|
+
)}
|
|
12
|
+
{...props}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { HTMLAttributes, TableHTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
|
|
5
|
+
export function TableContainer({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
className={cn("relative w-full overflow-auto rounded-md border border-border bg-card text-card-foreground shadow-sm", className)}
|
|
9
|
+
{...props}
|
|
10
|
+
/>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Table({ className, ...props }: TableHTMLAttributes<HTMLTableElement>) {
|
|
15
|
+
return <table className={cn("w-full caption-bottom border-collapse text-sm", className)} {...props} />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
|
19
|
+
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
|
23
|
+
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
|
27
|
+
return <tr className={cn("border-b border-border transition-colors hover:bg-muted/50", className)} {...props} />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
|
|
31
|
+
return (
|
|
32
|
+
<th
|
|
33
|
+
className={cn(
|
|
34
|
+
"sticky top-0 z-10 h-10 bg-muted px-1.5 text-left align-middle text-xs font-medium text-muted-foreground",
|
|
35
|
+
className
|
|
36
|
+
)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
|
|
43
|
+
return <td className={cn("max-w-[280px] break-words px-3 py-3 align-top", className)} {...props} />;
|
|
44
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
2
|
+
import type { ComponentProps } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../../lib/utils";
|
|
5
|
+
|
|
6
|
+
export function Tabs({ className, ...props }: ComponentProps<typeof TabsPrimitive.Root>) {
|
|
7
|
+
return <TabsPrimitive.Root className={cn("flex flex-col gap-2", className)} {...props} />;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function TabsList({ className, ...props }: ComponentProps<typeof TabsPrimitive.List>) {
|
|
11
|
+
return (
|
|
12
|
+
<TabsPrimitive.List
|
|
13
|
+
className={cn("inline-flex min-h-10 flex-wrap items-center gap-1 rounded-md bg-muted p-1 text-muted-foreground", className)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TabsTrigger({ className, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) {
|
|
20
|
+
return (
|
|
21
|
+
<TabsPrimitive.Trigger
|
|
22
|
+
className={cn(
|
|
23
|
+
"inline-flex min-h-8 items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
|
24
|
+
className
|
|
25
|
+
)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|