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.
Files changed (144) hide show
  1. package/LICENSE +183 -0
  2. package/README.md +209 -0
  3. package/bin/ownerlens.js +92 -0
  4. package/dist/assets/index-B9aAYpVl.css +1 -0
  5. package/dist/assets/index-BcwLk2bx.js +10 -0
  6. package/dist/index.html +13 -0
  7. package/package.json +73 -0
  8. package/src/App.tsx +18 -0
  9. package/src/components/azure/AzureComponent.test.tsx +625 -0
  10. package/src/components/azure/AzureComponent.tsx +189 -0
  11. package/src/components/azure/AzureRbacComponent.tsx +104 -0
  12. package/src/components/azure/ClosableAzureTab.tsx +42 -0
  13. package/src/components/azure/EntraPermissionsComponent.tsx +194 -0
  14. package/src/components/azure/ManagedIdentityComponent.test.tsx +324 -0
  15. package/src/components/azure/ManagedIdentityComponent.tsx +141 -0
  16. package/src/components/azure/ResourceGroupComponent.tsx +157 -0
  17. package/src/components/azure/ServicePrincipalComponent.test.tsx +457 -0
  18. package/src/components/azure/ServicePrincipalComponent.tsx +155 -0
  19. package/src/components/azure/ServicePrincipalFieldRenderers.tsx +140 -0
  20. package/src/components/azure/ZtaComponent.test.tsx +267 -0
  21. package/src/components/azure/ZtaComponent.tsx +276 -0
  22. package/src/components/azure/ZtaRemediationBadge.tsx +70 -0
  23. package/src/components/azure/api.ts +216 -0
  24. package/src/components/azure/azureReportConfig.ts +247 -0
  25. package/src/core/azure/azureRbac.ts +70 -0
  26. package/src/core/azure/entra/index.ts +1 -0
  27. package/src/core/azure/entra/managedIdentity.ts +21 -0
  28. package/src/core/azure/entra/servicePrincipal.ts +34 -0
  29. package/src/core/azure/entra/types.ts +56 -0
  30. package/src/core/azure/identityEnrichment.ts +65 -0
  31. package/src/core/azure/resources.ts +141 -0
  32. package/src/core/azure/ztaReport.ts +58 -0
  33. package/src/core/config.ts +39 -0
  34. package/src/core/ownership/OwnershipTarget.ts +32 -0
  35. package/src/core/ownership/resolveOwner.ts +5 -0
  36. package/src/core/ownership/types.ts +14 -0
  37. package/src/core/risk/types.ts +1 -0
  38. package/src/core/runtime/index.ts +1 -0
  39. package/src/core/runtime/localSnapshotFiles.ts +74 -0
  40. package/src/core/runtime/rest.ts +61 -0
  41. package/src/lib/searchFilterUtils.ts +17 -0
  42. package/src/lib/utils.ts +48 -0
  43. package/src/main.tsx +10 -0
  44. package/src/providers/azure/identities/azureIdentityTypes.ts +1 -0
  45. package/src/providers/azure/identities/buildAzureManagedIdentityAssignmentIndex.test.ts +32 -0
  46. package/src/providers/azure/identities/buildAzureManagedIdentityAssignmentIndex.ts +35 -0
  47. package/src/providers/azure/identities/userAssignedIdentityAssignments.ts +52 -0
  48. package/src/providers/azure/inputTransferObject/entra/EntraAppRoleAssignment.ts +10 -0
  49. package/src/providers/azure/inputTransferObject/entra/EntraApplication.ts +27 -0
  50. package/src/providers/azure/inputTransferObject/entra/EntraOAuth2PermissionGrant.ts +8 -0
  51. package/src/providers/azure/inputTransferObject/entra/EntraServicePrincipal.ts +43 -0
  52. package/src/providers/azure/inputTransferObject/entra/EntraSnapshot.ts +13 -0
  53. package/src/providers/azure/inputTransferObject/entra/EntraSnapshotMeta.ts +12 -0
  54. package/src/providers/azure/inputTransferObject/resources/AzureActivityLog.ts +1 -0
  55. package/src/providers/azure/inputTransferObject/resources/AzureResource.ts +1 -0
  56. package/src/providers/azure/inputTransferObject/resources/AzureResourceGroup.ts +1 -0
  57. package/src/providers/azure/inputTransferObject/resources/AzureRoleAssignment.ts +1 -0
  58. package/src/providers/azure/inputTransferObject/resources/AzureSnapshot.ts +1 -0
  59. package/src/providers/azure/inputTransferObject/resources/AzureSnapshotMeta.ts +1 -0
  60. package/src/providers/azure/inputTransferObject/resources/AzureSubscription.ts +1 -0
  61. package/src/providers/azure/inputTransferObject/resources/AzureUserAssignedManagedIdentity.ts +1 -0
  62. package/src/providers/azure/ownership/azureActivityOwnershipEvidence.ts +60 -0
  63. package/src/providers/azure/ownership/azureOwnerReportTypes.ts +13 -0
  64. package/src/providers/azure/ownership/azureOwnershipConfig.ts +21 -0
  65. package/src/providers/azure/ownership/azureOwnershipTypes.ts +46 -0
  66. package/src/providers/azure/ownership/buildAzureOwnershipReport.test.ts +99 -0
  67. package/src/providers/azure/ownership/buildAzureOwnershipReport.ts +90 -0
  68. package/src/providers/azure/ownership/buildAzureOwnershipTargets.test.ts +87 -0
  69. package/src/providers/azure/ownership/buildAzureOwnershipTargets.ts +42 -0
  70. package/src/providers/azure/ownership/resolveAzureOwner.ts +146 -0
  71. package/src/providers/azure/runtime/DisabledEvidenceStore.ts +34 -0
  72. package/src/providers/azure/runtime/EnrichmentService.ts +35 -0
  73. package/src/providers/azure/runtime/LocalReportRuntime.test.ts +2318 -0
  74. package/src/providers/azure/runtime/LocalReportRuntime.ts +302 -0
  75. package/src/providers/azure/runtime/RuntimeHost.ts +60 -0
  76. package/src/providers/azure/runtime/SnapshotImporter.ts +44 -0
  77. package/src/providers/azure/runtime/enrichment/azureIdentityEnrichment.ts +523 -0
  78. package/src/providers/azure/runtime/enrichment/azureScopeClassifier.ts +30 -0
  79. package/src/providers/azure/runtime/enrichment/evaluateAzureRoleAssignmentRisk.ts +88 -0
  80. package/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts +307 -0
  81. package/src/providers/azure/runtime/entra/LocalEntraReportRuntime.ts +227 -0
  82. package/src/providers/azure/runtime/entra/appRoleAssignmentsTable.ts +52 -0
  83. package/src/providers/azure/runtime/entra/applicationsTable.ts +175 -0
  84. package/src/providers/azure/runtime/entra/entraServicePrincipalMapper.ts +63 -0
  85. package/src/providers/azure/runtime/entra/localReportRuntimeRest.ts +41 -0
  86. package/src/providers/azure/runtime/entra/oauth2PermissionGrantsTable.ts +48 -0
  87. package/src/providers/azure/runtime/entra/principalProjection.ts +173 -0
  88. package/src/providers/azure/runtime/entra/servicePrincipalsTable.ts +149 -0
  89. package/src/providers/azure/runtime/entra/snapshotMetadataTable.ts +18 -0
  90. package/src/providers/azure/runtime/entra/snapshotStore.ts +102 -0
  91. package/src/providers/azure/runtime/localReportCollections.ts +101 -0
  92. package/src/providers/azure/runtime/localReportRuntimeRest.ts +71 -0
  93. package/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts +145 -0
  94. package/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts +114 -0
  95. package/src/providers/azure/runtime/resources/disabledOwnerEvidenceTable.ts +60 -0
  96. package/src/providers/azure/runtime/resources/localReportRuntimeRest.ts +81 -0
  97. package/src/providers/azure/runtime/resources/resourceGroupOwnership.ts +90 -0
  98. package/src/providers/azure/runtime/resources/snapshotMetadataTable.ts +19 -0
  99. package/src/providers/azure/runtime/resources/snapshotStore.ts +128 -0
  100. package/src/providers/azure/runtime/resources/tables.ts +441 -0
  101. package/src/providers/azure/runtime/runtimeRestQuery.ts +46 -0
  102. package/src/providers/azure/runtime/runtimeSqlSchema.ts +357 -0
  103. package/src/providers/azure/runtime/zta/Discovery.ts +141 -0
  104. package/src/providers/azure/runtime/zta/LocalZeroTrustAssessmentReportRuntime.ts +86 -0
  105. package/src/providers/azure/runtime/zta/ZeroTrustAssessmentQueryService.ts +124 -0
  106. package/src/providers/azure/runtime/zta/localReportRuntimeRest.ts +15 -0
  107. package/src/providers/azure/runtime/zta/snapshotMetadataTable.ts +77 -0
  108. package/src/providers/azure/runtime/zta/snapshotStore.ts +112 -0
  109. package/src/providers/azure/runtime/zta/tables.ts +361 -0
  110. package/src/providers/azure/runtime/zta/types.ts +7 -0
  111. package/src/providers/azure/runtime/zta/ztaReportMapper.ts +12 -0
  112. package/src/report/applyCollectionControls.ts +289 -0
  113. package/src/report/buildCollectionColumns.tsx +38 -0
  114. package/src/report/components/ConfidenceBadge.tsx +10 -0
  115. package/src/report/components/EvidenceList.test.ts +25 -0
  116. package/src/report/components/EvidenceList.tsx +52 -0
  117. package/src/report/components/GenericTable.tsx +373 -0
  118. package/src/report/components/PermissionRiskBadge.tsx +19 -0
  119. package/src/report/components/reportTableControls.test.ts +175 -0
  120. package/src/report/components/reportTableControls.tsx +483 -0
  121. package/src/report/components/ui/badge.tsx +35 -0
  122. package/src/report/components/ui/button.tsx +38 -0
  123. package/src/report/components/ui/card.tsx +23 -0
  124. package/src/report/components/ui/input.tsx +15 -0
  125. package/src/report/components/ui/table.tsx +44 -0
  126. package/src/report/components/ui/tabs.tsx +29 -0
  127. package/src/report/export/csv.ts +34 -0
  128. package/src/report/ownerManualPrecheck.test.ts +137 -0
  129. package/src/report/ownerManualPrecheck.ts +132 -0
  130. package/src/report/reportArchitecture.test.ts +125 -0
  131. package/src/report/reportTypes.ts +54 -0
  132. package/src/report/reportValueRenderers.tsx +54 -0
  133. package/src/report/runtimeCollectionQuery.ts +23 -0
  134. package/src/report/types.ts +14 -0
  135. package/src/styles.css +43 -0
  136. package/tools/README.md +108 -0
  137. package/tools/azure-activity-check.ps1 +164 -0
  138. package/tools/collect-azure.ps1 +54 -0
  139. package/tools/collect-entra.ps1 +47 -0
  140. package/tools/collect-scripts.test.ts +22 -0
  141. package/tools/prepare-entra-snapshot.ps1 +403 -0
  142. package/tools/prepare-entra-snapshot.test.ts +14 -0
  143. package/tools/prepare-resource-snapshot.ps1 +345 -0
  144. 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
+ }