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,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
+ }