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,289 @@
|
|
|
1
|
+
import { hasSearchExpression, matchesSearchExpression } from "../lib/searchFilterUtils";
|
|
2
|
+
import type { ReportDetailsValue, ReportFieldDescriptor } from "./reportTypes";
|
|
3
|
+
|
|
4
|
+
export type SortDirection = "asc" | "desc";
|
|
5
|
+
|
|
6
|
+
export type SortRule = {
|
|
7
|
+
columnId: string;
|
|
8
|
+
direction: SortDirection;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ColumnFilter =
|
|
12
|
+
| {
|
|
13
|
+
type: "text";
|
|
14
|
+
value: string;
|
|
15
|
+
}
|
|
16
|
+
| {
|
|
17
|
+
type: "values";
|
|
18
|
+
values: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ColumnFilters = Record<string, ColumnFilter>;
|
|
22
|
+
export type ColumnFilterOptions = Record<string, string[]>;
|
|
23
|
+
|
|
24
|
+
type ActiveFieldFilter<TRow> = {
|
|
25
|
+
field: ReportFieldDescriptor<TRow>;
|
|
26
|
+
filter: ColumnFilter;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const collator = new Intl.Collator(undefined, {
|
|
30
|
+
numeric: true,
|
|
31
|
+
sensitivity: "base"
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export function applyCollectionControls<TRow>(
|
|
35
|
+
rows: TRow[],
|
|
36
|
+
fields: ReportFieldDescriptor<TRow>[],
|
|
37
|
+
{
|
|
38
|
+
query = "",
|
|
39
|
+
filters = {},
|
|
40
|
+
sortRules = []
|
|
41
|
+
}: {
|
|
42
|
+
query?: string;
|
|
43
|
+
filters?: ColumnFilters;
|
|
44
|
+
sortRules?: SortRule[];
|
|
45
|
+
} = {}
|
|
46
|
+
) {
|
|
47
|
+
const filterOptions = getConfiguredFilterOptions(fields);
|
|
48
|
+
const searchedRows = applyCollectionSearch(rows, fields, query);
|
|
49
|
+
const activeFilters = buildActiveFieldFilters(fields, filters);
|
|
50
|
+
const filteredRows = applyCollectionFieldFilters(searchedRows, activeFilters);
|
|
51
|
+
const controlledRows = applyCollectionSort(filteredRows, fields, sortRules);
|
|
52
|
+
|
|
53
|
+
logCollectionControlDebug(rows, controlledRows, activeFilters, fields, filters);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
controlledRows,
|
|
57
|
+
filterOptions
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getConfiguredFilterOptions<TRow>(fields: ReportFieldDescriptor<TRow>[]): ColumnFilterOptions {
|
|
62
|
+
return Object.fromEntries(
|
|
63
|
+
fields.map((field) => [field.id, field.filter?.options ? [...field.filter.options] : []])
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function applyCollectionSearch<TRow>(
|
|
68
|
+
rows: TRow[],
|
|
69
|
+
fields: ReportFieldDescriptor<TRow>[],
|
|
70
|
+
query: string
|
|
71
|
+
): TRow[] {
|
|
72
|
+
if (!hasSearchExpression(query)) {
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const searchableFields = fields.filter((field) => field.searchable !== false);
|
|
77
|
+
|
|
78
|
+
return rows.filter((row) =>
|
|
79
|
+
matchesSearchExpression(
|
|
80
|
+
searchableFields.map((field) => formatReportSearchValue(field.getValue(row))).join(" "),
|
|
81
|
+
query
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function applyCollectionFieldFilters<TRow>(
|
|
87
|
+
rows: TRow[],
|
|
88
|
+
activeFilters: ActiveFieldFilter<TRow>[]
|
|
89
|
+
): TRow[] {
|
|
90
|
+
if (activeFilters.length === 0) {
|
|
91
|
+
return rows;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return rows.filter((row) =>
|
|
95
|
+
activeFilters.every(({ field, filter }) => {
|
|
96
|
+
const fieldValue = formatControlValue(getFieldFilterValue(field, row));
|
|
97
|
+
|
|
98
|
+
if (filter.type === "values") {
|
|
99
|
+
return filter.values.includes(fieldValue);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return matchesSearchExpression(fieldValue, filter.value);
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getFieldFilterValue<TRow>(field: ReportFieldDescriptor<TRow>, row: TRow): unknown {
|
|
108
|
+
return field.getFilterValue ? field.getFilterValue(row) : field.getValue(row);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function applyCollectionSort<TRow>(
|
|
112
|
+
rows: TRow[],
|
|
113
|
+
fields: ReportFieldDescriptor<TRow>[],
|
|
114
|
+
sortRules: SortRule[]
|
|
115
|
+
): TRow[] {
|
|
116
|
+
if (sortRules.length === 0) {
|
|
117
|
+
return rows;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const fieldById = new Map(fields.map((field) => [field.id, field]));
|
|
121
|
+
|
|
122
|
+
return rows
|
|
123
|
+
.map((row, index) => ({ row, index }))
|
|
124
|
+
.sort((left, right) => {
|
|
125
|
+
for (const rule of sortRules) {
|
|
126
|
+
const field = fieldById.get(rule.columnId);
|
|
127
|
+
if (!field) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const result = compareValues(field.getValue(left.row), field.getValue(right.row));
|
|
132
|
+
if (result !== 0) {
|
|
133
|
+
return rule.direction === "asc" ? result : -result;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return left.index - right.index;
|
|
138
|
+
})
|
|
139
|
+
.map(({ row }) => row);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildActiveFieldFilters<TRow>(
|
|
143
|
+
fields: ReportFieldDescriptor<TRow>[],
|
|
144
|
+
filters: ColumnFilters
|
|
145
|
+
): ActiveFieldFilter<TRow>[] {
|
|
146
|
+
return fields
|
|
147
|
+
.map((field) => ({
|
|
148
|
+
field,
|
|
149
|
+
filter: filters[field.id]
|
|
150
|
+
}))
|
|
151
|
+
.filter((entry): entry is ActiveFieldFilter<TRow> => isActiveFilter(entry.filter));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isActiveFilter(filter: ColumnFilter | undefined): boolean {
|
|
155
|
+
if (!filter) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (filter.type === "values") {
|
|
160
|
+
return filter.values.length > 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return hasSearchExpression(filter.value);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function compareValues(left: unknown, right: unknown): number {
|
|
167
|
+
const leftText = formatControlValue(left);
|
|
168
|
+
const rightText = formatControlValue(right);
|
|
169
|
+
|
|
170
|
+
if (!leftText && !rightText) {
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!leftText) {
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!rightText) {
|
|
179
|
+
return -1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return collator.compare(leftText, rightText);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function formatControlValue(value: unknown): string {
|
|
186
|
+
if (value === null || value === undefined || value === "") {
|
|
187
|
+
return "";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (Array.isArray(value)) {
|
|
191
|
+
return value.map(formatControlValue).filter(Boolean).join(", ");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (typeof value === "boolean") {
|
|
195
|
+
return value ? "Yes" : "No";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return String(value);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function formatReportSearchValue(value: unknown): string {
|
|
202
|
+
if (value === null || value === undefined) {
|
|
203
|
+
return "";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
207
|
+
return String(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
return value.map(formatReportSearchValue).filter(Boolean).join(", ");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (isReportDetailsValue(value)) {
|
|
215
|
+
return [
|
|
216
|
+
value.searchText,
|
|
217
|
+
value.title,
|
|
218
|
+
...value.details.flatMap((detail) => [detail.label, detail.value])
|
|
219
|
+
]
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.join(" ");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return JSON.stringify(value);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isReportDetailsValue(value: unknown): value is ReportDetailsValue {
|
|
228
|
+
return (
|
|
229
|
+
typeof value === "object" &&
|
|
230
|
+
value !== null &&
|
|
231
|
+
"title" in value &&
|
|
232
|
+
"details" in value &&
|
|
233
|
+
Array.isArray((value as ReportDetailsValue).details)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function logCollectionControlDebug<TRow>(
|
|
238
|
+
rows: TRow[],
|
|
239
|
+
controlledRows: TRow[],
|
|
240
|
+
activeFilters: ActiveFieldFilter<TRow>[],
|
|
241
|
+
fields: ReportFieldDescriptor<TRow>[],
|
|
242
|
+
filters: ColumnFilters
|
|
243
|
+
): void {
|
|
244
|
+
if (!isReportTableFilterDebugEnabled()) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const activeFieldIds = new Set(activeFilters.map(({ field }) => field.id));
|
|
249
|
+
const debugFields = fields.filter((field) => activeFieldIds.has(field.id));
|
|
250
|
+
const controlledRowSet = new Set(controlledRows);
|
|
251
|
+
|
|
252
|
+
console.groupCollapsed(
|
|
253
|
+
`[OwnerLens table filters] ${controlledRows.length}/${rows.length} rows after ${activeFilters.length} filters`
|
|
254
|
+
);
|
|
255
|
+
console.log("filters", filters);
|
|
256
|
+
console.log(
|
|
257
|
+
"activeFilters",
|
|
258
|
+
activeFilters.map(({ field, filter }) => ({
|
|
259
|
+
columnId: field.id,
|
|
260
|
+
label: field.label,
|
|
261
|
+
filter
|
|
262
|
+
}))
|
|
263
|
+
);
|
|
264
|
+
console.table(
|
|
265
|
+
rows.map((row) => {
|
|
266
|
+
const rowRecord = row as Record<string, unknown>;
|
|
267
|
+
const debugRow: Record<string, unknown> = {
|
|
268
|
+
included: controlledRowSet.has(row),
|
|
269
|
+
id: rowRecord.id,
|
|
270
|
+
displayName: rowRecord.displayName,
|
|
271
|
+
appId: rowRecord.appId
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
for (const field of debugFields) {
|
|
275
|
+
debugRow[field.id] = formatControlValue(field.getValue(row));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return debugRow;
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
console.groupEnd();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isReportTableFilterDebugEnabled(): boolean {
|
|
285
|
+
return (
|
|
286
|
+
typeof window !== "undefined" &&
|
|
287
|
+
window.localStorage.getItem("ownerLensDebugTableFilters") === "1"
|
|
288
|
+
);
|
|
289
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { ReportColumnHelp, ReportFieldDescriptor } from "./reportTypes";
|
|
4
|
+
import type { ReportTableColumn } from "./components/reportTableControls";
|
|
5
|
+
import { renderReportValue } from "./reportValueRenderers";
|
|
6
|
+
|
|
7
|
+
export type ReportColumnRenderers<TRow> = Partial<Record<string, (row: TRow) => ReactNode>>;
|
|
8
|
+
|
|
9
|
+
export function buildCollectionColumns<TRow>(
|
|
10
|
+
fields: ReportFieldDescriptor<TRow>[],
|
|
11
|
+
{
|
|
12
|
+
columnHelp = {},
|
|
13
|
+
renderers = {}
|
|
14
|
+
}: {
|
|
15
|
+
columnHelp?: Record<string, ReportColumnHelp>;
|
|
16
|
+
renderers?: ReportColumnRenderers<TRow>;
|
|
17
|
+
} = {}
|
|
18
|
+
): ReportTableColumn<TRow>[] {
|
|
19
|
+
return fields.map((field) => ({
|
|
20
|
+
id: field.id,
|
|
21
|
+
label: field.label,
|
|
22
|
+
help: field.help ?? columnHelp[field.id],
|
|
23
|
+
filter: getColumnFilterKind(field),
|
|
24
|
+
render: renderers[field.id] ?? ((row) => renderReportValue(field, row))
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getColumnFilterKind<TRow>(field: ReportFieldDescriptor<TRow>): ReportTableColumn<TRow>["filter"] {
|
|
29
|
+
if (field.filter?.kind === "multiSelect") {
|
|
30
|
+
return "multiselect";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (field.filter?.kind === "text") {
|
|
34
|
+
return "text";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return "auto";
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Badge } from "./ui/badge";
|
|
2
|
+
import type { OwnerConfidence } from "../../core/ownership/types";
|
|
3
|
+
|
|
4
|
+
export function ConfidenceBadge({ confidence }: { confidence: OwnerConfidence }) {
|
|
5
|
+
return (
|
|
6
|
+
<Badge className="min-w-16 justify-center capitalize" variant={confidence}>
|
|
7
|
+
{confidence}
|
|
8
|
+
</Badge>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
|
|
4
|
+
import { EvidenceList } from "./EvidenceList.tsx";
|
|
5
|
+
|
|
6
|
+
test("renders evidence users and timestamps", () => {
|
|
7
|
+
const html = renderToStaticMarkup(
|
|
8
|
+
React.createElement(EvidenceList, {
|
|
9
|
+
evidence: [
|
|
10
|
+
{
|
|
11
|
+
user: "owner@example.com",
|
|
12
|
+
date: "2026-04-30T12:34:56.000Z"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
user: "ownerGroup=platform@example.com",
|
|
16
|
+
date: null
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(html).toMatch(/owner@example\.com/);
|
|
23
|
+
expect(html).toMatch(/>2026-04-30T12:34:56\.000Z<\/time>/);
|
|
24
|
+
expect(html).toMatch(/ownerGroup=platform@example\.com/);
|
|
25
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { OwnerEvidence } from "../types";
|
|
2
|
+
|
|
3
|
+
type EvidenceListProps = {
|
|
4
|
+
evidence: OwnerEvidence[];
|
|
5
|
+
canDisable?: boolean;
|
|
6
|
+
onDisabledChange?: (entry: OwnerEvidence, disabled: boolean) => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function EvidenceList({ evidence, canDisable = false, onDisabledChange }: EvidenceListProps) {
|
|
10
|
+
if (evidence.length === 0) {
|
|
11
|
+
return "-";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex flex-col gap-1">
|
|
16
|
+
{evidence.map((entry) => {
|
|
17
|
+
const isEmailCandidate = entry.user.includes("@");
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={
|
|
22
|
+
entry.disabled
|
|
23
|
+
? "flex flex-col gap-0.5 rounded bg-muted px-1.5 py-1 leading-snug text-muted-foreground"
|
|
24
|
+
: "flex flex-col gap-0.5 rounded bg-emerald-100 px-1.5 py-1 leading-snug text-emerald-800"
|
|
25
|
+
}
|
|
26
|
+
key={`${entry.user}:${entry.date ?? ""}`}
|
|
27
|
+
>
|
|
28
|
+
<div className="flex items-start gap-1.5">
|
|
29
|
+
{isEmailCandidate && canDisable && onDisabledChange ? (
|
|
30
|
+
<button
|
|
31
|
+
aria-label={`${entry.disabled ? "Enable" : "Disable"} ${entry.user}`}
|
|
32
|
+
className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded border border-border bg-background text-xs leading-none text-muted-foreground hover:text-foreground"
|
|
33
|
+
title={entry.disabled ? "Enable owner candidate" : "Disable owner candidate"}
|
|
34
|
+
type="button"
|
|
35
|
+
onClick={() => onDisabledChange(entry, !entry.disabled)}
|
|
36
|
+
>
|
|
37
|
+
x
|
|
38
|
+
</button>
|
|
39
|
+
) : null}
|
|
40
|
+
<span className={entry.disabled ? "line-through" : undefined}>{entry.user}</span>
|
|
41
|
+
</div>
|
|
42
|
+
{entry.date ? (
|
|
43
|
+
<time className="text-xs text-muted-foreground" dateTime={entry.date}>
|
|
44
|
+
{entry.date}
|
|
45
|
+
</time>
|
|
46
|
+
) : null}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
})}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|