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,373 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+
3
+ import { buildCollectionColumns, type ReportColumnRenderers } from "../buildCollectionColumns";
4
+ import { getConfiguredFilterOptions } from "../applyCollectionControls";
5
+ import type { ReportColumnHelp, ReportFieldDescriptor } from "../reportTypes";
6
+ import { Button } from "./ui/button";
7
+ import { Card } from "./ui/card";
8
+ import { Table, TableBody, TableCell, TableContainer, TableHeader, TableRow } from "./ui/table";
9
+ import {
10
+ applyColumnFilterValueToggle,
11
+ applyColumnValuesFilter,
12
+ applyReportTableControls,
13
+ ReportTableHead,
14
+ type ColumnFilterOptions,
15
+ type ColumnFilters,
16
+ type SortRule,
17
+ useReportTableControls
18
+ } from "./reportTableControls";
19
+
20
+ type GenericTableProps<TRow> = {
21
+ columnHelp?: Record<string, ReportColumnHelp>;
22
+ emptyMessage: string;
23
+ fields: ReportFieldDescriptor<TRow>[];
24
+ filterOptions?: ColumnFilterOptions;
25
+ filters?: ColumnFilters;
26
+ fieldRenderers?: ReportColumnRenderers<TRow>;
27
+ getRowKey: (row: TRow) => string;
28
+ minWidthClassName: string;
29
+ onFiltersChange?: (filters: ColumnFilters) => void;
30
+ onPageChange?: (page: number) => void;
31
+ onSortRulesChange?: (sortRules: SortRule[]) => void;
32
+ page?: number;
33
+ pageSize?: number;
34
+ rows?: TRow[];
35
+ sortRules?: SortRule[];
36
+ totalCount?: number;
37
+ };
38
+
39
+ type GenericTablePage<TRow> = {
40
+ rows: TRow[];
41
+ page: number;
42
+ pageSize: number;
43
+ count: number;
44
+ };
45
+
46
+ type GenericRemoteTableProps<TRow> = Omit<
47
+ GenericTableProps<TRow>,
48
+ "filterOptions" | "filters" | "onFiltersChange" | "onPageChange" | "page" | "rows" | "sortRules" | "totalCount"
49
+ > & {
50
+ initialFilters?: ColumnFilters;
51
+ loadPage: (input: { filters: ColumnFilters; page: number; signal: AbortSignal }) => Promise<GenericTablePage<TRow>>;
52
+ loadingMessage: string;
53
+ };
54
+
55
+ type GenericTableWrapperProps<TRow> = GenericTableProps<TRow> | GenericRemoteTableProps<TRow>;
56
+
57
+ type LoadState =
58
+ | {
59
+ status: "loading";
60
+ }
61
+ | {
62
+ status: "ready";
63
+ }
64
+ | {
65
+ status: "error";
66
+ message: string;
67
+ };
68
+
69
+ function isRemoteTableProps<TRow>(props: GenericTableWrapperProps<TRow>): props is GenericRemoteTableProps<TRow> {
70
+ return "loadPage" in props;
71
+ }
72
+
73
+ export function GenericTable<TRow>(props: GenericTableWrapperProps<TRow>) {
74
+ if (isRemoteTableProps(props)) {
75
+ return <GenericRemoteTable {...props} />;
76
+ }
77
+
78
+ return <GenericTableView {...props} rows={props.rows ?? []} />;
79
+ }
80
+
81
+ function GenericRemoteTable<TRow>({
82
+ fields,
83
+ initialFilters,
84
+ loadPage,
85
+ loadingMessage,
86
+ ...tableProps
87
+ }: GenericRemoteTableProps<TRow>) {
88
+ const [collection, setCollection] = useState<GenericTablePage<TRow> | null>(null);
89
+ const [filters, setFilters] = useState<ColumnFilters>(() => initialFilters ?? {});
90
+ const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });
91
+ const [page, setPage] = useState(1);
92
+
93
+ useEffect(() => {
94
+ const controller = new AbortController();
95
+
96
+ async function loadCollectionPage() {
97
+ setLoadState({ status: "loading" });
98
+
99
+ try {
100
+ const nextCollection = await loadPage({
101
+ filters: remapColumnFiltersForRuntime(fields, filters),
102
+ page,
103
+ signal: controller.signal
104
+ });
105
+ setCollection(nextCollection);
106
+ setLoadState({ status: "ready" });
107
+ } catch (error) {
108
+ if (error instanceof DOMException && error.name === "AbortError") {
109
+ return;
110
+ }
111
+
112
+ setCollection(null);
113
+ setLoadState({
114
+ status: "error",
115
+ message: error instanceof Error ? error.message : "Could not load table data."
116
+ });
117
+ }
118
+ }
119
+
120
+ loadCollectionPage();
121
+
122
+ return () => controller.abort();
123
+ }, [fields, filters, loadPage, page]);
124
+
125
+ const filterOptions = useMemo(() => getConfiguredFilterOptions(fields), [fields]);
126
+
127
+ if (!collection && loadState.status === "loading") {
128
+ return <TableState>{loadingMessage}</TableState>;
129
+ }
130
+
131
+ if (!collection && loadState.status === "error") {
132
+ return <TableState variant="error">{loadState.message}</TableState>;
133
+ }
134
+
135
+ if (!collection) {
136
+ return null;
137
+ }
138
+
139
+ return (
140
+ <>
141
+ {loadState.status === "error" ? <TableState variant="error">{loadState.message}</TableState> : null}
142
+ <GenericTableView
143
+ {...tableProps}
144
+ emptyMessage={loadState.status === "loading" ? loadingMessage : tableProps.emptyMessage}
145
+ fields={fields}
146
+ filterOptions={filterOptions}
147
+ filters={filters}
148
+ page={collection.page}
149
+ pageSize={collection.pageSize}
150
+ rows={collection.rows}
151
+ sortRules={[]}
152
+ totalCount={collection.count}
153
+ onFiltersChange={(nextFilters) => {
154
+ setPage(1);
155
+ setFilters(nextFilters);
156
+ }}
157
+ onPageChange={setPage}
158
+ onSortRulesChange={() => undefined}
159
+ />
160
+ </>
161
+ );
162
+ }
163
+
164
+ function GenericTableView<TRow>({
165
+ columnHelp,
166
+ emptyMessage,
167
+ fields,
168
+ filterOptions: controlledFilterOptions,
169
+ filters: controlledFilters,
170
+ fieldRenderers,
171
+ getRowKey,
172
+ minWidthClassName,
173
+ onFiltersChange,
174
+ onPageChange,
175
+ onSortRulesChange,
176
+ page,
177
+ pageSize,
178
+ rows,
179
+ sortRules: controlledSortRules,
180
+ totalCount
181
+ }: GenericTableProps<TRow> & { rows: TRow[] }) {
182
+ const columns = useMemo(
183
+ () => buildCollectionColumns(fields, { columnHelp, renderers: fieldRenderers }),
184
+ [columnHelp, fields, fieldRenderers]
185
+ );
186
+ const localControls = useReportTableControls(rows, fields);
187
+ const filters = controlledFilters ?? localControls.filters;
188
+ const sortRules = controlledSortRules ?? localControls.sortRules;
189
+ const tableControls = useMemo(
190
+ () => applyReportTableControls(rows, fields, filters, sortRules),
191
+ [fields, filters, rows, sortRules]
192
+ );
193
+ const setColumnFilter =
194
+ onFiltersChange === undefined
195
+ ? localControls.setColumnFilter
196
+ : (columnId: string, value: string) => {
197
+ onFiltersChange(applyColumnTextFilter(filters, columnId, value));
198
+ };
199
+ const setColumnValuesFilter =
200
+ onFiltersChange === undefined
201
+ ? localControls.setColumnValuesFilter
202
+ : (columnId: string, values: string[]) => {
203
+ onFiltersChange(applyColumnValuesFilter(filters, columnId, values));
204
+ };
205
+ const toggleColumnValueFilter =
206
+ onFiltersChange === undefined
207
+ ? localControls.toggleColumnValueFilter
208
+ : (columnId: string, value: string, checked: boolean) => {
209
+ onFiltersChange(applyColumnFilterValueToggle(filters, columnId, value, checked));
210
+ };
211
+ const toggleColumnSort =
212
+ onSortRulesChange === undefined
213
+ ? localControls.toggleColumnSort
214
+ : (columnId: string) => {
215
+ onSortRulesChange(toggleSortRule(sortRules, columnId));
216
+ };
217
+ const controlledRows = totalCount === undefined ? tableControls.controlledRows : rows;
218
+ const filterOptions = resolveColumnFilterOptions(fields, controlledFilterOptions ?? tableControls.filterOptions);
219
+ const openFilterColumnId = localControls.openFilterColumnId;
220
+ const setColumnFilterOpen = localControls.setColumnFilterOpen;
221
+ const resolvedPage = page ?? 1;
222
+ const resolvedPageSize = pageSize ?? controlledRows.length;
223
+ const resolvedTotalCount = totalCount ?? controlledRows.length;
224
+
225
+ return (
226
+ <TableContainer>
227
+ <Table className={minWidthClassName}>
228
+ <TableHeader>
229
+ <TableRow>
230
+ <ReportTableHead
231
+ columns={columns}
232
+ filterOptions={filterOptions}
233
+ filters={filters}
234
+ openFilterColumnId={openFilterColumnId}
235
+ sortRules={sortRules}
236
+ onFilterChange={setColumnFilter}
237
+ onFilterOpenChange={setColumnFilterOpen}
238
+ onValueFilterToggle={toggleColumnValueFilter}
239
+ onValuesFilterChange={setColumnValuesFilter}
240
+ onSortToggle={toggleColumnSort}
241
+ />
242
+ </TableRow>
243
+ </TableHeader>
244
+ <TableBody>
245
+ {controlledRows.map((row) => (
246
+ <TableRow key={getRowKey(row)}>
247
+ {columns.map((column) => (
248
+ <TableCell key={column.id}>{column.render(row)}</TableCell>
249
+ ))}
250
+ </TableRow>
251
+ ))}
252
+ </TableBody>
253
+ </Table>
254
+ {controlledRows.length === 0 ? (
255
+ <div className="p-4 text-sm text-muted-foreground">{emptyMessage}</div>
256
+ ) : null}
257
+ {onPageChange && resolvedTotalCount > resolvedPageSize ? (
258
+ <TablePagination
259
+ count={resolvedTotalCount}
260
+ page={resolvedPage}
261
+ pageSize={resolvedPageSize}
262
+ onPageChange={onPageChange}
263
+ />
264
+ ) : null}
265
+ </TableContainer>
266
+ );
267
+ }
268
+
269
+ function remapColumnFiltersForRuntime<TRow>(
270
+ fields: ReportFieldDescriptor<TRow>[],
271
+ filters: ColumnFilters
272
+ ): ColumnFilters {
273
+ const filterColumnByFieldId = new Map(fields.map((field) => [field.id, field.filterColumnId ?? field.id]));
274
+ const next: ColumnFilters = {};
275
+
276
+ for (const [columnId, filter] of Object.entries(filters)) {
277
+ next[filterColumnByFieldId.get(columnId) ?? columnId] = filter;
278
+ }
279
+
280
+ return next;
281
+ }
282
+
283
+ function resolveColumnFilterOptions<TRow>(
284
+ fields: ReportFieldDescriptor<TRow>[],
285
+ filterOptions: ColumnFilterOptions
286
+ ): ColumnFilterOptions {
287
+ return Object.fromEntries(
288
+ fields.map((field) => [
289
+ field.id,
290
+ filterOptions[field.id] ?? (field.filterColumnId ? filterOptions[field.filterColumnId] : undefined) ?? []
291
+ ])
292
+ );
293
+ }
294
+
295
+ function TableState({ children, variant = "empty" }: { children: string; variant?: "empty" | "error" }) {
296
+ return (
297
+ <Card
298
+ className={
299
+ variant === "error"
300
+ ? "border-red-200 bg-red-50 p-4 text-sm text-red-900"
301
+ : "p-8 text-center text-sm text-muted-foreground"
302
+ }
303
+ >
304
+ {children}
305
+ </Card>
306
+ );
307
+ }
308
+
309
+ function applyColumnTextFilter(filters: ColumnFilters, columnId: string, value: string): ColumnFilters {
310
+ const next = { ...filters };
311
+
312
+ if (value.trim()) {
313
+ next[columnId] = { type: "text", value };
314
+ } else {
315
+ delete next[columnId];
316
+ }
317
+
318
+ return next;
319
+ }
320
+
321
+ function toggleSortRule(sortRules: SortRule[], columnId: string): SortRule[] {
322
+ const existingRule = sortRules.find((rule) => rule.columnId === columnId);
323
+
324
+ if (!existingRule) {
325
+ return [...sortRules, { columnId, direction: "asc" }];
326
+ }
327
+
328
+ if (existingRule.direction === "asc") {
329
+ return sortRules.map((rule) => (rule.columnId === columnId ? { ...rule, direction: "desc" } : rule));
330
+ }
331
+
332
+ return sortRules.filter((rule) => rule.columnId !== columnId);
333
+ }
334
+
335
+ function TablePagination({
336
+ count,
337
+ page,
338
+ pageSize,
339
+ onPageChange
340
+ }: {
341
+ count: number;
342
+ page: number;
343
+ pageSize: number;
344
+ onPageChange: (page: number) => void;
345
+ }) {
346
+ const pageCount = Math.max(1, Math.ceil(count / pageSize));
347
+
348
+ return (
349
+ <div className="flex items-center justify-end gap-3 border-t px-4 py-3 text-sm text-muted-foreground">
350
+ <span>
351
+ Page {page} of {pageCount}
352
+ </span>
353
+ <Button
354
+ disabled={page <= 1}
355
+ size="sm"
356
+ type="button"
357
+ variant="outline"
358
+ onClick={() => onPageChange(page - 1)}
359
+ >
360
+ Previous
361
+ </Button>
362
+ <Button
363
+ disabled={page >= pageCount}
364
+ size="sm"
365
+ type="button"
366
+ variant="outline"
367
+ onClick={() => onPageChange(page + 1)}
368
+ >
369
+ Next
370
+ </Button>
371
+ </div>
372
+ );
373
+ }
@@ -0,0 +1,19 @@
1
+ import { Badge, type BadgeProps } from "./ui/badge";
2
+ import type { PermissionRiskLevel } from "../../core/risk/types";
3
+
4
+ export type { PermissionRiskLevel };
5
+
6
+ const permissionRiskBadgeVariants: Record<PermissionRiskLevel, BadgeProps["variant"]> = {
7
+ high: "riskHigh",
8
+ medium: "riskMedium",
9
+ low: "riskLow",
10
+ none: "riskNone"
11
+ };
12
+
13
+ export function PermissionRiskBadge({ riskLevel }: { riskLevel: PermissionRiskLevel }) {
14
+ return (
15
+ <Badge className="capitalize" variant={permissionRiskBadgeVariants[riskLevel]}>
16
+ {riskLevel}
17
+ </Badge>
18
+ );
19
+ }
@@ -0,0 +1,175 @@
1
+ import {
2
+ applyColumnFilterOpen,
3
+ applyColumnFilterValueToggle,
4
+ applyColumnValueToggle,
5
+ applyReportTableControls
6
+ } from "./reportTableControls.tsx";
7
+ import type { ReportFieldDescriptor } from "../reportTypes.ts";
8
+
9
+ type Row = {
10
+ id: string;
11
+ ownership: "External" | "Tenant owned" | "Unknown";
12
+ risk: "high" | "low" | "none";
13
+ };
14
+
15
+ const rows: Row[] = [
16
+ { id: "external-low", ownership: "External", risk: "low" },
17
+ { id: "tenant-high", ownership: "Tenant owned", risk: "high" },
18
+ { id: "tenant-low", ownership: "Tenant owned", risk: "low" },
19
+ { id: "tenant-none", ownership: "Tenant owned", risk: "none" },
20
+ { id: "unknown-high", ownership: "Unknown", risk: "high" }
21
+ ];
22
+
23
+ const fields: ReportFieldDescriptor<Row>[] = [
24
+ {
25
+ id: "ownership",
26
+ label: "Ownership",
27
+ valueType: "text",
28
+ getValue: (row) => row.ownership
29
+ },
30
+ {
31
+ id: "risk",
32
+ label: "Permission risk",
33
+ valueType: "riskLevel",
34
+ getValue: (row) => row.risk
35
+ }
36
+ ];
37
+
38
+ test("applies multiple column value filters", () => {
39
+ const result = applyReportTableControls(rows, fields, {
40
+ ownership: { type: "values", values: ["External", "Tenant owned"] },
41
+ risk: { type: "values", values: ["low", "high"] }
42
+ });
43
+
44
+ expect(result.controlledRows.map((row) => row.id)).toEqual(["external-low", "tenant-high", "tenant-low"]);
45
+ });
46
+
47
+ test("applies text column filters as regular expressions", () => {
48
+ const result = applyReportTableControls(rows, fields, {
49
+ ownership: { type: "text", value: "^tenant\\s+owned$" }
50
+ });
51
+
52
+ expect(result.controlledRows.map((row) => row.id)).toEqual(["tenant-high", "tenant-low", "tenant-none"]);
53
+ });
54
+
55
+ test("constructs filters from column value toggles", () => {
56
+ const constructedFilters = applyColumnFilterValueToggle(
57
+ applyColumnFilterValueToggle(
58
+ applyColumnFilterValueToggle(applyColumnFilterValueToggle({}, "ownership", "External", true), "ownership", "Tenant owned", true),
59
+ "risk",
60
+ "low",
61
+ true
62
+ ),
63
+ "risk",
64
+ "high",
65
+ true
66
+ );
67
+
68
+ expect(constructedFilters).toEqual({
69
+ ownership: { type: "values", values: ["External", "Tenant owned"] },
70
+ risk: { type: "values", values: ["low", "high"] }
71
+ });
72
+
73
+ expect(applyReportTableControls(rows, fields, constructedFilters).controlledRows.map((row) => row.id)).toEqual([
74
+ "external-low",
75
+ "tenant-high",
76
+ "tenant-low"
77
+ ]);
78
+ });
79
+
80
+ test("keeps only one column filter popover open", () => {
81
+ const openFilterColumnId = applyColumnFilterOpen(
82
+ applyColumnFilterOpen(applyColumnFilterOpen(null, "ownership", true), "risk", true),
83
+ "ownership",
84
+ false
85
+ );
86
+
87
+ expect(openFilterColumnId).toBe("risk");
88
+ });
89
+
90
+ test("toggles column values", () => {
91
+ expect(applyColumnValueToggle(["External"], "Tenant owned", true)).toEqual(["External", "Tenant owned"]);
92
+
93
+ expect(applyColumnValueToggle(["External", "Tenant owned"], "External", false)).toEqual(["Tenant owned"]);
94
+ });
95
+
96
+ test("applies descriptor-backed ownership and permission risk filters through table columns", () => {
97
+ const fields: ReportFieldDescriptor<Row>[] = [
98
+ {
99
+ id: "ownership",
100
+ label: "Ownership",
101
+ valueType: "text",
102
+ getValue: (row) => row.ownership,
103
+ filter: {
104
+ kind: "multiSelect",
105
+ options: ["External", "Tenant owned", "Unknown"]
106
+ }
107
+ },
108
+ {
109
+ id: "permissionRisk",
110
+ label: "Permission risk",
111
+ valueType: "riskLevel",
112
+ getValue: (row) => row.risk,
113
+ filter: {
114
+ kind: "multiSelect",
115
+ options: ["high", "low", "none"]
116
+ }
117
+ }
118
+ ];
119
+ const result = applyReportTableControls(rows, fields, {
120
+ ownership: { type: "values", values: ["External", "Tenant owned"] },
121
+ permissionRisk: { type: "values", values: ["low", "high"] }
122
+ });
123
+
124
+ expect(result.controlledRows.map((row) => row.id)).toEqual([
125
+ "external-low",
126
+ "tenant-high",
127
+ "tenant-low"
128
+ ]);
129
+ expect(result.controlledRows.every((row) => ["External", "Tenant owned"].includes(row.ownership))).toBe(true);
130
+ });
131
+
132
+ test("uses descriptor filter values for options and filtering without changing display values", () => {
133
+ const fields: ReportFieldDescriptor<Row>[] = [
134
+ {
135
+ id: "riskSummary",
136
+ label: "Risk summary",
137
+ valueType: "text",
138
+ getValue: (row) => `Rendered ${row.risk}`,
139
+ getFilterValue: (row) => row.risk,
140
+ filter: {
141
+ kind: "multiSelect",
142
+ options: ["high", "low", "none"]
143
+ }
144
+ }
145
+ ];
146
+
147
+ const result = applyReportTableControls(rows, fields, {
148
+ riskSummary: { type: "values", values: ["high"] }
149
+ });
150
+
151
+ expect(result.filterOptions.riskSummary).toEqual(["high", "low", "none"]);
152
+ expect(result.controlledRows.map((row) => row.id)).toEqual(["tenant-high", "unknown-high"]);
153
+ });
154
+
155
+ test("uses configured multiselect options instead of narrowing options to filtered rows", () => {
156
+ const fields: ReportFieldDescriptor<Row>[] = [
157
+ {
158
+ id: "ownership",
159
+ label: "Ownership",
160
+ valueType: "text",
161
+ getValue: (row) => row.ownership,
162
+ filter: {
163
+ kind: "multiSelect",
164
+ options: ["External", "Tenant owned", "Unknown"]
165
+ }
166
+ }
167
+ ];
168
+
169
+ const result = applyReportTableControls(rows, fields, {
170
+ ownership: { type: "values", values: ["External"] }
171
+ });
172
+
173
+ expect(result.controlledRows.map((row) => row.id)).toEqual(["external-low"]);
174
+ expect(result.filterOptions.ownership).toEqual(["External", "Tenant owned", "Unknown"]);
175
+ });