diff --git a/apps/api/src/app/services/stripe.service.ts b/apps/api/src/app/services/stripe.service.ts index e4d1310bb..af38ad83b 100644 --- a/apps/api/src/app/services/stripe.service.ts +++ b/apps/api/src/app/services/stripe.service.ts @@ -295,6 +295,7 @@ export async function updateEntitlements(customerId: string, entitlements: Strip chromeExtension: false, recordSync: false, desktop: false, + analysisTools: false, }, ); diff --git a/apps/jetstream-canvas/src/app/AppRoutes.tsx b/apps/jetstream-canvas/src/app/AppRoutes.tsx index 9c2c60921..146acfb15 100644 --- a/apps/jetstream-canvas/src/app/AppRoutes.tsx +++ b/apps/jetstream-canvas/src/app/AppRoutes.tsx @@ -36,6 +36,23 @@ const ManagePermissionsSelection = lazy(() => const ManagePermissionsEditor = lazy(() => import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.ManagePermissionsEditor })), ); +const PermissionAnalysis = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysis })), +); +const PermissionAnalysisSelection = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisSelection })), +); +const PermissionAnalysisView = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisView })), +); + +const DataAnalysis = lazy(() => import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysis }))); +const DataAnalysisSelection = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysisSelection })), +); +const FieldUsageAnalysisView = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.FieldUsageAnalysisView })), +); const DeployMetadata = lazy(() => import('@jetstream/feature/deploy').then((module) => ({ default: module.DeployMetadata }))); const DeployMetadataSelection = lazy(() => @@ -141,6 +158,8 @@ export const AppRoutes = () => { AutomationControlEditor.preload(); } else if (location.pathname.includes('/permissions-manager')) { ManagePermissionsEditor.preload(); + } else if (location.pathname.includes('/data-analysis')) { + FieldUsageAnalysisView.preload(); } else if (location.pathname.includes('/deploy-metadata')) { DeployMetadataDeployment.preload(); } else if (location.pathname.includes('/create-fields')) { @@ -214,6 +233,30 @@ export const AppRoutes = () => { } /> } /> + + + + } + > + } /> + } /> + } /> + + + + + } + > + } /> + } /> + } /> + const ManagePermissionsEditor = lazy(() => import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.ManagePermissionsEditor })), ); +const PermissionAnalysis = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysis })), +); +const PermissionAnalysisSelection = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisSelection })), +); +const PermissionAnalysisView = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisView })), +); + +const DataAnalysis = lazy(() => import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysis }))); +const DataAnalysisSelection = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysisSelection })), +); +const FieldUsageAnalysisView = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.FieldUsageAnalysisView })), +); const DeployMetadata = lazy(() => import('@jetstream/feature/deploy').then((module) => ({ default: module.DeployMetadata }))); const DeployMetadataSelection = lazy(() => @@ -121,6 +138,8 @@ export const AppRoutes = () => { AutomationControlEditor.preload(); } else if (location.pathname.includes('/permissions-manager')) { ManagePermissionsEditor.preload(); + } else if (location.pathname.includes('/data-analysis')) { + FieldUsageAnalysisView.preload(); } else if (location.pathname.includes('/deploy-metadata')) { DeployMetadataDeployment.preload(); } else if (location.pathname.includes('/create-fields')) { @@ -197,6 +216,30 @@ export const AppRoutes = () => { } /> } /> + + + + } + > + } /> + } /> + } /> + + + + + } + > + } /> + } /> + } /> + { - logger.error('[DB] Error initializing db', ex); - }); + initDexieDb({ recordSyncEnabled }) + .then(() => pruneAnalysisJobHistory()) + .catch((ex) => { + logger.error('[DB] Error initializing db', ex); + }); }, [appInfo.serverUrl, authInfo.accessToken, authInfo.deviceId, recordSyncEnabled]); useEffect(() => { diff --git a/apps/jetstream-desktop/src/services/persistence.service.ts b/apps/jetstream-desktop/src/services/persistence.service.ts index a05c7f721..628ee1d06 100644 --- a/apps/jetstream-desktop/src/services/persistence.service.ts +++ b/apps/jetstream-desktop/src/services/persistence.service.ts @@ -285,6 +285,7 @@ export function getFullUserProfile() { desktop: false, chromeExtension: false, recordSync: true, + analysisTools: true, }, teamMembership: appData.userProfile.teamMembership, subscriptions: [], diff --git a/apps/jetstream-e2e/src/tests/app/routing.spec.ts b/apps/jetstream-e2e/src/tests/app/routing.spec.ts index 8b65764ef..131d9a6a3 100644 --- a/apps/jetstream-e2e/src/tests/app/routing.spec.ts +++ b/apps/jetstream-e2e/src/tests/app/routing.spec.ts @@ -19,6 +19,14 @@ const testCases = [ }, { cardTitle: 'AUTOMATION', menu: 'Automation Control', items: [{ link: 'Automation Control', path: '/automation-control' }] }, { cardTitle: 'PERMISSIONS', menu: 'Manage Permissions', items: [{ link: 'Manage Permissions', path: '/permissions-manager' }] }, + { + cardTitle: 'Analysis', + menu: 'Analysis Tools', + items: [ + { link: 'Permission Analysis', path: '/permission-analysis' }, + { link: 'Data Analysis', path: '/data-analysis' }, + ], + }, { cardTitle: 'DEPLOY', menu: 'Deploy Metadata', diff --git a/apps/jetstream/src/app/AppRoutes.tsx b/apps/jetstream/src/app/AppRoutes.tsx index 65b65fd63..13fe0e99b 100644 --- a/apps/jetstream/src/app/AppRoutes.tsx +++ b/apps/jetstream/src/app/AppRoutes.tsx @@ -39,6 +39,23 @@ const ManagePermissionsSelection = lazy(() => const ManagePermissionsEditor = lazy(() => import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.ManagePermissionsEditor })), ); +const PermissionAnalysis = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysis })), +); +const PermissionAnalysisSelection = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisSelection })), +); +const PermissionAnalysisView = lazy(() => + import('@jetstream/feature/manage-permissions').then((module) => ({ default: module.PermissionAnalysisView })), +); + +const DataAnalysis = lazy(() => import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysis }))); +const DataAnalysisSelection = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.DataAnalysisSelection })), +); +const FieldUsageAnalysisView = lazy(() => + import('@jetstream/feature/data-analysis').then((module) => ({ default: module.FieldUsageAnalysisView })), +); const DeployMetadata = lazy(() => import('@jetstream/feature/deploy').then((module) => ({ default: module.DeployMetadata }))); const DeployMetadataSelection = lazy(() => @@ -109,6 +126,8 @@ export const AppRoutes = () => { AutomationControlEditor.preload(); } else if (location.pathname.includes('/permissions-manager')) { ManagePermissionsEditor.preload(); + } else if (location.pathname.includes('/permission-analysis')) { + PermissionAnalysisView.preload(); } else if (location.pathname.includes('/deploy-metadata')) { DeployMetadataDeployment.preload(); } else if (location.pathname.includes('/create-fields')) { @@ -189,6 +208,30 @@ export const AppRoutes = () => { } /> } /> + + + + } + > + } /> + } /> + } /> + + + + + } + > + } /> + } /> + } /> + { - logger.error('[DB] Error initializing db', ex); - }); + initDexieDb({ recordSyncEnabled }) + .then(() => pruneAnalysisJobHistory()) + .catch((ex) => { + logger.error('[DB] Error initializing db', ex); + }); }, [appInfo.serverUrl, recordSyncEnabled]); useEffect(() => { diff --git a/libs/auth/acl/src/lib/acl.ts b/libs/auth/acl/src/lib/acl.ts index 0fe0e5fad..89056ec64 100644 --- a/libs/auth/acl/src/lib/acl.ts +++ b/libs/auth/acl/src/lib/acl.ts @@ -13,7 +13,7 @@ type Actions = 'read' | 'update'; type Subjects = 'Billing' | 'CoreFunctionality' | 'Profile' | 'Settings'; type EntitlementActions = 'access'; -type EntitlementSubjects = 'GoogleDrive' | 'ChromeExtension' | 'Desktop' | 'RecordSync'; +type EntitlementSubjects = 'GoogleDrive' | 'ChromeExtension' | 'Desktop' | 'RecordSync' | 'AnalysisTools'; type TeamActions = 'read' | 'update'; type TeamSubjects = 'Team' | 'TeamMember' | { type: 'TeamMember'; role: TeamMemberRole }; @@ -132,6 +132,10 @@ function getAbilityRules({ isBrowserExtension, isDesktop, isCanvasApp, user }: G if (user.entitlements.recordSync || isBrowserExtension || isDesktop) { can('access', 'RecordSync'); } + // Analysis Tools (Field Usage + Permission Analysis) are paid-only; desktop/extension/canvas are paid tiers. + if (user.entitlements.analysisTools || isBrowserExtension || isDesktop || isCanvasApp) { + can('access', 'AnalysisTools'); + } return rules; } diff --git a/libs/features/data-analysis/eslint.config.js b/libs/features/data-analysis/eslint.config.js new file mode 100644 index 000000000..6e5180a27 --- /dev/null +++ b/libs/features/data-analysis/eslint.config.js @@ -0,0 +1,17 @@ +const baseConfig = require('../../../eslint.config.js'); + +module.exports = [ + ...baseConfig, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: {}, + }, + { + files: ['**/*.ts', '**/*.tsx'], + rules: {}, + }, + { + files: ['**/*.js', '**/*.jsx'], + rules: {}, + }, +]; diff --git a/libs/features/data-analysis/project.json b/libs/features/data-analysis/project.json new file mode 100644 index 000000000..72b784059 --- /dev/null +++ b/libs/features/data-analysis/project.json @@ -0,0 +1,16 @@ +{ + "name": "features-data-analysis", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/features/data-analysis/src", + "projectType": "library", + "tags": ["scope:browser"], + "targets": { + "test": { + "executor": "@nx/vitest:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "{projectRoot}/../../../coverage/libs/features/data-analysis" + } + } + } +} diff --git a/libs/features/data-analysis/src/DataAnalysis.tsx b/libs/features/data-analysis/src/DataAnalysis.tsx new file mode 100644 index 000000000..08bc27fb6 --- /dev/null +++ b/libs/features/data-analysis/src/DataAnalysis.tsx @@ -0,0 +1,19 @@ +import { TITLES } from '@jetstream/shared/constants'; +import { useTitle } from '@jetstream/shared/ui-utils'; +import { AnalysisToolsPaywall } from '@jetstream/ui-core'; +import { analysisToolsAccessState } from '@jetstream/ui/app-state'; +import { useAtomValue } from 'jotai'; +import { FunctionComponent } from 'react'; +import { Outlet } from 'react-router-dom'; + +/** Route shell for **Data analysis** (field usage). Paid-only — see {@link analysisToolsAccessState}. */ +export const DataAnalysis: FunctionComponent = () => { + useTitle(TITLES.DATA_ANALYSIS); + const { hasAnalysisToolsAccess } = useAtomValue(analysisToolsAccessState); + if (!hasAnalysisToolsAccess) { + return ; + } + return ; +}; + +export default DataAnalysis; diff --git a/libs/features/data-analysis/src/DataAnalysisSelection.tsx b/libs/features/data-analysis/src/DataAnalysisSelection.tsx new file mode 100644 index 000000000..f7d2c1a2a --- /dev/null +++ b/libs/features/data-analysis/src/DataAnalysisSelection.tsx @@ -0,0 +1,145 @@ +import { css } from '@emotion/react'; +import { PermissionAnalysisHistoryModal, filterPermissionsSobjects } from '@jetstream/feature/manage-permissions'; +import { APP_ROUTES } from '@jetstream/shared/ui-router'; +import { useNonInitialEffect } from '@jetstream/shared/ui-utils'; +import { AsyncJobNew, DescribeGlobalSObjectResult, FieldUsageAnalysisJob } from '@jetstream/types'; +import { fromJetstreamEvents, jobsState } from '@jetstream/ui-core'; +import { + AutoFullHeightContainer, + ConnectedSobjectListMultiSelect, + fireToast, + Icon, + Page, + PageHeader, + PageHeaderActions, + PageHeaderRow, + PageHeaderTitle, + Tooltip, +} from '@jetstream/ui'; +import { selectedOrgState } from '@jetstream/ui/app-state'; +import { atom, useAtom, useAtomValue } from 'jotai'; +import { FunctionComponent, useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { isAnalysisJobActive } from './shared/analysis-job-runtime-state'; + +const selectedSObjectsAtom = atom([]); +const sobjectsAtom = atom(null); + +/** + * Object selection for field-usage style analysis; enqueues a browser-side job via the JobWorker pattern + * and routes the user to the results view keyed by the Dexie row that will receive the final blob. + */ +export const DataAnalysisSelection: FunctionComponent = () => { + const navigate = useNavigate(); + const selectedOrg = useAtomValue(selectedOrgState); + const jobs = useAtomValue(jobsState); + const [sobjects, setSobjects] = useAtom(sobjectsAtom); + const [selectedSObjects, setSelectedSObjects] = useAtom(selectedSObjectsAtom); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + + // These atoms are module-level, so clear the prior org's objects/selection when the org changes + // (avoids submitting a previous org's selection against the newly selected org). + useNonInitialEffect(() => { + setSobjects(null); + setSelectedSObjects([]); + }, [selectedOrg?.uniqueId, setSobjects, setSelectedSObjects]); + + const handleStartJob = useCallback(() => { + if (!selectedOrg || !selectedSObjects.length) { + fireToast({ message: 'Select at least one Object.', type: 'error' }); + return; + } + if (isAnalysisJobActive(jobs, selectedOrg.uniqueId, 'field_usage')) { + fireToast({ + message: 'A Field Usage job is already running for this org. Wait for it to finish before starting another.', + type: 'warning', + }); + return; + } + + const jobHistoryKey = `aj_${crypto.randomUUID()}`; + const meta: FieldUsageAnalysisJob = { + jobHistoryKey, + orgUniqueId: selectedOrg.uniqueId, + objectApiNames: selectedSObjects, + loadFullScan: false, + }; + const asyncJobNew: AsyncJobNew = { + type: 'FieldUsageAnalysis', + title: `Field Usage Analysis (${selectedSObjects.length} Object${selectedSObjects.length === 1 ? '' : 's'})`, + org: selectedOrg, + meta, + viewUrl: `${APP_ROUTES.DATA_ANALYSIS.ROUTE}/analysis?job=${encodeURIComponent(jobHistoryKey)}`, + }; + fromJetstreamEvents.emit({ type: 'newJob', payload: [asyncJobNew] }); + fireToast({ message: 'Field Usage job started. Loading results…', type: 'success' }); + navigate({ pathname: 'analysis', search: new URLSearchParams({ job: jobHistoryKey }).toString() }); + }, [jobs, navigate, selectedOrg, selectedSObjects]); + + return ( + + + + + + + + + + + + + + Select Salesforce objects, then start a job. Results are computed in your browser and persisted locally per org. + + + + {isHistoryOpen && selectedOrg && ( + setIsHistoryOpen(false)} + onSelectJob={(nextJobId) => { + setIsHistoryOpen(false); + navigate({ pathname: 'analysis', search: new URLSearchParams({ job: nextJobId }).toString() }); + }} + /> + )} + +
+ +
+
+
+ ); +}; + +export default DataAnalysisSelection; diff --git a/libs/features/data-analysis/src/FieldUsageAnalysisView.tsx b/libs/features/data-analysis/src/FieldUsageAnalysisView.tsx new file mode 100644 index 000000000..d8e862736 --- /dev/null +++ b/libs/features/data-analysis/src/FieldUsageAnalysisView.tsx @@ -0,0 +1,1620 @@ +import { css } from '@emotion/react'; +import { DeleteMetadataModal } from '@jetstream/feature/deploy'; +import { PermissionAnalysisHistoryModal } from '@jetstream/feature/manage-permissions'; +import { logger } from '@jetstream/shared/client-logger'; +import { APP_ROUTES } from '@jetstream/shared/ui-router'; +import { convertDateToLocale, formatNumber, setItemInLocalStorage } from '@jetstream/shared/ui-utils'; +import { getErrorMessage, gzipDecode, isCustomFieldApiName } from '@jetstream/shared/utils'; +import type { AsyncJob, AsyncJobNew, FieldUsageAnalysisJob, FieldUsageFullResult, SalesforceOrgUi } from '@jetstream/types'; +import { + AutoFullHeightContainer, + ColumnWithFilter, + DataTable, + DataTableSelectedContext, + DataTree, + DropDown, + Grid, + GridCol, + Icon, + KeyboardShortcut, + Modal, + Popover, + ProgressIndicator, + ReadOnlyFormElement, + SELECT_COLUMN_KEY, + SalesforceLogin, + ScopedNotification, + SelectColumn, + SelectFormatter, + Tabs, + Toast, + Toolbar, + ToolbarItemActions, + ToolbarItemGroup, + Tooltip, + fireToast, + salesforceLoginAndRedirect, + setColumnFromType, + type RenderGroupCellProps, + type SortColumn, +} from '@jetstream/ui'; +import { RequireMetadataApiBanner, fromJetstreamEvents, jobsState } from '@jetstream/ui-core'; +import { applicationCookieState, selectSkipFrontdoorAuth, selectedOrgState } from '@jetstream/ui/app-state'; +import { dexieDb } from '@jetstream/ui/db'; +import { isValid } from 'date-fns/isValid'; +import { parseISO } from 'date-fns/parseISO'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { useAtom, useAtomValue } from 'jotai'; +import groupBy from 'lodash/groupBy'; +import { Fragment, FunctionComponent, MouseEvent, useCallback, useEffect, useMemo, useState, type Key, type ReactElement } from 'react'; +import { Link, useHref, useSearchParams } from 'react-router-dom'; +import { + FIELD_USAGE_DELETE_INELIGIBLE_LABELS, + fieldUsageDestructiveDeleteIneligibleReason, + fieldUsageRowsToCustomFieldDeleteMetadata, + type FieldUsageDeleteIneligibleReason, +} from './field-usage-destructive-delete'; +import { + countWhereUsedByUiCategory, + fieldHasWhereUsedDeps, + getFieldUsageTypeLabel, + getWhereUsedDepsForFieldKey, + isFieldWhereUsedResolved, + parseFieldUsageJobResult, + type FieldUsageJobResultParsed, + type WhereUsedDependencyRowParsed, +} from './field-usage-result-parse'; +import { isAnalysisJobActive } from './shared/analysis-job-runtime-state'; +import { getWhereUsedOpenInSalesforcePath } from './where-used-open-in-salesforce'; + +const FIELD_USAGE_TABLE_ACTION_DELETE_METADATA = 'field-usage-delete-metadata'; + +/** + * Ineligibility reasons worth explaining inline (the field looks deletable but is blocked). Standard / + * packaged / name-field / object-error rows are never user-deletable so they show no indicator. + */ +const WHY_NOT_DELETABLE_REASONS = new Set([ + 'scan-truncated', + 'where-used-unknown', + 'has-dependencies', + 'has-data', +]); + +const HEIGHT_BUFFER = 170; + +/** True for local Vite dev; false in production builds — used to avoid exposing raw job payloads in prod. */ +const SHOW_RAW_JOB_JSON_UI = import.meta.env.DEV; + +/** Custom fields at or below this fill rate appear on the **Low usage** tab. */ +const LOW_USAGE_PCT_THRESHOLD = 5; + +/** One-line explainer for the Low usage tab (threshold lives in {@link LOW_USAGE_PCT_THRESHOLD}). */ +function getFieldUsageLowUsageIntroCopy(pctThreshold: number): string { + return `Unmanaged Custom Fields at or below ${pctThreshold}% population in the rows scanned for this job. Packaged Custom Fields with a namespace prefix are not listed here.`; +} + +const TREE_GROUP_BY = ['objectApiName'] as const; + +/** Tall enough for two-line Object/Field cell + padded "Where Used" control (react-data-grid clips overflow). */ +const TREE_ROW_HEIGHT_LEAF_PX = 56; +/** Single-line truncated object summary (see {@link renderFieldUsageObjectGroupCell}). */ +const TREE_ROW_HEIGHT_GROUP_PX = 48; + +/** Same reference every render — new arrays/functions here break TreeDataGrid measurement and can cause an update loop. */ +const FIELD_USAGE_DATA_TREE_GROUP_BY: readonly (keyof FieldUsageTreeRow)[] = TREE_GROUP_BY; + +const FIELD_USAGE_TREE_INITIAL_SORT_BY_FIELD: SortColumn[] = [{ columnKey: 'fieldApiName', direction: 'ASC' }]; + +const FIELD_USAGE_TREE_INITIAL_SORT_BY_PCT: SortColumn[] = [{ columnKey: 'pct', direction: 'ASC' }]; + +function fieldUsageDataTreeRowHeight({ type }: { type: string }): number { + if (type === 'GROUP') { + return TREE_ROW_HEIGHT_GROUP_PX; + } + return TREE_ROW_HEIGHT_LEAF_PX; +} + +/** Salesforce ISO timestamps shown in the browser locale/time zone (same as {@link convertDateToLocale} elsewhere). */ +function formatFieldUsageLatestModifiedCell(raw: string | null): string { + if (raw == null || raw === '') { + return '—'; + } + const parsed = parseISO(raw); + if (!isValid(parsed)) { + return raw; + } + return convertDateToLocale(raw) ?? raw; +} + +/** Leaf rows for {@link DataTree}, grouped by {@link FieldUsageTreeRow.objectApiName} (same pattern as field permissions editor). */ +interface FieldUsageTreeRow { + _key: string; + objectApiName: string; + objectLabel: string; + objectTotalRecords: number; + objectQueryTruncated: boolean; + objectCustomizable: boolean; + objectError?: string; + fieldApiName: string; + fieldLabel: string; + /** Describe-style type label ({@link getFieldUsageTypeLabel}). */ + type: string; + custom: boolean; + filled: number; + pct: number; + latestModified: string | null; + /** Synthetic row when the object payload has `error` and no field stats. */ + isObjectErrorPlaceholder?: boolean; + /** Unmanaged custom field (no namespace prefix) eligible for destructive delete; same rules as `isUnmanagedCustomFieldApiName` in shared utils. */ + destructiveDeleteEligible?: boolean; + /** Why the field is NOT delete-eligible (drives the "why can't I delete this?" tooltip); `null` when eligible. */ + destructiveDeleteIneligibleReason?: FieldUsageDeleteIneligibleReason | null; + /** Where Used row counts by Kind: Layout, Automation, Apex ({@link countWhereUsedByUiCategory}). */ + whereUsedOnLayout: number; + whereUsedInAutomation: number; + whereUsedInApex: number; +} + +function whereUsedUiCountsForField( + whereUsed: FieldUsageJobResultParsed['whereUsed'] | undefined, + objectApiName: string, + fieldApiName: string, +): { whereUsedOnLayout: number; whereUsedInAutomation: number; whereUsedInApex: number } { + if (!whereUsed || !isCustomFieldApiName(fieldApiName)) { + return { whereUsedOnLayout: 0, whereUsedInAutomation: 0, whereUsedInApex: 0 }; + } + const { onLayout, inAutomation, inApex } = countWhereUsedByUiCategory( + getWhereUsedDepsForFieldKey(whereUsed, `${objectApiName}.${fieldApiName}`), + ); + return { whereUsedOnLayout: onLayout, whereUsedInAutomation: inAutomation, whereUsedInApex: inApex }; +} + +/** Lightning Setup → Object Manager deep link (same pattern as permission analysis export). */ +function fieldUsageObjectManagerReturnUrl(objectApiName: string, view: 'details' | 'fields'): string { + const enc = encodeURIComponent(objectApiName); + if (view === 'fields') { + return `/lightning/setup/ObjectManager/${enc}/FieldsAndRelationships/view`; + } + return `/lightning/setup/ObjectManager/${enc}/Details/view`; +} + +/** SOQL for Query Records: analyzed fields plus Id / LastModifiedDate, no WHERE (same shape as field usage scan). */ +function buildFieldUsageObjectQuerySoql(objectApiName: string, childRows: readonly FieldUsageTreeRow[]): string { + const analyzedFields = childRows + .filter((row) => !row.isObjectErrorPlaceholder && row.objectApiName === objectApiName && row.fieldApiName && row.fieldApiName !== '—') + .map((row) => row.fieldApiName); + const orderedUnique = [...new Set(analyzedFields)].sort((a, b) => a.localeCompare(b)); + const selectList: string[] = ['Id', 'LastModifiedDate']; + for (const fieldName of orderedUnique) { + if (fieldName !== 'Id' && fieldName !== 'LastModifiedDate') { + selectList.push(fieldName); + } + } + return `SELECT ${selectList.join(', ')} FROM ${objectApiName}`; +} + +/** + * Opens Query Results in a new tab. Writes initial SOQL to `localStorage` under key `query` because `location.state` + * is not passed through `window.open`, and `sessionStorage` is not visible in the new tab (each top-level tab has its + * own session storage). {@link QueryResults} reads this handoff and clears `localStorage` after applying. + * `queryResultsHref` must come from {@link useHref} so the path includes the app router basename (e.g. `/app`). + */ +function openFieldUsageObjectQueryInNewTab( + objectApiName: string, + objectLabel: string, + childRows: readonly FieldUsageTreeRow[], + queryResultsHref: string, +): void { + const soql = buildFieldUsageObjectQuerySoql(objectApiName, childRows); + setItemInLocalStorage( + 'query', + JSON.stringify({ + soql, + isTooling: false, + sobject: { name: objectApiName, label: objectLabel }, + }), + ); + window.open(queryResultsHref, '_blank', 'noopener,noreferrer'); +} + +const FIELD_USAGE_POPOVER_PANEL_PROPS = { + onDoubleClick: (event: MouseEvent) => { + event.stopPropagation(); + }, +}; + +interface FieldUsageOrgLoginProps { + serverUrl: string | undefined; + org: SalesforceOrgUi | null | undefined; + skipFrontDoorAuth: boolean; +} + +function FieldUsageObjectGroupCell( + props: RenderGroupCellProps & + FieldUsageOrgLoginProps & { + /** From parent {@link useHref}; one value for all groups (avoids N identical hook calls). */ + queryResultsHref: string; + }, +): ReactElement { + const { groupKey, childRows, serverUrl, org, skipFrontDoorAuth, queryResultsHref } = props; + const api = String(groupKey); + const sample = childRows[0]; + const label = sample?.objectLabel?.trim() ? sample.objectLabel.trim() : api; + const analyzedFieldCount = childRows.filter((row) => !row.isObjectErrorPlaceholder).length; + const rowCount = sample?.objectTotalRecords ?? 0; + const slug = api.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const returnUrl = fieldUsageObjectManagerReturnUrl(api, 'details'); + const canDeepLink = Boolean(org?.uniqueId && serverUrl); + + const handleOpenQueryResults = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + openFieldUsageObjectQueryInNewTab(api, label, childRows, queryResultsHref); + }, + [api, label, childRows, queryResultsHref], + ); + + return ( + + + + } + content={ +
+ {canDeepLink && org && serverUrl ? ( + + View in Salesforce + + ) : null} + + + + + + + + + + + + + + + + + {canDeepLink ? ( + +
+ Use to skip this popup +
+
+ ) : null} +
+
+ } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ + width: '100%', + height: '100%', + alignItems: 'center', + display: 'flex', + lineHeight: 1.25, + padding: '0.25rem 0.5rem 0.25rem 0.25rem', + }} + > + ) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canDeepLink || !org || !serverUrl) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl, + org, + returnUrl, + skipFrontDoorAuth, + }); + } + }} + > + {label} {api} + + {' '} + · {formatNumber(analyzedFieldCount)} field{analyzedFieldCount === 1 ? '' : 's'} + {' · '} + {formatNumber(rowCount)} rows scanned + {sample?.objectQueryTruncated ? ' · truncated' : ''} + {sample?.objectCustomizable ? '' : ' · not customizable'} + + {sample?.objectError ? · {sample.objectError} : null} + +
+ ); +} + +function FieldUsageFieldNameCell({ + row, + serverUrl, + org, + skipFrontDoorAuth, +}: { + row: FieldUsageTreeRow; +} & FieldUsageOrgLoginProps): ReactElement { + const slug = `${row.objectApiName}-${row.fieldApiName}`.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const returnUrl = fieldUsageObjectManagerReturnUrl(row.objectApiName, 'fields'); + const canDeepLink = Boolean(org?.uniqueId && serverUrl); + + return ( + + {canDeepLink && org && serverUrl ? ( + + View in Salesforce + + ) : null} + + + + + + + + + + + {canDeepLink ? ( + +
+ Use to skip this popup +
+
+ ) : null} +
+ + } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ width: '100%', height: '100%', padding: 0 }} + > +
) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canDeepLink || !org || !serverUrl) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl, + org, + returnUrl, + skipFrontDoorAuth, + }); + } + }} + > +
+ {row.fieldApiName} +
+
{row.fieldLabel}
+
+
+ ); +} + +interface WhereUsedTableRow { + _key: string; + componentType: string; + componentName: string; + kindLabel: string; + /** Flow `VersionNumber` when known; em dash otherwise. */ + flowVersionLabel: string; + /** Relative path for Salesforce login link when opening the dependency in the org. */ + openInSalesforcePath: string | null; +} + +function formatJobResultJson(result: unknown): string { + try { + return JSON.stringify(result, null, 2); + } catch { + return String(result); + } +} + +/** + * Lazily stringifies the result when the Raw JSON tab actually renders. Inlining the stringify in the + * `resultTabs` useMemo would re-run on every memo dep change even if the tab isn't active — for very + * large blobs this is noticeable jank when toggling filters. + */ +const RawJsonTabContent: FunctionComponent<{ result: unknown }> = ({ result }) => { + const formatted = useMemo(() => formatJobResultJson(result), [result]); + return ( +
+
+        {formatted}
+      
+
+ ); +}; + +/** + * Field usage results workspace. Subscribes to the in-flight job entry (jotai jobsState) for progress + * and to Dexie `analysis_job_history` for the terminal row; no HTTP polling. Result decoding happens + * once per Dexie row (gzip decompress) and feeds the existing field-usage parser. + */ +export const FieldUsageAnalysisView: FunctionComponent = () => { + const selectedOrg = useAtomValue(selectedOrgState); + const [{ serverUrl }] = useAtom(applicationCookieState); + const skipFrontDoorAuth = useAtomValue(selectSkipFrontdoorAuth); + const jobs = useAtomValue(jobsState); + const [searchParams, setSearchParams] = useSearchParams(); + const jobId = searchParams.get('job'); + + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [loadAllRecordsModalOpen, setLoadAllRecordsModalOpen] = useState(false); + const [whereUsedForKey, setWhereUsedForKey] = useState(null); + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + const [fieldUsageSelectedRowKeys, setFieldUsageSelectedRowKeys] = useState(() => new Set()); + const [deleteFieldMetadataModalOpen, setDeleteFieldMetadataModalOpen] = useState(false); + const [decodedFullResult, setDecodedFullResult] = useState(null); + const [decodeError, setDecodeError] = useState(null); + + const fieldUsageQueryResultsHref = useHref({ + pathname: `${APP_ROUTES.QUERY.ROUTE}/results`, + ...(APP_ROUTES.QUERY.SEARCH_PARAM ? { search: `?${APP_ROUTES.QUERY.SEARCH_PARAM}` } : {}), + }); + + /** + * Live in-flight AsyncJob for this jobHistoryKey, when present. Drives the progress UI before the + * Dexie terminal row lands; the Jobs popover shows the same entry. + */ + const inFlightJob: AsyncJob | null = useMemo(() => { + if (!jobId) { + return null; + } + for (const candidate of Object.values(jobs)) { + if (candidate.type !== 'FieldUsageAnalysis') { + continue; + } + const meta = candidate.meta as FieldUsageAnalysisJob | undefined; + if (meta?.jobHistoryKey === jobId) { + return candidate as AsyncJob; + } + } + return null; + }, [jobs, jobId]); + + const inFlightStatus = inFlightJob?.status; + const isJobRunning = inFlightStatus === 'pending' || inFlightStatus === 'in-progress'; + + /** + * Terminal Dexie row for this jobHistoryKey, kept reactive via useLiveQuery so the view updates + * the moment the JobWorker writes the row. Scoped to the selected org: a row whose `org` does not + * match (bookmarked/copied key, or org switched while the URL still has an old key) resolves to + * `undefined` so we never show another org's result — destructive/delete and deep-link actions + * target `selectedOrg`, so a mismatch would be dangerous. + */ + const selectedOrgId = selectedOrg?.uniqueId; + const historyRow = useLiveQuery(async () => { + if (!jobId || !selectedOrgId) { + return undefined; + } + const row = await dexieDb.analysis_job_history.get(jobId); + return row && row.org === selectedOrgId ? row : undefined; + }, [jobId, selectedOrgId]); + + useEffect(() => { + // Eagerly drop the prior decoded payload so switching between large completed runs doesn't keep both + // the old and new uncompressed blobs in memory while the new gunzip resolves. + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale decoded payload when job/row changes + setDecodedFullResult(null); + setDecodeError(null); + if (!historyRow || historyRow.status !== 'completed' || !historyRow.resultBlob) { + return; + } + let cancelled = false; + gzipDecode(historyRow.resultBlob) + .then((decoded) => { + if (!cancelled) { + setDecodedFullResult(decoded); + } + }) + .catch((ex) => { + if (!cancelled) { + logger.error('Failed to decode field_usage history blob', ex); + setDecodeError(getErrorMessage(ex)); + } + }); + return () => { + cancelled = true; + }; + }, [historyRow]); + + // Derived status / errors used by the existing render branches. + const jobStatusNormalized = useMemo(() => { + if (historyRow?.status === 'completed' || historyRow?.status === 'failed') { + return historyRow.status; + } + if (isJobRunning) { + return 'running'; + } + if (inFlightStatus === 'failed' || inFlightStatus === 'aborted') { + return 'failed'; + } + return ''; + }, [historyRow?.status, isJobRunning, inFlightStatus]); + const isTerminal = jobStatusNormalized === 'completed' || jobStatusNormalized === 'failed'; + const fetchError = decodeError; + const terminalErrorMessage = historyRow?.errorMessage ?? inFlightJob?.statusMessage ?? null; + const liveProgress = inFlightJob?.progress; + const isFieldUsageJobActiveForOrg = selectedOrg ? isAnalysisJobActive(jobs, selectedOrg.uniqueId, 'field_usage') : false; + + /** Must be memoized: a fresh object every render makes `[parsedResult]` effects run forever. */ + const parsedResult: FieldUsageJobResultParsed | null = useMemo(() => { + if (jobStatusNormalized !== 'completed' || !decodedFullResult) { + return null; + } + return parseFieldUsageJobResult(decodedFullResult); + }, [jobStatusNormalized, decodedFullResult]); + + useEffect(() => { + if (!parsedResult) { + return; + } + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset expansion when a new job result loads so object groups start expanded + setExpandedGroupIds(new Set(Object.keys(parsedResult.objects))); + }, [parsedResult]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- new job result invalidates row selection + setFieldUsageSelectedRowKeys(new Set()); + }, [parsedResult]); + + const treeFieldRows: FieldUsageTreeRow[] = useMemo(() => { + if (!parsedResult) { + return []; + } + const rows: FieldUsageTreeRow[] = []; + for (const objectApiName of Object.keys(parsedResult.objects).sort((a, b) => a.localeCompare(b))) { + const payload = parsedResult.objects[objectApiName]; + const base = { + objectApiName, + objectLabel: payload.label, + objectTotalRecords: payload.totalRecords, + objectQueryTruncated: payload.queryTruncated, + objectCustomizable: payload.customizable, + ...(payload.error ? { objectError: payload.error } : {}), + }; + if (payload.error) { + rows.push({ + _key: `${objectApiName}::__error__`, + ...base, + fieldApiName: '—', + fieldLabel: payload.error, + type: '', + custom: false, + filled: 0, + pct: 0, + latestModified: null, + isObjectErrorPlaceholder: true, + destructiveDeleteEligible: false, + destructiveDeleteIneligibleReason: 'object-error', + whereUsedOnLayout: 0, + whereUsedInAutomation: 0, + whereUsedInApex: 0, + }); + continue; + } + for (const fieldApiName of Object.keys(payload.fieldUsage).sort((a, b) => a.localeCompare(b))) { + const stat = payload.fieldUsage[fieldApiName]; + const meta = payload.fieldMeta[fieldApiName]; + const whereUsedCounts = whereUsedUiCountsForField(parsedResult.whereUsed, objectApiName, fieldApiName); + const eligibilityArgs = { + isObjectErrorPlaceholder: false, + fieldApiName, + meta, + objectQueryTruncated: payload.queryTruncated, + whereUsedDependencyCount: + whereUsedCounts.whereUsedOnLayout + whereUsedCounts.whereUsedInAutomation + whereUsedCounts.whereUsedInApex, + // Per-field resolution: an unresolved field (Tooling Id not found / dependency query failed) is + // UNKNOWN, never "0 dependencies", so it can't be marked deletable. + whereUsedKnown: isFieldWhereUsedResolved(parsedResult, `${objectApiName}.${fieldApiName}`), + filled: stat.filled, + }; + const destructiveDeleteIneligibleReason = fieldUsageDestructiveDeleteIneligibleReason(eligibilityArgs); + const destructiveDeleteEligible = destructiveDeleteIneligibleReason === null; + rows.push({ + _key: `${objectApiName}.${fieldApiName}`, + ...base, + fieldApiName, + fieldLabel: meta?.label ?? fieldApiName, + type: getFieldUsageTypeLabel(meta), + custom: meta?.custom ?? false, + filled: stat.filled, + pct: stat.pct, + latestModified: stat.latestFilledRowModified, + destructiveDeleteEligible, + destructiveDeleteIneligibleReason, + ...whereUsedCounts, + }); + } + } + return rows; + }, [parsedResult]); + + const getTreeRowKey = useCallback((row: FieldUsageTreeRow) => row._key, []); + + const objectsTabTotals = useMemo(() => { + const objectCount = parsedResult ? Object.keys(parsedResult.objects).length : 0; + const analyzedFieldCount = treeFieldRows.filter((row) => !row.isObjectErrorPlaceholder).length; + return { objectCount, analyzedFieldCount }; + }, [parsedResult, treeFieldRows]); + + const fieldUsageReloadObjectApiNames = useMemo(() => { + if (!parsedResult) { + return []; + } + return Object.keys(parsedResult.objects).sort((a, b) => a.localeCompare(b)); + }, [parsedResult]); + + const canLoadAllRecords = parsedResult?.truncated === true && fieldUsageReloadObjectApiNames.length > 0 && Boolean(selectedOrg?.uniqueId); + + const handleConfirmLoadAllRecords = useCallback(() => { + if (!selectedOrg || fieldUsageReloadObjectApiNames.length === 0) { + return; + } + if (isAnalysisJobActive(jobs, selectedOrg.uniqueId, 'field_usage')) { + fireToast({ + message: 'A Field Usage job is already running for this org. Wait for it to finish before starting another.', + type: 'warning', + }); + return; + } + + const newJobHistoryKey = `aj_${crypto.randomUUID()}`; + const meta: FieldUsageAnalysisJob = { + jobHistoryKey: newJobHistoryKey, + orgUniqueId: selectedOrg.uniqueId, + objectApiNames: fieldUsageReloadObjectApiNames, + loadFullScan: true, + }; + const asyncJobNew: AsyncJobNew = { + type: 'FieldUsageAnalysis', + title: `Field Usage Full Scan (${fieldUsageReloadObjectApiNames.length} Object${fieldUsageReloadObjectApiNames.length === 1 ? '' : 's'})`, + org: selectedOrg, + meta, + viewUrl: `${APP_ROUTES.DATA_ANALYSIS.ROUTE}/analysis?job=${encodeURIComponent(newJobHistoryKey)}`, + }; + fromJetstreamEvents.emit({ type: 'newJob', payload: [asyncJobNew] }); + setLoadAllRecordsModalOpen(false); + fireToast({ message: 'Full scan job started. Loading results…', type: 'success' }); + setSearchParams({ job: newJobHistoryKey }, { replace: true }); + }, [jobs, selectedOrg, fieldUsageReloadObjectApiNames, setSearchParams]); + + const lowUsageTreeRows: FieldUsageTreeRow[] = useMemo(() => { + // Derived from treeFieldRows (built once above) instead of re-iterating the parsed result: unmanaged + // Custom Fields at or below the low-usage threshold, sorted by ascending fill rate. Avoids a second + // full object/field pass and a duplicate Where Used computation per field. + return treeFieldRows + .filter((row) => !row.isObjectErrorPlaceholder && row.custom && row.pct <= LOW_USAGE_PCT_THRESHOLD) + .sort((a, b) => a.pct - b.pct || a.objectApiName.localeCompare(b.objectApiName)); + }, [treeFieldRows]); + + const fieldUsageRowByKey = useMemo(() => { + const map = new Map(); + for (const row of treeFieldRows) { + map.set(row._key, row); + } + for (const row of lowUsageTreeRows) { + map.set(row._key, row); + } + return map; + }, [treeFieldRows, lowUsageTreeRows]); + + const fieldUsageSelectedDestructiveDeleteCount = useMemo(() => { + let count = 0; + for (const key of fieldUsageSelectedRowKeys) { + if (fieldUsageRowByKey.get(key)?.destructiveDeleteEligible) { + count += 1; + } + } + return count; + }, [fieldUsageRowByKey, fieldUsageSelectedRowKeys]); + + const fieldUsageDeleteSelectedMetadata = useMemo(() => { + const rows = Array.from(fieldUsageSelectedRowKeys) + .map((key) => fieldUsageRowByKey.get(key)) + .filter((row): row is FieldUsageTreeRow => Boolean(row)); + return fieldUsageRowsToCustomFieldDeleteMetadata(rows); + }, [fieldUsageRowByKey, fieldUsageSelectedRowKeys]); + + const handleFieldUsageToolbarDropdown = useCallback( + (actionId: string) => { + if (actionId === FIELD_USAGE_TABLE_ACTION_DELETE_METADATA) { + if (fieldUsageSelectedDestructiveDeleteCount === 0) { + return; + } + setDeleteFieldMetadataModalOpen(true); + } + }, + [fieldUsageSelectedDestructiveDeleteCount], + ); + + const handleFieldUsageSelectedRowsChange = useCallback((next: Set) => { + setFieldUsageSelectedRowKeys(new Set(Array.from(next, (key) => String(key)))); + }, []); + + const whereUsedRows: WhereUsedTableRow[] = useMemo(() => { + if (!parsedResult || !whereUsedForKey) { + return []; + } + const deps: WhereUsedDependencyRowParsed[] = getWhereUsedDepsForFieldKey(parsedResult.whereUsed, whereUsedForKey); + return deps.map((row, index) => { + const fv = row.flowVersionNumber; + const flowVersionLabel = row.type.trim() === 'Flow' && fv != null && Number.isFinite(Number(fv)) ? String(fv) : '—'; + return { + _key: `${row.type}:${row.name}:${String(index)}`, + componentType: row.type, + componentName: row.name, + kindLabel: row.kind === 'automation' ? 'Automation' : row.kind === 'apex' ? 'Apex' : row.kind === 'layout' ? 'Layout' : 'Other', + flowVersionLabel, + openInSalesforcePath: getWhereUsedOpenInSalesforcePath(row), + }; + }); + }, [parsedResult, whereUsedForKey]); + + const treeColumns: ColumnWithFilter[] = useMemo( + () => [ + { + ...SelectColumn, + key: SELECT_COLUMN_KEY, + resizable: false, + sortable: false, + minWidth: 36, + width: 40, + maxWidth: 44, + renderCell: (args) => { + if (!args.row.destructiveDeleteEligible) { + const reason = args.row.destructiveDeleteIneligibleReason; + // Explain why an unmanaged custom field a user might expect to delete is blocked. Standard / + // packaged / name / object-error rows are never user-deletable, so showing nothing is cleaner. + if (reason && WHY_NOT_DELETABLE_REASONS.has(reason)) { + return ( + + + + ); + } + return null; + } + return SelectColumn.renderCell?.(args) || ; + }, + renderGroupCell: () => null, + }, + { + ...setColumnFromType('objectApiName', 'text'), + name: '', + key: 'objectApiName', + width: 40, + minWidth: 36, + maxWidth: 44, + resizable: false, + sortable: false, + renderGroupCell: ({ isExpanded, toggleGroup }) => ( + + + + ), + renderCell: () => null, + }, + { + ...setColumnFromType('fieldApiName', 'text'), + name: 'Object / Field', + key: 'fieldApiName', + width: 340, + minWidth: 200, + renderGroupCell: (groupProps) => ( + + ), + renderCell: (p) => + p.row.isObjectErrorPlaceholder ? ( + {p.row.fieldLabel} + ) : ( + + ), + getValue: ({ row }) => `${row.fieldApiName} ${row.fieldLabel}`, + }, + { + ...setColumnFromType('type', 'text'), + name: 'Type', + key: 'type', + width: 200, + minWidth: 160, + renderGroupCell: () => null, + renderCell: (p) => {p.row.isObjectErrorPlaceholder ? '' : p.row.type}, + getValue: ({ row }) => (row.isObjectErrorPlaceholder ? '' : row.type), + }, + { + ...setColumnFromType('custom', 'text'), + name: 'Custom Field', + key: 'custom', + width: 100, + minWidth: 80, + renderGroupCell: () => null, + renderCell: (p) => {p.row.isObjectErrorPlaceholder ? '' : p.row.custom ? 'Yes' : 'No'}, + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder) { + return ''; + } + return row.custom ? 'Yes' : 'No'; + }, + }, + { + ...setColumnFromType('filled', 'number'), + name: 'Filled', + key: 'filled', + width: 80, + minWidth: 80, + renderGroupCell: () => null, + renderCell: (p) => {p.row.isObjectErrorPlaceholder ? '' : formatNumber(p.row.filled)}, + getValue: ({ row }) => (row.isObjectErrorPlaceholder ? '' : formatNumber(row.filled)), + }, + { + ...setColumnFromType('pct', 'number'), + name: '% Filled', + key: 'pct', + width: 100, + minWidth: 100, + renderGroupCell: () => null, + renderCell: (p) => {p.row.isObjectErrorPlaceholder ? '' : `${p.row.pct.toFixed(1)}%`}, + getValue: ({ row }) => (row.isObjectErrorPlaceholder ? '' : `${row.pct.toFixed(1)}%`), + }, + { + ...setColumnFromType('latestModified', 'text'), + name: 'Latest Row Modified (any field)', + key: 'latestModified', + width: 220, + minWidth: 100, + renderGroupCell: () => null, + renderCell: (p) => {p.row.isObjectErrorPlaceholder ? '' : formatFieldUsageLatestModifiedCell(p.row.latestModified)}, + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder) { + return ''; + } + return formatFieldUsageLatestModifiedCell(row.latestModified); + }, + }, + { + ...setColumnFromType('whereUsedOnLayout', 'number'), + name: 'On Layout', + key: 'whereUsedOnLayout', + width: 120, + minWidth: 100, + renderGroupCell: () => null, + renderCell: (p) => ( + {p.row.isObjectErrorPlaceholder ? '' : p.row.whereUsedOnLayout > 0 ? formatNumber(p.row.whereUsedOnLayout) : '—'} + ), + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder) { + return ''; + } + return row.whereUsedOnLayout > 0 ? String(row.whereUsedOnLayout) : ''; + }, + }, + { + ...setColumnFromType('whereUsedInAutomation', 'number'), + name: 'In Automation', + key: 'whereUsedInAutomation', + width: 140, + minWidth: 140, + renderGroupCell: () => null, + renderCell: (p) => ( + + {p.row.isObjectErrorPlaceholder ? '' : p.row.whereUsedInAutomation > 0 ? formatNumber(p.row.whereUsedInAutomation) : '—'} + + ), + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder) { + return ''; + } + return row.whereUsedInAutomation > 0 ? String(row.whereUsedInAutomation) : ''; + }, + }, + { + ...setColumnFromType('whereUsedInApex', 'number'), + name: 'In Apex', + key: 'whereUsedInApex', + width: 100, + minWidth: 100, + renderGroupCell: () => null, + renderCell: (p) => ( + {p.row.isObjectErrorPlaceholder ? '' : p.row.whereUsedInApex > 0 ? formatNumber(p.row.whereUsedInApex) : '—'} + ), + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder) { + return ''; + } + return row.whereUsedInApex > 0 ? String(row.whereUsedInApex) : ''; + }, + }, + { + ...setColumnFromType('whereUsed', 'text'), + name: '', + key: 'whereUsed', + width: 188, + minWidth: 160, + sortable: false, + renderGroupCell: () => null, + renderCell: (p) => { + if (p.row.isObjectErrorPlaceholder || !isCustomFieldApiName(p.row.fieldApiName)) { + return ; + } + const fieldKey = `${p.row.objectApiName}.${p.row.fieldApiName}`; + if (!parsedResult || !fieldHasWhereUsedDeps(parsedResult.whereUsed, fieldKey)) { + return ; + } + return ( +
+ +
+ ); + }, + getValue: ({ row }) => { + if (row.isObjectErrorPlaceholder || !isCustomFieldApiName(row.fieldApiName)) { + return '—'; + } + const fieldKey = `${row.objectApiName}.${row.fieldApiName}`; + if (!parsedResult || !fieldHasWhereUsedDeps(parsedResult.whereUsed, fieldKey)) { + return '—'; + } + return 'Where Used'; + }, + }, + ], + [parsedResult, serverUrl, selectedOrg, skipFrontDoorAuth, fieldUsageQueryResultsHref], + ); + + const whereUsedColumns: ColumnWithFilter[] = useMemo( + () => [ + { + ...setColumnFromType('componentType', 'text'), + name: 'Metadata Type', + key: 'componentType', + width: 160, + maxWidth: 220, + }, + { + ...setColumnFromType('componentName', 'text'), + name: 'Name', + key: 'componentName', + width: 480, + minWidth: 280, + }, + { + ...setColumnFromType('flowVersionLabel', 'text'), + name: 'Flow Ver.', + key: 'flowVersionLabel', + width: 88, + minWidth: 72, + maxWidth: 100, + }, + { + ...setColumnFromType('kindLabel', 'text'), + name: 'Kind', + key: 'kindLabel', + width: 120, + maxWidth: 140, + }, + { + ...setColumnFromType('openInSalesforcePath', 'text'), + name: 'Open', + key: 'openInSalesforcePath', + width: 108, + minWidth: 96, + sortable: false, + renderCell: (p) => { + const returnUrl = p.row.openInSalesforcePath; + if (!returnUrl || !selectedOrg?.uniqueId || !serverUrl) { + return ; + } + return ( + + Open + + ); + }, + getValue: ({ row }) => (row.openInSalesforcePath ? 'Open' : ''), + }, + ], + [selectedOrg, serverUrl, skipFrontDoorAuth], + ); + + const resultTabs = useMemo(() => { + if (!parsedResult) { + return null; + } + return [ + { + id: 'objects', + title: ( + + + + + Objects & Fields + + ), + titleText: 'Objects & Fields', + content: ( +
+ {parsedResult.truncated && ( +
+ + At least one Object hit the row scan cap; percentages reflect scanned rows only. + +
+ )} + {treeFieldRows.length === 0 ? ( + No Object rows in this result. + ) : ( + +

+ {formatNumber(objectsTabTotals.analyzedFieldCount)} analyzed field + {objectsTabTotals.analyzedFieldCount === 1 ? '' : 's'} across {formatNumber(objectsTabTotals.objectCount)} Object + {objectsTabTotals.objectCount === 1 ? '' : 's'}. +

+ + + +
+ )} +
+ ), + }, + { + id: 'low-usage', + title: ( + + + + + Low Usage (≤{LOW_USAGE_PCT_THRESHOLD}%) + + ), + titleText: 'Low Usage Custom Fields', + content: ( +
+

+ {getFieldUsageLowUsageIntroCopy(LOW_USAGE_PCT_THRESHOLD)} +

+ {lowUsageTreeRows.length === 0 ? ( + + No unmanaged Custom Fields at or below {LOW_USAGE_PCT_THRESHOLD}% population for the objects in this scan. + + ) : ( + + + + )} +
+ ), + }, + ...(SHOW_RAW_JOB_JSON_UI + ? [ + { + id: 'raw-json', + title: ( + + + + + Raw JSON + + ), + titleText: 'Raw JSON', + content: , + }, + ] + : []), + ]; + }, [ + parsedResult, + treeFieldRows, + treeColumns, + lowUsageTreeRows, + expandedGroupIds, + getTreeRowKey, + objectsTabTotals, + decodedFullResult, + fieldUsageSelectedRowKeys, + handleFieldUsageSelectedRowsChange, + selectedOrg, + serverUrl, + skipFrontDoorAuth, + ]); + + return ( +
+ + +
+
+ + + + Go Back + + +
+
+
+ + {parsedResult && treeFieldRows.some((row) => !row.isObjectErrorPlaceholder) && ( + + } + position="right" + actionText="Field Actions" + items={[ + { + id: FIELD_USAGE_TABLE_ACTION_DELETE_METADATA, + subheader: 'Deploy Actions', + icon: { type: 'utility', icon: 'delete', description: 'Delete Selected Custom Fields' }, + value: 'Delete Selected Metadata', + disabled: fieldUsageSelectedDestructiveDeleteCount === 0, + title: + fieldUsageSelectedDestructiveDeleteCount === 0 + ? 'Select unmanaged Custom Fields (no namespace prefix) on the Objects & Fields or Low Usage tab' + : 'Same destructive deploy flow as Deploy Metadata (validate or delete from this org)', + }, + ]} + onSelected={handleFieldUsageToolbarDropdown} + /> + )} + + + + + + + +
+
+ + {deleteFieldMetadataModalOpen && selectedOrg && Object.keys(fieldUsageDeleteSelectedMetadata).length > 0 && ( + setDeleteFieldMetadataModalOpen(false)} + /> + )} + {loadAllRecordsModalOpen && ( + setLoadAllRecordsModalOpen(false)} + footer={ + + + + + } + > +
+ + This runs a full row scan for each Object in this job. It can take a long time and use many Salesforce API calls (REST query + and queryMore), counting against your org's daily limits. + +

+ A new analysis job will start. When it completes, this page will show that job's results. +

+
+
+ )} + {isHistoryOpen && selectedOrg && ( + setIsHistoryOpen(false)} + onSelectJob={(nextJobId) => { + setSearchParams({ job: nextJobId }, { replace: true }); + }} + /> + )} + {whereUsedForKey && ( + setWhereUsedForKey(null)} + footer={ + + } + > + {whereUsedRows.length === 0 ? ( +

+ No dependency rows were returned for this field (or Where Used could not be computed for this org). +

+ ) : ( +
+
+ + row._key} + includeQuickFilter + rowHeight={34} + /> + +
+
+ )} +
+ )} + + {!jobId && ( +
+ + No analysis job is linked to this page. Start a Field Usage job from Data Analysis, then you will be redirected here + automatically. + +
+ )} + {jobId && fetchError && {fetchError}} + {jobId && !fetchError && jobStatusNormalized === 'failed' && terminalErrorMessage != null && ( +
+ {terminalErrorMessage} +
+ )} + {jobId && !fetchError && !isTerminal && ( +
+

Field usage analysis in progress…

+

+ {isJobRunning && liveProgress?.label ? liveProgress.label : 'Preparing'} + {isJobRunning && liveProgress && liveProgress.total > 0 + ? ` — object ${formatNumber(liveProgress.current)} of ${formatNumber(liveProgress.total)}` + : ''} +

+ +

+ You can leave this page — the job will keep running and you'll find it in the Jobs popover. +

+
+ )} + {jobId && !fetchError && isTerminal && jobStatusNormalized === 'completed' && !parsedResult && ( +
+ + This job completed but the result payload is not a recognized Field Usage envelope (missing `phase: field_usage_v1`). + +
+ )} + {jobId && !fetchError && isTerminal && jobStatusNormalized === 'completed' && parsedResult && resultTabs && ( + + +
+

+ Summary + {parsedResult.summary} +

+
+ {parsedResult.failedObjects.length > 0 && ( +
+ Objects with errors: {parsedResult.failedObjects.join(', ')}. +
+ )} + {parsedResult.truncated && ( +
+ + The row scan stopped early for one or more Objects, so usage percentages reflect only the rows that were scanned. A + field showing 0% or low usage here is not proof it is unused — it may be populated in rows that were + not scanned. Fields on truncated Objects are excluded from Delete Selected Metadata; use{' '} + Load All Records for complete counts before deleting. + +
+ )} + {!parsedResult.whereUsedComputed && ( +
+ + Where Used (metadata dependency) lookup could not be completed for this run, so field dependencies are unknown. + Delete Selected Metadata is disabled to avoid deleting a field that may be referenced by a layout, automation, + or Apex. Re-run the analysis to determine dependencies. + +
+ )} + {parsedResult.whereUsedComputed && ( +
+ + Before deleting: "Where Used" detects references in page layouts, automation, and Apex only. + It does not detect references in reports, list views, validation rules, email templates, or dashboards, + so "no dependencies" is a strong signal but not absolute proof a field is unused. Review those manually before deleting + — deletion permanently removes the field and all of its data. + +
+ )} + tab.id).join('|')} initialActiveId="objects" tabs={resultTabs} /> +
+
+ )} +
+
+ ); +}; + +export default FieldUsageAnalysisView; diff --git a/libs/features/data-analysis/src/__tests__/field-usage-destructive-delete.spec.ts b/libs/features/data-analysis/src/__tests__/field-usage-destructive-delete.spec.ts new file mode 100644 index 000000000..f954f1f3e --- /dev/null +++ b/libs/features/data-analysis/src/__tests__/field-usage-destructive-delete.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { + fieldUsageDestructiveDeleteIneligibleReason, + fieldUsageRowEligibleForDestructiveDelete, + fieldUsageRowsToCustomFieldDeleteMetadata, +} from '../field-usage-destructive-delete'; +import type { FieldUsageFieldMetaParsed } from '../field-usage-result-parse'; + +const customMeta = (overrides: Partial = {}): FieldUsageFieldMetaParsed => ({ + label: 'Test', + calculated: false, + type: 'string', + custom: true, + ...overrides, +}); + +describe('fieldUsageRowEligibleForDestructiveDelete', () => { + it('rejects placeholders, non-custom, name fields, non-__c, and namespaced custom fields', () => { + expect( + fieldUsageRowEligibleForDestructiveDelete({ + isObjectErrorPlaceholder: true, + fieldApiName: 'X__c', + meta: customMeta(), + }), + ).toBe(false); + + expect( + fieldUsageRowEligibleForDestructiveDelete({ + fieldApiName: 'Name', + meta: { ...customMeta(), custom: false }, + }), + ).toBe(false); + + expect( + fieldUsageRowEligibleForDestructiveDelete({ + fieldApiName: 'My_Field__c', + meta: customMeta({ nameField: true }), + }), + ).toBe(false); + + expect( + fieldUsageRowEligibleForDestructiveDelete({ + fieldApiName: 'Industry', + meta: customMeta({ custom: false }), + }), + ).toBe(false); + + expect( + fieldUsageRowEligibleForDestructiveDelete({ + fieldApiName: 'ns__F__c', + meta: customMeta(), + }), + ).toBe(false); + }); + + it('accepts unmanaged custom fields', () => { + expect( + fieldUsageRowEligibleForDestructiveDelete({ + fieldApiName: 'Unused_Field__c', + meta: customMeta(), + }), + ).toBe(true); + }); + + describe('safety gates', () => { + const eligibleArgs = { fieldApiName: 'Unused_Field__c', meta: customMeta() }; + + it('blocks deletion when the object scan was truncated (0% may be incomplete)', () => { + expect(fieldUsageRowEligibleForDestructiveDelete({ ...eligibleArgs, objectQueryTruncated: true })).toBe(false); + }); + + it('blocks deletion when the field has where-used metadata dependencies', () => { + expect(fieldUsageRowEligibleForDestructiveDelete({ ...eligibleArgs, whereUsedDependencyCount: 1 })).toBe(false); + }); + + it('blocks deletion when where-used could not be determined (fail safe)', () => { + expect(fieldUsageRowEligibleForDestructiveDelete({ ...eligibleArgs, whereUsedKnown: false })).toBe(false); + }); + + it('allows deletion only when scan complete, no dependencies, and where-used known', () => { + expect( + fieldUsageRowEligibleForDestructiveDelete({ + ...eligibleArgs, + objectQueryTruncated: false, + whereUsedDependencyCount: 0, + whereUsedKnown: true, + }), + ).toBe(true); + }); + + it('blocks deletion when the field still holds data (filled > 0)', () => { + expect(fieldUsageRowEligibleForDestructiveDelete({ ...eligibleArgs, whereUsedKnown: true, filled: 3 })).toBe(false); + }); + + it('allows deletion of an empty field with proven-zero dependencies', () => { + expect(fieldUsageRowEligibleForDestructiveDelete({ ...eligibleArgs, whereUsedKnown: true, filled: 0 })).toBe(true); + }); + }); +}); + +describe('fieldUsageDestructiveDeleteIneligibleReason', () => { + const base = { fieldApiName: 'Unused_Field__c', meta: customMeta(), whereUsedKnown: true }; + + it('returns null only when fully eligible', () => { + expect(fieldUsageDestructiveDeleteIneligibleReason({ ...base, filled: 0 })).toBeNull(); + }); + + it('reports each blocking reason in precedence order', () => { + expect(fieldUsageDestructiveDeleteIneligibleReason({ ...base, isObjectErrorPlaceholder: true })).toBe('object-error'); + expect(fieldUsageDestructiveDeleteIneligibleReason({ ...base, meta: { ...customMeta(), custom: false } })).toBe('standard-field'); + expect(fieldUsageDestructiveDeleteIneligibleReason({ ...base, meta: customMeta({ nameField: true }) })).toBe('name-field'); + expect(fieldUsageDestructiveDeleteIneligibleReason({ ...base, fieldApiName: 'ns__F__c' })).toBe('packaged-field'); + expect(fieldUsageDestructiveDeleteIneligibleReason({ ...base, objectQueryTruncated: true })).toBe('scan-truncated'); + expect(fieldUsageDestructiveDeleteIneligibleReason({ ...base, whereUsedKnown: false })).toBe('where-used-unknown'); + expect(fieldUsageDestructiveDeleteIneligibleReason({ ...base, whereUsedDependencyCount: 1 })).toBe('has-dependencies'); + expect(fieldUsageDestructiveDeleteIneligibleReason({ ...base, filled: 5 })).toBe('has-data'); + }); +}); + +describe('fieldUsageRowsToCustomFieldDeleteMetadata', () => { + it('builds CustomField members with Object.Field fullName', () => { + const map = fieldUsageRowsToCustomFieldDeleteMetadata([ + { + objectApiName: 'Account', + fieldApiName: 'Jetstream_Test_Field__c', + destructiveDeleteEligible: true, + }, + { + objectApiName: 'Account', + fieldApiName: 'Industry', + destructiveDeleteEligible: false, + }, + ]); + expect(Object.keys(map)).toEqual(['CustomField']); + expect(map.CustomField).toHaveLength(1); + expect(map.CustomField[0].fullName).toBe('Account.Jetstream_Test_Field__c'); + expect(map.CustomField[0].type).toBe('CustomField'); + }); +}); diff --git a/libs/features/data-analysis/src/__tests__/where-used-open-in-salesforce.spec.ts b/libs/features/data-analysis/src/__tests__/where-used-open-in-salesforce.spec.ts new file mode 100644 index 000000000..25f5584ae --- /dev/null +++ b/libs/features/data-analysis/src/__tests__/where-used-open-in-salesforce.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { getWhereUsedOpenInSalesforcePath } from '../where-used-open-in-salesforce'; + +describe('getWhereUsedOpenInSalesforcePath', () => { + it('uses stored path from job when present', () => { + expect( + getWhereUsedOpenInSalesforcePath({ + type: 'Flow', + name: 'X', + kind: 'automation', + openInSalesforcePath: '/builder_platform_interaction/flowBuilder.app?flowId=abc', + }), + ).toBe('/builder_platform_interaction/flowBuilder.app?flowId=abc'); + }); + + it('builds Flow builder URL from component id when path omitted', () => { + expect( + getWhereUsedOpenInSalesforcePath({ + type: 'Flow', + name: 'My_Flow', + kind: 'automation', + componentId: '301xx000000abcd', + }), + ).toBe('/builder_platform_interaction/flowBuilder.app?flowId=301xx000000abcd'); + }); + + it('returns Process Builder home without component id', () => { + expect( + getWhereUsedOpenInSalesforcePath({ + type: 'ProcessDefinition', + name: 'P', + kind: 'automation', + }), + ).toBe('/lightning/setup/ProcessAutomation/home'); + }); + + it('encodes the setup address exactly once (no double-encoding)', () => { + expect( + getWhereUsedOpenInSalesforcePath({ + type: 'ApexClass', + name: 'MyClass', + kind: 'apex', + componentId: '01pxx0000000001', + }), + ).toBe('/lightning/setup/ApexClasses/page?address=%2F01pxx0000000001'); + }); +}); diff --git a/libs/features/data-analysis/src/data-analysis.spec.ts b/libs/features/data-analysis/src/data-analysis.spec.ts new file mode 100644 index 000000000..20c24ec19 --- /dev/null +++ b/libs/features/data-analysis/src/data-analysis.spec.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest'; +import { + countWhereUsedByUiCategory, + fieldHasWhereUsedDeps, + getFieldUsageTypeLabel, + getWhereUsedDepsForFieldKey, + parseFieldUsageJobResult, +} from './field-usage-result-parse'; + +describe('@jetstream/feature/data-analysis', () => { + it('parseFieldUsageJobResult returns null for wrong phase', () => { + expect(parseFieldUsageJobResult({ phase: 'permission_export_v1', objects: {} })).toBeNull(); + expect(parseFieldUsageJobResult(null)).toBeNull(); + }); + + it('parseFieldUsageJobResult parses field_usage_v1 envelope', () => { + const parsed = parseFieldUsageJobResult({ + phase: 'field_usage_v1', + summary: 'ok', + truncated: false, + failedObjects: [], + whereUsed: { + 'Custom__c.Field__c': [{ type: 'Flow', name: 'My_Flow', kind: 'automation' }], + }, + objects: { + Custom__c: { + label: 'Custom', + customizable: true, + totalRecords: 10, + queryTruncated: false, + fieldUsage: { + Field__c: { filled: 2, pct: 20, latestFilledRowModified: null }, + }, + fieldMeta: { + Field__c: { label: 'Field', calculated: false, type: 'string', custom: true, length: 255 }, + }, + }, + }, + }); + expect(parsed).not.toBeNull(); + expect(parsed?.phase).toBe('field_usage_v1'); + expect(parsed?.objects.Custom__c.fieldUsage.Field__c.pct).toBe(20); + expect(parsed?.whereUsed['Custom__c.Field__c']).toHaveLength(1); + expect(parsed?.whereUsed['Custom__c.Field__c'][0].kind).toBe('automation'); + }); + + it('parseFieldUsageJobResult infers apex/automation from metadata type when kind is other (legacy payloads)', () => { + const parsed = parseFieldUsageJobResult({ + phase: 'field_usage_v1', + summary: 'ok', + truncated: false, + failedObjects: [], + whereUsed: { + 'O__c.F__c': [ + { type: 'ApexClass', name: 'Foo', kind: 'other' }, + { type: 'Flow', name: 'Bar', kind: 'other' }, + ], + }, + objects: { + O__c: { + label: 'O', + customizable: true, + totalRecords: 0, + queryTruncated: false, + fieldUsage: { F__c: { filled: 0, pct: 0, latestFilledRowModified: null } }, + fieldMeta: { + F__c: { label: 'F', calculated: false, type: 'string', custom: true, length: 10 }, + }, + }, + }, + }); + const legacyRows = parsed?.whereUsed['O__c.F__c'] ?? []; + expect(legacyRows.find((row) => row.type === 'ApexClass')?.kind).toBe('apex'); + expect(legacyRows.find((row) => row.type === 'Flow')?.kind).toBe('automation'); + }); + + it('parseFieldUsageJobResult infers layout kind from Layout / FlexiPage / FieldSet when kind is other (legacy payloads)', () => { + const parsed = parseFieldUsageJobResult({ + phase: 'field_usage_v1', + summary: 'ok', + truncated: false, + failedObjects: [], + whereUsed: { + 'O__c.F__c': [ + { type: 'Layout', name: 'Account Layout', kind: 'other' }, + { type: 'FlexiPage', name: 'My_App_Page', kind: 'other' }, + { type: 'FieldSet', name: 'Task_Fields', kind: 'other' }, + ], + }, + objects: { + O__c: { + label: 'O', + customizable: true, + totalRecords: 0, + queryTruncated: false, + fieldUsage: { F__c: { filled: 0, pct: 0, latestFilledRowModified: null } }, + fieldMeta: { + F__c: { label: 'F', calculated: false, type: 'string', custom: true, length: 10 }, + }, + }, + }, + }); + const layoutRows = parsed?.whereUsed['O__c.F__c'] ?? []; + expect(layoutRows.find((row) => row.type === 'Layout')?.kind).toBe('layout'); + expect(layoutRows.find((row) => row.type === 'FlexiPage')?.kind).toBe('layout'); + expect(layoutRows.find((row) => row.type === 'FieldSet')?.kind).toBe('layout'); + }); + + it('getWhereUsedDepsForFieldKey matches exact key and falls back to case-insensitive map keys', () => { + const map = { + 'ns__Obj__c.Field__c': [{ type: 'Flow', name: 'F', kind: 'other' as const }], + }; + expect(getWhereUsedDepsForFieldKey(map, 'ns__Obj__c.Field__c')).toHaveLength(1); + expect(getWhereUsedDepsForFieldKey(map, 'NS__Obj__c.Field__c')).toHaveLength(1); + expect(getWhereUsedDepsForFieldKey(map, ' missing.Key__c ')).toEqual([]); + }); + + it('fieldHasWhereUsedDeps is true only when the map has non-empty rows for that field key', () => { + const map = { + 'A__c.F__c': [{ type: 'Flow', name: 'X', kind: 'other' as const }], + 'B__c.G__c': [] as { type: string; name: string; kind: 'automation' | 'apex' | 'layout' | 'other' }[], + }; + expect(fieldHasWhereUsedDeps(map, 'A__c.F__c')).toBe(true); + expect(fieldHasWhereUsedDeps(map, 'B__c.G__c')).toBe(false); + expect(fieldHasWhereUsedDeps(map, 'Unknown__c.X__c')).toBe(false); + }); + + it('countWhereUsedByUiCategory buckets by kind (matches Kind column)', () => { + expect( + countWhereUsedByUiCategory([ + { type: 'Layout', name: 'L1', kind: 'layout' }, + { type: 'FlexiPage', name: 'FP1', kind: 'layout' }, + { type: 'FieldSet', name: 'FS1', kind: 'layout' }, + { type: 'Flow', name: 'Fl1', kind: 'automation' }, + { type: 'WorkflowRule', name: 'W1', kind: 'automation' }, + { type: 'ProcessDefinition', name: 'P1', kind: 'automation' }, + { type: 'ApexTrigger', name: 'T1', kind: 'automation' }, + { type: 'ApexClass', name: 'C1', kind: 'apex' }, + { type: 'CustomLabel', name: 'X', kind: 'other' }, + ]), + ).toEqual({ onLayout: 3, inAutomation: 4, inApex: 1 }); + }); + + it('countWhereUsedByUiCategory uses kind only (not Tooling type)', () => { + expect(countWhereUsedByUiCategory([{ type: 'Layout', name: 'L1', kind: 'other' }])).toEqual({ + onLayout: 0, + inAutomation: 0, + inApex: 0, + }); + }); + + it('parseFieldUsageJobResult dedupes Flow + FlowDefinition so automation count is per logical flow', () => { + const parsed = parseFieldUsageJobResult({ + phase: 'field_usage_v1', + summary: 'ok', + truncated: false, + failedObjects: [], + whereUsed: { + 'O__c.F__c': [ + { type: 'Flow', name: 'Dup_Flow-2', kind: 'automation' }, + { type: 'FlowDefinition', name: 'Dup_Flow', kind: 'automation' }, + ], + }, + objects: { + O__c: { + label: 'O', + customizable: true, + totalRecords: 0, + queryTruncated: false, + fieldUsage: { F__c: { filled: 0, pct: 0, latestFilledRowModified: null } }, + fieldMeta: { + F__c: { label: 'F', calculated: false, type: 'string', custom: true, length: 10 }, + }, + }, + }, + }); + const deps = parsed?.whereUsed['O__c.F__c'] ?? []; + expect(deps).toHaveLength(1); + expect(countWhereUsedByUiCategory(deps).inAutomation).toBe(1); + }); + + it('getFieldUsageTypeLabel capitalizes API type when describe metadata is absent (legacy jobs)', () => { + expect( + getFieldUsageTypeLabel({ + label: 'Status', + calculated: false, + type: 'picklist', + custom: true, + }), + ).toBe('Picklist'); + expect( + getFieldUsageTypeLabel({ + label: 'Qty', + calculated: false, + type: 'int', + custom: true, + }), + ).toBe('Number'); + expect( + getFieldUsageTypeLabel({ + label: 'Amt', + calculated: false, + type: 'double', + custom: true, + }), + ).toBe('Number'); + }); + + it('getFieldUsageTypeLabel uses polyfill-style labels when describe metadata is present', () => { + expect( + getFieldUsageTypeLabel({ + label: 'Amount', + calculated: false, + type: 'currency', + custom: true, + precision: 18, + scale: 2, + }), + ).toBe('Currency (18, 2)'); + expect( + getFieldUsageTypeLabel({ + label: 'Acct', + calculated: false, + type: 'reference', + custom: false, + referenceTo: ['Account'], + relationshipName: 'Account__r', + }), + ).toBe('Reference (Account)'); + expect( + getFieldUsageTypeLabel({ + label: 'Notes', + calculated: false, + type: 'textarea', + custom: true, + length: 32768, + }), + ).toBe('Long Text Area (32768)'); + expect( + getFieldUsageTypeLabel({ + label: 'Case Number', + calculated: false, + type: 'string', + custom: false, + autoNumber: true, + length: 30, + displayFormat: 'CS-{000000}', + }), + ).toBe('Auto Number (CS-{000000})'); + expect( + getFieldUsageTypeLabel({ + label: 'Seq', + calculated: false, + type: 'string', + custom: true, + autoNumber: true, + length: 10, + digits: 9, + }), + ).toBe('Auto Number (9 digits max)'); + }); +}); diff --git a/libs/features/data-analysis/src/field-usage-destructive-delete.ts b/libs/features/data-analysis/src/field-usage-destructive-delete.ts new file mode 100644 index 000000000..a44cbb24e --- /dev/null +++ b/libs/features/data-analysis/src/field-usage-destructive-delete.ts @@ -0,0 +1,135 @@ +import { isUnmanagedCustomFieldApiName } from '@jetstream/shared/utils'; +import type { ListMetadataResult } from '@jetstream/types'; +import type { FieldUsageFieldMetaParsed } from './field-usage-result-parse'; + +export interface FieldUsageDeleteEligibilityArgs { + isObjectErrorPlaceholder?: boolean; + fieldApiName: string; + meta?: FieldUsageFieldMetaParsed | null; + objectQueryTruncated?: boolean; + whereUsedDependencyCount?: number; + /** + * Whether THIS field's dependencies were proven complete (Tooling Id resolved AND dependency query + * succeeded). Pass per-field — not the whole-run flag — so an unresolved field is never deletable. + */ + whereUsedKnown?: boolean; + /** Non-null populated record count from a non-truncated scan. Any data present blocks auto-eligibility. */ + filled?: number; +} + +/** Why a field is NOT eligible for destructive delete; `null` means it IS eligible. Ordered by precedence. */ +export type FieldUsageDeleteIneligibleReason = + | 'object-error' + | 'standard-field' + | 'name-field' + | 'packaged-field' + | 'scan-truncated' + | 'where-used-unknown' + | 'has-dependencies' + | 'has-data'; + +/** Human-readable explanations for the "why can't I delete this?" UI. */ +export const FIELD_USAGE_DELETE_INELIGIBLE_LABELS: Record = { + 'object-error': 'Object could not be analyzed', + 'standard-field': 'Standard fields cannot be deleted', + 'name-field': 'Name fields cannot be deleted', + 'packaged-field': 'Packaged (namespaced) fields cannot be deleted here', + 'scan-truncated': 'Scan was truncated — usage may be incomplete (run a full scan first)', + 'where-used-unknown': 'Dependencies could not be determined for this field', + 'has-dependencies': 'Referenced by a layout, automation, or Apex', + 'has-data': 'Field has data — deleting permanently destroys it', +}; + +/** + * Returns why a field is NOT eligible for a destructive CustomField deploy, or `null` when it IS eligible. + * + * Safety gates (each, independently, prevents deleting a field that may be in use): + * - Standard / name / packaged fields cannot be deleted by this tool. + * - `objectQueryTruncated` — the scan hit the row budget, so a 0%/low reading is NOT proof of disuse. + * - `whereUsedKnown === false` — dependencies for this field could not be proven; treat as in-use (fail safe). + * This is now per-field: an unresolved field (Tooling Id not found, or its dependency query failed) is + * UNKNOWN, never "0 dependencies", closing the previous gap where partial failures looked delete-safe. + * - `whereUsedDependencyCount > 0` — referenced by metadata (layout / automation / Apex). + * - `filled > 0` — the field holds data; deleting a CustomField permanently destroys all of it, so a field + * with ANY populated records is never auto-eligible (the user can still delete via the normal metadata tools). + * + * Note: `MetadataComponentDependency` does NOT detect references in reports, list views, validation rules, + * or email templates — so "no dependencies" means no *code/layout* reference, not "unreferenced". The UI + * must communicate this before deletion. + */ +export function fieldUsageDestructiveDeleteIneligibleReason( + args: FieldUsageDeleteEligibilityArgs, +): FieldUsageDeleteIneligibleReason | null { + const { isObjectErrorPlaceholder, fieldApiName, meta, objectQueryTruncated, whereUsedDependencyCount, whereUsedKnown, filled } = args; + if (isObjectErrorPlaceholder) { + return 'object-error'; + } + if (!meta?.custom) { + return 'standard-field'; + } + if (meta.nameField) { + return 'name-field'; + } + if (!isUnmanagedCustomFieldApiName(fieldApiName)) { + return 'packaged-field'; + } + if (objectQueryTruncated) { + return 'scan-truncated'; + } + if (whereUsedKnown === false) { + return 'where-used-unknown'; + } + if ((whereUsedDependencyCount ?? 0) > 0) { + return 'has-dependencies'; + } + if ((filled ?? 0) > 0) { + return 'has-data'; + } + return null; +} + +/** + * Whether a field usage row may be included in a destructive CustomField deploy (delete from org). + * Thin wrapper over {@link fieldUsageDestructiveDeleteIneligibleReason}. + */ +export function fieldUsageRowEligibleForDestructiveDelete(args: FieldUsageDeleteEligibilityArgs): boolean { + return fieldUsageDestructiveDeleteIneligibleReason(args) === null; +} + +export interface FieldUsageDestructiveDeleteRow { + objectApiName: string; + fieldApiName: string; + destructiveDeleteEligible?: boolean; +} + +/** + * Builds `selectedMetadata` for {@link DeleteMetadataModal} / destructive deploy (CustomField only). + */ +export function fieldUsageRowsToCustomFieldDeleteMetadata( + rows: readonly FieldUsageDestructiveDeleteRow[], +): Record { + const members: ListMetadataResult[] = []; + for (const row of rows) { + if (!row.destructiveDeleteEligible) { + continue; + } + const fullName = `${row.objectApiName}.${row.fieldApiName}`; + members.push({ + createdById: null, + createdByName: null, + createdDate: null, + fileName: `objects/${row.objectApiName}/fields/${row.fieldApiName}.field-meta.xml`, + fullName, + id: null, + lastModifiedById: null, + lastModifiedByName: null, + lastModifiedDate: null, + manageableState: 'unmanaged', + type: 'CustomField', + }); + } + if (members.length === 0) { + return {}; + } + return { CustomField: members }; +} diff --git a/libs/features/data-analysis/src/field-usage-result-parse.ts b/libs/features/data-analysis/src/field-usage-result-parse.ts new file mode 100644 index 000000000..42b7d383f --- /dev/null +++ b/libs/features/data-analysis/src/field-usage-result-parse.ts @@ -0,0 +1,488 @@ +import { polyfillFieldDefinition } from '@jetstream/shared/ui-utils'; +import { dedupeFieldUsageWhereUsedRows, sortFieldUsageWhereUsedRows } from '@jetstream/shared/utils'; +import type { Field, FieldType } from '@jetstream/types'; + +export interface FieldUsageStatParsed { + filled: number; + pct: number; + latestFilledRowModified: string | null; + /** Records scanned for this field's chunk (the denominator behind `pct`). Optional on legacy rows. */ + scanned?: number; +} + +export interface FieldUsageFieldMetaParsed { + label: string; + calculated: boolean; + /** Salesforce describe API `type` (e.g. `string`, `reference`). */ + type: string; + custom: boolean; + /** Present on newer job results; used with {@link getFieldUsageTypeLabel}. */ + autoNumber?: boolean; + calculatedFormula?: string | null; + externalId?: boolean; + nameField?: boolean; + extraTypeInfo?: string | null; + length?: number; + precision?: number | null; + scale?: number; + referenceTo?: string[] | null; + relationshipName?: string | null; + digits?: number | null; + /** Auto-number display pattern when describe provides it. */ + displayFormat?: string | null; + /** Describe `aggregatable` — whether the field supports SOQL aggregate functions. */ + aggregatable?: boolean; + /** Describe `defaultedOnCreate` — populated by Salesforce on insert; a high fill rate may be default-driven. */ + defaultedOnCreate?: boolean; +} + +export interface FieldUsageObjectPayloadParsed { + label: string; + customizable: boolean; + totalRecords: number; + queryTruncated: boolean; + fieldUsage: Record; + fieldMeta: Record; + error?: string; +} + +export interface WhereUsedDependencyRowParsed { + type: string; + name: string; + kind: 'automation' | 'apex' | 'layout' | 'other'; + /** Tooling `MetadataComponentDependency.MetadataComponentId` when present on the job payload. */ + componentId?: string; + /** For Flow dependencies: Tooling `Flow.VersionNumber` when the API resolved the row. */ + flowVersionNumber?: number | null; + /** Relative path in the org to open this component; clients may compute a fallback from `componentId` + `type`. */ + openInSalesforcePath?: string | null; +} + +export type WhereUsedMapParsed = Record; + +/** + * Resolves tooling dependency rows for `ObjectApi.FieldApi`, tolerating stray whitespace or key casing drift in stored JSON. + */ +export function getWhereUsedDepsForFieldKey(whereUsed: WhereUsedMapParsed, objectDotField: string): WhereUsedDependencyRowParsed[] { + const trimmed = objectDotField.trim(); + const direct = whereUsed[trimmed]; + if (direct) { + return direct; + } + const normalized = trimmed.toLowerCase(); + for (const [storedKey, rows] of Object.entries(whereUsed)) { + if (storedKey.trim().toLowerCase() === normalized) { + return rows; + } + } + return []; +} + +/** True when the job includes at least one Tooling dependency row for this `Object.Field__c` key. */ +export function fieldHasWhereUsedDeps(whereUsed: WhereUsedMapParsed, objectDotField: string): boolean { + return getWhereUsedDepsForFieldKey(whereUsed, objectDotField).length > 0; +} + +/** Tooling `MetadataComponentType` values rolled into the **On layout** column and Kind Layout. */ +const WHERE_USED_UI_LAYOUT_TYPES = new Set(['Layout', 'FlexiPage', 'FieldSet']); + +/** + * Workflow, Process Builder, Flow, and Apex triggers — **In automation** column. + * Matches common `MetadataComponentDependency.MetadataComponentType` strings. + */ +const WHERE_USED_UI_AUTOMATION_TYPES = new Set([ + 'WorkflowRule', + 'WorkflowFieldUpdate', + 'ProcessDefinition', + 'Flow', + 'FlowDefinition', + 'ApexTrigger', +]); + +/** **In Apex** column: ApexClass, ApexPage, ApexComponent (same set as API `kind: apex`; triggers stay automation). */ +const WHERE_USED_UI_APEX_TYPES = new Set(['ApexClass', 'ApexPage', 'ApexComponent']); + +function inferWhereUsedKindFromMetadataType(metadataType: string): WhereUsedDependencyRowParsed['kind'] | undefined { + const trimmed = metadataType.trim(); + if (!trimmed) { + return undefined; + } + if (WHERE_USED_UI_LAYOUT_TYPES.has(trimmed)) { + return 'layout'; + } + if (WHERE_USED_UI_AUTOMATION_TYPES.has(trimmed)) { + return 'automation'; + } + if (WHERE_USED_UI_APEX_TYPES.has(trimmed)) { + return 'apex'; + } + return undefined; +} + +export interface WhereUsedUiCategoryCounts { + onLayout: number; + inAutomation: number; + inApex: number; +} + +/** + * Counts dependency rows per UI bucket using each row’s {@link WhereUsedDependencyRowParsed.kind} + * (same basis as the Where Used **Kind** column: layout / automation / apex). `other` is excluded from these three totals. + */ +export function countWhereUsedByUiCategory(deps: WhereUsedDependencyRowParsed[]): WhereUsedUiCategoryCounts { + let onLayout = 0; + let inAutomation = 0; + let inApex = 0; + for (const dep of deps) { + if (dep.kind === 'layout') { + onLayout++; + } else if (dep.kind === 'automation') { + inAutomation++; + } else if (dep.kind === 'apex') { + inApex++; + } + } + return { onLayout, inAutomation, inApex }; +} + +export interface FieldUsageJobResultParsed { + phase: 'field_usage_v1'; + summary: string; + truncated: boolean; + objects: Record; + whereUsed: WhereUsedMapParsed; + failedObjects: string[]; + /** False when the Tooling where-used lookup failed entirely (dependencies unknown, not absent). */ + whereUsedComputed: boolean; + /** + * `Object.Field__c` keys whose dependencies were FULLY determined. `null` on legacy rows written + * before per-field tracking existed — callers then fall back to {@link whereUsedComputed}. + * Use {@link isFieldWhereUsedResolved} rather than reading this directly. + */ + whereUsedResolvedFieldKeys: Set | null; +} + +/** + * Whether this field's metadata dependencies were PROVEN complete (safe to treat "0 deps" as "no refs"). + * Legacy rows (no per-field data) fall back to the whole-run {@link FieldUsageJobResultParsed.whereUsedComputed}. + */ +export function isFieldWhereUsedResolved(parsed: FieldUsageJobResultParsed, objectDotField: string): boolean { + if (!parsed.whereUsedComputed) { + return false; + } + if (parsed.whereUsedResolvedFieldKeys == null) { + return true; + } + return parsed.whereUsedResolvedFieldKeys.has(objectDotField.trim()); +} + +function asRecord(value: unknown): Record | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return null; +} + +function parseFieldUsageStat(value: unknown): FieldUsageStatParsed | null { + const rec = asRecord(value); + if (!rec) { + return null; + } + const filled = rec.filled; + const pct = rec.pct; + const latest = rec.latestFilledRowModified; + if (typeof filled !== 'number' || typeof pct !== 'number') { + return null; + } + return { + filled, + pct, + latestFilledRowModified: latest == null || latest === '' ? null : String(latest), + ...(typeof rec.scanned === 'number' ? { scanned: rec.scanned } : {}), + }; +} + +function parseOptionalNumber(value: unknown): number | undefined { + if (typeof value === 'number' && !Number.isNaN(value)) { + return value; + } + return undefined; +} + +function parseOptionalNumberOrNull(value: unknown): number | null | undefined { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + if (typeof value === 'number' && !Number.isNaN(value)) { + return value; + } + return undefined; +} + +function parseReferenceTo(value: unknown): string[] | null | undefined { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + if (!Array.isArray(value)) { + return undefined; + } + return value.filter((entry): entry is string => typeof entry === 'string'); +} + +function parseFieldMetaEntry(value: unknown): FieldUsageFieldMetaParsed | null { + const rec = asRecord(value); + if (!rec) { + return null; + } + const label = rec.label != null ? String(rec.label) : ''; + const type = rec.type != null ? String(rec.type) : ''; + return { + label, + calculated: rec.calculated === true, + type, + custom: rec.custom === true, + ...(rec.autoNumber === true || rec.autoNumber === false ? { autoNumber: rec.autoNumber === true } : {}), + ...(rec.calculatedFormula !== undefined + ? { calculatedFormula: rec.calculatedFormula == null ? null : String(rec.calculatedFormula) } + : {}), + ...(rec.externalId === true || rec.externalId === false ? { externalId: rec.externalId === true } : {}), + ...(rec.nameField === true || rec.nameField === false ? { nameField: rec.nameField === true } : {}), + ...(rec.extraTypeInfo !== undefined ? { extraTypeInfo: rec.extraTypeInfo == null ? null : String(rec.extraTypeInfo) } : {}), + ...(parseOptionalNumber(rec.length) !== undefined ? { length: parseOptionalNumber(rec.length) } : {}), + ...(parseOptionalNumberOrNull(rec.precision) !== undefined ? { precision: parseOptionalNumberOrNull(rec.precision) } : {}), + ...(parseOptionalNumber(rec.scale) !== undefined ? { scale: parseOptionalNumber(rec.scale) } : {}), + ...(parseReferenceTo(rec.referenceTo) !== undefined ? { referenceTo: parseReferenceTo(rec.referenceTo) } : {}), + ...(rec.relationshipName !== undefined ? { relationshipName: rec.relationshipName == null ? null : String(rec.relationshipName) } : {}), + ...(parseOptionalNumberOrNull(rec.digits) !== undefined ? { digits: parseOptionalNumberOrNull(rec.digits) } : {}), + ...(rec.displayFormat !== undefined ? { displayFormat: rec.displayFormat == null ? null : String(rec.displayFormat) } : {}), + ...(rec.aggregatable === true || rec.aggregatable === false ? { aggregatable: rec.aggregatable === true } : {}), + ...(rec.defaultedOnCreate === true || rec.defaultedOnCreate === false ? { defaultedOnCreate: rec.defaultedOnCreate === true } : {}), + }; +} + +function parseObjectPayload(value: unknown): FieldUsageObjectPayloadParsed | null { + const rec = asRecord(value); + if (!rec) { + return null; + } + const fieldUsageRaw = asRecord(rec.fieldUsage); + const fieldMetaRaw = asRecord(rec.fieldMeta); + if (!fieldUsageRaw || !fieldMetaRaw) { + return null; + } + const fieldUsage: Record = {}; + for (const [fieldName, stat] of Object.entries(fieldUsageRaw)) { + const parsed = parseFieldUsageStat(stat); + if (parsed) { + fieldUsage[fieldName] = parsed; + } + } + const fieldMeta: Record = {}; + for (const [fieldName, meta] of Object.entries(fieldMetaRaw)) { + const parsed = parseFieldMetaEntry(meta); + if (parsed) { + fieldMeta[fieldName] = parsed; + } + } + const totalRecords = rec.totalRecords; + return { + label: rec.label != null ? String(rec.label) : '', + customizable: rec.customizable === true, + totalRecords: typeof totalRecords === 'number' ? totalRecords : 0, + queryTruncated: rec.queryTruncated === true, + fieldUsage, + fieldMeta, + ...(typeof rec.error === 'string' && rec.error.trim() ? { error: rec.error } : {}), + }; +} + +function parseWhereUsedMap(value: unknown): WhereUsedMapParsed { + const rec = asRecord(value); + if (!rec) { + return {}; + } + const out: WhereUsedMapParsed = {}; + for (const [fieldKey, rowsUnknown] of Object.entries(rec)) { + if (!Array.isArray(rowsUnknown)) { + continue; + } + const rows: WhereUsedDependencyRowParsed[] = []; + for (const rowUnknown of rowsUnknown) { + const row = asRecord(rowUnknown); + if (!row) { + continue; + } + const rawKind = + row.kind === 'automation' || row.kind === 'apex' || row.kind === 'layout' || row.kind === 'other' ? row.kind : 'other'; + const typeStr = row.type != null ? String(row.type) : ''; + const inferredKind = inferWhereUsedKindFromMetadataType(typeStr); + const kind = rawKind === 'automation' || rawKind === 'apex' || rawKind === 'layout' ? rawKind : (inferredKind ?? rawKind); + const componentIdRaw = row.componentId; + const componentId = typeof componentIdRaw === 'string' && componentIdRaw.trim() ? componentIdRaw.trim() : undefined; + const fv = row.flowVersionNumber; + let flowVersionNumberParsed: number | undefined; + if (typeof fv === 'number' && Number.isFinite(fv)) { + flowVersionNumberParsed = fv; + } else if (fv != null && String(fv).trim() !== '') { + const coerced = Number(fv); + if (Number.isFinite(coerced)) { + flowVersionNumberParsed = coerced; + } + } + const pathRaw = row.openInSalesforcePath; + const openInSalesforcePathParsed = + typeof pathRaw === 'string' ? pathRaw : pathRaw != null && String(pathRaw).trim() ? String(pathRaw) : undefined; + rows.push({ + type: typeStr, + name: row.name != null ? String(row.name) : '', + kind, + ...(componentId ? { componentId } : {}), + ...(flowVersionNumberParsed !== undefined && Number.isFinite(flowVersionNumberParsed) + ? { flowVersionNumber: flowVersionNumberParsed } + : {}), + ...(openInSalesforcePathParsed !== undefined ? { openInSalesforcePath: openInSalesforcePathParsed } : {}), + }); + } + out[fieldKey] = sortFieldUsageWhereUsedRows(dedupeFieldUsageWhereUsedRows(rows)); + } + return out; +} + +function hasExtendedDescribeMeta(meta: FieldUsageFieldMetaParsed): boolean { + return ( + meta.autoNumber !== undefined || + meta.calculatedFormula !== undefined || + meta.externalId !== undefined || + meta.nameField !== undefined || + meta.extraTypeInfo !== undefined || + meta.length !== undefined || + meta.precision !== undefined || + meta.scale !== undefined || + meta.referenceTo !== undefined || + meta.relationshipName !== undefined || + meta.digits !== undefined || + meta.displayFormat !== undefined + ); +} + +function legacyApiTypeLabel(apiType: string): string { + const normalized = apiType.toLowerCase(); + if (normalized === 'int' || normalized === 'double') { + return 'Number'; + } + if (!apiType) { + return ''; + } + return `${apiType[0].toUpperCase()}${apiType.slice(1)}`; +} + +function isUsageReferenceLike(meta: FieldUsageFieldMetaParsed): boolean { + const refs = meta.referenceTo?.filter((target) => target.trim().length > 0) ?? []; + return (meta.type === 'reference' || meta.type === 'string') && !!meta.relationshipName && refs.length > 0; +} + +function rewriteLookupLabelToReference(label: string): string { + const lookupMatch = /^Lookup\s*\((.*)\)$/.exec(label.trim()); + if (lookupMatch) { + return `Reference (${lookupMatch[1]})`; + } + return label; +} + +/** + * Human-readable field type for the field usage grid (based on {@link polyfillFieldDefinition}, with usage-specific wording). + * References show as `Reference (Account, …)`; integers read as Number; auto-number includes format when available. + */ +export function getFieldUsageTypeLabel(meta: FieldUsageFieldMetaParsed | undefined): string { + if (!meta?.type) { + return ''; + } + if (!hasExtendedDescribeMeta(meta)) { + return legacyApiTypeLabel(meta.type); + } + + if (meta.autoNumber) { + const formatPattern = meta.displayFormat?.trim(); + if (formatPattern) { + return `Auto Number (${formatPattern})`; + } + if (meta.digits != null && meta.digits > 0) { + return `Auto Number (${meta.digits} digit${meta.digits === 1 ? '' : 's'} max)`; + } + return 'Auto Number'; + } + + if (isUsageReferenceLike(meta)) { + const targets = (meta.referenceTo ?? []).join(', '); + return `Reference (${targets})`; + } + + const textareaDefaultPlain = + meta.type === 'textarea' && (meta.extraTypeInfo == null || meta.extraTypeInfo === '') ? ('plaintextarea' as const) : meta.extraTypeInfo; + + const partial = { + autoNumber: false, + type: meta.type as FieldType, + calculated: meta.calculated, + calculatedFormula: meta.calculatedFormula ?? null, + externalId: meta.externalId ?? false, + nameField: meta.nameField ?? false, + extraTypeInfo: textareaDefaultPlain ?? null, + length: meta.length ?? 0, + precision: meta.precision ?? null, + scale: meta.scale ?? 0, + referenceTo: meta.referenceTo ?? null, + relationshipName: meta.relationshipName ?? null, + } as Field; + + return rewriteLookupLabelToReference(polyfillFieldDefinition(partial)); +} + +/** + * Parses `analysis_job.result` for completed **field_usage** jobs (`phase: field_usage_v1`). + * + * @param result JSON value stored on the analysis job row. + * @returns Parsed envelope or `null` when shape does not match. + */ +export function parseFieldUsageJobResult(result: unknown): FieldUsageJobResultParsed | null { + const rec = asRecord(result); + if (!rec || rec.phase !== 'field_usage_v1') { + return null; + } + const objectsRaw = asRecord(rec.objects); + if (!objectsRaw) { + return null; + } + const objects: Record = {}; + for (const [apiName, payloadUnknown] of Object.entries(objectsRaw)) { + const parsed = parseObjectPayload(payloadUnknown); + if (parsed) { + objects[apiName] = parsed; + } + } + const failedRaw = rec.failedObjects; + const failedObjects = Array.isArray(failedRaw) ? failedRaw.filter((entry): entry is string => typeof entry === 'string') : []; + + const resolvedRaw = rec.whereUsedResolvedFieldKeys; + const whereUsedResolvedFieldKeys = Array.isArray(resolvedRaw) + ? new Set(resolvedRaw.filter((entry): entry is string => typeof entry === 'string').map((entry) => entry.trim())) + : null; + + return { + phase: 'field_usage_v1', + summary: rec.summary != null ? String(rec.summary) : '', + truncated: rec.truncated === true, + objects, + whereUsed: parseWhereUsedMap(rec.whereUsed), + failedObjects, + // Absent on rows written before this flag existed — treat as computed (true) for backward compatibility. + whereUsedComputed: rec.whereUsedComputed !== false, + whereUsedResolvedFieldKeys, + }; +} diff --git a/libs/features/data-analysis/src/field-usage/__tests__/compute-field-usage-where-used.spec.ts b/libs/features/data-analysis/src/field-usage/__tests__/compute-field-usage-where-used.spec.ts new file mode 100644 index 000000000..6019dbf70 --- /dev/null +++ b/libs/features/data-analysis/src/field-usage/__tests__/compute-field-usage-where-used.spec.ts @@ -0,0 +1,153 @@ +import type { SalesforceOrgUi } from '@jetstream/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { computeFieldUsageWhereUsed } from '../compute-field-usage-where-used'; +import type { FieldUsageObjectPayload } from '../run-field-usage'; + +const { queryAllMock, queryAllUsingOffsetMock } = vi.hoisted(() => ({ + queryAllMock: vi.fn(), + queryAllUsingOffsetMock: vi.fn(), +})); + +vi.mock('@jetstream/shared/data', () => ({ queryAll: queryAllMock, queryAllUsingOffset: queryAllUsingOffsetMock })); +vi.mock('@jetstream/shared/client-logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() } })); + +const org = { uniqueId: 'org-1' } as unknown as SalesforceOrgUi; + +function soqlOf(args: unknown[]): string { + return (args.find((arg) => typeof arg === 'string') as string | undefined) ?? ''; +} + +function depResult(records: Record[]) { + return { queryResults: { done: true, records } }; +} + +function objectWithFields(fieldNames: string[]): Record { + const fieldUsage: FieldUsageObjectPayload['fieldUsage'] = {}; + for (const name of fieldNames) { + fieldUsage[name] = { filled: 0, pct: 0, latestFilledRowModified: null, scanned: 0 }; + } + return { + Account: { label: 'Account', customizable: false, totalRecords: 0, queryTruncated: false, fieldUsage, fieldMeta: {} }, + }; +} + +/** CustomField resolution (and Flow enrichment) go through `queryAll`; configure which fields resolve. */ +function mockCustomFieldResolution(resolved: { id: string; developerName: string }[]) { + queryAllMock.mockImplementation(async (...args: unknown[]) => { + const soql = soqlOf(args); + if (soql.includes('FROM CustomField')) { + return { + queryResults: { + records: resolved.map((field) => ({ + Id: field.id, + EntityDefinition: { QualifiedApiName: 'Account' }, + DeveloperName: field.developerName, + NamespacePrefix: null, + })), + }, + }; + } + return { queryResults: { records: [] } }; // Flow enrichment, etc. + }); +} + +describe('computeFieldUsageWhereUsed — batched dependency lookup', () => { + beforeEach(() => { + queryAllMock.mockReset(); + queryAllUsingOffsetMock.mockReset(); + }); + + it('fetches dependencies for all fields in a SINGLE batched IN(...) query, not one per field', async () => { + mockCustomFieldResolution([ + { id: 'fieldA', developerName: 'A' }, + { id: 'fieldB', developerName: 'B' }, + ]); + queryAllUsingOffsetMock.mockResolvedValue(depResult([])); + + const result = await computeFieldUsageWhereUsed(org, objectWithFields(['A__c', 'B__c'])); + + const dependencyCalls = queryAllUsingOffsetMock.mock.calls.filter((call) => soqlOf(call).includes('MetadataComponentDependency')); + expect(dependencyCalls).toHaveLength(1); + expect(soqlOf(dependencyCalls[0])).toContain('IN '); + expect([...result.resolvedFieldKeys].sort()).toEqual(['Account.A__c', 'Account.B__c']); + }); + + it('groups batched rows back to each field and marks them resolved (proven-clean / with deps)', async () => { + mockCustomFieldResolution([ + { id: 'fieldA', developerName: 'A' }, + { id: 'fieldB', developerName: 'B' }, + ]); + queryAllUsingOffsetMock.mockResolvedValue( + depResult([ + { + RefMetadataComponentId: 'fieldB', + MetadataComponentId: 'apx1', + MetadataComponentType: 'ApexClass', + MetadataComponentName: 'MyClass', + }, + ]), + ); + + const result = await computeFieldUsageWhereUsed(org, objectWithFields(['A__c', 'B__c'])); + + expect(result.whereUsed['Account.A__c']).toEqual([]); // proven zero deps + expect(result.whereUsed['Account.B__c']).toHaveLength(1); + expect(result.whereUsed['Account.B__c'][0]).toMatchObject({ type: 'ApexClass', kind: 'apex' }); + expect([...result.resolvedFieldKeys].sort()).toEqual(['Account.A__c', 'Account.B__c']); + }); + + it('splits the id list and re-queries when a batch fills the offset ceiling (more rows beyond OFFSET cap)', async () => { + mockCustomFieldResolution([ + { id: 'fieldA', developerName: 'A' }, + { id: 'fieldB', developerName: 'B' }, + ]); + // The combined batch returns a full page (2000) → treated as truncated → split into single-id queries. + const fullPage = Array.from({ length: 2000 }, () => ({ + RefMetadataComponentId: 'fieldA', + MetadataComponentType: 'Layout', + MetadataComponentName: 'L', + })); + queryAllUsingOffsetMock.mockImplementation(async (...args: unknown[]) => { + const soql = soqlOf(args); + if (soql.includes('fieldA') && soql.includes('fieldB')) { + return depResult(fullPage); + } + const refId = soql.includes('fieldA') ? 'fieldA' : 'fieldB'; + return depResult([ + { RefMetadataComponentId: refId, MetadataComponentId: 'lay1', MetadataComponentType: 'Layout', MetadataComponentName: 'L' }, + ]); + }); + + const result = await computeFieldUsageWhereUsed(org, objectWithFields(['A__c', 'B__c'])); + + const dependencyCalls = queryAllUsingOffsetMock.mock.calls.filter((call) => soqlOf(call).includes('MetadataComponentDependency')); + expect(dependencyCalls).toHaveLength(3); // 1 combined (full page) + 2 split + expect(result.whereUsed['Account.A__c']).toHaveLength(1); + expect(result.whereUsed['Account.B__c']).toHaveLength(1); + expect([...result.resolvedFieldKeys].sort()).toEqual(['Account.A__c', 'Account.B__c']); + }); + + it('marks all batched fields UNKNOWN (not resolved) when the dependency query fails', async () => { + mockCustomFieldResolution([ + { id: 'fieldA', developerName: 'A' }, + { id: 'fieldB', developerName: 'B' }, + ]); + queryAllUsingOffsetMock.mockRejectedValue(new Error('transient API limit')); + + const result = await computeFieldUsageWhereUsed(org, objectWithFields(['A__c', 'B__c'])); + + expect(result.resolvedFieldKeys).toEqual([]); // both UNKNOWN → never delete-eligible + expect(result.whereUsed['Account.A__c']).toEqual([]); + expect(result.whereUsed['Account.B__c']).toEqual([]); + }); + + it('does not resolve a field whose Tooling Id never resolved', async () => { + mockCustomFieldResolution([{ id: 'fieldA', developerName: 'A' }]); // B never resolves + queryAllUsingOffsetMock.mockResolvedValue(depResult([])); + + const result = await computeFieldUsageWhereUsed(org, objectWithFields(['A__c', 'B__c'])); + + expect(result.resolvedFieldKeys).toEqual(['Account.A__c']); + expect(result.whereUsed['Account.B__c']).toEqual([]); + }); +}); diff --git a/libs/features/data-analysis/src/field-usage/__tests__/run-field-usage.spec.ts b/libs/features/data-analysis/src/field-usage/__tests__/run-field-usage.spec.ts new file mode 100644 index 000000000..3c9362f0d --- /dev/null +++ b/libs/features/data-analysis/src/field-usage/__tests__/run-field-usage.spec.ts @@ -0,0 +1,309 @@ +import type { SalesforceOrgUi } from '@jetstream/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runFieldUsageQueryForObjects } from '../run-field-usage'; + +const { describeMock, queryMock, queryMoreMock } = vi.hoisted(() => ({ + describeMock: vi.fn(), + queryMock: vi.fn(), + queryMoreMock: vi.fn(), +})); + +vi.mock('@jetstream/shared/data', () => { + async function queryWithRecordBudget( + org: unknown, + soql: string, + isTooling: boolean, + budget: { remaining: number }, + onPage: (records: Record[]) => void, + ): Promise<{ truncated: boolean }> { + let response = await queryMock(org, soql, isTooling); + while (true) { + const records = response.queryResults.records as Record[]; + if (budget.remaining <= 0) { + return { truncated: true }; + } + if (records.length > budget.remaining) { + onPage(records.slice(0, budget.remaining)); + budget.remaining = 0; + return { truncated: true }; + } + onPage(records); + budget.remaining -= records.length; + if (response.queryResults.done) { + break; + } + const nextUrl = response.queryResults.nextRecordsUrl; + if (!nextUrl) { + break; + } + response = await queryMoreMock(org, nextUrl, isTooling); + } + return { truncated: false }; + } + return { + describeSObject: describeMock, + query: queryMock, + queryMore: queryMoreMock, + queryWithRecordBudget, + }; +}); + +const fakeOrg = { uniqueId: 'org-1' } as unknown as SalesforceOrgUi; + +function buildDescribe() { + return { + data: { + label: 'Account', + queryable: true, + custom: false, + fields: [ + { + name: 'Id', + label: 'Id', + type: 'id', + queryable: true, + calculated: false, + custom: false, + autoNumber: false, + externalId: false, + nameField: false, + length: 18, + scale: 0, + }, + { + name: 'LastModifiedDate', + label: 'LMD', + type: 'datetime', + queryable: true, + calculated: false, + custom: false, + autoNumber: false, + externalId: false, + nameField: false, + length: 0, + scale: 0, + }, + { + name: 'Name', + label: 'Name', + type: 'string', + queryable: true, + calculated: false, + custom: false, + autoNumber: false, + externalId: false, + nameField: true, + length: 255, + scale: 0, + }, + { + name: 'Industry', + label: 'Industry', + type: 'picklist', + queryable: true, + calculated: false, + custom: false, + autoNumber: false, + externalId: false, + nameField: false, + length: 0, + scale: 0, + }, + { + name: 'Active__c', + label: 'Active', + type: 'boolean', + queryable: true, + calculated: false, + custom: true, + autoNumber: false, + externalId: false, + nameField: false, + length: 0, + scale: 0, + }, + ], + }, + }; +} + +function buildPage(records: Record[], nextRecordsUrl: string | null) { + return { + queryResults: { + done: nextRecordsUrl == null, + records, + ...(nextRecordsUrl ? { nextRecordsUrl } : {}), + }, + }; +} + +describe('runFieldUsageQueryForObjects (streaming)', () => { + beforeEach(() => { + describeMock.mockReset(); + queryMock.mockReset(); + queryMoreMock.mockReset(); + }); + + it('aggregates 1000 records across 5 pages without retaining record arrays', async () => { + describeMock.mockResolvedValue(buildDescribe()); + + const pageSize = 200; + const totalPages = 5; + const pages: Record[][] = []; + for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) { + const records: Record[] = []; + for (let recordIndex = 0; recordIndex < pageSize; recordIndex++) { + const globalIndex = pageIndex * pageSize + recordIndex; + records.push({ + Id: `001${String(globalIndex).padStart(15, '0')}`, + LastModifiedDate: `2026-01-${String((globalIndex % 28) + 1).padStart(2, '0')}T00:00:00.000+0000`, + Name: globalIndex % 2 === 0 ? `Account ${globalIndex}` : '', + Industry: globalIndex < 100 ? 'Technology' : null, + // Checkbox: never null in SOQL — true on first 250 rows, false on the rest. + Active__c: globalIndex < 250, + }); + } + pages.push(records); + } + + queryMock.mockResolvedValueOnce(buildPage(pages[0], '/q/next-1')); + queryMoreMock + .mockResolvedValueOnce(buildPage(pages[1], '/q/next-2')) + .mockResolvedValueOnce(buildPage(pages[2], '/q/next-3')) + .mockResolvedValueOnce(buildPage(pages[3], '/q/next-4')) + .mockResolvedValueOnce(buildPage(pages[4], null)); + + const progressEvents: { current: number; total: number; percent: number; label: string }[] = []; + const result = await runFieldUsageQueryForObjects(fakeOrg, ['Account'], { + onProgress: (progress) => progressEvents.push(progress), + }); + + expect(result.failedObjects).toEqual([]); + expect(result.anyQueryTruncated).toBe(false); + + const account = result.objects.Account; + expect(account).toBeDefined(); + expect(account.totalRecords).toBe(1000); + expect(account.queryTruncated).toBe(false); + + // Half the rows have a non-empty Name (even indices) — 500 filled out of 1000. + expect(account.fieldUsage.Name.filled).toBe(500); + expect(account.fieldUsage.Name.pct).toBeCloseTo(50, 5); + // First 100 rows have an Industry value. + expect(account.fieldUsage.Industry.filled).toBe(100); + expect(account.fieldUsage.Industry.pct).toBeCloseTo(10, 5); + + // Checkbox fields count only `true` (250 of 1000), NOT every non-null value — otherwise every + // checkbox would read 100% and hide genuinely-unused (all-false) checkboxes. + expect(account.fieldUsage.Active__c.filled).toBe(250); + expect(account.fieldUsage.Active__c.pct).toBeCloseTo(25, 5); + expect(account.fieldUsage.Active__c.scanned).toBe(1000); + + // Latest filled row modified for Name is the largest even index in any page (998 → day 27). + expect(account.fieldUsage.Name.latestFilledRowModified).toBe('2026-01-27T00:00:00.000+0000'); + + expect(progressEvents).toHaveLength(1); + expect(progressEvents[0]).toEqual({ + current: 1, + total: 1, + percent: 100, + label: 'Analyzing Account (1 of 1)', + }); + + expect(queryMock).toHaveBeenCalledTimes(1); + expect(queryMoreMock).toHaveBeenCalledTimes(4); + }); + + it('uses an exact server-side COUNT aggregate for aggregatable fields (no row scan, no truncation)', async () => { + describeMock.mockResolvedValue({ + data: { + label: 'Account', + queryable: true, + custom: false, + fields: [ + { + name: 'Id', + label: 'Id', + type: 'id', + queryable: true, + calculated: false, + custom: false, + autoNumber: false, + externalId: false, + nameField: false, + length: 18, + scale: 0, + aggregatable: true, + }, + { + name: 'LastModifiedDate', + label: 'LMD', + type: 'datetime', + queryable: true, + calculated: false, + custom: false, + autoNumber: false, + externalId: false, + nameField: false, + length: 0, + scale: 0, + aggregatable: true, + }, + { + name: 'Custom_Text__c', + label: 'Text', + type: 'string', + queryable: true, + calculated: false, + custom: true, + autoNumber: false, + externalId: false, + nameField: false, + length: 255, + scale: 0, + aggregatable: true, + }, + ], + }, + }); + + // The COUNT aggregate returns a single row: total records + non-null count per field alias. + queryMock.mockImplementation(async (_org: unknown, soql: string) => { + if (typeof soql === 'string' && soql.includes('COUNT(')) { + return { queryResults: { done: true, totalSize: 1, records: [{ total: 1000, c0: 300 }] } }; + } + throw new Error(`Unexpected non-aggregate query: ${String(soql)}`); + }); + + const result = await runFieldUsageQueryForObjects(fakeOrg, ['Account']); + + const account = result.objects.Account; + expect(account.totalRecords).toBe(1000); + expect(account.queryTruncated).toBe(false); + expect(result.anyQueryTruncated).toBe(false); + expect(account.fieldUsage.Custom_Text__c.filled).toBe(300); + expect(account.fieldUsage.Custom_Text__c.pct).toBeCloseTo(30, 5); + expect(account.fieldUsage.Custom_Text__c.scanned).toBe(1000); + // COUNT cannot report a per-field latest-modified, so it is null (not fabricated). + expect(account.fieldUsage.Custom_Text__c.latestFilledRowModified).toBeNull(); + // No row scan happened — only the aggregate query. + expect(queryMock).toHaveBeenCalledTimes(1); + expect(queryMoreMock).not.toHaveBeenCalled(); + expect(queryMock.mock.calls[0][1]).toContain('COUNT('); + }); + + it('throws when the cancellation signal trips between objects', async () => { + describeMock.mockResolvedValue(buildDescribe()); + queryMock.mockResolvedValue(buildPage([], null)); + + let cancelCalls = 0; + const promise = runFieldUsageQueryForObjects(fakeOrg, ['Account'], { + isCanceled: () => { + cancelCalls += 1; + return cancelCalls > 0; + }, + }); + + await expect(promise).rejects.toThrow('Job canceled'); + }); +}); diff --git a/libs/features/data-analysis/src/field-usage/compute-field-usage-where-used.ts b/libs/features/data-analysis/src/field-usage/compute-field-usage-where-used.ts new file mode 100644 index 000000000..f8a0ea6a8 --- /dev/null +++ b/libs/features/data-analysis/src/field-usage/compute-field-usage-where-used.ts @@ -0,0 +1,546 @@ +import { logger } from '@jetstream/shared/client-logger'; +import { queryAll, queryAllUsingOffset } from '@jetstream/shared/data'; +import { + dedupeFieldUsageWhereUsedRows, + parseCustomFieldApiNameForTooling, + sortFieldUsageWhereUsedRows, + splitArrayToMaxSize, +} from '@jetstream/shared/utils'; +import type { FieldUsageJobResultData, SalesforceOrgUi } from '@jetstream/types'; +import { composeQuery, getField } from '@jetstreamapp/soql-parser-js'; +import type { FieldUsageObjectPayload } from './run-field-usage'; + +const WHERE_USED_AUTOMATION_TYPES = new Set([ + 'ApexTrigger', + 'Flow', + 'FlowDefinition', + 'WorkflowFieldUpdate', + 'WorkflowRule', + 'ProcessDefinition', +]); + +/** Apex code / Visualforce metadata (not triggers — those stay in the automation bucket). */ +const WHERE_USED_APEX_TYPES = new Set(['ApexClass', 'ApexPage', 'ApexComponent']); + +/** Page UI metadata — On layout bucket (classic layout, Lightning page, field sets on layouts). */ +const WHERE_USED_LAYOUT_TYPES = new Set(['Layout', 'FlexiPage', 'FieldSet']); + +/** Batch size for the (object, developerName) tuples per Tooling CustomField lookup. */ +const CUSTOM_FIELD_LOOKUP_CHUNK_SIZE = 200; + +/** Concurrency for batched `MetadataComponentDependency` lookups. */ +const DEPENDENCY_LOOKUP_CONCURRENCY = 5; + +/** CustomField ids per batched `RefMetadataComponentId IN (...)` dependency query (was 1 field = 1 query). */ +const DEPENDENCY_REF_ID_BATCH_SIZE = 200; + +/** + * `queryAllUsingOffset` page size / Salesforce max SOQL OFFSET. `MetadataComponentDependency` does not + * support `queryMore` and caps OFFSET at 2000, so a batch is paged to this ceiling and then, if it still + * returns a full page (more rows exist beyond the OFFSET cap), its id list is split + re-queried. + */ +const DEPENDENCY_PAGE_SAFETY_CAP = 2000; + +/** Chunk size for Flow Id `IN (...)` filters during enrichment. */ +const FLOW_ID_CHUNK_SIZE = 200; + +export type WhereUsedDependencyRow = { + type: string; + name: string; + kind: 'automation' | 'apex' | 'layout' | 'other'; + /** Tooling `MetadataComponentDependency.MetadataComponentId` when returned. */ + componentId?: string; + /** Populated for `Flow` rows when Tooling `Flow` can be resolved (same dependency row as a specific version). */ + flowVersionNumber?: number | null; + /** + * Relative path in the org (leading `/`) to open this component (Flow Builder, Apex setup, etc.). + * Omitted when unknown; clients may fall back from {@link componentId} + {@link type}. + */ + openInSalesforcePath?: string | null; +}; + +export type WhereUsedMap = Record; + +export interface ComputeFieldUsageWhereUsedResult { + whereUsed: FieldUsageJobResultData['whereUsed']; + /** + * `Object.Field__c` keys whose dependencies were FULLY determined — the Tooling CustomField Id + * resolved AND the `MetadataComponentDependency` query succeeded. Only these keys may be treated as + * "0 dependency rows = no metadata references" for delete-eligibility. A field absent from this list + * is UNKNOWN (Id unresolved or query failed), not proven-clean, so it must never be delete-eligible. + */ + resolvedFieldKeys: string[]; +} + +function depKind(componentType: string): WhereUsedDependencyRow['kind'] { + if (WHERE_USED_AUTOMATION_TYPES.has(componentType)) { + return 'automation'; + } + if (WHERE_USED_APEX_TYPES.has(componentType)) { + return 'apex'; + } + if (WHERE_USED_LAYOUT_TYPES.has(componentType)) { + return 'layout'; + } + return 'other'; +} + +/** + * Fills {@link WhereUsedDependencyRow.flowVersionNumber}, {@link WhereUsedDependencyRow.openInSalesforcePath} + * for Flow rows via Tooling `Flow` (`Id` + `VersionNumber` only — not `DurableId`, which is not on Tooling `Flow`), + * and generic setup paths when we have a `MetadataComponentId`. + */ +async function enrichWhereUsedDependencyRows(org: SalesforceOrgUi, rows: WhereUsedDependencyRow[]): Promise { + const flowIds = new Set(); + for (const row of rows) { + if (row.type === 'Flow' && row.componentId) { + flowIds.add(row.componentId); + } + } + const flowVersionById = new Map(); + if (flowIds.size > 0) { + for (const idChunk of splitArrayToMaxSize([...flowIds], FLOW_ID_CHUNK_SIZE)) { + const soql = composeQuery({ + fields: [getField('Id'), getField('VersionNumber')], + sObject: 'Flow', + where: { + left: { + field: 'Id', + operator: 'IN', + value: idChunk, + literalType: 'STRING', + }, + }, + }); + const records = (await queryAll>(org, soql, true)).queryResults.records; + for (const record of records) { + const id = record.Id != null ? String(record.Id) : ''; + if (!id) { + continue; + } + const versionRaw = record.VersionNumber; + const versionNumber = typeof versionRaw === 'number' ? versionRaw : Number(versionRaw); + flowVersionById.set(id, { + versionNumber: Number.isFinite(versionNumber) ? versionNumber : 0, + }); + } + } + } + + for (const row of rows) { + const componentType = row.type; + const componentId = row.componentId; + if (componentType === 'Flow' && componentId) { + const info = flowVersionById.get(componentId); + if (info) { + row.flowVersionNumber = info.versionNumber; + row.openInSalesforcePath = `/builder_platform_interaction/flowBuilder.app?flowId=${encodeURIComponent(componentId)}`; + continue; + } + } + if (row.openInSalesforcePath) { + continue; + } + if (componentType === 'ProcessDefinition') { + row.openInSalesforcePath = '/lightning/setup/ProcessAutomation/home'; + continue; + } + if (!componentId) { + continue; + } + if (componentType === 'ApexClass') { + row.openInSalesforcePath = `/lightning/setup/ApexClasses/page?address=${encodeURIComponent(`/${componentId}`)}`; + } else if (componentType === 'ApexTrigger') { + row.openInSalesforcePath = `/lightning/setup/ApexTriggers/page?address=${encodeURIComponent(`/${componentId}`)}`; + } else if (componentType === 'ApexPage') { + row.openInSalesforcePath = `/lightning/setup/ApexPages/page?address=${encodeURIComponent(`/${componentId}`)}`; + } else if (componentType === 'ApexComponent') { + row.openInSalesforcePath = `/lightning/setup/ApexComponents/page?address=${encodeURIComponent(`/${componentId}`)}`; + } else if (componentType === 'FlexiPage') { + row.openInSalesforcePath = `/lightning/setup/FlexiPageList/page?address=${encodeURIComponent(`/${componentId}`)}`; + } else if (componentType === 'Layout') { + row.openInSalesforcePath = `/lightning/setup/LayoutDefinitions/page?address=${encodeURIComponent(`/${componentId}`)}`; + } else if (componentType === 'FieldSet') { + row.openInSalesforcePath = `/lightning/setup/FieldSets/page?address=${encodeURIComponent(`/${componentId}`)}`; + } else if (componentType === 'WorkflowRule' || componentType === 'WorkflowFieldUpdate') { + row.openInSalesforcePath = `/lightning/setup/WorkflowRules/page?address=${encodeURIComponent(`/${componentId}`)}`; + } + } +} + +interface ParsedFieldRef { + key: string; + object: string; + field: string; + developerName: string; + namespacePrefix: string | null; +} + +function namespaceMatches(rowNamespacePrefix: unknown, expected: string | null): boolean { + const rowValue = typeof rowNamespacePrefix === 'string' ? rowNamespacePrefix : ''; + if (expected == null || expected.length === 0) { + return rowValue.length === 0; + } + return rowValue === expected; +} + +/** + * Batches Tooling `CustomField` lookups by (object, developerName) tuples. + * Returns a map keyed by `${object}.${field}` → Tooling CustomField Id. + * Tries `EntityDefinition.QualifiedApiName` first, then falls back to `TableEnumOrId` for any unresolved fields. + */ +async function resolveCustomFieldIds(org: SalesforceOrgUi, refs: { object: string; field: string }[]): Promise> { + const parsedRefs: ParsedFieldRef[] = []; + for (const ref of refs) { + const parsed = parseCustomFieldApiNameForTooling(ref.field); + if (!ref.object || !parsed) { + continue; + } + parsedRefs.push({ + key: `${ref.object}.${ref.field}`, + object: ref.object, + field: ref.field, + developerName: parsed.developerName, + namespacePrefix: parsed.namespacePrefix, + }); + } + if (parsedRefs.length === 0) { + return new Map(); + } + + const resolved = new Map(); + const unresolved = new Map(); + for (const ref of parsedRefs) { + unresolved.set(ref.key, ref); + } + + async function runLookup(filterField: 'EntityDefinition.QualifiedApiName' | 'TableEnumOrId'): Promise { + const pending = [...unresolved.values()]; + if (pending.length === 0) { + return; + } + for (const chunk of splitArrayToMaxSize(pending, CUSTOM_FIELD_LOOKUP_CHUNK_SIZE)) { + const objectNames = [...new Set(chunk.map((ref) => ref.object))]; + const developerNames = [...new Set(chunk.map((ref) => ref.developerName))]; + const soql = composeQuery({ + fields: [ + getField('Id'), + getField('EntityDefinition.QualifiedApiName'), + getField('TableEnumOrId'), + getField('DeveloperName'), + getField('NamespacePrefix'), + ], + sObject: 'CustomField', + where: { + left: { + field: filterField, + operator: 'IN', + value: objectNames, + literalType: 'STRING', + }, + operator: 'AND', + right: { + left: { + field: 'DeveloperName', + operator: 'IN', + value: developerNames, + literalType: 'STRING', + }, + }, + }, + }); + + let records: Record[] = []; + try { + records = (await queryAll>(org, soql, true)).queryResults.records; + } catch (err) { + logger.warn('CustomField batch lookup failed; skipping chunk', { err, filterField }); + continue; + } + + const byObjectAndDevName = new Map[]>(); + for (const record of records) { + let objectName = ''; + if (filterField === 'EntityDefinition.QualifiedApiName') { + const entity = record.EntityDefinition; + if (entity && typeof entity === 'object') { + const qualifiedApiName = (entity as { QualifiedApiName?: unknown }).QualifiedApiName; + objectName = typeof qualifiedApiName === 'string' ? qualifiedApiName : ''; + } + } else { + objectName = typeof record.TableEnumOrId === 'string' ? record.TableEnumOrId : ''; + } + const developerName = typeof record.DeveloperName === 'string' ? record.DeveloperName : ''; + if (!objectName || !developerName) { + continue; + } + const bucketKey = `${objectName}.${developerName}`; + let bucket = byObjectAndDevName.get(bucketKey); + if (!bucket) { + bucket = []; + byObjectAndDevName.set(bucketKey, bucket); + } + bucket.push(record); + } + + for (const ref of chunk) { + if (resolved.has(ref.key)) { + continue; + } + const bucket = byObjectAndDevName.get(`${ref.object}.${ref.developerName}`); + if (!bucket) { + continue; + } + const match = bucket.find((rec) => namespaceMatches(rec.NamespacePrefix, ref.namespacePrefix)); + if (match && typeof match.Id === 'string') { + resolved.set(ref.key, match.Id); + unresolved.delete(ref.key); + } + } + } + } + + await runLookup('EntityDefinition.QualifiedApiName'); + if (unresolved.size > 0) { + await runLookup('TableEnumOrId'); + } + + return resolved; +} + +interface DependencyBatchResult { + /** Dependency rows keyed by the field's Tooling `RefMetadataComponentId`. Absent key = zero dependencies. */ + rowsByRefId: Map; + /** Ref ids whose dependencies were FULLY determined (query succeeded and was not truncated). */ + resolvedRefIds: Set; +} + +function buildDependencyByRefIdsSoql(refIds: string[]): string { + return composeQuery({ + fields: [ + getField('RefMetadataComponentId'), + getField('MetadataComponentId'), + getField('MetadataComponentType'), + getField('MetadataComponentName'), + ], + sObject: 'MetadataComponentDependency', + where: { + left: { + field: 'RefMetadataComponentType', + operator: '=', + value: 'CustomField', + literalType: 'STRING', + }, + operator: 'AND', + right: { + left: { + field: 'RefMetadataComponentId', + operator: 'IN', + value: refIds, + literalType: 'STRING', + }, + }, + }, + }); +} + +function toDependencyRow(record: Record): WhereUsedDependencyRow | null { + const componentType = record.MetadataComponentType != null ? String(record.MetadataComponentType) : ''; + const componentName = record.MetadataComponentName != null ? String(record.MetadataComponentName) : ''; + if (!componentType && !componentName) { + return null; + } + const componentIdRaw = record.MetadataComponentId; + const componentId = typeof componentIdRaw === 'string' ? componentIdRaw : ''; + return { type: componentType, name: componentName, kind: depKind(componentType), ...(componentId ? { componentId } : {}) }; +} + +/** + * Fetches `MetadataComponentDependency` rows for many CustomField ids in ONE query via + * `RefMetadataComponentId IN (...)` — instead of one query per field (previously hundreds of API calls). + * + * `MetadataComponentDependency` is a Tooling object that does not support `queryMore` and caps OFFSET at + * 2000, so we page it with {@link queryAllUsingOffset} (which detects "more" by a full page, not the + * unreliable `done` flag) up to the OFFSET ceiling. If a batch still returns a full page (more rows exist + * beyond what OFFSET can reach), we recursively split the id list and re-query the halves until every + * query is complete — so we never silently drop a field's dependencies. A batch query error leaves its ids + * OUT of `resolvedRefIds` (UNKNOWN, never delete-eligible) rather than treating "no rows" as "no + * dependencies" — failing safe. + */ +async function fetchDependencyRowsForRefIds(org: SalesforceOrgUi, refIds: string[]): Promise { + const rowsByRefId = new Map(); + const resolvedRefIds = new Set(); + + const process = async (ids: string[]): Promise => { + if (ids.length === 0) { + return; + } + let records: Record[]; + let possiblyTruncated: boolean; + try { + const response = await queryAllUsingOffset>( + org, + buildDependencyByRefIdsSoql(ids), + true, + DEPENDENCY_PAGE_SAFETY_CAP, + ); + records = response.queryResults.records; + // A full page means more rows may exist beyond the OFFSET ceiling — narrow the id list to reach them. + possiblyTruncated = records.length >= DEPENDENCY_PAGE_SAFETY_CAP; + } catch (err) { + logger.warn('field usage dependency batch query failed; marking fields UNKNOWN (not delete-eligible)', { + err, + count: ids.length, + }); + return; // ids stay out of resolvedRefIds → UNKNOWN + } + + if (possiblyTruncated && ids.length > 1) { + const mid = Math.ceil(ids.length / 2); + await process(ids.slice(0, mid)); + await process(ids.slice(mid)); + return; + } + + // Complete — or a single field whose dependencies exceed one page (the partial rows still prove it + // has dependencies, which is all the delete-safety gate needs). + for (const id of ids) { + resolvedRefIds.add(id); + } + for (const record of records) { + const refId = typeof record.RefMetadataComponentId === 'string' ? record.RefMetadataComponentId : ''; + if (!refId) { + continue; + } + const row = toDependencyRow(record); + if (!row) { + continue; + } + let bucket = rowsByRefId.get(refId); + if (!bucket) { + bucket = []; + rowsByRefId.set(refId, bucket); + } + bucket.push(row); + } + }; + + await process(refIds); + return { rowsByRefId, resolvedRefIds }; +} + +async function runWithConcurrency( + items: TItem[], + concurrency: number, + handler: (item: TItem) => Promise, +): Promise { + const results: TResult[] = new Array(items.length); + const cursor = { next: 0 }; + const worker = async (): Promise => { + while (true) { + const currentIndex = cursor.next; + cursor.next += 1; + if (currentIndex >= items.length) { + return; + } + results[currentIndex] = await handler(items[currentIndex]); + } + }; + const workerCount = Math.min(concurrency, items.length); + const workers: Promise[] = []; + for (let workerIndex = 0; workerIndex < workerCount; workerIndex++) { + workers.push(worker()); + } + await Promise.all(workers); + return results; +} + +function collectCustomFieldKeys(objects: Record): { object: string; field: string }[] { + const refs: { object: string; field: string }[] = []; + for (const objectName of Object.keys(objects).sort()) { + const payload = objects[objectName]; + if (!payload || payload.error) { + continue; + } + for (const fieldName of Object.keys(payload.fieldUsage)) { + if (fieldName.endsWith('__c')) { + refs.push({ object: objectName, field: fieldName }); + } + } + } + return refs; +} + +/** + * Tooling MetadataComponentDependency map keyed `ObjectApi.FieldApi` (custom fields only). + * + * Resolves all CustomField Ids in batched Tooling queries, then fetches dependency rows + * with bounded concurrency. Flow enrichment runs once across the union of all rows. + */ +export async function computeFieldUsageWhereUsed( + org: SalesforceOrgUi, + objects: Record, +): Promise { + const refs = collectCustomFieldKeys(objects); + const results: WhereUsedMap = {}; + for (const ref of refs) { + results[`${ref.object}.${ref.field}`] = []; + } + if (refs.length === 0) { + return { whereUsed: results as FieldUsageJobResultData['whereUsed'], resolvedFieldKeys: [] }; + } + + const fieldIdByKey = await resolveCustomFieldIds(org, refs); + + // Reverse map fieldId → keys so batched dependency rows can be assigned back to each Object.Field. + const keysByFieldId = new Map(); + for (const [key, fieldId] of fieldIdByKey) { + let keys = keysByFieldId.get(fieldId); + if (!keys) { + keys = []; + keysByFieldId.set(fieldId, keys); + } + keys.push(key); + } + + // Batch the dependency lookups (`RefMetadataComponentId IN (...)`) instead of one query per field. + const idBatches = splitArrayToMaxSize([...keysByFieldId.keys()], DEPENDENCY_REF_ID_BATCH_SIZE); + const batchResults = await runWithConcurrency(idBatches, DEPENDENCY_LOOKUP_CONCURRENCY, (batch) => + fetchDependencyRowsForRefIds(org, batch), + ); + + const rowsByFieldId = new Map(); + const resolvedFieldIds = new Set(); + for (const batchResult of batchResults) { + for (const [fieldId, rows] of batchResult.rowsByRefId) { + rowsByFieldId.set(fieldId, rows); + } + for (const fieldId of batchResult.resolvedRefIds) { + resolvedFieldIds.add(fieldId); + } + } + + const allRows: WhereUsedDependencyRow[] = []; + for (const rows of rowsByFieldId.values()) { + allRows.push(...rows); + } + try { + await enrichWhereUsedDependencyRows(org, allRows); + } catch (err) { + logger.warn('field usage where-used enrichment failed; returning dependency rows without paths/versions', { err }); + } + + const resolvedFieldKeys: string[] = []; + for (const [fieldId, keys] of keysByFieldId) { + const sortedRows = sortFieldUsageWhereUsedRows(dedupeFieldUsageWhereUsedRows(rowsByFieldId.get(fieldId) ?? [])); + const resolved = resolvedFieldIds.has(fieldId); + for (const key of keys) { + results[key] = sortedRows; + if (resolved) { + resolvedFieldKeys.push(key); + } + } + } + + return { whereUsed: results as FieldUsageJobResultData['whereUsed'], resolvedFieldKeys }; +} diff --git a/libs/features/data-analysis/src/field-usage/run-field-usage.ts b/libs/features/data-analysis/src/field-usage/run-field-usage.ts new file mode 100644 index 000000000..65c954d1e --- /dev/null +++ b/libs/features/data-analysis/src/field-usage/run-field-usage.ts @@ -0,0 +1,513 @@ +import { logger } from '@jetstream/shared/client-logger'; +import { describeSObject, query, queryWithRecordBudget } from '@jetstream/shared/data'; +import type { DescribeSObjectResult, Field, SalesforceOrgUi } from '@jetstream/types'; +import { composeQuery, getField } from '@jetstreamapp/soql-parser-js'; + +/** Aligns with permission export row budget intent — keeps long runs bounded per object. */ +export const FIELD_USAGE_MAX_ROWS_PER_OBJECT = 100_000; + +/** + * Hard cap used when {@link RunFieldUsageOptions.loadFullScan} is true. Keeps the worst case + * bounded even in the browser; the previous server-side `Number.MAX_SAFE_INTEGER` would have + * permitted billion-row scans which is hostile UX. + */ +export const FIELD_USAGE_FULL_SCAN_ROW_BUDGET = 5_000_000; + +/** Stay under typical REST SOQL URL limits; split field lists when SELECT grows large. */ +const TARGET_SELECT_CLAUSE_CHARS = 9000; + +export interface FieldUsageStat { + filled: number; + pct: number; + latestFilledRowModified: string | null; + /** + * Records scanned for THIS field's chunk. Equals the object's `totalRecords` for non-truncated + * scans, but is tracked per-chunk so `pct = filled / scanned` is always internally consistent + * (avoids `filled > totalRecords` when row counts drift between sequential chunk queries). + */ + scanned: number; +} + +export interface FieldUsageObjectPayload { + label: string; + customizable: boolean; + totalRecords: number; + queryTruncated: boolean; + fieldUsage: Record; + fieldMeta: Record< + string, + { + label: string; + calculated: boolean; + type: string; + custom: boolean; + autoNumber: boolean; + calculatedFormula?: string | null; + externalId: boolean; + nameField: boolean; + extraTypeInfo?: string | null; + length: number; + precision?: number | null; + scale: number; + referenceTo?: string[] | null; + relationshipName?: string | null; + digits?: number | null; + /** Present on some describe payloads for auto-number fields (pattern like `ANN-{0000}`). */ + displayFormat?: string | null; + /** Describe `aggregatable` — whether the field supports SOQL aggregate functions (COUNT, etc.). */ + aggregatable?: boolean; + /** + * Describe `defaultedOnCreate` — the field is populated by Salesforce on every insert. A high fill + * rate may be default-driven rather than real usage; surfaced so admins don't read it as "used". + */ + defaultedOnCreate?: boolean; + } + >; + error?: string; +} + +export interface FieldUsageProgress { + current: number; + total: number; + percent: number; + label: string; +} + +export interface RunFieldUsageOptions { + /** When true, lift the per-object row cap to {@link FIELD_USAGE_FULL_SCAN_ROW_BUDGET}. */ + loadFullScan?: boolean; + onProgress?: (progress: FieldUsageProgress) => void; + isCanceled?: () => boolean; +} + +export interface RunFieldUsageQueryResult { + objects: Record; + failedObjects: string[]; + anyQueryTruncated: boolean; +} + +function fieldIsQueryable(field: Field): boolean { + const queryable = (field as unknown as { queryable?: boolean }).queryable; + return queryable !== false; +} + +/** + * Compound and blob types excluded from the scan: + * - `address` / `location` are compound fields; SELECTing them is awkward and their components are + * separate fields, so the compound itself yields no useful fill signal. + * - `base64` (blob, e.g. `Attachment.Body`) returns large payloads — selecting it across up to millions + * of rows is a serious payload/performance hazard and has no cleanup value. + */ +const UNCOUNTABLE_FIELD_TYPES = new Set(['address', 'location', 'base64']); + +function getCountableFields(describe: DescribeSObjectResult): Field[] { + return describe.fields.filter((field) => { + if (UNCOUNTABLE_FIELD_TYPES.has(field.type)) { + return false; + } + if (!field.name || field.name === 'Id' || field.name === 'LastModifiedDate') { + return false; + } + return fieldIsQueryable(field); + }); +} + +function buildFieldUsageSoql(objectApiName: string, fieldNames: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('LastModifiedDate'), ...fieldNames.map((name) => getField(name))], + sObject: objectApiName, + // Deterministic order so that when a wide object is split into multiple field-chunks AND the scan is + // truncated at the row budget, every chunk scans the SAME first N rows. Without this, each chunk could + // sample a different (arbitrary) subset, making per-field percentages inconsistent within one object. + orderBy: { field: 'Id', order: 'ASC' }, + }); +} + +/** + * Splits a list of field names into chunks whose composed SELECT clause fits within the SOQL URL limit. + * Uses a manual character count instead of re-composing the SOQL per candidate field (which made the + * previous implementation O(N²) in composeQuery calls for wide objects). + */ +function buildFieldChunks(fieldNames: string[], objectApiName: string): string[][] { + // Mirror composeQuery output: `SELECT Id, LastModifiedDate, FROM `. + // Each appended field contributes `, ` to the SELECT clause. + const baselineLength = `SELECT Id, LastModifiedDate FROM ${objectApiName}`.length; + const chunks: string[][] = []; + let current: string[] = []; + let currentLength = baselineLength; + + for (const name of fieldNames) { + const addedLength = name.length + 2; + if (current.length > 0 && currentLength + addedLength > TARGET_SELECT_CLAUSE_CHARS) { + chunks.push(current); + current = []; + currentLength = baselineLength; + } + current.push(name); + currentLength += addedLength; + } + if (current.length > 0) { + chunks.push(current); + } + return chunks; +} + +function mergeSfDatetimeMax(left: string | null, right: string | null): string | null { + if (!right) { + return left; + } + if (!left) { + return right; + } + return left.localeCompare(right) >= 0 ? left : right; +} + +/** + * Whether a value counts as "filled" for usage purposes, given the field's describe type. + * + * Boolean (checkbox) fields are never null in SOQL results — they are always `true` or `false` — so + * counting any non-null value would make every checkbox read ~100% filled and hide genuinely-unused + * (all-`false`) checkboxes. For booleans we therefore count only `true` ("how often is it checked"). + */ +function isFilledValue(value: unknown, fieldType: string): boolean { + if (value === null || value === undefined) { + return false; + } + if (fieldType === 'boolean') { + return value === true || value === 'true'; + } + if (typeof value === 'string') { + return value.trim() !== ''; + } + return true; +} + +function buildFieldMeta(countable: Field[]): FieldUsageObjectPayload['fieldMeta'] { + const fieldMeta: FieldUsageObjectPayload['fieldMeta'] = {}; + for (const field of countable) { + const describeFieldExtras = field as Field & { displayFormat?: string | null }; + fieldMeta[field.name] = { + label: field.label, + calculated: field.calculated, + type: field.type, + custom: field.custom, + autoNumber: field.autoNumber, + calculatedFormula: field.calculatedFormula ?? null, + externalId: field.externalId, + nameField: field.nameField, + extraTypeInfo: field.extraTypeInfo ?? null, + length: field.length, + precision: field.precision ?? null, + scale: field.scale, + referenceTo: field.referenceTo ?? null, + relationshipName: field.relationshipName ?? null, + digits: field.digits ?? null, + displayFormat: describeFieldExtras.displayFormat ?? null, + aggregatable: field.aggregatable, + defaultedOnCreate: field.defaultedOnCreate, + }; + } + return fieldMeta; +} + +function throwIfCanceled(isCanceled?: () => boolean): void { + if (isCanceled?.()) { + throw new Error('Job canceled'); + } +} + +/** + * Fields counted with a server-side SOQL aggregate (`COUNT(field)`): exact, full-object, no row transfer. + * Booleans are excluded — `COUNT(checkbox)` counts both true and false (always ~100%), so they go through + * the row scan where we count only `true`. Non-aggregatable types (long text, rich text, encrypted, + * base64, compound) cannot be aggregated and also fall back to the scan. + */ +function fieldSupportsCountAggregate(field: Field): boolean { + return field.aggregatable === true && field.type !== 'boolean'; +} + +/** Splits COUNT fields into chunks whose composed SELECT clause stays under {@link TARGET_SELECT_CLAUSE_CHARS}. */ +function buildCountFieldChunks(fieldNames: string[], objectApiName: string): string[][] { + const baselineLength = `SELECT COUNT(Id) total FROM ${objectApiName}`.length; + const chunks: string[][] = []; + let current: string[] = []; + let currentLength = baselineLength; + for (let index = 0; index < fieldNames.length; index++) { + const name = fieldNames[index]; + // `, COUNT() c` — pad the alias allowance generously so we never overshoot the URL limit. + const addedLength = name.length + 16; + if (current.length > 0 && currentLength + addedLength > TARGET_SELECT_CLAUSE_CHARS) { + chunks.push(current); + current = []; + currentLength = baselineLength; + } + current.push(name); + currentLength += addedLength; + } + if (current.length > 0) { + chunks.push(current); + } + return chunks; +} + +interface CountFieldUsageResult { + filled: Record; + total: number; + /** Fields whose aggregate query failed (timeout/limit) and must fall back to a row scan. */ + failedFields: string[]; +} + +/** + * Exact per-field filled counts via `SELECT COUNT(Id) total, COUNT(f0) c0, … FROM Object`. Each chunk + * returns a single aggregate row across the ENTIRE object — no truncation, no record transfer. A chunk + * that errors (e.g. query timeout on a very large object) has its fields returned in {@link failedFields} + * so the caller can fall back to the bounded row scan. + */ +async function runCountForObject( + org: SalesforceOrgUi, + objectApiName: string, + fieldNames: string[], + isCanceled?: () => boolean, +): Promise { + const filled: Record = {}; + const failedFields: string[] = []; + let total = 0; + for (const chunk of buildCountFieldChunks(fieldNames, objectApiName)) { + throwIfCanceled(isCanceled); + const aliasPairs = chunk.map((name, index) => ({ name, alias: `c${index}` })); + const soql = `SELECT COUNT(Id) total, ${aliasPairs.map(({ name, alias }) => `COUNT(${name}) ${alias}`).join(', ')} FROM ${objectApiName}`; + try { + const response = await query>(org, soql, false); + const row = response.queryResults.records[0] ?? {}; + const rowTotal = Number(row.total); + if (Number.isFinite(rowTotal)) { + total = Math.max(total, rowTotal); + } + for (const { name, alias } of aliasPairs) { + const count = Number(row[alias]); + filled[name] = Number.isFinite(count) ? count : 0; + } + } catch (err) { + logger.warn('field usage COUNT aggregate failed; falling back to row scan for these fields', { err, objectApiName }); + failedFields.push(...chunk); + } + } + return { filled, total, failedFields }; +} + +interface ScanFieldUsageResult { + filled: Record; + scannedByField: Record; + maxLmd: Record; + truncated: boolean; + /** Rows scanned by the first chunk — the object-level "records scanned" when no COUNT total is available. */ + scanTotal: number; +} + +/** + * Streaming row scan for fields that cannot use COUNT (booleans, long text, encrypted, …). Counts filled + * values per field without retaining records; bounded by {@link rowBudget}. Reused by the full-scan path. + */ +async function scanFieldUsage( + org: SalesforceOrgUi, + objectApiName: string, + fieldNames: string[], + typeByField: Record, + rowBudget: number, + isCanceled?: () => boolean, +): Promise { + const filled: Record = Object.fromEntries(fieldNames.map((name) => [name, 0])); + const maxLmd: Record = Object.fromEntries(fieldNames.map((name) => [name, null])); + const scannedByField: Record = Object.fromEntries(fieldNames.map((name) => [name, 0])); + let truncated = false; + let scanTotal = 0; + const chunks = buildFieldChunks(fieldNames, objectApiName); + + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + throwIfCanceled(isCanceled); + const chunkNames = chunks[chunkIndex]; + const soql = buildFieldUsageSoql(objectApiName, chunkNames); + const budget = { remaining: rowBudget }; + let chunkRecordCount = 0; + + const result = await queryWithRecordBudget>(org, soql, false, budget, (records) => { + for (const record of records) { + chunkRecordCount += 1; + const lmdRaw = record.LastModifiedDate; + const lmd = typeof lmdRaw === 'string' ? lmdRaw : null; + for (const fieldName of chunkNames) { + if (isFilledValue(record[fieldName], typeByField[fieldName] ?? '')) { + filled[fieldName] += 1; + if (lmd) { + maxLmd[fieldName] = mergeSfDatetimeMax(maxLmd[fieldName], lmd); + } + } + } + } + }); + + for (const fieldName of chunkNames) { + scannedByField[fieldName] = chunkRecordCount; + } + if (result.truncated) { + truncated = true; + } + if (chunkIndex === 0) { + scanTotal = chunkRecordCount; + } + } + + return { filled, scannedByField, maxLmd, truncated, scanTotal }; +} + +/** + * Wide SOQL field usage with streaming aggregation: counts filled values per field without + * retaining records in memory. Memory stays O(fields × objects) regardless of row volume. + */ +export async function runFieldUsageQueryForObjects( + org: SalesforceOrgUi, + objectApiNames: string[], + options?: RunFieldUsageOptions, +): Promise { + const objects: Record = {}; + const failedObjects: string[] = []; + let anyQueryTruncated = false; + const rowBudget = options?.loadFullScan === true ? FIELD_USAGE_FULL_SCAN_ROW_BUDGET : FIELD_USAGE_MAX_ROWS_PER_OBJECT; + const totalObjects = objectApiNames.length; + + for (let objectIndex = 0; objectIndex < totalObjects; objectIndex++) { + const objectApiName = objectApiNames[objectIndex]; + throwIfCanceled(options?.isCanceled); + + const currentProgress = objectIndex + 1; + options?.onProgress?.({ + current: currentProgress, + total: totalObjects, + percent: totalObjects > 0 ? (currentProgress / totalObjects) * 100 : 0, + label: `Analyzing ${objectApiName} (${currentProgress} of ${totalObjects})`, + }); + + try { + const describeResponse = await describeSObject(org, objectApiName); + const describe = describeResponse.data; + + if (!describe.queryable) { + objects[objectApiName] = { + label: describe.label, + customizable: describe.custom, + totalRecords: 0, + queryTruncated: false, + fieldUsage: {}, + fieldMeta: {}, + error: 'Object is not queryable', + }; + continue; + } + + const countable = getCountableFields(describe); + const names = countable.map((field) => field.name); + const fieldMeta = buildFieldMeta(countable); + + if (names.length === 0) { + objects[objectApiName] = { + label: describe.label, + customizable: describe.custom, + totalRecords: 0, + queryTruncated: false, + fieldUsage: {}, + fieldMeta, + }; + continue; + } + + const typeByField: Record = Object.fromEntries(countable.map((field) => [field.name, field.type])); + const filled: Record = {}; + const maxLmdWhenFilled: Record = {}; + const scannedByField: Record = {}; + let queryTruncated = false; + let countTotal: number | null = null; + + // Fields that support an exact server-side COUNT aggregate vs those that need a bounded row scan. + const countFieldNames = countable.filter(fieldSupportsCountAggregate).map((field) => field.name); + const countFieldNameSet = new Set(countFieldNames); + const scanFieldNames = names.filter((name) => !countFieldNameSet.has(name)); + const fallbackScanFieldNames: string[] = []; + + if (countFieldNames.length > 0) { + const countResult = await runCountForObject(org, objectApiName, countFieldNames, options?.isCanceled); + countTotal = countResult.total; + const failed = new Set(countResult.failedFields); + for (const name of countFieldNames) { + if (failed.has(name)) { + fallbackScanFieldNames.push(name); + continue; + } + filled[name] = countResult.filled[name] ?? 0; + // COUNT is computed across the whole object, so the denominator is the full record count and + // there is no truncation. COUNT cannot report a per-field "latest modified", so it stays null. + scannedByField[name] = countResult.total; + maxLmdWhenFilled[name] = null; + } + } + + const allScanFieldNames = [...scanFieldNames, ...fallbackScanFieldNames]; + let scanTotal = 0; + if (allScanFieldNames.length > 0) { + const scanResult = await scanFieldUsage(org, objectApiName, allScanFieldNames, typeByField, rowBudget, options?.isCanceled); + scanTotal = scanResult.scanTotal; + for (const name of allScanFieldNames) { + filled[name] = scanResult.filled[name] ?? 0; + scannedByField[name] = scanResult.scannedByField[name] ?? 0; + maxLmdWhenFilled[name] = scanResult.maxLmd[name] ?? null; + } + if (scanResult.truncated) { + queryTruncated = true; + anyQueryTruncated = true; + } + } + + // Object-level record count: prefer the exact COUNT total; otherwise the scanned count. + const totalRecords = countTotal != null ? countTotal : scanTotal; + + const fieldUsage: Record = {}; + for (const name of names) { + const filledCount = filled[name] ?? 0; + const scanned = scannedByField[name] ?? 0; + const rawPct = scanned > 0 ? (filledCount / scanned) * 100 : 0; + fieldUsage[name] = { + filled: filledCount, + scanned, + // Clamp defensively so display never shows >100% / <0% from any count drift. + pct: Math.max(0, Math.min(100, rawPct)), + latestFilledRowModified: maxLmdWhenFilled[name] ?? null, + }; + } + + objects[objectApiName] = { + label: describe.label, + customizable: describe.custom, + totalRecords, + queryTruncated, + fieldUsage, + fieldMeta, + }; + } catch (ex) { + if (ex instanceof Error && ex.message === 'Job canceled') { + throw ex; + } + failedObjects.push(objectApiName); + const message = ex instanceof Error ? ex.message : 'Unknown error'; + objects[objectApiName] = { + label: objectApiName, + customizable: false, + totalRecords: 0, + queryTruncated: false, + fieldUsage: {}, + fieldMeta: {}, + error: message.slice(0, 500), + }; + } + } + + return { objects, anyQueryTruncated, failedObjects }; +} diff --git a/libs/features/data-analysis/src/index.ts b/libs/features/data-analysis/src/index.ts new file mode 100644 index 000000000..51e2f94c8 --- /dev/null +++ b/libs/features/data-analysis/src/index.ts @@ -0,0 +1,20 @@ +export * from './DataAnalysis'; +export * from './DataAnalysisSelection'; +export * from './FieldUsageAnalysisView'; +export * from './field-usage-result-parse'; +export { analysisJobRuntimeStateAtom, analysisJobRuntimeStateKey, isAnalysisJobActive } from './shared/analysis-job-runtime-state'; +export type { AnalysisJobRuntimeState } from './shared/analysis-job-runtime-state'; +export { computeFieldUsageWhereUsed } from './field-usage/compute-field-usage-where-used'; +export type { WhereUsedDependencyRow, WhereUsedMap } from './field-usage/compute-field-usage-where-used'; +export { + FIELD_USAGE_FULL_SCAN_ROW_BUDGET, + FIELD_USAGE_MAX_ROWS_PER_OBJECT, + runFieldUsageQueryForObjects, +} from './field-usage/run-field-usage'; +export type { + FieldUsageObjectPayload, + FieldUsageProgress, + FieldUsageStat, + RunFieldUsageOptions, + RunFieldUsageQueryResult, +} from './field-usage/run-field-usage'; diff --git a/libs/features/data-analysis/src/shared/analysis-job-runtime-state.ts b/libs/features/data-analysis/src/shared/analysis-job-runtime-state.ts new file mode 100644 index 000000000..60c423ba0 --- /dev/null +++ b/libs/features/data-analysis/src/shared/analysis-job-runtime-state.ts @@ -0,0 +1,51 @@ +import type { AnalysisJobType, AsyncJob, AsyncJobProgress } from '@jetstream/types'; +import { atom } from 'jotai'; + +export interface AnalysisJobRuntimeState { + /** AsyncJob.id from the global jobs system, links runtime state to the Jobs popover entry. */ + jobId: string; + /** Dexie row key that will receive the final result. Same as job.meta.jobHistoryKey. */ + jobHistoryKey: string; + /** Most recent progress event from the job runner. */ + progress: AsyncJobProgress | null; + /** When the job was kicked off, used for elapsed-time display. */ + startedAt: Date; +} + +/** + * Keyed by `${orgUniqueId}:${jobType}` so the views can subscribe to the matching in-flight job + * without scanning the global jobsState. Concurrency rule: one job per (org, jobType). + */ +export const analysisJobRuntimeStateAtom = atom>({}); + +export function analysisJobRuntimeStateKey(orgUniqueId: string, jobType: AnalysisJobType): string { + return `${orgUniqueId}:${jobType}`; +} + +/** + * Returns true if the global jobs map currently holds an in-flight analysis job of the given type + * for the given org. Used by selection screens to block double-enqueue. + */ +export function isAnalysisJobActive( + jobs: Record>, + orgUniqueId: string, + jobType: AnalysisJobType, +): boolean { + return Object.values(jobs).some((job) => { + if (!job) { + return false; + } + const matchesType = + (jobType === 'permission_export' && job.type === 'PermissionExportAnalysis') || + (jobType === 'field_usage' && job.type === 'FieldUsageAnalysis'); + if (!matchesType) { + return false; + } + const inFlight = job.status === 'pending' || job.status === 'in-progress'; + if (!inFlight) { + return false; + } + const meta = job.meta as { orgUniqueId?: string } | undefined; + return meta?.orgUniqueId === orgUniqueId; + }); +} diff --git a/libs/features/data-analysis/src/where-used-open-in-salesforce.ts b/libs/features/data-analysis/src/where-used-open-in-salesforce.ts new file mode 100644 index 000000000..461ab72bf --- /dev/null +++ b/libs/features/data-analysis/src/where-used-open-in-salesforce.ts @@ -0,0 +1,51 @@ +import type { WhereUsedDependencyRowParsed } from './field-usage-result-parse'; + +/** + * Relative path (leading `/`) to open this dependency in Salesforce when the job did not store + * {@link WhereUsedDependencyRowParsed.openInSalesforcePath} (older jobs) or for client-side fallback. + */ +export function getWhereUsedOpenInSalesforcePath(row: WhereUsedDependencyRowParsed): string | null { + const fromJob = row.openInSalesforcePath?.trim(); + if (fromJob) { + return fromJob; + } + const t = row.type.trim(); + if (t === 'ProcessDefinition') { + return '/lightning/setup/ProcessAutomation/home'; + } + const id = row.componentId?.trim(); + if (!id) { + return null; + } + if (t === 'ApexClass') { + return `/lightning/setup/ApexClasses/page?address=${encodeURIComponent(`/${id}`)}`; + } + if (t === 'ApexTrigger') { + return `/lightning/setup/ApexTriggers/page?address=${encodeURIComponent(`/${id}`)}`; + } + if (t === 'ApexPage') { + return `/lightning/setup/ApexPages/page?address=${encodeURIComponent(`/${id}`)}`; + } + if (t === 'ApexComponent') { + return `/lightning/setup/ApexComponents/page?address=${encodeURIComponent(`/${id}`)}`; + } + if (t === 'FlexiPage') { + return `/lightning/setup/FlexiPageList/page?address=${encodeURIComponent(`/${id}`)}`; + } + if (t === 'Layout') { + return `/lightning/setup/LayoutDefinitions/page?address=${encodeURIComponent(`/${id}`)}`; + } + if (t === 'FieldSet') { + return `/lightning/setup/FieldSets/page?address=${encodeURIComponent(`/${id}`)}`; + } + if (t === 'WorkflowRule' || t === 'WorkflowFieldUpdate') { + return `/lightning/setup/WorkflowRules/page?address=${encodeURIComponent(`/${id}`)}`; + } + if (t === 'Flow') { + return `/builder_platform_interaction/flowBuilder.app?flowId=${encodeURIComponent(id)}`; + } + if (t === 'FlowDefinition') { + return '/lightning/setup/Flows/home'; + } + return null; +} diff --git a/libs/features/data-analysis/tsconfig.json b/libs/features/data-analysis/tsconfig.json new file mode 100644 index 000000000..9fae121dd --- /dev/null +++ b/libs/features/data-analysis/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@emotion/react", + "allowJs": false, + "lib": ["DOM", "ESNext"], + "strict": true + }, + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }] +} diff --git a/libs/features/data-analysis/tsconfig.lib.json b/libs/features/data-analysis/tsconfig.lib.json new file mode 100644 index 000000000..edefd42fa --- /dev/null +++ b/libs/features/data-analysis/tsconfig.lib.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["node"] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "vite.config.ts", + "vitest.config.ts" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"], + "files": [ + "../../../node_modules/@nx/react/typings/cssmodule.d.ts", + "../../../node_modules/@nx/react/typings/image.d.ts", + "../../../custom-typings/index.d.ts" + ] +} diff --git a/libs/features/data-analysis/tsconfig.spec.json b/libs/features/data-analysis/tsconfig.spec.json new file mode 100644 index 000000000..6c423ca5b --- /dev/null +++ b/libs/features/data-analysis/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"] + }, + "include": [ + "vite.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/features/data-analysis/vite.config.ts b/libs/features/data-analysis/vite.config.ts new file mode 100644 index 000000000..d4d482559 --- /dev/null +++ b/libs/features/data-analysis/vite.config.ts @@ -0,0 +1,22 @@ +/// +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/features/data-analysis', + plugins: [nxViteTsPaths()], + test: { + name: 'features-data-analysis', + watch: false, + globals: true, + environment: 'jsdom', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + passWithNoTests: true, + coverage: { + reportsDirectory: '../../../coverage/libs/features/data-analysis', + provider: 'v8' as const, + }, + }, +})); diff --git a/libs/features/deploy/src/index.ts b/libs/features/deploy/src/index.ts index 1152efe45..c09e58f94 100644 --- a/libs/features/deploy/src/index.ts +++ b/libs/features/deploy/src/index.ts @@ -1,3 +1,4 @@ export * from './DeployMetadata'; export * from './DeployMetadataDeployment'; export * from './DeployMetadataSelection'; +export { DeleteMetadataModal } from './delete-metadata/DeleteMetadataModal'; diff --git a/libs/features/manage-permissions/eslint.config.js b/libs/features/manage-permissions/eslint.config.js index 63a44a705..84c65364a 100644 --- a/libs/features/manage-permissions/eslint.config.js +++ b/libs/features/manage-permissions/eslint.config.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line @nx/enforce-module-boundaries -- workspace root ESLint preset for Nx library projects const baseConfig = require('../../../eslint.config.js'); module.exports = [ diff --git a/libs/features/manage-permissions/src/ManagePermissionsEditor.tsx b/libs/features/manage-permissions/src/ManagePermissionsEditor.tsx index 802ef7259..236d9d985 100644 --- a/libs/features/manage-permissions/src/ManagePermissionsEditor.tsx +++ b/libs/features/manage-permissions/src/ManagePermissionsEditor.tsx @@ -24,6 +24,7 @@ import { PermissionTableTabVisibilityCellPermission, TabVisibilityPermissionDefinitionMap, TabVisibilityPermissionRecordForSave, + UiTabSection, UploadToGoogleJob, } from '@jetstream/types'; import { @@ -49,7 +50,7 @@ import { ConfirmPageChange, RequireMetadataApiBanner, fromJetstreamEvents, fromP import { applicationCookieState, googleDriveAccessState, selectedOrgState } from '@jetstream/ui/app-state'; import { useAtom, useAtomValue } from 'jotai'; import { useResetAtom } from 'jotai/utils'; -import { Fragment, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; +import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import ManagePermissionsEditorFieldTable from './ManagePermissionsEditorFieldTable'; import ManagePermissionsEditorObjectTable from './ManagePermissionsEditorObjectTable'; @@ -142,7 +143,18 @@ export const ManagePermissionsEditor: FunctionComponent[]>([]); + const objectColumnsProfilesOnly = useMemo( + () => getObjectColumns(selectedProfiles, [], profilesById, permissionSetsById), + [selectedProfiles, profilesById, permissionSetsById], + ); + const objectColumnsPermissionSetsOnly = useMemo( + () => getObjectColumns([], selectedPermissionSets, profilesById, permissionSetsById), + [selectedPermissionSets, profilesById, permissionSetsById], + ); + const objectColumnsCombined = useMemo( + () => getObjectColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById), + [selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById], + ); const [objectRows, setObjectRows] = useState(null); const [visibleObjectRows, setVisibleObjectRows] = useState(null); const [dirtyObjectRows, setDirtyObjectRows] = useState>>({}); @@ -354,7 +366,6 @@ export const ManagePermissionsEditor: FunctionComponent, ) { if (includeColumns) { - setObjectColumns(getObjectColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById)); setFieldColumns(getFieldColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById)); setTabVisibilityColumns(getTabVisibilityColumns(selectedProfiles, selectedPermissionSets, profilesById, permissionSetsById)); } @@ -619,6 +630,84 @@ export const ManagePermissionsEditor: FunctionComponent 0) { + objectPermissionEditorTabs.push({ + id: 'profiles-object-permissions', + title: ( + + + + + Profiles {dirtyObjectCount ? `(${dirtyObjectCount})` : ''} + + + ), + titleText: 'Profiles', + disabled: true, + content: , + }); + } + if (selectedPermissionSets.length > 0) { + objectPermissionEditorTabs.push({ + id: 'permission-sets-object-permissions', + title: ( + + + + + Permission Sets {dirtyObjectCount ? `(${dirtyObjectCount})` : ''} + + + ), + titleText: 'Permission Sets', + disabled: true, + content: , + }); + } + if (objectPermissionEditorTabs.length === 0) { + objectPermissionEditorTabs.push({ + id: 'object-permissions', + title: ( + + + + + Object Permissions {dirtyObjectCount ? `(${dirtyObjectCount})` : ''} + + + ), + titleText: 'Object Permissions', + disabled: true, + content: , + }); + } + return (
@@ -727,40 +816,10 @@ export const ManagePermissionsEditor: FunctionComponent - - - - Object Permissions {dirtyObjectCount ? `(${dirtyObjectCount})` : ''} - - - ), - titleText: 'Object Permissions', - disabled: true, - content: ( - - ), - }, - + ...objectPermissionEditorTabs, { id: 'tab-visibility-permissions', title: ( diff --git a/libs/features/manage-permissions/src/ManagePermissionsSelection.tsx b/libs/features/manage-permissions/src/ManagePermissionsSelection.tsx index 75146829f..61cafc4fd 100644 --- a/libs/features/manage-permissions/src/ManagePermissionsSelection.tsx +++ b/libs/features/manage-permissions/src/ManagePermissionsSelection.tsx @@ -2,7 +2,15 @@ import { css } from '@emotion/react'; import { APP_ROUTES } from '@jetstream/shared/ui-router'; import { useNonInitialEffect, usePrimaryActionShortcut, useProfilesAndPermSets } from '@jetstream/shared/ui-utils'; import { SplitWrapper as Split } from '@jetstream/splitjs'; -import { DescribeGlobalSObjectResult, ListItem, PermissionSetNoProfileRecord, PermissionSetWithProfileRecord } from '@jetstream/types'; +import { + AsyncJob, + AsyncJobNew, + DescribeGlobalSObjectResult, + ListItem, + PermissionExportAnalysisJob, + PermissionSetNoProfileRecord, + PermissionSetWithProfileRecord, +} from '@jetstream/types'; import { AutoFullHeightContainer, ConnectedSobjectListMultiSelect, @@ -17,22 +25,47 @@ import { ProfileOrPermSetPopover, ProfileOrPermSetRecordType, Tooltip, + fireToast, getModifierKey, } from '@jetstream/ui'; -import { RequireMetadataApiBanner, fromPermissionsState } from '@jetstream/ui-core'; +import { RequireMetadataApiBanner, fromJetstreamEvents, fromPermissionsState, jobsState } from '@jetstream/ui-core'; import { applicationCookieState, selectSkipFrontdoorAuth, selectedOrgState } from '@jetstream/ui/app-state'; import { recentHistoryItemsDb } from '@jetstream/ui/db'; import { useAtom, useAtomValue } from 'jotai'; import { useResetAtom } from 'jotai/utils'; -import { FunctionComponent, useEffect } from 'react'; +import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; +import { PermissionAnalysisHistoryModal } from './PermissionAnalysisHistoryModal'; import { filterPermissionsSobjects } from './utils/permission-manager-utils'; const HEIGHT_BUFFER = 170; -export interface ManagePermissionsSelectionProps {} +/** + * Local mirror of `isAnalysisJobActive` from `@jetstream/feature/data-analysis`. Inlined to avoid + * a circular dependency (data-analysis imports `PermissionAnalysisHistoryModal` from this lib). + */ +function isPermissionExportJobActive(jobs: Record>, orgUniqueId: string): boolean { + return Object.values(jobs).some((job) => { + if (!job || job.type !== 'PermissionExportAnalysis') { + return false; + } + const inFlight = job.status === 'pending' || job.status === 'in-progress'; + if (!inFlight) { + return false; + } + const meta = job.meta as { orgUniqueId?: string } | undefined; + return meta?.orgUniqueId === orgUniqueId; + }); +} + +export type ManagePermissionsSelectionMode = 'manage' | 'permission-analysis'; + +export interface ManagePermissionsSelectionProps { + /** `permission-analysis` uses the same object picker to optionally narrow ObjectPermissions / FieldPermissions export rows (via job payload `objectApiNames`). */ + selectionMode?: ManagePermissionsSelectionMode; +} -export const ManagePermissionsSelection: FunctionComponent = () => { +export const ManagePermissionsSelection: FunctionComponent = ({ selectionMode = 'manage' }) => { const navigate = useNavigate(); const selectedOrg = useAtomValue(selectedOrgState); const { serverUrl } = useAtomValue(applicationCookieState); @@ -64,10 +97,22 @@ export const ManagePermissionsSelection: FunctionComponent { + return selectedProfiles.length > 0 || selectedPermissionSets.length > 0; + }, [selectedProfiles.length, selectedPermissionSets.length]); + + const continueEnabled = isAnalysis ? canContinueAnalysis : hasSelectionsMade; + // Run only on first render useEffect(() => { if (!profiles || !permissionSets) { @@ -94,23 +139,62 @@ export const ManagePermissionsSelection: FunctionComponent { + if (!canContinueAnalysis || !selectedOrg) { + return; + } + if (isPermissionExportJobActive(jobs, selectedOrg.uniqueId)) { + fireToast({ + message: 'A Permission Export job is already running for this org. Wait for it to finish before starting another.', + type: 'warning', + }); + return; + } + + const jobHistoryKey = `aj_${crypto.randomUUID()}`; + const meta: PermissionExportAnalysisJob = { + jobHistoryKey, + orgUniqueId: selectedOrg.uniqueId, + profileIds: selectedProfiles, + permissionSetIds: selectedPermissionSets, + ...(selectedSObjects.length > 0 ? { objectApiNames: selectedSObjects } : {}), + }; + const asyncJobNew: AsyncJobNew = { + type: 'PermissionExportAnalysis', + title: `Permission Export (${selectedProfiles.length + selectedPermissionSets.length} selection${ + selectedProfiles.length + selectedPermissionSets.length === 1 ? '' : 's' + })`, + org: selectedOrg, + meta, + viewUrl: `${APP_ROUTES.PERMISSION_ANALYSIS.ROUTE}/analysis?job=${encodeURIComponent(jobHistoryKey)}`, + }; + fromJetstreamEvents.emit({ type: 'newJob', payload: [asyncJobNew] }); + fireToast({ message: 'Permission Export job started. Loading results…', type: 'success' }); + navigate(`analysis?job=${encodeURIComponent(jobHistoryKey)}`); + }, [canContinueAnalysis, jobs, navigate, selectedOrg, selectedPermissionSets, selectedProfiles, selectedSObjects]); + function handleContinue() { - if (!sobjects?.length) { + if (!sobjects || !sobjects.length) { return; } recentHistoryItemsDb.addItemToRecentHistoryItems( selectedOrg.uniqueId, 'sobject', - sobjects.map(({ name }) => name), + sobjects.map((row: DescribeGlobalSObjectResult) => row.name), ); } + // Cmd/Ctrl+Enter triggers the primary "Continue" action for whichever mode is active. usePrimaryActionShortcut( () => { + if (isAnalysis) { + handleContinueAnalysis(); + return; + } handleContinue(); navigate('editor'); }, - { disabled: !hasSelectionsMade }, + { disabled: !continueEnabled }, ); return ( @@ -120,11 +204,32 @@ export const ManagePermissionsSelection: FunctionComponent - {hasSelectionsMade && ( + {isAnalysis && ( + + + + )} + {!isAnalysis && ( + + Open in Permission Analysis + + )} + {continueEnabled && !isAnalysis && ( )} - {!hasSelectionsMade && ( + {continueEnabled && isAnalysis && ( + + +
+ } + > + + + )} + {!continueEnabled && ( + + +); + +export default PermissionAnalysisExpandCollapseControls; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisExportGrid.tsx b/libs/features/manage-permissions/src/PermissionAnalysisExportGrid.tsx new file mode 100644 index 000000000..9a4477e36 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisExportGrid.tsx @@ -0,0 +1,756 @@ +import { css } from '@emotion/react'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import type { ColumnWithFilter, RowWithKey } from '@jetstream/ui'; +import { + AutoFullHeightContainer, + DataTable, + Grid, + GridCol, + Icon, + KeyboardShortcut, + Popover, + ReadOnlyFormElement, + SalesforceLogin, + ScopedNotification, + salesforceLoginAndRedirect, +} from '@jetstream/ui'; +import { Fragment, FunctionComponent, useCallback, useMemo, useState, type MouseEvent } from 'react'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { + buildContainerIdFindingSeverity, + buildDynamicExportColumns, + buildFieldPermissionFindingCellHighlights, + fieldPermissionCellSeverity, + formatObjectLabelForModalSummary, + listFindingsForExportContainer, + listFindingsForFieldPermissionCell, + pickAssignmentExportClickableColumnKeys, + pickPermissionSetExportClickableColumnKeys, + pickTabVisibilityExportClickableColumnKeys, + type PermissionAnalysisFinding, + type PermissionExportRow, + type SobjectExportDetail, +} from './permission-export-result-view'; + +const OBJECT_PERMISSIONS_OMIT_KEYS = new Set(['attributes', 'Id', 'ParentId']); + +/** Bare (borderless) base icon buttons for object cell actions, grouped in SLDS button group. */ +const OBJECT_TYPE_ACTION_BUTTON_CLASSNAME = 'slds-button slds-button_icon slds-button_icon-bare'; + +/** Default grid width for text columns; Object column needs extra space for label + actions. */ +const DEFAULT_TEXT_COLUMN_WIDTH = 175; + +/** Label + tooltip; optional Object Manager icon only (no separate info popover). */ +const OBJECT_NAME_COLUMN_MIN_WIDTH_ONE_ACTION = 200; +/** `minmax` + `fr`: Object column grows; floor keeps label + icon actions usable. */ +const EXPORT_GRID_OBJECT_NAME_FR = 1.65; + +const PERMISSION_ANALYSIS_POPOVER_PANEL_PROPS = { + onDoubleClick: (event: MouseEvent) => { + event.stopPropagation(); + }, +}; + +export type PermissionAnalysisExportGridVariant = 'default' | 'object_permissions'; + +/** Which export surface receives issue highlights and cell drill-in (see permission export analysis job). */ +export type PermissionAnalysisExportFindingSurface = + | 'none' + | 'field_permissions' + | 'container_row' + | 'assignment_row' + | 'tab_visibility_row'; + +export interface PermissionAnalysisExportGridProps { + rows: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + /** Describe + EntityDefinition metadata for SobjectType cells (label, API Name, description). */ + sobjectExportDetails?: Record; + /** `object_permissions` — hides Id/ParentId/attributes and adds Object Manager link on SobjectType. */ + variant?: PermissionAnalysisExportGridVariant; + /** Parsed `analysis_job.result.findings` when this grid should surface issue rows. */ + findings?: PermissionAnalysisFinding[]; + /** How issues map onto this grid; defaults to `none`. */ + findingSurface?: PermissionAnalysisExportFindingSurface; + /** Display names for permission set Ids (modal context lines). */ + containerLabelById?: Map; +} + +/** Object label (click for API / label / description popover, Field Usage–style) and optional Object Manager link. */ +export const SobjectTypeCellContent: FunctionComponent<{ + apiName: string; + detail: SobjectExportDetail | undefined; + objectManager?: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }; + /** Hide the inline Object Manager icon (the popover still offers "Open in Object Manager"). */ + hideInlineObjectManagerLink?: boolean; +}> = ({ apiName, detail, objectManager, hideInlineObjectManagerLink }) => { + const label = detail?.label?.trim() ? detail.label.trim() : apiName; + const descriptionText = + detail?.description != null && String(detail.description).trim().length > 0 ? String(detail.description).trim() : null; + const slug = apiName.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const objectManagerReturnUrl = `/lightning/setup/ObjectManager/${encodeURIComponent(apiName)}/Details/view`; + const canDeepLink = Boolean(objectManager?.org?.uniqueId && objectManager.serverUrl); + + return ( +
+
+ + {canDeepLink && objectManager ? ( + + Open in Object Manager + + ) : null} + + + + + + + + + + + {canDeepLink ? ( + +
+ Use to skip this popup +
+
+ ) : null} +
+
+ } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ + width: '100%', + minWidth: 0, + height: 'auto', + padding: 0, + }} + > + ) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canDeepLink || !objectManager) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl: objectManager.serverUrl, + org: objectManager.org, + returnUrl: objectManagerReturnUrl, + skipFrontDoorAuth: objectManager.skipFrontDoorAuth, + }); + } + }} + > + {label} + + +
+ {objectManager && !hideInlineObjectManagerLink && ( +
+ + + +
+ )} + + ); +}; + +function relaxExportPermissionColumnsForFlex(columns: ColumnWithFilter[]): ColumnWithFilter[] { + return columns.map((column) => { + if (typeof column.key !== 'string' || !column.key.startsWith('Permissions')) { + return column; + } + return { + ...column, + width: 'minmax(100px, 0.32fr)', + minWidth: 100, + } as ColumnWithFilter; + }); +} + +function applySobjectTypeColumn( + columns: ColumnWithFilter[], + options: { + sobjectExportDetails?: Record; + objectManager?: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }; + }, +): ColumnWithFilter[] { + const { sobjectExportDetails, objectManager } = options; + const detailCount = sobjectExportDetails ? Object.keys(sobjectExportDetails).length : 0; + if (!objectManager && detailCount === 0) { + return columns; + } + + return columns.map((column) => { + if (column.key !== 'SobjectType') { + return column; + } + const priorMinWidth = typeof column.minWidth === 'number' ? column.minWidth : 0; + const reservedMinWidth = objectManager ? OBJECT_NAME_COLUMN_MIN_WIDTH_ONE_ACTION : DEFAULT_TEXT_COLUMN_WIDTH; + const objectNameFloor = Math.max(priorMinWidth, reservedMinWidth, DEFAULT_TEXT_COLUMN_WIDTH); + + return { + ...column, + minWidth: objectNameFloor, + width: `minmax(${objectNameFloor}px, ${EXPORT_GRID_OBJECT_NAME_FR}fr)`, + getValue: ({ row }) => { + const api = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + if (!api) { + return null; + } + const detail = sobjectExportDetails?.[api]; + const label = detail?.label?.trim() ? detail.label.trim() : api; + const parts = [label, api]; + if (detail?.description != null && String(detail.description).trim().length > 0) { + parts.push(String(detail.description).trim()); + } + return parts.join(' '); + }, + renderCell: (props) => { + const raw = props.row?.SobjectType; + const apiName = typeof raw === 'string' ? raw.trim() : ''; + if (!apiName) { + return
; + } + const detail = sobjectExportDetails?.[apiName]; + return ; + }, + } as ColumnWithFilter; + }); +} + +type FieldCellModalState = { + kind: 'field'; + parentId: string; + objectApiName: string; + fieldApiName: string; + columnKey: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +}; + +type ContainerModalState = { + kind: 'container'; + containerId: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +}; + +type ExportFindingsModalState = FieldCellModalState | ContainerModalState | null; + +function mergeFindingCellClass( + column: ColumnWithFilter, + extraClass: (row: T) => string | undefined, +): ColumnWithFilter { + const prior = column.cellClass; + return { + ...column, + cellClass: (row: T) => { + const a = typeof prior === 'function' ? prior(row) : prior; + const b = extraClass(row); + const merged = [a, b].filter(Boolean).join(' '); + return merged.length > 0 ? merged : undefined; + }, + } as ColumnWithFilter; +} + +/** + * Read-only SOQL export rows with dynamic columns and quick filter. + * Optional issue highlights for field permissions, permission set / profile rows, and assignments. + */ +export const PermissionAnalysisExportGrid: FunctionComponent = ({ + rows, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + sobjectExportDetails, + variant = 'default', + findings = [], + findingSurface = 'none', + containerLabelById, +}) => { + const [modalState, setModalState] = useState(null); + + const fieldHighlights = useMemo(() => { + if (findingSurface !== 'field_permissions' || findings.length === 0) { + return null; + } + return buildFieldPermissionFindingCellHighlights(findings); + }, [findingSurface, findings]); + + const containerSeverity = useMemo(() => { + if ( + (findingSurface !== 'container_row' && findingSurface !== 'assignment_row' && findingSurface !== 'tab_visibility_row') || + findings.length === 0 + ) { + return null; + } + return buildContainerIdFindingSeverity(findings); + }, [findingSurface, findings]); + + const permissionSetClickColumns = useMemo(() => { + if (findingSurface !== 'container_row' || rows.length === 0) { + return [] as string[]; + } + return pickPermissionSetExportClickableColumnKeys(rows[0]); + }, [findingSurface, rows]); + + const assignmentClickColumns = useMemo(() => { + if (findingSurface !== 'assignment_row' || rows.length === 0) { + return [] as string[]; + } + return pickAssignmentExportClickableColumnKeys(rows[0]); + }, [findingSurface, rows]); + + const tabVisibilityClickColumns = useMemo(() => { + if (findingSurface !== 'tab_visibility_row' || rows.length === 0) { + return [] as string[]; + } + return pickTabVisibilityExportClickableColumnKeys(rows[0]); + }, [findingSurface, rows]); + + const baseColumns = useMemo(() => { + const dynamicOptions = variant === 'object_permissions' ? { omitColumnKeys: OBJECT_PERMISSIONS_OMIT_KEYS } : undefined; + + const base = buildDynamicExportColumns(rows, dynamicOptions); + + const objectManager = variant === 'object_permissions' ? { org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin } : undefined; + const detailCount = sobjectExportDetails ? Object.keys(sobjectExportDetails).length : 0; + if (!objectManager && detailCount === 0) { + return base; + } + const withObjectColumn = applySobjectTypeColumn(base, { sobjectExportDetails, objectManager }); + return relaxExportPermissionColumnsForFlex(withObjectColumn); + }, [rows, variant, org, serverUrl, skipFrontdoorLogin, sobjectExportDetails]); + + const columns = useMemo(() => { + if (findings.length === 0 || findingSurface === 'none') { + return baseColumns; + } + + if (findingSurface === 'field_permissions' && fieldHighlights) { + return baseColumns.map((col) => { + const key = typeof col.key === 'string' ? col.key : ''; + return mergeFindingCellClass(col, (row: RowWithKey) => { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const objectApi = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const fieldApi = typeof row.Field === 'string' ? row.Field.trim() : ''; + if (!parentId || !objectApi || !fieldApi) { + return undefined; + } + const severity = fieldPermissionCellSeverity(fieldHighlights, parentId, objectApi, fieldApi, key); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }); + }); + } + + if (findingSurface === 'container_row' && containerSeverity) { + return baseColumns.map((col) => { + const key = typeof col.key === 'string' ? col.key : ''; + const isClickColumn = permissionSetClickColumns.includes(key); + return mergeFindingCellClass(col, (row: RowWithKey) => { + if (!isClickColumn) { + return undefined; + } + const rowId = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (!rowId) { + return undefined; + } + const severity = containerSeverity.get(rowId); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }); + }); + } + + if (findingSurface === 'assignment_row' && containerSeverity) { + return baseColumns.map((col) => { + const key = typeof col.key === 'string' ? col.key : ''; + const isClickColumn = assignmentClickColumns.includes(key); + return mergeFindingCellClass(col, (row: RowWithKey) => { + if (!isClickColumn) { + return undefined; + } + const permissionSetId = typeof row.PermissionSetId === 'string' ? row.PermissionSetId.trim() : ''; + if (!permissionSetId) { + return undefined; + } + const severity = containerSeverity.get(permissionSetId); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }); + }); + } + + if (findingSurface === 'tab_visibility_row' && containerSeverity) { + return baseColumns.map((col) => { + const key = typeof col.key === 'string' ? col.key : ''; + const isClickColumn = tabVisibilityClickColumns.includes(key); + return mergeFindingCellClass(col, (row: RowWithKey) => { + if (!isClickColumn) { + return undefined; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + if (!parentId) { + return undefined; + } + const severity = containerSeverity.get(parentId); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }); + }); + } + + return baseColumns; + }, [ + baseColumns, + findings.length, + findingSurface, + fieldHighlights, + containerSeverity, + permissionSetClickColumns, + assignmentClickColumns, + tabVisibilityClickColumns, + ]); + + const openCellFindings = useCallback( + (row: PermissionExportRow, columnKey: string, columnLabel: string) => { + if (findings.length === 0) { + return; + } + + if (findingSurface === 'field_permissions' && fieldHighlights) { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const objectApiName = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const fieldApiName = typeof row.Field === 'string' ? row.Field.trim() : ''; + if (!parentId || !objectApiName || !fieldApiName) { + return; + } + const severity = fieldPermissionCellSeverity(fieldHighlights, parentId, objectApiName, fieldApiName, columnKey); + if (!severity) { + return; + } + const matches = listFindingsForFieldPermissionCell(findings, parentId, objectApiName, fieldApiName, columnKey); + if (matches.length === 0) { + return; + } + setModalState({ + kind: 'field', + parentId, + objectApiName, + fieldApiName, + columnKey, + columnLabel, + matches, + }); + return; + } + + if (findingSurface === 'container_row' && containerSeverity) { + const rowId = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (!rowId || !permissionSetClickColumns.includes(columnKey)) { + return; + } + if (!containerSeverity.has(rowId)) { + return; + } + const matches = listFindingsForExportContainer(findings, rowId); + if (matches.length === 0) { + return; + } + setModalState({ + kind: 'container', + containerId: rowId, + columnLabel, + matches, + }); + return; + } + + if (findingSurface === 'assignment_row' && containerSeverity) { + const permissionSetId = typeof row.PermissionSetId === 'string' ? row.PermissionSetId.trim() : ''; + if (!permissionSetId || !assignmentClickColumns.includes(columnKey)) { + return; + } + if (!containerSeverity.has(permissionSetId)) { + return; + } + const matches = listFindingsForExportContainer(findings, permissionSetId); + if (matches.length === 0) { + return; + } + setModalState({ + kind: 'container', + containerId: permissionSetId, + columnLabel, + matches, + }); + return; + } + + if (findingSurface === 'tab_visibility_row' && containerSeverity) { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + if (!parentId || !tabVisibilityClickColumns.includes(columnKey)) { + return; + } + if (!containerSeverity.has(parentId)) { + return; + } + const matches = listFindingsForExportContainer(findings, parentId); + if (matches.length === 0) { + return; + } + setModalState({ + kind: 'container', + containerId: parentId, + columnLabel, + matches, + }); + } + }, + [ + findings, + findingSurface, + fieldHighlights, + containerSeverity, + permissionSetClickColumns, + assignmentClickColumns, + tabVisibilityClickColumns, + ], + ); + + const rowsMap = useMemo(() => new WeakMap(rows.map((row, index) => [row, `export-row-${index}`])), [rows]); + const getRowKey = useCallback((row: PermissionExportRow) => rowsMap.get(row) ?? 'row', [rowsMap]); + + // The new grid has no `onCellClick`; resolve the clicked cell from the rendered DOM (`data-col-id` is + // the column key, `data-row-id` is `getRowKey(row)`) and route it through the finding handler. + const rowByKey = useMemo(() => { + const map = new Map(); + for (const row of rows) { + map.set(getRowKey(row), row); + } + return map; + }, [rows, getRowKey]); + + const columnLabelByKey = useMemo(() => { + const map = new Map(); + for (const column of columns) { + if (typeof column.key === 'string') { + map.set(column.key, typeof column.name === 'string' && column.name.trim().length > 0 ? column.name : column.key); + } + } + return map; + }, [columns]); + + const handleGridClick = useCallback( + (event: MouseEvent) => { + const cellEl = (event.target as HTMLElement).closest('.jgrid-cell[data-col-id]'); + const columnKey = cellEl?.getAttribute('data-col-id') ?? ''; + if (!columnKey) { + return; + } + const rowId = cellEl?.closest('.jgrid-row[data-row-id]')?.getAttribute('data-row-id'); + const row = rowId ? rowByKey.get(rowId) : undefined; + if (!row) { + return; + } + openCellFindings(row, columnKey, columnLabelByKey.get(columnKey) ?? columnKey); + }, + [rowByKey, columnLabelByKey, openCellFindings], + ); + + const fieldModalObjectSummary = useMemo(() => { + if (!modalState || modalState.kind !== 'field') { + return null; + } + return formatObjectLabelForModalSummary(modalState.objectApiName, sobjectExportDetails); + }, [modalState, sobjectExportDetails]); + + if (!rows.length) { + return ( +
+ No rows in this export slice. +
+ ); + } + + const showFindingClickHandler = + findings.length > 0 && + (findingSurface === 'field_permissions' || + findingSurface === 'container_row' || + findingSurface === 'assignment_row' || + findingSurface === 'tab_visibility_row'); + + return ( + + {/* The grid offers no onCellClick; delegate clicks to resolve the finding cell from the DOM. */} +
+ {/* Columns are built generically over RowWithKey (cellClass/renderCell only do string-key access); + ColumnWithFilter is invariant in its row type, so bridge to the data's row type here. */} + []} + data={rows} + getRowKey={getRowKey} + includeQuickFilter + context={{ defaultApiVersion }} + /> +
+ + {modalState?.kind === 'field' && ( + setModalState(null)} + findings={modalState.matches} + summaryLine={ + + {modalState.columnLabel} + {' · '} + {fieldModalObjectSummary?.displayLabel ? ( + fieldModalObjectSummary.showApiInParens ? ( + + {fieldModalObjectSummary.displayLabel} + + {' '} + ({modalState.objectApiName}) + + + ) : ( + {fieldModalObjectSummary.displayLabel} + ) + ) : null} + {' · '} + {modalState.fieldApiName} + {' · '} + {containerLabelById?.get(modalState.parentId) ?? modalState.parentId} — {modalState.matches.length}{' '} + {modalState.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} + + {modalState?.kind === 'container' && ( + setModalState(null)} + findings={modalState.matches} + summaryLine={ + + {modalState.columnLabel} + {' · '} + {containerLabelById?.get(modalState.containerId) ?? modalState.containerId} — {modalState.matches.length}{' '} + {modalState.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} +
+ ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisFieldPermissionsTree.tsx b/libs/features/manage-permissions/src/PermissionAnalysisFieldPermissionsTree.tsx new file mode 100644 index 000000000..665127d78 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisFieldPermissionsTree.tsx @@ -0,0 +1,977 @@ +/* eslint-disable react-hooks/refs */ +import { css } from '@emotion/react'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import type { RenderCellProps, RenderGroupCellProps } from '@jetstream/ui'; +import { + AutoFullHeightContainer, + ColumnWithFilter, + DataTree, + getProfileOrPermSetSetupUrl, + getRowTypeFromValue, + Grid, + GridCol, + Icon, + KeyboardShortcut, + Popover, + ReadOnlyFormElement, + SalesforceLogin, + salesforceLoginAndRedirect, + ScopedNotification, + setColumnFromType, + type ProfileOrPermSetRecordType, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { + forwardRef, + Fragment, + FunctionComponent, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type MouseEvent, +} from 'react'; +import { usePermissionAnalysisExportMetadata } from './permission-analysis-export-metadata-context'; +import { permissionAnalysisPermissionContainerGroupTitleLine } from './permission-analysis-tree-group-title'; +import { permissionAnalysisAssignmentTypeLabelCss } from './permission-analysis-viewer-badge.styles'; +import { + buildFieldPermissionFindingCellHighlights, + buildPermissionSetIdLabelMap, + FIELD_PERMISSION_BOOLEAN_COLUMN_KEYS, + fieldExportDetailLookupKey, + fieldPermissionCellSeverity, + fieldPermissionQualifiedFieldShortApi, + formatObjectLabelForModalSummary, + getExportColumnHeaderLabel, + listFindingsForFieldPermissionCell, + sortFieldPermissionExportRowsForAnalysisTree, + type PermissionAnalysisFinding, + type PermissionExportRow, + type SobjectExportDetail, +} from './permission-export-result-view'; +import { PermissionAnalysisExpandCollapseControls } from './PermissionAnalysisExpandCollapseControls'; +import { SobjectTypeCellContent } from './PermissionAnalysisExportGrid'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { buildPermissionSetTooltipFieldsFromExportRow, PermissionSetDetailPopoverContent } from './PermissionAnalysisPermissionSetsTree'; + +const TREE_GROUP_BY = ['_treePermSetGroupKey', '_treeObjectGroupKey'] as const; + +const TREE_PERM_SET_MIN_PX = 140; +const TREE_PERM_SET_MAX_PX = 420; +const TREE_COL_PERM_SET = `minmax(${TREE_PERM_SET_MIN_PX}px, min(${TREE_PERM_SET_MAX_PX}px, 1.35fr))`; + +const TREE_OBJECT_GROUP_WIDTH_PX = 236; + +const TREE_FIELD_COL = `minmax(200px, min(320px, 1.25fr))`; +const TREE_COL_PERMISSION_BOOL = 'minmax(104px, 0.42fr)'; + +const TREE_MIN_PERM_SET = TREE_PERM_SET_MIN_PX; +const TREE_MIN_PERMISSION_BOOL = 104; + +const TREE_ROW_HEIGHT_LEAF_PX = 35; +const TREE_ROW_HEIGHT_GROUP_PX = 68; + +const OBJECT_TYPE_ACTION_BUTTON_CLASSNAME = 'slds-button slds-button_icon slds-button_icon-bare'; + +const PERMISSION_ANALYSIS_POPOVER_PANEL_PROPS = { + onDoubleClick: (event: MouseEvent) => { + event.stopPropagation(); + }, +}; + +function resolveSetupTargetForFieldTreeParentRow( + permissionSetId: string, + row: PermissionExportRow | undefined, +): { recordType: ProfileOrPermSetRecordType; recordId: string } { + if (!row) { + return { recordType: 'PermissionSet', recordId: permissionSetId }; + } + const profileId = typeof row.ProfileId === 'string' && row.ProfileId.trim().length > 0 ? row.ProfileId.trim() : null; + const isProfileOwned = row.IsOwnedByProfile === true; + if (isProfileOwned && profileId) { + return { recordType: 'Profile', recordId: profileId }; + } + return { recordType: 'PermissionSet', recordId: permissionSetId }; +} + +export type FieldPermissionTreeRow = PermissionExportRow & { + _treePermSetGroupKey: string; + _treeObjectGroupKey: string; +}; + +function buildFieldPermissionTreeRows(fieldPermissionRows: PermissionExportRow[]): FieldPermissionTreeRow[] { + return fieldPermissionRows.map((row, index) => { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + return { + ...row, + _treePermSetGroupKey: parentId || `__missing_parent_${index}`, + _treeObjectGroupKey: sobjectType || `__missing_object_${index}`, + }; + }); +} + +/** Level-1 (permission set) group keys only — the default expansion (nested objects + fields stay collapsed). */ +function collectFieldPermissionPermSetGroupKeys(rows: readonly PermissionExportRow[]): Set { + const ids = new Set(); + for (let index = 0; index < rows.length; index++) { + const row = rows[index]; + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + ids.add(parentId || `__missing_parent_${index}`); + } + return ids; +} + +/** + * Both grouping levels for "Expand all" (so the grandchild field rows show). The DataTree bridge maps each + * value to a TanStack group-row id `${grouping[0]}:${value}`, and a nested object row's id is + * `_treePermSetGroupKey:>_treeObjectGroupKey:`, so the object value must encode that path. + */ +function collectAllFieldPermissionExpandedGroupIds(rows: readonly PermissionExportRow[]): Set { + const ids = new Set(); + for (let index = 0; index < rows.length; index++) { + const row = rows[index]; + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const permSetKey = parentId || `__missing_parent_${index}`; + const objectKey = sobjectType || `__missing_object_${index}`; + ids.add(permSetKey); + ids.add(`${permSetKey}>_treeObjectGroupKey:${objectKey}`); + } + return ids; +} + +function renderFieldPermissionPermissionSetGroupCell( + labelByParentId: Map, + permissionSetRowById: Map, + setupLogin: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }, + { groupKey, childRows, isExpanded, toggleGroup }: RenderGroupCellProps, +) { + const id = String(groupKey); + const exportLabel = labelByParentId.get(id) ?? id; + const permSetRow = permissionSetRowById.get(id); + const isProfileOwned = permSetRow?.IsOwnedByProfile === true; + const titleLine = permissionAnalysisPermissionContainerGroupTitleLine(exportLabel, isProfileOwned); + const typeKind = isProfileOwned ? 'profile' : 'permission_set'; + const typeCaption = isProfileOwned ? 'Profile' : 'Permission set'; + const detailFields = buildPermissionSetTooltipFieldsFromExportRow(permSetRow) ?? { + label: exportLabel, + name: '—', + description: null, + createdWhen: null, + createdByName: null, + lastModifiedWhen: null, + lastModifiedByName: null, + }; + const { recordType, recordId } = resolveSetupTargetForFieldTreeParentRow(id, permSetRow); + const returnUrl = getProfileOrPermSetSetupUrl(recordType, recordId); + const containerKind: 'Profile' | 'PermissionSet' = recordType === 'Profile' ? 'Profile' : 'PermissionSet'; + const detailSlug = id.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const canDeepLink = Boolean(setupLogin.org?.uniqueId && setupLogin.serverUrl); + + return ( +
+ + + } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ + flex: 1, + minWidth: 0, + height: 'auto', + alignItems: 'flex-start', + display: 'flex', + padding: 0, + }} + > + ) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canDeepLink) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl: setupLogin.serverUrl, + org: setupLogin.org, + returnUrl, + skipFrontDoorAuth: setupLogin.skipFrontDoorAuth, + }); + } + }} + > +

+ {typeCaption} +

+ + {titleLine} + ({childRows.length}) + +
+
+
+ ); +} + +interface FieldPermissionObjectGroupCellContentProps { + groupKey: unknown; + childRows: readonly FieldPermissionTreeRow[]; + isExpanded: boolean; + toggleGroup: () => void; + objectManager: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }; +} + +/** + * Renders the per-object group header inside the field permissions tree. + * Reads `sobjectExportDetails` via context so async label loads repaint this cell without rebuilding + * the parent `columns` memo (which would force `useDataTable` to redo its INIT/set-filter pass). + */ +function FieldPermissionObjectGroupCellContent({ + groupKey, + childRows, + isExpanded, + toggleGroup, + objectManager, +}: FieldPermissionObjectGroupCellContentProps) { + const { sobjectExportDetails } = usePermissionAnalysisExportMetadata(); + const apiName = String(groupKey).trim(); + if (!apiName) { + return null; + } + const detail = sobjectExportDetails?.[apiName]; + return ( +
+ +
+

Object

+
+
+ +
+ + {childRows.length} {childRows.length === 1 ? 'field' : 'fields'} + + { + event.stopPropagation(); + }} + > + + +
+
+
+ ); +} + +interface FieldPermissionFieldCellContentProps { + row: FieldPermissionTreeRow | undefined; + objectManager: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }; +} + +/** + * Renders the Field column cell. Subscribes to metadata via context so a metadata batch repaints + * just the visible cells without invalidating the parent `columns` memo (and dragging `useDataTable` + * through a full set-filter / search-index rebuild). + */ +function FieldPermissionFieldCellContent({ row, objectManager }: FieldPermissionFieldCellContentProps) { + const { sobjectExportDetails, fieldExportDetails } = usePermissionAnalysisExportMetadata(); + const obj = typeof row?.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const short = row ? fieldPermissionQualifiedFieldShortApi(row) : ''; + const full = typeof row?.Field === 'string' ? row.Field.trim() : ''; + const lookupKey = obj && short ? fieldExportDetailLookupKey(obj, short) : ''; + const fd = lookupKey ? fieldExportDetails?.[lookupKey] : undefined; + const label = fd?.label?.trim() ? fd.label.trim() : short || full; + const apiLine = short || full; + const descriptionText = fd?.description != null && String(fd.description).trim().length > 0 ? String(fd.description).trim() : null; + const durableId = fd?.durableId?.trim() ? fd.durableId.trim() : null; + // FieldDefinition.DurableId is the composite `EntityId.FieldId` (e.g. `01I….00N…`); the Object Manager + // field URL needs only the field id (the part after the dot). + const fieldDurableId = durableId ? (durableId.includes('.') ? durableId.slice(durableId.lastIndexOf('.') + 1) : durableId) : null; + const fieldSetupUrl = + fieldDurableId && obj + ? `/lightning/setup/ObjectManager/${encodeURIComponent(obj)}/FieldsAndRelationships/${encodeURIComponent(fieldDurableId)}/view` + : null; + const fieldSlug = `${obj}-${apiLine}`.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const canFieldDeepLink = Boolean(fieldSetupUrl && objectManager.org?.uniqueId && objectManager.serverUrl); + const parentObjectLabel = obj && sobjectExportDetails?.[obj]?.label?.trim() ? String(sobjectExportDetails[obj].label).trim() : obj; + const parentObjectSummary = obj ? `${parentObjectLabel} (${obj})` : '—'; + + if (!label && !full) { + return
; + } + + return ( +
+
+ + {canFieldDeepLink && fieldSetupUrl ? ( + + View in Salesforce + + ) : null} + + + + + + + + + + + + + + {canFieldDeepLink ? ( + +
+ Use to skip this popup +
+
+ ) : null} +
+
+ } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ + width: '100%', + minWidth: 0, + height: 'auto', + padding: 0, + }} + > + ) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canFieldDeepLink || !fieldSetupUrl) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl: objectManager.serverUrl, + org: objectManager.org, + returnUrl: fieldSetupUrl, + skipFrontDoorAuth: objectManager.skipFrontDoorAuth, + }); + } + }} + > + {label || apiLine} + + +
+ {fieldSetupUrl ? ( +
+ + + +
+ ) : null} + + ); +} + +function isFieldPermissionLeafRow(row: unknown): row is FieldPermissionTreeRow { + if (row === null || typeof row !== 'object') { + return false; + } + const record = row as Record; + return typeof record.ParentId === 'string' && record.ParentId.trim().length > 0; +} + +interface CellFindingsModalState { + parentId: string; + objectApiName: string; + fieldApiName: string; + columnKey: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +} + +interface CellFindingsModalHostHandle { + open: (state: CellFindingsModalState) => void; +} + +interface CellFindingsModalHostProps { + labelByParentId: Map; + sobjectExportDetails?: Record; +} + +/** + * Owns the cell-findings modal state in isolation so opening/closing the modal doesn't re-render + * the field-permissions tree (which would cascade 1000+ Cell re-renders for large datasets). + * The parent calls `open(state)` imperatively via the forwarded ref. + */ +const CellFindingsModalHost = forwardRef( + ({ labelByParentId, sobjectExportDetails }, ref) => { + const [state, setState] = useState(null); + + useImperativeHandle(ref, () => ({ open: setState }), []); + + const objectSummary = useMemo(() => { + if (!state) { + return null; + } + return formatObjectLabelForModalSummary(state.objectApiName, sobjectExportDetails); + }, [state, sobjectExportDetails]); + + if (!state) { + return null; + } + return ( + setState(null)} + findings={state.matches} + summaryLine={ + + {state.columnLabel} + {' · '} + {objectSummary?.displayLabel ? ( + objectSummary.showApiInParens ? ( + + {objectSummary.displayLabel} + + {' '} + ({state.objectApiName}) + + + ) : ( + {objectSummary.displayLabel} + ) + ) : null} + {' · '} + {state.fieldApiName} + {' · '} + {labelByParentId.get(state.parentId) ?? state.parentId} — {state.matches.length}{' '} + {state.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + ); + }, +); +CellFindingsModalHost.displayName = 'CellFindingsModalHost'; + +export interface PermissionAnalysisFieldPermissionsTreeProps { + fieldPermissionRows: PermissionExportRow[]; + permissionSetRows: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + sobjectExportDetails?: Record; + findings?: PermissionAnalysisFinding[]; +} + +/** + * Field permissions grouped by profile or permission set, then by object; Read and Edit columns match object-level order. + */ +export const PermissionAnalysisFieldPermissionsTree: FunctionComponent = ({ + fieldPermissionRows, + permissionSetRows, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + sobjectExportDetails, + findings = [], +}) => { + const { fieldExportDetails } = usePermissionAnalysisExportMetadata(); + /** + * Sort by API name only — keeps `sortedRows` identity stable across async metadata loads so + * `treeRows`, `columns`, and the rdg row-array reference don't churn on each label batch. + * Cells still re-display updated labels because cell renderers read metadata at render time. + */ + const sortedRows = useMemo( + () => sortFieldPermissionExportRowsForAnalysisTree(fieldPermissionRows, permissionSetRows), + [fieldPermissionRows, permissionSetRows], + ); + const treeRows = useMemo(() => buildFieldPermissionTreeRows(sortedRows), [sortedRows]); + const labelByParentId = useMemo(() => buildPermissionSetIdLabelMap(permissionSetRows), [permissionSetRows]); + const permissionSetRowById = useMemo(() => { + const map = new Map(); + for (const row of permissionSetRows) { + const rowId = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (rowId) { + map.set(rowId, row); + } + } + return map; + }, [permissionSetRows]); + + const findingCellHighlights = useMemo(() => buildFieldPermissionFindingCellHighlights(findings), [findings]); + + /** + * Read finding highlights through a ref inside `cellClass` so the `columns` memo identity stays + * stable when findings change. Otherwise every findings update invalidates `columns`, which forces + * `useDataTable` to rebuild the per-row search index (~40K ops on 6729 rows) and re-render. + */ + const findingCellHighlightsRef = useRef(findingCellHighlights); + findingCellHighlightsRef.current = findingCellHighlights; + + /** + * Same trick for async metadata: cells that need labels read them through context (see + * `FieldPermissionObjectGroupCellContent` / `FieldPermissionFieldCellContent`) so a metadata + * batch landing repaints just the visible cells. `getValue` runs outside of render (sort/filter + * pipelines) and can't use hooks, so it reads through these refs instead. + */ + const sobjectExportDetailsRef = useRef(sobjectExportDetails); + sobjectExportDetailsRef.current = sobjectExportDetails; + const fieldExportDetailsRef = useRef(fieldExportDetails); + fieldExportDetailsRef.current = fieldExportDetails; + + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + const cellFindingsModalHostRef = useRef(null); + + /** + * Reset expand-all only when the underlying job data actually changes (new analysis loaded). + * Sorting / metadata refresh changes `treeRows` identity but not `fieldPermissionRows`, so + * the user's manually-collapsed groups stick during initial metadata loading. + */ + useEffect(() => { + // Default: expand permission sets, leave the nested objects (and their field rows) collapsed. + setExpandedGroupIds(collectFieldPermissionPermSetGroupKeys(fieldPermissionRows)); + }, [fieldPermissionRows]); + + const objectManager = useMemo(() => ({ org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin }), [org, serverUrl, skipFrontdoorLogin]); + + const columns = useMemo((): ColumnWithFilter[] => { + if (!treeRows.length) { + return []; + } + const row0 = treeRows[0]; + + const groupPermSetCol: ColumnWithFilter = { + ...setColumnFromType('_treePermSetGroupKey', 'text'), + name: 'Profile / Permission set', + key: '_treePermSetGroupKey', + field: '_treePermSetGroupKey', + resizable: true, + width: TREE_COL_PERM_SET, + minWidth: TREE_MIN_PERM_SET, + maxWidth: TREE_PERM_SET_MAX_PX, + // Two-level grouping (Permission set → Object): this column owns the header only at the permission-set + // level, spanning the full row there; at the nested Object level it is an empty placeholder (span 1). + colSpan: (args) => + args.type === 'GROUP' ? (args.groupingColumnId === '_treePermSetGroupKey' ? Number.MAX_SAFE_INTEGER : 1) : undefined, + renderGroupCell: (props) => + props.tanstackRow.groupingColumnId === '_treePermSetGroupKey' + ? renderFieldPermissionPermissionSetGroupCell(labelByParentId, permissionSetRowById, objectManager, props) + : null, + getValue: ({ row }) => { + const id = row._treePermSetGroupKey; + return labelByParentId.get(id) ?? id; + }, + } as ColumnWithFilter; + + const groupObjectCol: ColumnWithFilter = { + ...setColumnFromType('_treeObjectGroupKey', 'textOrSalesforceId'), + name: 'Object', + key: '_treeObjectGroupKey', + field: '_treeObjectGroupKey', + resizable: false, + width: TREE_OBJECT_GROUP_WIDTH_PX, + minWidth: TREE_OBJECT_GROUP_WIDTH_PX, + maxWidth: TREE_OBJECT_GROUP_WIDTH_PX, + // Owns the header only at the nested Object level, spanning the remaining row width from this column. + colSpan: (args) => + args.type === 'GROUP' ? (args.groupingColumnId === '_treeObjectGroupKey' ? Number.MAX_SAFE_INTEGER : 1) : undefined, + renderGroupCell: ({ groupKey, childRows, isExpanded, toggleGroup, tanstackRow }) => + tanstackRow.groupingColumnId === '_treeObjectGroupKey' ? ( + + ) : null, + getValue: ({ row }) => { + const api = row._treeObjectGroupKey; + const detail = sobjectExportDetailsRef.current?.[api]; + const label = detail?.label?.trim() ? detail.label.trim() : api; + return label; + }, + } as ColumnWithFilter; + + const fieldCol: ColumnWithFilter = { + ...setColumnFromType('Field', 'text'), + name: 'Field', + key: 'Field', + field: 'Field', + resizable: true, + width: TREE_FIELD_COL, + getValue: ({ row }) => { + const obj = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const short = fieldPermissionQualifiedFieldShortApi(row); + const full = typeof row.Field === 'string' ? row.Field.trim() : ''; + const key = obj && short ? fieldExportDetailLookupKey(obj, short) : ''; + const fd = key ? fieldExportDetailsRef.current?.[key] : undefined; + const label = fd?.label?.trim() ? fd.label.trim() : short || full; + return label || '—'; + }, + renderCell: (props: RenderCellProps) => ( + + ), + } as ColumnWithFilter; + + const permissionCols: ColumnWithFilter[] = []; + for (const key of FIELD_PERMISSION_BOOLEAN_COLUMN_KEYS) { + if (!(key in row0)) { + continue; + } + const fieldType = getRowTypeFromValue(row0[key], false); + const headerLabel = getExportColumnHeaderLabel(key); + permissionCols.push({ + ...setColumnFromType(key, fieldType), + name: headerLabel, + key, + field: key, + resizable: true, + width: TREE_COL_PERMISSION_BOOL, + minWidth: TREE_MIN_PERMISSION_BOOL, + cellClass: (row: FieldPermissionTreeRow) => { + if (!isFieldPermissionLeafRow(row)) { + return undefined; + } + if (key !== 'PermissionsRead' && key !== 'PermissionsEdit') { + return undefined; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const fieldFull = typeof row.Field === 'string' ? row.Field.trim() : ''; + if (!parentId || !sobjectType || !fieldFull) { + return undefined; + } + const severity = fieldPermissionCellSeverity(findingCellHighlightsRef.current, parentId, sobjectType, fieldFull, key); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }, + } as ColumnWithFilter); + } + + return [groupPermSetCol, groupObjectCol, fieldCol, ...permissionCols]; + // Reads intentionally routed through refs/context so the columns memo stays stable: + // - `findingCellHighlights` via `findingCellHighlightsRef.current` inside `cellClass` + // - `sobjectExportDetails` / `fieldExportDetails` via refs in `getValue` and via context in the + // `FieldPermissionObjectGroupCellContent` / `FieldPermissionFieldCellContent` cell components + // Keeping all of those out of deps stops `useDataTable` from redoing its INIT pass and the + // per-row search-index rebuild every time findings or async label metadata change. + }, [treeRows, labelByParentId, permissionSetRowById, objectManager]); + + const getRowKey = useCallback((row: FieldPermissionTreeRow) => { + if (typeof row.Id === 'string' && row.Id.length > 0) { + return row.Id; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const obj = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const field = typeof row.Field === 'string' ? row.Field.trim() : ''; + return `${parentId}::${obj}::${field}`; + }, []); + + const openCellFindings = useCallback( + (row: FieldPermissionTreeRow, columnKey: string, columnLabel: string) => { + if (!isFieldPermissionLeafRow(row)) { + return; + } + if (columnKey !== 'PermissionsRead' && columnKey !== 'PermissionsEdit') { + return; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const objectApiName = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const fieldApiName = typeof row.Field === 'string' ? row.Field.trim() : ''; + if (!parentId || !objectApiName || !fieldApiName) { + return; + } + const highlightSeverity = fieldPermissionCellSeverity(findingCellHighlights, parentId, objectApiName, fieldApiName, columnKey); + if (!highlightSeverity) { + return; + } + const matches = listFindingsForFieldPermissionCell(findings, parentId, objectApiName, fieldApiName, columnKey); + if (matches.length === 0) { + return; + } + cellFindingsModalHostRef.current?.open({ + parentId, + objectApiName, + fieldApiName, + columnKey, + columnLabel, + matches, + }); + }, + [findingCellHighlights, findings], + ); + + // The new grid has no `onCellClick`, so resolve the clicked cell from the rendered DOM: `data-col-id` + // is the column key and `data-row-id` is `getRowKey(row)` (see buildColumnDefs / getRowId). + const rowByKey = useMemo(() => { + const map = new Map(); + for (const row of treeRows) { + map.set(getRowKey(row), row); + } + return map; + }, [treeRows, getRowKey]); + + const columnLabelByKey = useMemo(() => { + const map = new Map(); + for (const column of columns) { + if (typeof column.key === 'string') { + map.set(column.key, typeof column.name === 'string' && column.name.trim().length > 0 ? column.name : column.key); + } + } + return map; + }, [columns]); + + const handleGridClick = useCallback( + (event: MouseEvent) => { + const cellEl = (event.target as HTMLElement).closest('.jgrid-cell[data-col-id]'); + const columnKey = cellEl?.getAttribute('data-col-id') ?? ''; + if (!columnKey.startsWith('Permissions')) { + return; + } + const rowId = cellEl?.closest('.jgrid-row[data-row-id]')?.getAttribute('data-row-id'); + const row = rowId ? rowByKey.get(rowId) : undefined; + if (!row) { + return; + } + openCellFindings(row, columnKey, columnLabelByKey.get(columnKey) ?? columnKey); + }, + [rowByKey, columnLabelByKey, openCellFindings], + ); + + if (!fieldPermissionRows.length) { + return ( +
+ No field permission rows in this export. +
+ ); + } + + return ( + <> + setExpandedGroupIds(collectAllFieldPermissionExpandedGroupIds(fieldPermissionRows))} + onCollapseAll={() => setExpandedGroupIds(new Set())} + /> + + {/* The grid offers no onCellClick; delegate clicks to resolve the finding cell from the DOM. */} +
+ (type === 'GROUP' ? TREE_ROW_HEIGHT_GROUP_PX : TREE_ROW_HEIGHT_LEAF_PX)} + /> +
+ + +
+ + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisFindingsFiltersBar.tsx b/libs/features/manage-permissions/src/PermissionAnalysisFindingsFiltersBar.tsx new file mode 100644 index 000000000..d28b4c4eb --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisFindingsFiltersBar.tsx @@ -0,0 +1,288 @@ +import { css } from '@emotion/react'; +import classNames from 'classnames'; +import { Icon, Popover, type PopoverRef } from '@jetstream/ui'; +import { FunctionComponent, useRef } from 'react'; +import { type UsePermissionAnalysisIssuesFiltersResult } from './permission-analysis-issues-filters'; +import { type PermissionAnalysisFinding } from './permission-export-result-view'; + +/** Keeps `` from sitting inline with the first radio (fieldset default layout). */ +const filterPanelLegendCss = css` + display: block; + width: 100%; + float: none; + padding: 0; + margin-bottom: 0.375rem; +`; + +const filterPanelHelpTextCss = css` + display: block; + width: 100%; + margin-bottom: 0.5rem; +`; + +/** Vertical spacing between filter sections inside the combined popover. */ +const filterSectionCss = css` + & + & { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--slds-g-color-border-base-1, #e5e5e5); + } +`; + +/** + * Inline-size container for this toolbar strip so nested `@container` rules track the middle column width, + * not the viewport. + */ +const findingsFiltersBarRootCss = css` + width: 100%; + min-width: 0; +`; + +/** + * Single horizontal row with the main toolbar (back | filters | history). Parent may scroll on very narrow widths. + */ +const findingsFiltersToolbarClusterCss = css` + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; + gap: 0.35rem 0.5rem; + width: max-content; + max-width: none; +`; + +/** Do not flex-shrink (avoids a ~0px box that breaks every word onto its own line). */ +const findingsFiltersStatsCss = css` + flex: 0 0 auto; + text-align: left; + white-space: nowrap; +`; + +export interface PermissionAnalysisFindingsFiltersBarProps { + /** Unfiltered findings (total count for toolbar stats). */ + findings: PermissionAnalysisFinding[]; + /** Single hook result from {@link usePermissionAnalysisIssuesFilters} in the parent view. */ + issuesFilters: UsePermissionAnalysisIssuesFiltersResult; +} + +/** + * Global issue filters (URL-backed) consolidated into a single popover; popover body matches DataTable filter panels. + */ +export const PermissionAnalysisFindingsFiltersBar: FunctionComponent = ({ + findings, + issuesFilters, +}) => { + const popoverRef = useRef(null); + const { + severityFilter, + olsFlsFilter, + directAssignmentFilter, + scopeFilter, + issueScopeFilterContext, + hasAssignmentData, + filteredFindings, + errorTotal, + warningTotal, + errorFiltered, + warningFiltered, + updateParams, + } = issuesFilters; + + const supportsExportScopeFilter = issueScopeFilterContext?.supportsExportScopeFilter === true; + const activeFilterCount = + (severityFilter !== 'all' ? 1 : 0) + + (olsFlsFilter !== 'all' ? 1 : 0) + + (directAssignmentFilter !== 'all' ? 1 : 0) + + (supportsExportScopeFilter && scopeFilter !== 'all' ? 1 : 0); + const hasActiveFilters = activeFilterCount > 0; + + const resetAllFilters = () => { + updateParams({ + issueSeverity: null, + issueOlsFls: null, + issueDirectAssign: null, + issueScope: null, + }); + }; + + return ( +
+
+
ev.stopPropagation()} + onPointerDown={(ev) => ev.stopPropagation()} + onKeyDown={(ev) => ev.stopPropagation()} + > + ev.stopPropagation()}> +

Filters

+ + } + footer={ +
+ +
+ } + content={ +
ev.stopPropagation()}> +
+ {supportsExportScopeFilter && ( +
+ + Export scope + +

+ Narrow issues to permission containers that were selected as profiles or as permission sets for this export job. +

+
+ {(['all', 'profiles', 'permissionSets'] as const).map((value) => ( +
+ updateParams({ issueScope: value === 'all' ? null : value })} + /> + +
+ ))} +
+
+ )} + +
+ + Direct assignments + +

+ Filter issues to permission sets that have—or lack—a direct assignment to a Salesforce User. +

+
+ {(['all', 'assigned', 'unassigned'] as const).map((value) => ( +
+ updateParams({ issueDirectAssign: value === 'all' ? null : value })} + /> + +
+ ))} +
+ {!hasAssignmentData && ( +

+ No assignment rows in this export; run a new export after upgrading Jetstream. +

+ )} +
+ +
+ + Issue severity + +
+ {(['all', 'errors', 'warnings'] as const).map((value) => ( +
+ updateParams({ issueSeverity: value === 'all' ? null : value })} + /> + +
+ ))} +
+
+ +
+ + Issue security layer + +
+ {(['all', 'ols', 'fls'] as const).map((value) => ( +
+ updateParams({ issueOlsFls: value === 'all' ? null : value })} + /> + +
+ ))} +
+
+
+
+ } + buttonProps={{ + className: 'slds-button slds-button_neutral', + onClick: (ev) => ev.stopPropagation(), + 'aria-label': 'Filters', + title: 'Filters', + }} + > + + Filters{hasActiveFilters ? ` (${activeFilterCount})` : ''} +
+
+ +
+ Errors: {errorFiltered} / {errorTotal} · Warnings: {warningFiltered} / {warningTotal} · Showing {filteredFindings.length} of{' '} + {findings.length} issues +
+
+
+ ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisFindingsModal.tsx b/libs/features/manage-permissions/src/PermissionAnalysisFindingsModal.tsx new file mode 100644 index 000000000..de2d3de4a --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisFindingsModal.tsx @@ -0,0 +1,232 @@ +import { css } from '@emotion/react'; +import { Grid, Modal } from '@jetstream/ui'; +import { FunctionComponent, ReactNode } from 'react'; +import { getFindingCodeDisplayParts, getFindingLabelForCode, type PermissionAnalysisFinding } from './permission-export-result-view'; + +function severityLabelForFinding(finding: PermissionAnalysisFinding): string { + const normalized = String(finding.severity ?? '').toLowerCase(); + if (normalized === 'error' || normalized === 'errors') { + return 'Error'; + } + if (normalized === 'warning' || normalized === 'warnings') { + return 'Warning'; + } + if (normalized.length > 0) { + return normalized.charAt(0).toUpperCase() + normalized.slice(1); + } + return 'Info'; +} + +function primaryFindingExplanation(finding: PermissionAnalysisFinding): string { + const message = String(finding.message ?? '').trim(); + if (message.length > 0) { + return message; + } + const code = typeof finding.code === 'string' ? finding.code : ''; + return getFindingLabelForCode(code) || '—'; +} + +function findingBlockChrome(finding: PermissionAnalysisFinding): { accent: string; tint: string } { + const normalized = String(finding.severity ?? '').toLowerCase(); + if (normalized === 'error' || normalized === 'errors') { + return { accent: '#ba0517', tint: 'rgba(186, 5, 23, 0.06)' }; + } + if (normalized === 'warning' || normalized === 'warnings') { + return { accent: '#dd7a01', tint: 'rgba(221, 122, 1, 0.1)' }; + } + return { accent: '#0176d3', tint: 'rgba(1, 118, 211, 0.07)' }; +} + +function findingDetailText(finding: PermissionAnalysisFinding, catalogSummary: string): string | null { + const detail = primaryFindingExplanation(finding).trim(); + if (!detail) { + return null; + } + if (detail === catalogSummary.trim()) { + return null; + } + return detail; +} + +export interface PermissionAnalysisFindingsModalProps { + testId?: string; + open: boolean; + title: string; + tagline: string; + summaryLine: ReactNode; + findings: PermissionAnalysisFinding[]; + onClose: () => void; +} + +/** + * Read-only modal listing structured permission export issues (shared by object tree and export grids). + */ +export const PermissionAnalysisFindingsModal: FunctionComponent = ({ + testId = 'permission-analysis-issues-modal', + open, + title, + tagline, + summaryLine, + findings, + onClose, +}) => { + if (!open) { + return null; + } + + return ( + + + + } + onClose={onClose} + className="slds-p-around_small" + > +
+
{summaryLine}
+
+ {findings.map((finding, index) => { + const code = typeof finding.code === 'string' ? finding.code.trim() : ''; + const codeParts = getFindingCodeDisplayParts(code || undefined); + const summaryTitle = codeParts.title.trim(); + const detailText = findingDetailText(finding, summaryTitle); + const { accent, tint } = findingBlockChrome(finding); + return ( +
+
+
+ {code ? ( + <> + {severityLabelForFinding(finding)} + + + + {codeParts.technicalCode ? ( + <> + {summaryTitle}{' '} + + {codeParts.technicalCode} + + + ) : ( + + {summaryTitle} + + )} + + ) : ( + {severityLabelForFinding(finding)} + )} +
+ {detailText ? ( +

+ {detailText} +

+ ) : null} + {typeof finding.objectApiName === 'string' && finding.objectApiName.trim().length > 0 ? ( +

+ Object: {finding.objectApiName.trim()} +

+ ) : null} + {typeof finding.fieldApiName === 'string' && finding.fieldApiName.trim().length > 0 ? ( +

+ Field: {finding.fieldApiName.trim()} +

+ ) : null} + {typeof finding.permissionSetId === 'string' && finding.permissionSetId.trim().length > 0 ? ( +

+ Permission set Id: {finding.permissionSetId.trim()} +

+ ) : null} +
+
+ ); + })} +
+
+
+ ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisHistoryModal.tsx b/libs/features/manage-permissions/src/PermissionAnalysisHistoryModal.tsx new file mode 100644 index 000000000..c1f682494 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisHistoryModal.tsx @@ -0,0 +1,626 @@ +import { css } from '@emotion/react'; +import { formatNumber } from '@jetstream/shared/ui-utils'; +import { multiWordObjectFilter } from '@jetstream/shared/utils'; +import { AnalysisJobHistoryItem, AnalysisJobType, SalesforceOrgUi } from '@jetstream/types'; +import { Badge, EmptyState, Grid, Icon, List, Modal, Popover, PopoverRef, SearchInput, Spinner } from '@jetstream/ui'; +import { dexieDb } from '@jetstream/ui/db'; +import classNames from 'classnames'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { formatAnalysisJobStatusForDisplay } from './analysis-job-status-display'; +import { permissionScopeBadgeCss } from './permission-analysis-viewer-badge.styles'; +import { FunctionComponent, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +export type PermissionScopeKind = 'profile' | 'permission_set' | 'object'; + +export type PermissionAnalysisScopeBadge = { + key: string; + id: string; + label: string; + kind: PermissionScopeKind; +}; + +export type PermissionAnalysisHistoryRow = { + key: string; + id: string; + status: string; + jobType: string; + startedLabel: string; + profileScopeIds: string[]; + permissionSetScopeIds: string[]; + scopeBadges: PermissionAnalysisScopeBadge[]; + /** Space-delimited labels and ids for text search */ + scopeSearchText: string; +}; + +export type PermissionScopeFilterOption = { + id: string; + label: string; + kind: PermissionScopeKind; +}; + +function formatJobStartedAt(value: unknown): string { + if (value == null) { + return '—'; + } + const date = value instanceof Date ? value : new Date(typeof value === 'string' ? value : String(value)); + if (Number.isNaN(date.getTime())) { + return '—'; + } + return date.toLocaleString(); +} + +/** Short reference for list rows; full id stays in `title` for copy/paste and search still matches full `id`. */ +function shortJobIdForDisplay(jobId: string): string { + const normalized = jobId.trim(); + if (normalized.length <= 14) { + return normalized; + } + return `${normalized.slice(0, 8)}…${normalized.slice(-4)}`; +} + +function badgeTypeForStatus(status: string): 'success' | 'error' | 'warning' | 'default' { + const normalized = status.trim().toLowerCase(); + if (normalized === 'completed') { + return 'success'; + } + if (normalized === 'failed') { + return 'error'; + } + if (normalized === 'running') { + return 'warning'; + } + return 'default'; +} + +function stringIdArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((id): id is string => typeof id === 'string'); +} + +/** + * Builds ordered scope badges from request payload IDs only. Full PermissionSet labels live inside + * the gzip-compressed `resultBlob`; we intentionally do not decode for the list view (would force + * a gunzip per row). Labels resolve to the raw Id; per-run drill-in still shows full labels. + */ +function buildScopeBadges(profileScopeIds: string[], permissionSetScopeIds: string[]): PermissionAnalysisScopeBadge[] { + const badges: PermissionAnalysisScopeBadge[] = []; + for (const id of profileScopeIds) { + badges.push({ + key: `profile:${id}`, + id, + label: id, + kind: 'profile', + }); + } + for (const id of permissionSetScopeIds) { + badges.push({ + key: `permset:${id}`, + id, + label: id, + kind: 'permission_set', + }); + } + return badges; +} + +function parseFieldUsageDexieRowScopes(row: AnalysisJobHistoryItem): { + profileScopeIds: string[]; + permissionSetScopeIds: string[]; + scopeBadges: PermissionAnalysisScopeBadge[]; + scopeSearchText: string; +} { + const objectApiNames = stringIdArray(row.requestPayload?.objectApiNames).filter((name) => name.trim().length > 0); + const scopeBadges: PermissionAnalysisScopeBadge[] = objectApiNames.map((name) => ({ + key: `object:${name}`, + id: name, + label: name, + kind: 'object', + })); + return { + profileScopeIds: [], + permissionSetScopeIds: [], + scopeBadges, + scopeSearchText: objectApiNames.join(' '), + }; +} + +function parsePermissionExportDexieRowScopes(row: AnalysisJobHistoryItem): { + profileScopeIds: string[]; + permissionSetScopeIds: string[]; + scopeBadges: PermissionAnalysisScopeBadge[]; + scopeSearchText: string; +} { + const profileScopeIds = stringIdArray(row.requestPayload?.profileIds); + const permissionSetScopeIds = stringIdArray(row.requestPayload?.permissionSetIds); + const scopeBadges = buildScopeBadges(profileScopeIds, permissionSetScopeIds); + return { + profileScopeIds, + permissionSetScopeIds, + scopeBadges, + scopeSearchText: [...profileScopeIds, ...permissionSetScopeIds].join(' '), + }; +} + +function mapDexieRowsToHistoryRows( + rows: readonly AnalysisJobHistoryItem[], + analysisJobType: AnalysisJobType, +): PermissionAnalysisHistoryRow[] { + return rows.map((row) => { + const { profileScopeIds, permissionSetScopeIds, scopeBadges, scopeSearchText } = + analysisJobType === 'field_usage' ? parseFieldUsageDexieRowScopes(row) : parsePermissionExportDexieRowScopes(row); + return { + key: row.key, + id: row.key, + status: row.status, + jobType: row.jobType, + startedLabel: formatJobStartedAt(row.createdAt), + profileScopeIds, + permissionSetScopeIds, + scopeBadges, + scopeSearchText, + }; + }); +} + +function sortScopeFilterOptions(options: PermissionScopeFilterOption[]): PermissionScopeFilterOption[] { + return [...options].sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind === 'profile' ? -1 : 1; + } + return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); + }); +} + +function uniqueScopeOptionsFromRows(rows: PermissionAnalysisHistoryRow[]): PermissionScopeFilterOption[] { + const byId = new Map(); + for (const row of rows) { + for (const badge of row.scopeBadges) { + if (!byId.has(badge.id)) { + byId.set(badge.id, { id: badge.id, label: badge.label, kind: badge.kind }); + } + } + } + return sortScopeFilterOptions(Array.from(byId.values())); +} + +export interface PermissionAnalysisHistoryModalProps { + selectedOrg: SalesforceOrgUi; + /** Which analysis job family this modal lists; the Dexie query is keyed by (org, jobType). */ + analysisJobType: AnalysisJobType; + currentJobId: string | null; + onClose: () => void; + onSelectJob: (jobId: string) => void; +} + +const scopeBadgeWrapCss = css` + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; + max-width: 100%; +`; + +/** ~two lines of SLDS badges + flex gap; paired with overflow hidden when collapsed */ +const scopeBadgeTwoRowClampCss = css` + max-height: 4.5rem; + overflow: hidden; +`; + +type ScopeBadgesCollapsibleProps = { + badges: PermissionAnalysisScopeBadge[]; +}; + +/** + * Renders scope badges collapsed to ~two rows by default; offers expand/collapse when content overflows. + * Parent should pass `key={jobRowKey}` so expand state resets per run. + */ +const ScopeBadgesCollapsible: FunctionComponent = ({ badges }) => { + const [expanded, setExpanded] = useState(false); + const [canExpand, setCanExpand] = useState(false); + const wrapRef = useRef(null); + + useLayoutEffect(() => { + const el = wrapRef.current; + if (!el) { + return; + } + + function measureCollapsedOverflow(): void { + const node = wrapRef.current; + if (!node || expanded) { + return; + } + setCanExpand(node.scrollHeight > node.clientHeight + 1); + } + + measureCollapsedOverflow(); + const observer = new ResizeObserver(() => { + measureCollapsedOverflow(); + }); + observer.observe(el); + return () => { + observer.disconnect(); + }; + }, [badges, expanded]); + + const showToggle = canExpand || expanded; + + return ( +
+
+ {badges.map((badge) => ( + + {badge.label} + + ))} +
+ {showToggle && ( + + )} +
+ ); +}; + +export const PermissionAnalysisHistoryModal: FunctionComponent = ({ + selectedOrg, + analysisJobType, + currentJobId, + onClose, + onSelectJob, +}) => { + const [filterValue, setFilterValue] = useState(''); + const [scopeFilterParentId, setScopeFilterParentId] = useState(null); + const [selectedKey, setSelectedKey] = useState(currentJobId); + const scopePopoverRef = useRef(null); + + /** + * Live Dexie subscription to the per-(org, jobType) history. sortBy always materializes ascending — + * we reverse the array in JS to render newest first. + */ + const dexieRows = useLiveQuery( + () => + dexieDb.analysis_job_history + .where('[org+jobType+createdAt]') + .between([selectedOrg.uniqueId, analysisJobType, new Date(0)], [selectedOrg.uniqueId, analysisJobType, new Date(8.64e15)]) + .sortBy('createdAt') + .then((rows) => rows.reverse()), + [selectedOrg.uniqueId, analysisJobType], + ); + + const loading = dexieRows === undefined; + const rows = useMemo( + () => (dexieRows ? mapDexieRowsToHistoryRows(dexieRows, analysisJobType) : []), + [dexieRows, analysisJobType], + ); + + const scopeFilterOptions = useMemo(() => uniqueScopeOptionsFromRows(rows), [rows]); + + const activeScopeFilterLabel = useMemo(() => { + if (!scopeFilterParentId) { + return null; + } + const match = scopeFilterOptions.find((option) => option.id === scopeFilterParentId); + return match?.label ?? scopeFilterParentId; + }, [scopeFilterOptions, scopeFilterParentId]); + + const filteredRows = useMemo(() => { + let next = rows; + if (analysisJobType === 'permission_export' && scopeFilterParentId) { + next = next.filter( + (row) => row.profileScopeIds.includes(scopeFilterParentId) || row.permissionSetScopeIds.includes(scopeFilterParentId), + ); + } + if (!filterValue.trim()) { + return next; + } + return next.filter(multiWordObjectFilter(['id', 'status', 'startedLabel', 'scopeSearchText'], filterValue)); + }, [rows, filterValue, scopeFilterParentId, analysisJobType]); + + const handleListSelect = useCallback( + (key: string) => { + setSelectedKey(key); + onSelectJob(key); + onClose(); + }, + [onClose, onSelectJob], + ); + + const handleClearScopeFilter = useCallback(() => { + setScopeFilterParentId(null); + scopePopoverRef.current?.close(); + }, []); + + const handlePickScopeFilter = useCallback((parentId: string) => { + setScopeFilterParentId(parentId); + scopePopoverRef.current?.close(); + }, []); + + /** + * Single padded surface inside `slds-popover__body` (body padding removed) so header/body + * spacing is predictable and all rows share the same width. + */ + const scopePopoverSurfaceCss = css` + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.25rem; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 0.375rem 0.625rem 0.625rem; + max-height: min(50vh, 280px); + overflow-x: hidden; + overflow-y: auto; + + /* SLDS adds margin-left between adjacent .slds-button; stack spacing comes from gap above. */ + .slds-button + .slds-button { + margin-left: 0; + margin-inline-start: 0; + } + `; + + const scopePopoverHeaderCss = css` + margin: 0; + padding: 0.5rem 0.625rem 0.375rem; + box-sizing: border-box; + `; + + /** + * SLDS `slds-button_stretch` uses `justify-content: center`; neutral/brand borders differ by 1px. + * Stretch inner grid, equal insets, explicit 1px border so rows align with "All Runs". + */ + const scopeFilterPopoverButtonCss = css` + &&.slds-button_stretch { + justify-content: flex-start; + } + + display: flex; + align-items: center; + align-self: stretch; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + margin: 0; + text-align: left; + border-width: 1px; + border-style: solid; + --slds-c-button-spacing-inline-start: 0.625rem; + --slds-c-button-spacing-inline-end: 0.625rem; + --slds-c-button-neutral-spacing-inline-start: 0.625rem; + --slds-c-button-neutral-spacing-inline-end: 0.625rem; + + > .slds-grid { + flex: 1 1 auto; + width: 100%; + min-width: 0; + } + `; + + const scopePopoverOptionRowCss = css` + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + column-gap: 0.5rem; + `; + + const scopePopoverLabelCellCss = css` + min-width: 0; + `; + + const scopePopoverContent = ( +
+ + {scopeFilterOptions.length === 0 && ( +

No profiles or permission sets are recorded on these runs.

+ )} + {scopeFilterOptions.map((option) => { + const isSelected = scopeFilterParentId === option.id; + return ( + + ); + })} +
+ ); + + const modalHeader = analysisJobType === 'permission_export' ? 'Permission export history' : 'Field Usage History'; + const modalTagline = 'Runs for this org, newest first.'; + const emptyHeadline = analysisJobType === 'permission_export' ? 'No export jobs yet' : 'No Field Usage jobs yet'; + const emptySubHeading = + analysisJobType === 'permission_export' + ? 'Start a run from Permission Analysis selection, then open history again.' + : 'Start a run from Data Analysis selection, then open history again.'; + const searchPlaceholder = + analysisJobType === 'permission_export' + ? 'Filter by job id, status, time, or scope names' + : 'Filter by job id, status, time, or object API names'; + const scopeEmptyDetail = + analysisJobType === 'permission_export' ? 'No scope saved for this job.' : 'No objects recorded for this job payload.'; + + return ( + + + + } + directionalFooter + > + {loading && ( +
+ +
+ )} + {!loading && rows.length === 0 && } + {!loading && rows.length > 0 && ( +
+ +
+ +
+ {analysisJobType === 'permission_export' && ( +
+ +

+ Filter by Scope +

+ + } + bodyClassName="slds-popover__body slds-p-around_none" + bodyStyle={css` + box-sizing: border-box; + max-width: 100%; + margin: 0; + padding: 0; + overflow-x: hidden; + `} + content={scopePopoverContent} + tooltipProps={{ content: 'Filter runs by profile or permission set included in the export' }} + buttonProps={{ + className: 'slds-button slds-button_icon slds-button_icon-border-filled', + 'aria-label': 'Filter by Scope', + }} + > + +
+
+ )} +
+ {analysisJobType === 'permission_export' && scopeFilterParentId && activeScopeFilterLabel && ( +
+ + Showing runs that include + {activeScopeFilterLabel} + + +
+ )} +
+ Showing {formatNumber(filteredRows.length)} of {formatNumber(rows.length)} runs +
+
+ item.key === selectedKey} + onSelected={handleListSelect} + getContent={(item: PermissionAnalysisHistoryRow) => ({ + key: item.key, + heading: ( + + + {item.startedLabel} + + {formatAnalysisJobStatusForDisplay(item.status)} + + ), + subheading: `Job ${shortJobIdForDisplay(item.id)}`, + trailingHeader: + item.key === currentJobId ? ( + + ) : undefined, + children: + item.scopeBadges.length > 0 ? ( + + ) : ( +

{scopeEmptyDetail}

+ ), + })} + /> +
+

Select a row to open that run in this view.

+
+ )} +
+ ); +}; + +export default PermissionAnalysisHistoryModal; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisIssuesTab.tsx b/libs/features/manage-permissions/src/PermissionAnalysisIssuesTab.tsx new file mode 100644 index 000000000..e2135d802 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisIssuesTab.tsx @@ -0,0 +1,1161 @@ +import { css } from '@emotion/react'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import type { RenderCellProps } from '@jetstream/ui'; +import { + AutoFullHeightContainer, + Card, + ColumnWithFilter, + DataTable, + DataTree, + Icon, + Popover, + RowWithKey, + ScopedNotification, + setColumnFromType, +} from '@jetstream/ui'; +import { FunctionComponent, useCallback, useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { + isErrorSeverity, + ISSUES_GRID_COLUMN_KEYS, + ISSUES_GRID_COLUMN_LABELS, + isWarningSeverity, + type IssuesGridColumnKey, + type IssuesGroupBy, + type UsePermissionAnalysisIssuesFiltersResult, +} from './permission-analysis-issues-filters'; +import { + aggregatePermissionAnalysisFindings, + formatObjectLabelForModalSummary, + getFindingCodeDisplayParts, + getFindingContainerId, + type PermissionAnalysisFinding, + type PermissionFindingCodeRollup, + type PermissionFindingObjectRollup, + type SobjectExportDetail, +} from './permission-export-result-view'; + +export type { IssuesGroupBy } from './permission-analysis-issues-filters'; + +export interface PermissionAnalysisIssuesTabProps { + findings: PermissionAnalysisFinding[]; + /** Shared hook result from {@link PermissionAnalysisView} (single filter pipeline with the toolbar). */ + issuesFilters: UsePermissionAnalysisIssuesFiltersResult; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + /** Describe labels for object API names; when absent, rollup titles fall back to API names. */ + sobjectExportDetails?: Record; +} + +function normalizeSeverity(value: string | undefined): string { + return (value ?? '').toLowerCase(); +} + +function groupIssueFindingsByColumnKey( + rows: readonly PermissionAnalysisFinding[], + columnKey: keyof PermissionAnalysisFinding, +): Record { + const groups: Record = {}; + for (const row of rows) { + let key: string; + switch (columnKey) { + case 'severity': { + const normalized = normalizeSeverity(row.severity as string | undefined); + key = normalized.length > 0 ? normalized : '(none)'; + break; + } + case 'objectApiName': { + const raw = String(row.objectApiName ?? '').trim(); + key = raw.length > 0 ? raw : '(no object)'; + break; + } + case 'code': { + const raw = String(row.code ?? '').trim(); + key = raw.length > 0 ? raw : '(no code)'; + break; + } + case 'containerId': { + const id = getFindingContainerId(row); + key = id && id.length > 0 ? id : '(none)'; + break; + } + default: { + const raw = row[columnKey as keyof PermissionAnalysisFinding]; + key = raw != null && String(raw).trim().length > 0 ? String(raw) : '(none)'; + } + } + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(row); + } + return groups; +} + +const issuesTabFilterLegendCss = css` + display: block; + width: 100%; + float: none; + padding: 0; + margin-bottom: 0.375rem; +`; + +const issuesTabFilterHelpCss = css` + display: block; + width: 100%; + margin-bottom: 0.5rem; +`; + +const issuesTabVerticalRootCss = css` + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +`; + +const issuesWorkspaceCss = css` + position: relative; + display: flex; + flex: 1; + min-height: 0; + flex-direction: row; + align-items: stretch; +`; + +const issuesAggregatedRailCss = css` + flex-shrink: 0; + width: 2.75rem; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem 0.25rem; + border-right: 1px solid var(--slds-g-color-border-base-1, #c9c9c9); + background: var(--slds-g-color-neutral-base-95, #f3f3f3); +`; + +const issuesAggregatedRailToggleCss = css` + display: flex; + flex-direction: column; + align-items: center; + gap: 0.375rem; + cursor: pointer; + border: none; + background: transparent; + color: var(--slds-g-color-neutral-base-30, #444); + padding: 0.375rem 0.125rem; + border-radius: 0.25rem; + width: 100%; + + .slds-icon { + width: 1.25rem; + height: 1.25rem; + fill: currentColor; + } + + &:hover, + &:focus { + background: var(--slds-g-color-neutral-base-90, #e5e5e5); + outline: none; + } + + &:focus-visible { + box-shadow: 0 0 0 2px var(--slds-g-color-brand-base-50, #0176d3); + } +`; + +const issuesAggregatedRailLabelCss = css` + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + white-space: nowrap; + line-height: 1.2; +`; + +const issuesAggregatedRailCountCss = css` + margin-top: 0.5rem; + font-size: 0.65rem; + font-weight: 700; + color: var(--slds-g-color-neutral-base-30, #444); + writing-mode: vertical-rl; + transform: rotate(180deg); +`; + +const issuesMainPaneCss = css` + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +`; + +const issuesAggregatedBackdropCss = css` + position: absolute; + inset: 0; + z-index: 350; + border: none; + padding: 0; + margin: 0; + background: rgba(8, 7, 7, 0.28); + cursor: pointer; +`; + +const issuesAggregatedFlyoutCss = css` + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: min(28rem, calc(100% - 1rem)); + max-width: calc(100vw - 4rem); + z-index: 400; + display: flex; + flex-direction: column; + background: var(--slds-g-color-neutral-base-100, #fff); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.14); + border-right: 1px solid var(--slds-g-color-border-base-1, #c9c9c9); + overflow: hidden; +`; + +const issuesAggregatedFlyoutHeaderCss = css` + flex-shrink: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 0.75rem 0.5rem; + border-bottom: 1px solid var(--slds-g-color-border-base-1, #e5e5e5); +`; + +const issuesAggregatedFlyoutBodyCss = css` + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.75rem; +`; + +const issuesGridSectionCss = css` + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +`; + +const aggregatedFlyoutSectionsCss = css` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +const aggregatedNestedRollupsListCss = css` + display: flex; + flex-direction: column; + gap: 0.625rem; +`; + +const aggregatedRollupRowInteractiveCss = css` + cursor: pointer; + border-radius: 0.25rem; + + &:focus { + outline: none; + } + + &:focus-visible { + box-shadow: 0 0 0 2px var(--slds-g-color-brand-base-50, #0176d3); + outline: none; + } + + &:hover article.slds-card { + background: var(--slds-g-color-neutral-base-95, #f3f3f3); + } +`; + +const aggregatedRollupMetricsFooterCss = css` + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem 1rem; + width: 100%; +`; + +const aggregatedObjectRollupCardTitleCss = css` + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.125rem; + min-width: 0; + width: 100%; +`; + +const aggregatedRollupDrillInChevronCss = css` + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + color: var(--slds-g-color-neutral-base-50, #706e6b); + + svg { + width: 0.875rem; + height: 0.875rem; + fill: currentColor; + } +`; + +const aggregatedSectionToggleButtonCss = css` + && { + color: var(--slds-g-color-neutral-base-30, #444444); + } + + && svg { + fill: currentColor; + } +`; + +const aggregatedSectionToggleChevronSvgCss = (expanded: boolean) => css` + width: 0.875rem; + height: 0.875rem; + fill: currentColor; + transform: rotate(${expanded ? '90deg' : '0deg'}); + transition: transform 0.15s ease; +`; + +const FindingCodeInline: FunctionComponent<{ code: string | undefined }> = ({ code }) => { + const { title, technicalCode } = getFindingCodeDisplayParts(code); + return ( + + {title} + {technicalCode ? ( + + {' '} + ({technicalCode}) + + ) : null} + + ); +}; + +function aggregatedRollupRowKeyDown(onOpen: () => void) { + return (event: ReactKeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen(); + } + }; +} + +const AggregatedIssueCodeRollupCard: FunctionComponent<{ + row: PermissionFindingCodeRollup; + onOpen: () => void; +}> = ({ row, onOpen }) => { + const subtitle = + row.label.trim().length > 0 && row.label.trim() !== row.code ? ( +

{row.label}

+ ) : null; + + return ( +
+ } + actions={ + + } + footer={ +
+ + {row.count} + issue{row.count === 1 ? '' : 's'} + + + {row.errorCount} errors + · + {row.warningCount} warnings + +
+ } + > + {subtitle} +
+
+ ); +}; + +const AggregatedObjectRollupCardTitle: FunctionComponent<{ + objectApiName: string; + sobjectExportDetails: Record | undefined; +}> = ({ objectApiName, sobjectExportDetails }) => { + if (objectApiName === '(no object)') { + return No object; + } + + const { displayLabel, showApiInParens } = formatObjectLabelForModalSummary(objectApiName, sobjectExportDetails); + + return ( +
+ + {displayLabel} + + {showApiInParens ? ( + + {objectApiName} + + ) : null} +
+ ); +}; + +const AggregatedObjectRollupCard: FunctionComponent<{ + row: PermissionFindingObjectRollup; + sobjectExportDetails: Record | undefined; + onOpen: () => void; +}> = ({ row, sobjectExportDetails, onOpen }) => { + const hint = + row.objectApiName === '(no object)' ? ( +

These issues are not tied to a specific object.

+ ) : null; + + return ( +
+ } + actions={ + + } + footer={ +
+ + {row.count} + issue{row.count === 1 ? '' : 's'} + + + {row.errorCount} errors + · + {row.warningCount} warnings + +
+ } + > + {hint} +
+
+ ); +}; + +/** + * Maps Issues "Group By" to the grid column key used by {@link TreeDataGrid} `groupBy` / `rowGrouper`. + * Container grouping uses {@link getFindingContainerId} in the grouper (not only `containerId` on the row). + */ +function issuesGroupByToTreeColumnKey(groupBy: IssuesGroupBy): IssuesGridColumnKey | null { + switch (groupBy) { + case 'none': + return null; + case 'severity': + return 'severity'; + case 'object': + return 'objectApiName'; + case 'code': + return 'code'; + case 'container': + return 'containerId'; + default: + return null; + } +} + +function sortFindings(rows: PermissionAnalysisFinding[], groupBy: IssuesGroupBy): PermissionAnalysisFinding[] { + const copy = [...rows]; + const getter = (row: PermissionAnalysisFinding): string => { + switch (groupBy) { + case 'severity': + return normalizeSeverity(row.severity as string | undefined); + case 'object': + return String(row.objectApiName ?? ''); + case 'code': + return String(row.code ?? ''); + case 'container': + return getFindingContainerId(row) ?? ''; + default: + return ''; + } + }; + copy.sort((a, b) => getter(a).localeCompare(getter(b)) || String(a.message ?? '').localeCompare(String(b.message ?? ''))); + return copy; +} + +type StandardFindingColumnKey = + | 'severity' + | 'code' + | 'objectApiName' + | 'fieldApiName' + | 'message' + | 'permissionSetId' + | 'parentId' + | 'containerId'; + +function mapStandardFindingColumn(key: StandardFindingColumnKey): ColumnWithFilter { + const fieldType = key.endsWith('Id') ? 'salesforceId' : 'text'; + const base = setColumnFromType(key, fieldType); + const severityCellClass = (row: RowWithKey) => { + const finding = row as PermissionAnalysisFinding; + const severityValue = finding.severity as string | undefined; + if (isWarningSeverity(severityValue) && !isErrorSeverity(severityValue)) { + return 'permission-finding-severity-cell--warning'; + } + return undefined; + }; + const renderCell = + key === 'message' + ? ({ row }: RenderCellProps) => ( + + {String((row as PermissionAnalysisFinding).message ?? '')} + + ) + : key === 'code' + ? ({ row }: RenderCellProps) => { + const finding = row as PermissionAnalysisFinding; + const rawCode = finding.code; + const normalized = typeof rawCode === 'string' ? rawCode : undefined; + const { title: codeTitle, technicalCode } = getFindingCodeDisplayParts(normalized); + return ( + + {codeTitle} + {technicalCode ? ( + + {' '} + ({technicalCode}) + + ) : null} + + ); + } + : base.renderCell; + + return { + ...base, + name: ISSUES_GRID_COLUMN_LABELS[key], + key, + field: key, + resizable: true, + ...(key === 'severity' ? { cellClass: severityCellClass } : {}), + renderCell, + } as ColumnWithFilter; +} + +function buildFindingColumns(): ColumnWithFilter[] { + const keys: StandardFindingColumnKey[] = [ + 'severity', + 'code', + 'objectApiName', + 'fieldApiName', + 'message', + 'permissionSetId', + 'parentId', + 'containerId', + ]; + return keys.map(mapStandardFindingColumn); +} + +const FINDING_COLUMNS = buildFindingColumns(); + +const issuesTabGroupByTriggerClassName = 'slds-button slds-button_neutral'; + +interface IssuesFindingTreeDataGridProps { + sortedFindings: PermissionAnalysisFinding[]; + treeGroupColumnKey: IssuesGridColumnKey; + dataTreeColumns: ColumnWithFilter[]; + getRowKey: (row: PermissionAnalysisFinding) => string; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + onSortedAndFilteredRowsChange: (rows: readonly PermissionAnalysisFinding[]) => void; +} + +/** + * Mount with a changing `key` from the parent whenever toolbar-filtered rows change so expanded groups + * reset without a state-sync effect. + */ +const IssuesFindingTreeDataGrid: FunctionComponent = ({ + sortedFindings, + treeGroupColumnKey, + dataTreeColumns, + getRowKey, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + onSortedAndFilteredRowsChange, +}) => { + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => { + const grouped = groupIssueFindingsByColumnKey(sortedFindings, treeGroupColumnKey); + return new Set(Object.keys(grouped)); + }); + + return ( + []} + data={sortedFindings} + getRowKey={getRowKey} + includeQuickFilter + context={{ defaultApiVersion }} + groupBy={[treeGroupColumnKey]} + rowGrouper={groupIssueFindingsByColumnKey} + expandedGroupIds={expandedGroupIds} + onExpandedGroupIdsChange={(nextExpanded) => setExpandedGroupIds(nextExpanded)} + onSortedAndFilteredRowsChange={onSortedAndFilteredRowsChange} + rowClass={(row) => { + const severityValue = (row as PermissionAnalysisFinding).severity as string | undefined; + if (isErrorSeverity(severityValue)) { + return 'permission-finding-row--error'; + } + return undefined; + }} + /> + ); +}; + +export const PermissionAnalysisIssuesTab: FunctionComponent = ({ + findings, + issuesFilters, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + sobjectExportDetails, +}) => { + const [aggregatedDetailsModal, setAggregatedDetailsModal] = useState<{ + findings: PermissionAnalysisFinding[]; + title: string; + tagline: string; + summaryLine: string; + } | null>(null); + + const [gridFilteredFindings, setGridFilteredFindings] = useState(null); + const [aggregatedSidePanelOpen, setAggregatedSidePanelOpen] = useState(false); + const [aggregatedIssueCodeSectionExpanded, setAggregatedIssueCodeSectionExpanded] = useState(true); + const [aggregatedByObjectSectionExpanded, setAggregatedByObjectSectionExpanded] = useState(true); + + const { filteredFindings, groupBy, updateParams, hiddenIssueGridColumns } = issuesFilters; + + const gridColumns = useMemo(() => { + const filtered = FINDING_COLUMNS.filter((col) => !hiddenIssueGridColumns.has(col.key as IssuesGridColumnKey)); + return filtered.length > 0 ? filtered : FINDING_COLUMNS; + }, [hiddenIssueGridColumns]); + + const treeGroupColumnKey = issuesGroupByToTreeColumnKey(groupBy); + + const dataTreeColumns = useMemo(() => { + if (!treeGroupColumnKey) { + return gridColumns; + } + const hasGroupCol = gridColumns.some((col) => col.key === treeGroupColumnKey); + const groupColumnDef = FINDING_COLUMNS.find((col) => col.key === treeGroupColumnKey); + if (!groupColumnDef) { + return gridColumns; + } + if (hasGroupCol) { + return gridColumns; + } + return [groupColumnDef, ...gridColumns]; + }, [gridColumns, treeGroupColumnKey]); + + const setIssueColumnHidden = useCallback( + (columnKey: IssuesGridColumnKey, hide: boolean) => { + const nextHidden = new Set(hiddenIssueGridColumns); + if (hide) { + if (ISSUES_GRID_COLUMN_KEYS.length - nextHidden.size <= 1) { + return; + } + nextHidden.add(columnKey); + } else { + nextHidden.delete(columnKey); + } + updateParams({ issueHiddenCols: nextHidden.size === 0 ? null : [...nextHidden].sort().join(',') }); + }, + [hiddenIssueGridColumns, updateParams], + ); + + const sortedFindings = useMemo(() => sortFindings(filteredFindings, groupBy), [filteredFindings, groupBy]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- quick-filter state invalidates when sorted findings change + setGridFilteredFindings(null); + }, [sortedFindings]); + + const rollupFindings = gridFilteredFindings ?? sortedFindings; + + const aggregation = useMemo(() => aggregatePermissionAnalysisFindings(rollupFindings), [rollupFindings]); + + const openAggregatedDetailsForCode = useCallback( + (codeKey: string) => { + const matches = rollupFindings.filter((finding) => { + const codeRaw = String(finding.code ?? '').trim(); + const key = codeRaw.length > 0 ? codeRaw : '(no code)'; + return key === codeKey; + }); + const displayCode = codeKey === '(no code)' ? undefined : codeKey; + const { title: issueTitle } = getFindingCodeDisplayParts(displayCode); + const title = + codeKey === '(no code)' ? 'Issues with no code' : issueTitle.trim().length > 0 ? `Issues: ${issueTitle}` : `Issues: ${codeKey}`; + setAggregatedDetailsModal({ + findings: sortFindings(matches, groupBy), + title, + tagline: 'Issue details for the current filters.', + summaryLine: `${matches.length} issue${matches.length === 1 ? '' : 's'} for this issue code.`, + }); + }, + [rollupFindings, groupBy], + ); + + const openAggregatedDetailsForObject = useCallback( + (objectKey: string) => { + const matches = rollupFindings.filter((finding) => { + const objectRaw = String(finding.objectApiName ?? '').trim(); + const key = objectRaw.length > 0 ? objectRaw : '(no object)'; + return key === objectKey; + }); + const title = objectKey === '(no object)' ? 'Issues without object' : `Issues: ${objectKey}`; + setAggregatedDetailsModal({ + findings: sortFindings(matches, groupBy), + title, + tagline: 'Issue details for the current filters.', + summaryLine: `${matches.length} issue${matches.length === 1 ? '' : 's'} for this object.`, + }); + }, + [rollupFindings, groupBy], + ); + + const rowsMap = useMemo(() => new WeakMap(sortedFindings.map((row, index) => [row, `issue-${index}`])), [sortedFindings]); + const getRowKey = useCallback((row: PermissionAnalysisFinding) => rowsMap.get(row) ?? 'issue', [rowsMap]); + + const issueTreeMountKey = useMemo(() => sortedFindings.map(getRowKey).join('\u001f'), [sortedFindings, getRowKey]); + + const handleSortedAndFilteredRowsChange = useCallback((rows: readonly PermissionAnalysisFinding[]) => { + setGridFilteredFindings([...rows]); + }, []); + + useEffect(() => { + if (!aggregatedSidePanelOpen) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setAggregatedSidePanelOpen(false); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [aggregatedSidePanelOpen]); + + if (!findings.length) { + return ( +
+ + No issues for this job yet. Run a permission export analysis to evaluate object vs field read access; results include an + aggregated summary (opened from the left rail when issues exist), toolbar filters (same style as data table filters), column + filters on the grid, and Columns / Group By controls above the grid when issues exist. + +
+ ); + } + + const topCodeRollups = aggregation.byCode.slice(0, 12); + const topObjectRollups = aggregation.byObject.slice(0, 12); + + const aggregatedFlyoutBody = ( + <> +

+ Summary for the rows currently visible in the grid ({rollupFindings.length} row{rollupFindings.length === 1 ? '' : 's'}). Refine + results with toolbar filters and grid header filters; use Columns and Group By for layout and tree grouping. Select a card below to + open full issue messages and metadata. +

+
+ setAggregatedIssueCodeSectionExpanded((previous) => !previous)} + > + + + } + footer={ + aggregatedIssueCodeSectionExpanded && aggregation.byCode.length > topCodeRollups.length ? ( +

+ Showing top {topCodeRollups.length} of {aggregation.byCode.length} codes. +

+ ) : undefined + } + > + +
+ + setAggregatedByObjectSectionExpanded((previous) => !previous)} + > + + + } + footer={ + aggregatedByObjectSectionExpanded && aggregation.byObject.length > topObjectRollups.length ? ( +

+ Showing top {topObjectRollups.length} of {aggregation.byObject.length} objects. +

+ ) : undefined + } + > + +
+
+ + ); + + return ( +
+
+
+ + +
+ +
+ {aggregatedSidePanelOpen ? ( + <> + +
+
{aggregatedFlyoutBody}
+ + + ) : null} + +
+ +
+ + Visible columns + +

+ Uncheck to hide a column. At least one column stays visible. +

+
+ {ISSUES_GRID_COLUMN_KEYS.map((columnKey) => { + const visible = !hiddenIssueGridColumns.has(columnKey); + const disableUncheck = visible && ISSUES_GRID_COLUMN_KEYS.length - hiddenIssueGridColumns.size <= 1; + return ( +
+ setIssueColumnHidden(columnKey, !ev.target.checked)} + /> + +
+ ); + })} +
+
+
+ +
+
+ } + > + Columns + + +
+ + Issues group by + +
+ {( + [ + ['none', 'None (default sort)'], + ['severity', 'Severity'], + ['object', 'Object'], + ['code', 'Code'], + ['container', 'Container'], + ] as const + ).map(([value, label]) => ( +
+ updateParams({ cfGroup: value === 'none' ? null : value })} + /> + +
+ ))} +
+
+
+ } + > + Group By + +
+ + {findings.length > 0 && filteredFindings.length === 0 ? ( +
+ + No issues match the current toolbar filters. Clear them from the summary line under the toolbar, or change filter selections + there and in the toolbar popovers. + +
+ ) : null} + +
+ + {groupBy === 'none' || !treeGroupColumnKey ? ( + []} + data={sortedFindings} + getRowKey={getRowKey} + includeQuickFilter + autoRowHeight + context={{ defaultApiVersion }} + onSortedAndFilteredRowsChange={handleSortedAndFilteredRowsChange} + rowClass={(row) => { + const severityValue = (row as PermissionAnalysisFinding).severity as string | undefined; + if (isErrorSeverity(severityValue)) { + return 'permission-finding-row--error'; + } + return undefined; + }} + /> + ) : ( + + )} + +
+ + + + {aggregatedDetailsModal && ( + setAggregatedDetailsModal(null)} + /> + )} + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisObjectPermissionsTree.tsx b/libs/features/manage-permissions/src/PermissionAnalysisObjectPermissionsTree.tsx new file mode 100644 index 000000000..0573fe7ed --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisObjectPermissionsTree.tsx @@ -0,0 +1,482 @@ +import { css } from '@emotion/react'; +import { PermissionAnalysisExpandCollapseControls } from './PermissionAnalysisExpandCollapseControls'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import type { RenderCellProps, RenderGroupCellProps } from '@jetstream/ui'; +import { + AutoFullHeightContainer, + ColumnWithFilter, + DataTree, + Icon, + ScopedNotification, + getRowTypeFromValue, + setColumnFromType, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useState, type MouseEvent } from 'react'; +import { permissionAnalysisPermissionContainerGroupTitleLine } from './permission-analysis-tree-group-title'; +import { permissionAnalysisAssignmentTypeLabelCss } from './permission-analysis-viewer-badge.styles'; +import { + buildObjectPermissionFindingCellHighlights, + buildPermissionSetIdLabelMap, + formatObjectLabelForModalSummary, + getExportColumnHeaderLabel, + listFindingsForObjectPermissionCell, + objectPermissionFindingRowKey, + sortObjectPermissionExportRowsForAnalysisTree, + sortedObjectPermissionBooleanKeys, + type PermissionAnalysisFinding, + type PermissionExportRow, + type SobjectExportDetail, +} from './permission-export-result-view'; +import { SobjectTypeCellContent } from './PermissionAnalysisExportGrid'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; + +/** Grouped by profile or permission set (`ParentId`); `TreeDataGrid` clears `renderCell` on every `groupBy` column, so object + actions live on a separate `SobjectType` column. */ +const TREE_GROUP_BY = ['_treePermSetGroupKey'] as const; + +const OMIT_FROM_LEAF_KEYS = new Set(['attributes', 'Id', 'ParentId', 'SobjectType', '_treePermSetGroupKey', '_treeObjectGroupKey']); + +/** Permission set: grows with grid width but never exceeds {@link TREE_PERM_SET_MAX_PX}. */ +const TREE_PERM_SET_MIN_PX = 140; +const TREE_PERM_SET_MAX_PX = 420; +const TREE_COL_PERM_SET = `minmax(${TREE_PERM_SET_MIN_PX}px, min(${TREE_PERM_SET_MAX_PX}px, 1.35fr))`; + +/** Fixed-width object column (label + info + Object Manager), not a flexible `fr` track. */ +/** Label + tooltip + optional Object Manager only (no info popover). */ +const TREE_OBJECT_WIDTH_PX = 236; + +/** Boolean permission cells can shrink to ~104px before headers feel too tight. */ +const TREE_COL_PERMISSION_BOOL = 'minmax(104px, 0.42fr)'; + +const TREE_MIN_PERM_SET = TREE_PERM_SET_MIN_PX; +const TREE_MIN_PERMISSION_BOOL = 104; + +/** Default data row height; group rows are taller for type pill + title + count. */ +const TREE_ROW_HEIGHT_LEAF_PX = 35; +const TREE_ROW_HEIGHT_GROUP_PX = 68; + +export type ObjectPermissionTreeRow = PermissionExportRow & { + _treePermSetGroupKey: string; + _treeObjectGroupKey: string; +}; + +function buildObjectPermissionTreeRows(objectPermissionRows: PermissionExportRow[]): ObjectPermissionTreeRow[] { + return objectPermissionRows.map((row, index) => { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + return { + ...row, + _treePermSetGroupKey: parentId || `__missing_parent_${index}`, + _treeObjectGroupKey: sobjectType || `__missing_object_${index}`, + }; + }); +} + +function collectAllPermissionSetGroupIds(rows: ObjectPermissionTreeRow[]): Set { + return new Set(rows.map((row) => row._treePermSetGroupKey)); +} + +/** TreeDataGrid injects synthetic group rows; only Salesforce leaf rows have `ParentId`. */ +function isObjectPermissionLeafRow(row: unknown): row is ObjectPermissionTreeRow { + if (row === null || typeof row !== 'object') { + return false; + } + const record = row as Record; + return typeof record.ParentId === 'string' && record.ParentId.trim().length > 0; +} + +interface CellFindingsModalState { + parentId: string; + objectApiName: string; + columnKey: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +} + +function renderPermissionSetGroupCell( + labelByParentId: Map, + permissionSetRowById: Map, + { groupKey, childRows, isExpanded, toggleGroup }: RenderGroupCellProps, +) { + const id = String(groupKey); + const exportLabel = labelByParentId.get(id) ?? id; + const permSetRow = permissionSetRowById.get(id); + const isProfileOwned = permSetRow?.IsOwnedByProfile === true; + const titleLine = permissionAnalysisPermissionContainerGroupTitleLine(exportLabel, isProfileOwned); + const typeKind = isProfileOwned ? 'profile' : 'permission_set'; + const typeCaption = isProfileOwned ? 'Profile' : 'Permission set'; + return ( + + ); +} + +export interface PermissionAnalysisObjectPermissionsTreeProps { + objectPermissionRows: PermissionExportRow[]; + permissionSetRows: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + sobjectExportDetails?: Record; + /** When present, highlights object-level permission cells that correspond to each issue. */ + findings?: PermissionAnalysisFinding[]; +} + +/** + * Object permissions from the export, grouped by profile or permission set (`ParentId`), with Object + actions + * and CRUD / View All / Modify All columns on leaf rows. Groups sort profile-first then alphabetically; objects sort + * alphabetically by label within each group. + */ +export const PermissionAnalysisObjectPermissionsTree: FunctionComponent = ({ + objectPermissionRows, + permissionSetRows, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + sobjectExportDetails, + findings = [], +}) => { + const sortedObjectPermissionRows = useMemo( + () => sortObjectPermissionExportRowsForAnalysisTree(objectPermissionRows, permissionSetRows, sobjectExportDetails), + [objectPermissionRows, permissionSetRows, sobjectExportDetails], + ); + const treeRows = useMemo(() => buildObjectPermissionTreeRows(sortedObjectPermissionRows), [sortedObjectPermissionRows]); + const labelByParentId = useMemo(() => buildPermissionSetIdLabelMap(permissionSetRows), [permissionSetRows]); + const permissionSetRowById = useMemo(() => { + const map = new Map(); + for (const row of permissionSetRows) { + const rowId = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (rowId) { + map.set(rowId, row); + } + } + return map; + }, [permissionSetRows]); + const findingCellHighlights = useMemo(() => buildObjectPermissionFindingCellHighlights(findings), [findings]); + + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + const [cellFindingsModal, setCellFindingsModal] = useState(null); + + useEffect(() => { + setExpandedGroupIds(collectAllPermissionSetGroupIds(treeRows)); + }, [treeRows]); + + const objectManager = useMemo(() => ({ org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin }), [org, serverUrl, skipFrontdoorLogin]); + + const columns = useMemo((): ColumnWithFilter[] => { + if (!treeRows.length) { + return []; + } + const row0 = treeRows[0]; + const groupPermSetCol: ColumnWithFilter = { + ...setColumnFromType('_treePermSetGroupKey', 'text'), + name: 'Profile / Permission set', + key: '_treePermSetGroupKey', + field: '_treePermSetGroupKey', + resizable: true, + width: TREE_COL_PERM_SET, + minWidth: TREE_MIN_PERM_SET, + maxWidth: TREE_PERM_SET_MAX_PX, + // Group header spans the full row (clamped to the column count by the grid) so the label + actions + // lay out across the whole width instead of overflowing the narrow grouping column. + colSpan: ({ type }) => (type === 'GROUP' ? Number.MAX_SAFE_INTEGER : undefined), + renderGroupCell: (props) => renderPermissionSetGroupCell(labelByParentId, permissionSetRowById, props), + getValue: ({ row }) => { + const id = row._treePermSetGroupKey; + return labelByParentId.get(id) ?? id; + }, + } as ColumnWithFilter; + + const objectCol: ColumnWithFilter = { + ...setColumnFromType('SobjectType', 'textOrSalesforceId'), + name: 'Object', + key: 'SobjectType', + field: 'SobjectType', + resizable: false, + width: TREE_OBJECT_WIDTH_PX, + minWidth: TREE_OBJECT_WIDTH_PX, + maxWidth: TREE_OBJECT_WIDTH_PX, + getValue: ({ row }) => { + const api = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + if (!api) { + return null; + } + const detail = sobjectExportDetails?.[api]; + const label = detail?.label?.trim() ? detail.label.trim() : api; + const parts = [label, api]; + if (detail?.description != null && String(detail.description).trim().length > 0) { + parts.push(String(detail.description).trim()); + } + return parts.join(' '); + }, + renderCell: (props: RenderCellProps) => { + const raw = props.row?.SobjectType; + const apiName = typeof raw === 'string' ? raw.trim() : ''; + if (!apiName) { + return
; + } + const detail = sobjectExportDetails?.[apiName]; + return ; + }, + } as ColumnWithFilter; + + const permissionCols: ColumnWithFilter[] = []; + for (const key of sortedObjectPermissionBooleanKeys(treeRows)) { + if (OMIT_FROM_LEAF_KEYS.has(key)) { + continue; + } + const fieldType = getRowTypeFromValue(row0[key], false); + const headerLabel = getExportColumnHeaderLabel(key); + const columnKey = key; + permissionCols.push({ + ...setColumnFromType(key, fieldType), + name: headerLabel, + key, + field: key, + resizable: true, + width: TREE_COL_PERMISSION_BOOL, + minWidth: TREE_MIN_PERMISSION_BOOL, + cellClass: (row: ObjectPermissionTreeRow) => { + if (!isObjectPermissionLeafRow(row)) { + return undefined; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + if (!parentId || !sobjectType) { + return undefined; + } + const rowKey = objectPermissionFindingRowKey(parentId, sobjectType); + const severity = findingCellHighlights.get(rowKey)?.get(columnKey); + if (severity === 'error') { + return 'permission-finding-cell--error permission-finding-cell--clickable'; + } + if (severity === 'warning') { + return 'permission-finding-severity-cell--warning permission-finding-cell--clickable'; + } + return undefined; + }, + } as ColumnWithFilter); + } + + return [groupPermSetCol, objectCol, ...permissionCols]; + }, [treeRows, labelByParentId, permissionSetRowById, sobjectExportDetails, objectManager, findingCellHighlights]); + + const getRowKey = useCallback((row: ObjectPermissionTreeRow) => { + if (typeof row.Id === 'string' && row.Id.length > 0) { + return row.Id; + } + return `${row._treePermSetGroupKey}::${row._treeObjectGroupKey}`; + }, []); + + const openCellFindings = useCallback( + (row: ObjectPermissionTreeRow, columnKey: string, columnLabel: string) => { + if (!isObjectPermissionLeafRow(row)) { + return; + } + if (!columnKey.startsWith('Permissions')) { + return; + } + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const objectApiName = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + if (!parentId || !objectApiName) { + return; + } + const rowKey = objectPermissionFindingRowKey(parentId, objectApiName); + const highlightSeverity = findingCellHighlights.get(rowKey)?.get(columnKey); + if (!highlightSeverity) { + return; + } + const matches = listFindingsForObjectPermissionCell(findings, parentId, objectApiName, columnKey); + if (matches.length === 0) { + return; + } + setCellFindingsModal({ + parentId, + objectApiName, + columnKey, + columnLabel, + matches, + }); + }, + [findingCellHighlights, findings], + ); + + // The new grid has no `onCellClick`, so resolve the clicked cell from the rendered DOM: `data-col-id` + // is the column key and `data-row-id` is `getRowKey(row)` (see buildColumnDefs / getRowId). + const rowByKey = useMemo(() => { + const map = new Map(); + for (const row of treeRows) { + map.set(getRowKey(row), row); + } + return map; + }, [treeRows, getRowKey]); + + const columnLabelByKey = useMemo(() => { + const map = new Map(); + for (const column of columns) { + if (typeof column.key === 'string') { + map.set(column.key, typeof column.name === 'string' && column.name.trim().length > 0 ? column.name : column.key); + } + } + return map; + }, [columns]); + + const handleGridClick = useCallback( + (event: MouseEvent) => { + const cellEl = (event.target as HTMLElement).closest('.jgrid-cell[data-col-id]'); + const columnKey = cellEl?.getAttribute('data-col-id') ?? ''; + if (!columnKey.startsWith('Permissions')) { + return; + } + const rowId = cellEl?.closest('.jgrid-row[data-row-id]')?.getAttribute('data-row-id'); + const row = rowId ? rowByKey.get(rowId) : undefined; + if (!row) { + return; + } + openCellFindings(row, columnKey, columnLabelByKey.get(columnKey) ?? columnKey); + }, + [rowByKey, columnLabelByKey, openCellFindings], + ); + + const cellModalObjectSummary = useMemo(() => { + if (!cellFindingsModal) { + return null; + } + return formatObjectLabelForModalSummary(cellFindingsModal.objectApiName, sobjectExportDetails); + }, [cellFindingsModal, sobjectExportDetails]); + + if (!objectPermissionRows.length) { + return ( +
+ No object permission rows in this export. +
+ ); + } + + return ( + <> + setExpandedGroupIds(collectAllPermissionSetGroupIds(treeRows))} + onCollapseAll={() => setExpandedGroupIds(new Set())} + /> + + {/* The grid offers no onCellClick; delegate clicks to resolve the finding cell from the DOM. */} +
+ (type === 'GROUP' ? TREE_ROW_HEIGHT_GROUP_PX : TREE_ROW_HEIGHT_LEAF_PX)} + /> +
+ + {cellFindingsModal && ( + setCellFindingsModal(null)} + findings={cellFindingsModal.matches} + summaryLine={ + + {cellFindingsModal.columnLabel} + {' · '} + {cellModalObjectSummary?.displayLabel ? ( + cellModalObjectSummary.showApiInParens ? ( + + {cellModalObjectSummary.displayLabel} + + {' '} + ({cellFindingsModal.objectApiName}) + + + ) : ( + {cellModalObjectSummary.displayLabel} + ) + ) : null} + {' · '} + {labelByParentId.get(cellFindingsModal.parentId) ?? cellFindingsModal.parentId} — {cellFindingsModal.matches.length}{' '} + {cellFindingsModal.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} +
+ + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisPermissionSetsTree.tsx b/libs/features/manage-permissions/src/PermissionAnalysisPermissionSetsTree.tsx new file mode 100644 index 000000000..4ff4cf441 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisPermissionSetsTree.tsx @@ -0,0 +1,943 @@ +import { css } from '@emotion/react'; +import { PermissionAnalysisExpandCollapseControls } from './PermissionAnalysisExpandCollapseControls'; +import { logger } from '@jetstream/shared/client-logger'; +import { query } from '@jetstream/shared/data'; +import { escapeSoqlString } from '@jetstream/shared/ui-utils'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import type { RenderCellProps, RenderGroupCellProps } from '@jetstream/ui'; +import { + AutoFullHeightContainer, + Badge, + ColumnWithFilter, + dataTableDateFormatter, + DataTree, + getProfileOrPermSetSetupUrl, + getSalesforceUserManageSetupUrl, + Grid, + GridCol, + Icon, + KeyboardShortcut, + Popover, + ReadOnlyFormElement, + SalesforceLogin, + salesforceLoginAndRedirect, + ScopedNotification, + setColumnFromType, + Spinner, + type ProfileOrPermSetRecordType, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { + Fragment, + FunctionComponent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, + type MouseEvent, + type ReactElement, +} from 'react'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { + buildContainerIdFindingSeverity, + buildPermissionSetAssignmentsTreeRows, + buildPermissionSetIdLabelMap, + isPermissionSetAssignmentsTreePlaceholderLeaf, + isPermissionSetAssignmentsTreeUserLeaf, + listFindingsForExportContainer, + type PermissionAnalysisFinding, + type PermissionExportRow, + type PermissionSetAssignmentsTreeRow, +} from './permission-export-result-view'; + +const TREE_GROUP_BY = ['_treePermissionSetGroupKey'] as const; + +const TREE_PERM_SET_MIN_PX = 140; +const TREE_PERM_SET_MAX_PX = 420; +const TREE_COL_PERM_SET = `minmax(${TREE_PERM_SET_MIN_PX}px, min(${TREE_PERM_SET_MAX_PX}px, 1.35fr))`; + +const USER_COL_MIN_PX = 200; +const TREE_COL_USER = `minmax(${USER_COL_MIN_PX}px, 1fr)`; + +/** Chunk size for `User` Id IN queries (Salesforce SOQL limits). */ +const USER_SOQL_CHUNK_SIZE = 200; + +const TREE_ROW_HEIGHT_LEAF_PX = 48; +/** Taller group rows so permission set label + created / last modified lines fit. */ +const TREE_ROW_HEIGHT_GROUP_PX = 60; + +const OBJECT_TYPE_ACTION_BUTTON_CLASSNAME = 'slds-button slds-button_icon slds-button_icon-bare'; + +const PERMISSION_ANALYSIS_POPOVER_PANEL_PROPS = { + onDoubleClick: (event: MouseEvent) => { + event.stopPropagation(); + }, +}; + +interface AssigneeDisplay { + name: string; + username: string; + /** When `false`, an inactive badge is shown next to the name. */ + isActive: boolean; +} + +function userRecordIsActive(record: { IsActive?: unknown }): boolean { + const value = record.IsActive; + if (value === false || value === 'false') { + return false; + } + return true; +} + +function collectUniqueUserAssigneeIds(assignments: PermissionExportRow[]): string[] { + const ids = new Set(); + for (const row of assignments) { + const assigneeId = row.AssigneeId; + if (typeof assigneeId === 'string' && assigneeId.trim().startsWith('005')) { + ids.add(assigneeId.trim()); + } + } + return [...ids].sort((a, b) => a.localeCompare(b)); +} + +export interface PermissionSetTooltipFields { + label: string; + name: string; + description: string | null; + createdWhen: string | null; + createdByName: string | null; + lastModifiedWhen: string | null; + lastModifiedByName: string | null; +} + +function readSalesforceRelationshipName(value: unknown): string | null { + if (value && typeof value === 'object' && 'Name' in value) { + const name = (value as { Name?: unknown }).Name; + if (typeof name === 'string' && name.trim()) { + return name.trim(); + } + } + return null; +} + +function readIsoDatetimeDisplay(value: unknown): string | null { + if (typeof value !== 'string' || !value.trim()) { + return null; + } + return dataTableDateFormatter(value.trim()); +} + +function formatAuditLine(prefix: string, when: string | null, by: string | null): string | null { + if (!when && !by) { + return null; + } + if (when && by) { + return `${prefix} ${when} · ${by}`; + } + if (when) { + return `${prefix} ${when}`; + } + return `${prefix} · ${by}`; +} + +/** Single permission-set (or profile-owned) export row → metadata for the detail popover. */ +export function buildPermissionSetTooltipFieldsFromExportRow(row: PermissionExportRow | undefined): PermissionSetTooltipFields | null { + if (!row) { + return null; + } + const id = row.Id; + if (typeof id !== 'string' || !id.trim()) { + return null; + } + const trimmedId = id.trim(); + const label = typeof row.Label === 'string' && row.Label.trim() ? row.Label.trim() : ''; + const name = typeof row.Name === 'string' && row.Name.trim() ? row.Name.trim() : ''; + const rawDescription = row.Description; + const description = rawDescription != null && String(rawDescription).trim().length > 0 ? String(rawDescription).trim() : null; + return { + label: label || name || trimmedId, + name: name || '—', + description, + createdWhen: readIsoDatetimeDisplay(row.CreatedDate), + createdByName: readSalesforceRelationshipName(row.CreatedBy), + lastModifiedWhen: readIsoDatetimeDisplay(row.LastModifiedDate), + lastModifiedByName: readSalesforceRelationshipName(row.LastModifiedBy), + }; +} + +export function PermissionSetDetailPopoverContent({ + fields, + containerKind, + setupLogin, + returnUrl, + slug, +}: { + fields: PermissionSetTooltipFields; + containerKind: 'Profile' | 'PermissionSet'; + setupLogin: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }; + returnUrl: string; + slug: string; +}): ReactElement { + const labelHeading = containerKind === 'Profile' ? 'Profile Label' : 'Permission Set Label'; + const apiHeading = containerKind === 'Profile' ? 'Profile API Name' : 'Permission Set API Name'; + const canDeepLink = Boolean(setupLogin.org?.uniqueId && setupLogin.serverUrl); + const createdLine = formatAuditLine('Created', fields.createdWhen, fields.createdByName); + const modifiedLine = formatAuditLine('Last Modified', fields.lastModifiedWhen, fields.lastModifiedByName); + + return ( +
+ {canDeepLink ? ( + + View in Salesforce + + ) : null} + + + + + + + + + + + + + + + + + {canDeepLink ? ( + +
+ Use to skip this popup +
+
+ ) : null} +
+
+ ); +} + +function buildPermissionSetTooltipFieldsById(rows: PermissionExportRow[]): Map { + const map = new Map(); + for (const row of rows) { + const id = row.Id; + if (typeof id !== 'string' || !id.trim()) { + continue; + } + const fields = buildPermissionSetTooltipFieldsFromExportRow(row); + if (fields) { + map.set(id.trim(), fields); + } + } + return map; +} + +type ContainerFindingsModalState = { + containerId: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +}; + +function renderPermissionSetGroupCell( + labelByPermissionSetId: Map, + tooltipByPermissionSetId: Map, + containerSeverity: Map | null, + setupLogin: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }, + onOpenFindings: (permissionSetId: string) => void, + resolveSetupTarget: (permissionSetId: string) => { recordType: ProfileOrPermSetRecordType; recordId: string }, + openInSetupTitle: string, + findingsForContainerButtonTitle: string, + { groupKey, childRows, isExpanded, toggleGroup }: RenderGroupCellProps, +) { + const permissionSetId = String(groupKey); + const titleLabel = labelByPermissionSetId.get(permissionSetId) ?? permissionSetId; + const tooltipFields = tooltipByPermissionSetId.get(permissionSetId) ?? { + label: titleLabel, + name: '—', + description: null, + createdWhen: null, + createdByName: null, + lastModifiedWhen: null, + lastModifiedByName: null, + }; + const createdLine = formatAuditLine('Created', tooltipFields.createdWhen, tooltipFields.createdByName); + const modifiedLine = formatAuditLine('Last Modified', tooltipFields.lastModifiedWhen, tooltipFields.lastModifiedByName); + const severity = containerSeverity?.get(permissionSetId); + const { recordType, recordId } = resolveSetupTarget(permissionSetId); + const returnUrl = getProfileOrPermSetSetupUrl(recordType, recordId); + + const detailSlug = permissionSetId.replace(/[^a-zA-Z0-9_-]+/g, '-'); + const containerKind: 'Profile' | 'PermissionSet' = recordType === 'Profile' ? 'Profile' : 'PermissionSet'; + const canDeepLink = Boolean(setupLogin.org?.uniqueId && setupLogin.serverUrl); + + return ( +
+
+ + + } + buttonProps={{ + className: 'slds-button slds-button_reset slds-text-align_left', + }} + buttonStyle={{ + flex: 1, + minWidth: 0, + height: 'auto', + alignItems: 'flex-start', + display: 'flex', + lineHeight: 1.35, + overflowWrap: 'anywhere', + whiteSpace: 'normal', + wordBreak: 'break-word', + padding: 0, + }} + > + ) => { + if (event.shiftKey || event.ctrlKey || event.metaKey) { + if (!canDeepLink) { + return; + } + event.stopPropagation(); + event.preventDefault(); + salesforceLoginAndRedirect({ + serverUrl: setupLogin.serverUrl, + org: setupLogin.org, + returnUrl, + skipFrontDoorAuth: setupLogin.skipFrontDoorAuth, + }); + } + }} + > + + {titleLabel} + ({childRows.length}) + + {(createdLine || modifiedLine) && ( +
+ {createdLine && ( +
+ {createdLine} +
+ )} + {modifiedLine && ( +
+ {modifiedLine} +
+ )} +
+ )} +
+
+
+
+ { + event.stopPropagation(); + }} + > + + + {severity && ( + + )} +
+
+ ); +} + +export type PermissionAnalysisPermissionSetsTreePresentation = 'permission_sets' | 'profiles'; + +export interface PermissionAnalysisPermissionSetsTreeProps { + permissionSetRows: PermissionExportRow[]; + permissionSetAssignments: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + findings?: PermissionAnalysisFinding[]; + containerLabelById?: Map; + /** + * `profiles` uses Profile Setup URLs (via `ProfileId` on profile-owned permission set rows) and profile-oriented labels. + * `permission_sets` (default) matches standalone permission sets. + */ + treePresentation?: PermissionAnalysisPermissionSetsTreePresentation; +} + +/** + * Permission sets or profiles (profile-owned permission sets) from the export, grouped by container with tooltip metadata + * and expandable user assignment leaves. Use {@link PermissionAnalysisPermissionSetsTreeProps.treePresentation} to match the tab. + * Groups start expanded. + */ +export const PermissionAnalysisPermissionSetsTree: FunctionComponent = ({ + permissionSetRows, + permissionSetAssignments, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + findings = [], + containerLabelById, + treePresentation = 'permission_sets', +}) => { + const isProfilesTree = treePresentation === 'profiles'; + const groupColumnName = isProfilesTree ? 'Profile' : 'Permission Set'; + const openInSetupTitle = isProfilesTree ? 'Open this profile in Salesforce Setup' : 'Open this permission set in Salesforce Setup'; + const findingsForContainerButtonTitle = isProfilesTree ? 'View issues for this profile' : 'View issues for this permission set'; + + const rowByPermissionSetId = useMemo(() => { + const map = new Map(); + for (const row of permissionSetRows) { + const id = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (id) { + map.set(id, row); + } + } + return map; + }, [permissionSetRows]); + + const resolveSetupTarget = useCallback( + (permissionSetId: string): { recordType: ProfileOrPermSetRecordType; recordId: string } => { + if (!isProfilesTree) { + return { recordType: 'PermissionSet', recordId: permissionSetId }; + } + const row = rowByPermissionSetId.get(permissionSetId); + const profileId = row && typeof row.ProfileId === 'string' && row.ProfileId.trim().length > 0 ? row.ProfileId.trim() : null; + const isProfileOwned = row?.IsOwnedByProfile === true; + if (isProfileOwned && profileId) { + return { recordType: 'Profile', recordId: profileId }; + } + return { recordType: 'PermissionSet', recordId: permissionSetId }; + }, + [isProfilesTree, rowByPermissionSetId], + ); + + const treeRows = useMemo( + () => buildPermissionSetAssignmentsTreeRows(permissionSetRows, permissionSetAssignments), + [permissionSetRows, permissionSetAssignments], + ); + const labelByPermissionSetId = useMemo(() => buildPermissionSetIdLabelMap(permissionSetRows), [permissionSetRows]); + const tooltipByPermissionSetId = useMemo(() => buildPermissionSetTooltipFieldsById(permissionSetRows), [permissionSetRows]); + const containerSeverity = useMemo(() => { + if (findings.length === 0) { + return null; + } + return buildContainerIdFindingSeverity(findings); + }, [findings]); + + const allExpandedGroupIds = useMemo(() => { + const ids = new Set(); + for (const row of treeRows) { + ids.add(row._treePermissionSetGroupKey); + } + return ids; + }, [treeRows]); + + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + useLayoutEffect(() => { + setExpandedGroupIds(new Set(allExpandedGroupIds)); + }, [allExpandedGroupIds]); + + const [findingsModal, setFindingsModal] = useState(null); + const [assigneeDisplayById, setAssigneeDisplayById] = useState>(() => new Map()); + const [assigneeDisplayLoading, setAssigneeDisplayLoading] = useState(false); + + useEffect(() => { + if (!org?.uniqueId) { + setAssigneeDisplayById(new Map()); + setAssigneeDisplayLoading(false); + return; + } + const ids = collectUniqueUserAssigneeIds(permissionSetAssignments); + if (ids.length === 0) { + setAssigneeDisplayById(new Map()); + setAssigneeDisplayLoading(false); + return; + } + + let cancelled = false; + setAssigneeDisplayLoading(true); + + void (async () => { + try { + const merged = new Map(); + for (let index = 0; index < ids.length; index += USER_SOQL_CHUNK_SIZE) { + const chunk = ids.slice(index, index + USER_SOQL_CHUNK_SIZE); + const inList = chunk.map((id) => `'${escapeSoqlString(id)}'`).join(', '); + const soql = `SELECT Id, Name, Username, IsActive FROM User WHERE Id IN (${inList})`; + const response = await query<{ Id: string; Name?: string; Username?: string; IsActive?: boolean }>(org, soql); + for (const record of response.queryResults.records ?? []) { + const recordId = typeof record.Id === 'string' ? record.Id.trim() : ''; + if (!recordId) { + continue; + } + merged.set(recordId, { + name: typeof record.Name === 'string' && record.Name.trim() ? record.Name.trim() : recordId, + username: typeof record.Username === 'string' && record.Username.trim() ? record.Username.trim() : '', + isActive: userRecordIsActive(record), + }); + } + } + if (!cancelled) { + setAssigneeDisplayById(merged); + } + } catch (error) { + logger.warn('Failed to load User rows for permission set tree assignees', error); + if (!cancelled) { + setAssigneeDisplayById(new Map()); + } + } finally { + if (!cancelled) { + setAssigneeDisplayLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [org, permissionSetAssignments]); + + const setupLogin = useMemo(() => ({ org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin }), [org, serverUrl, skipFrontdoorLogin]); + + const openFindingsForPermissionSet = useCallback( + (permissionSetId: string) => { + const id = permissionSetId.trim(); + if (!id || !containerSeverity?.has(id)) { + return; + } + const matches = listFindingsForExportContainer(findings, id); + if (matches.length === 0) { + return; + } + setFindingsModal({ + containerId: id, + columnLabel: groupColumnName, + matches, + }); + }, + [containerSeverity, findings, groupColumnName], + ); + + const columns = useMemo((): ColumnWithFilter[] => { + if (!treeRows.length) { + return []; + } + const groupCol: ColumnWithFilter = { + ...setColumnFromType('_treePermissionSetGroupKey', 'text'), + name: groupColumnName, + key: '_treePermissionSetGroupKey', + field: '_treePermissionSetGroupKey', + resizable: true, + width: TREE_COL_PERM_SET, + minWidth: TREE_PERM_SET_MIN_PX, + maxWidth: TREE_PERM_SET_MAX_PX, + // Group header spans the full row (clamped to the column count by the grid) so the label + actions + // lay out across the whole width instead of overflowing the narrow grouping column. + colSpan: ({ type }) => (type === 'GROUP' ? Number.MAX_SAFE_INTEGER : undefined), + renderGroupCell: (props) => + renderPermissionSetGroupCell( + labelByPermissionSetId, + tooltipByPermissionSetId, + containerSeverity, + setupLogin, + openFindingsForPermissionSet, + resolveSetupTarget, + openInSetupTitle, + findingsForContainerButtonTitle, + props, + ), + getValue: ({ row }) => { + const id = row._treePermissionSetGroupKey; + return labelByPermissionSetId.get(id) ?? id; + }, + } as ColumnWithFilter; + + const userCol: ColumnWithFilter = { + ...setColumnFromType('AssigneeId', 'salesforceId'), + name: 'Assigned User', + key: 'AssigneeId', + field: 'AssigneeId', + resizable: true, + width: TREE_COL_USER, + minWidth: USER_COL_MIN_PX, + getValue: ({ row }) => { + if (isPermissionSetAssignmentsTreeUserLeaf(row)) { + const userLeaf = row; + const userId = typeof userLeaf.AssigneeId === 'string' ? userLeaf.AssigneeId.trim() : ''; + const display = userId ? assigneeDisplayById.get(userId) : undefined; + if (display) { + const inactiveTag = display.isActive ? '' : ' inactive'; + return `${display.name} ${display.username}${inactiveTag} ${userId}`.trim(); + } + return userId; + } + if (isPermissionSetAssignmentsTreePlaceholderLeaf(row)) { + return 'No direct user assignments'; + } + return ''; + }, + renderCell: (props: RenderCellProps) => { + const row = props.row; + if (!row) { + return null; + } + if (isPermissionSetAssignmentsTreeUserLeaf(row)) { + const userId = typeof row.AssigneeId === 'string' ? row.AssigneeId.trim() : ''; + if (!userId) { + return null; + } + const userReturnUrl = getSalesforceUserManageSetupUrl(userId); + const openUserButton = ( + { + event.stopPropagation(); + }} + > + + + ); + if (assigneeDisplayLoading) { + return ( +
+ + {openUserButton} +
+ ); + } + const display = assigneeDisplayById.get(userId); + if (display) { + const nameTitle = `${display.name} — ${display.username}${display.isActive ? '' : ' (inactive)'}`; + return ( +
+
+
+ {display.name} + {!display.isActive && ( + + + Inactive + + + )} +
+

{display.username}

+
+
{openUserButton}
+
+ ); + } + return ( +
+
+ {userId} +
+
{openUserButton}
+
+ ); + } + if (isPermissionSetAssignmentsTreePlaceholderLeaf(row)) { + return
No direct user assignments
; + } + return null; + }, + } as ColumnWithFilter; + + return [groupCol, userCol]; + }, [ + treeRows, + groupColumnName, + labelByPermissionSetId, + tooltipByPermissionSetId, + containerSeverity, + setupLogin, + openFindingsForPermissionSet, + resolveSetupTarget, + openInSetupTitle, + findingsForContainerButtonTitle, + assigneeDisplayById, + assigneeDisplayLoading, + ]); + + const getRowKey = useCallback((row: PermissionSetAssignmentsTreeRow) => { + if (typeof row.Id === 'string' && row.Id.length > 0) { + return row.Id; + } + return row._treePermissionSetGroupKey; + }, []); + + if (!permissionSetRows.length) { + return ( +
+ + {isProfilesTree ? 'No profiles in this export slice.' : 'No permission sets in this export slice.'} + +
+ ); + } + + if (!treeRows.length) { + return ( +
+ + {isProfilesTree + ? 'No profile rows with Ids were available to build this tree.' + : 'No permission set rows with Ids were available to build this tree.'} + +
+ ); + } + + return ( + <> + setExpandedGroupIds(new Set(allExpandedGroupIds))} + onCollapseAll={() => setExpandedGroupIds(new Set())} + /> + + (type === 'GROUP' ? TREE_ROW_HEIGHT_GROUP_PX : TREE_ROW_HEIGHT_LEAF_PX)} + /> + + {findingsModal && ( + setFindingsModal(null)} + findings={findingsModal.matches} + summaryLine={ + + {findingsModal.columnLabel} + {' · '} + {containerLabelById?.get(findingsModal.containerId) ?? findingsModal.containerId} — {findingsModal.matches.length}{' '} + {findingsModal.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} + + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisSelection.tsx b/libs/features/manage-permissions/src/PermissionAnalysisSelection.tsx new file mode 100644 index 000000000..4d972d710 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisSelection.tsx @@ -0,0 +1,9 @@ +import { FunctionComponent } from 'react'; +import { ManagePermissionsSelection } from './ManagePermissionsSelection'; + +/** Permission analysis entry: same selection UX as Manage Permissions; object selection is optional (used to scope the export). */ +export const PermissionAnalysisSelection: FunctionComponent = () => ( + +); + +export default PermissionAnalysisSelection; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisTabVisibilityTree.tsx b/libs/features/manage-permissions/src/PermissionAnalysisTabVisibilityTree.tsx new file mode 100644 index 000000000..b64d3ae53 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisTabVisibilityTree.tsx @@ -0,0 +1,421 @@ +import { css } from '@emotion/react'; +import { PermissionAnalysisExpandCollapseControls } from './PermissionAnalysisExpandCollapseControls'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import type { RenderCellProps, RenderGroupCellProps } from '@jetstream/ui'; +import { + AutoFullHeightContainer, + ColumnWithFilter, + DataTree, + Icon, + SalesforceLogin, + ScopedNotification, + getProfileOrPermSetSetupUrl, + getRowTypeFromValue, + setColumnFromType, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; +import { usePermissionAnalysisExportMetadata } from './permission-analysis-export-metadata-context'; +import { permissionAnalysisPermissionContainerGroupTitleLine } from './permission-analysis-tree-group-title'; +import { permissionAnalysisAssignmentTypeLabelCss } from './permission-analysis-viewer-badge.styles'; +import { + buildContainerIdFindingSeverity, + buildPermissionSetIdLabelMap, + formatTabSettingVisibilityDisplay, + getExportColumnHeaderLabel, + listFindingsForExportContainer, + sortTabSettingExportRowsForAnalysisTree, + type PermissionAnalysisFinding, + type PermissionExportRow, + type PermissionObjectFindingCellSeverity, +} from './permission-export-result-view'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; + +const TREE_GROUP_BY = ['_treeParentGroupKey'] as const; + +const TREE_PARENT_COL = `minmax(160px, min(380px, 1.35fr))`; +const TREE_TAB_COL = `minmax(180px, 1fr)`; +const TREE_VISIBILITY_COL = `minmax(120px, 0.45fr)`; + +const TREE_ROW_HEIGHT_LEAF_PX = 35; +const TREE_ROW_HEIGHT_GROUP_PX = 68; + +const OBJECT_TYPE_ACTION_BUTTON_CLASSNAME = 'slds-button slds-button_icon slds-button_icon-bare'; + +export type TabVisibilityTreeRow = PermissionExportRow & { + _treeParentGroupKey: string; + _treeTabKey: string; +}; + +function buildTabVisibilityTreeRows(rows: PermissionExportRow[]): TabVisibilityTreeRow[] { + return rows.map((row, index) => { + const parentId = typeof row.ParentId === 'string' ? row.ParentId.trim() : ''; + const tabName = typeof row.Name === 'string' ? row.Name.trim() : ''; + return { + ...row, + _treeParentGroupKey: parentId || `__missing_parent_${index}`, + _treeTabKey: tabName || `__missing_tab_${index}`, + }; + }); +} + +function collectAllParentGroupKeys(rows: TabVisibilityTreeRow[]): Set { + return new Set(rows.map((row) => row._treeParentGroupKey)); +} + +function renderTabVisibilityGroupCell( + labelByParentId: Map, + permissionSetRowById: Map, + setupLogin: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }, + containerSeverity: Map | null, + onOpenFindingsForParent: (parentId: string) => void, + { groupKey, childRows, isExpanded, toggleGroup }: RenderGroupCellProps, +) { + const id = String(groupKey); + const exportLabel = labelByParentId.get(id) ?? id; + const severity = containerSeverity?.get(id); + const permSetRow = permissionSetRowById.get(id); + const isProfileOwned = permSetRow?.IsOwnedByProfile === true; + const titleLine = permissionAnalysisPermissionContainerGroupTitleLine(exportLabel, isProfileOwned); + const typeKind = isProfileOwned ? 'profile' : 'permission_set'; + const typeCaption = isProfileOwned ? 'Profile' : 'Permission set'; + const profileId = + permSetRow && typeof permSetRow.ProfileId === 'string' && permSetRow.ProfileId.trim().length > 0 ? permSetRow.ProfileId.trim() : null; + const recordType = isProfileOwned && profileId ? 'Profile' : 'PermissionSet'; + const setupTargetId = recordType === 'Profile' && profileId ? profileId : id; + const returnUrl = getProfileOrPermSetSetupUrl(recordType, setupTargetId); + + return ( +
+ +
+ { + event.stopPropagation(); + }} + > + + + {severity ? ( + + ) : null} +
+
+ ); +} + +export interface PermissionAnalysisTabVisibilityTreeProps { + tabSettingRows: PermissionExportRow[]; + permissionSetRows: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + findings?: PermissionAnalysisFinding[]; + containerLabelById?: Map; +} + +interface TabFindingsModalState { + parentId: string; + matches: PermissionAnalysisFinding[]; +} + +/** + * Tab visibility (`PermissionSetTabSetting`) from the export, grouped by profile or permission set (`ParentId`), + * with one leaf row per tab. Ordering matches the object-permissions tree: profiles first, then permission sets, + * then tabs alphabetically by label when loaded, otherwise by API name. + */ +export const PermissionAnalysisTabVisibilityTree: FunctionComponent = ({ + tabSettingRows, + permissionSetRows, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + findings = [], + containerLabelById, +}) => { + const { tabLabelBySettingName } = usePermissionAnalysisExportMetadata(); + const sortedRows = useMemo( + () => sortTabSettingExportRowsForAnalysisTree(tabSettingRows, permissionSetRows, tabLabelBySettingName), + [tabSettingRows, permissionSetRows, tabLabelBySettingName], + ); + const treeRows = useMemo(() => buildTabVisibilityTreeRows(sortedRows), [sortedRows]); + const labelByParentId = useMemo(() => buildPermissionSetIdLabelMap(permissionSetRows), [permissionSetRows]); + const permissionSetRowById = useMemo(() => { + const map = new Map(); + for (const row of permissionSetRows) { + const rowId = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (rowId) { + map.set(rowId, row); + } + } + return map; + }, [permissionSetRows]); + + const containerSeverity = useMemo(() => { + if (findings.length === 0) { + return null; + } + return buildContainerIdFindingSeverity(findings); + }, [findings]); + + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + const [findingsModal, setFindingsModal] = useState(null); + + const openFindingsForParent = useCallback( + (parentId: string) => { + const matches = listFindingsForExportContainer(findings, parentId); + if (matches.length === 0) { + return; + } + setFindingsModal({ parentId, matches }); + }, + [findings, setFindingsModal], + ); + + useEffect(() => { + setExpandedGroupIds(collectAllParentGroupKeys(treeRows)); + }, [treeRows]); + + const setupLogin = useMemo(() => ({ org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin }), [org, serverUrl, skipFrontdoorLogin]); + + const columns = useMemo((): ColumnWithFilter[] => { + if (!treeRows.length) { + return []; + } + const row0 = treeRows[0]; + + const parentCol: ColumnWithFilter = { + ...setColumnFromType('_treeParentGroupKey', 'text'), + name: 'Profile / Permission set', + key: '_treeParentGroupKey', + field: '_treeParentGroupKey', + resizable: true, + width: TREE_PARENT_COL, + // Group header spans the full row (clamped to the column count by the grid) so the label + actions + // lay out across the whole width instead of overflowing the narrow grouping column. + colSpan: ({ type }) => (type === 'GROUP' ? Number.MAX_SAFE_INTEGER : undefined), + renderGroupCell: (props) => + renderTabVisibilityGroupCell(labelByParentId, permissionSetRowById, setupLogin, containerSeverity, openFindingsForParent, props), + getValue: ({ row }) => { + const parentKey = row._treeParentGroupKey; + return labelByParentId.get(parentKey) ?? parentKey; + }, + } as ColumnWithFilter; + + const visibilityHeader = getExportColumnHeaderLabel('Visibility'); + + const tabCol: ColumnWithFilter = { + ...setColumnFromType('Name', 'text'), + name: 'Tab', + key: 'Name', + field: 'Name', + resizable: true, + width: TREE_TAB_COL, + getValue: ({ row }) => { + const api = typeof row.Name === 'string' ? row.Name.trim() : ''; + const label = tabLabelBySettingName?.get(api)?.trim(); + const display = label && label.length > 0 ? label : api; + return display.length > 0 ? display : '—'; + }, + renderCell: (props: RenderCellProps) => { + const api = typeof props.row?.Name === 'string' ? props.row.Name.trim() : ''; + if (api.length === 0) { + return
; + } + const label = tabLabelBySettingName?.get(api)?.trim(); + const display = label && label.length > 0 ? label : api; + const title = display !== api ? `${display} (${api})` : display; + return ( +
+ {display} + {display !== api ? ({api}) : null} +
+ ); + }, + } as ColumnWithFilter; + + const visibilityFieldType = getRowTypeFromValue(row0.Visibility, false); + const visibilityCol: ColumnWithFilter = { + ...setColumnFromType('Visibility', visibilityFieldType), + name: visibilityHeader, + key: 'Visibility', + field: 'Visibility', + resizable: true, + width: TREE_VISIBILITY_COL, + getValue: ({ row }) => formatTabSettingVisibilityDisplay(row.Visibility), + renderCell: (props: RenderCellProps) => { + const text = formatTabSettingVisibilityDisplay(props.row?.Visibility); + return ( +
+ {text} +
+ ); + }, + } as ColumnWithFilter; + + return [parentCol, tabCol, visibilityCol]; + }, [treeRows, labelByParentId, permissionSetRowById, setupLogin, containerSeverity, openFindingsForParent, tabLabelBySettingName]); + + const getRowKey = useCallback((row: TabVisibilityTreeRow) => { + if (typeof row.Id === 'string' && row.Id.length > 0) { + return row.Id; + } + return `${row._treeParentGroupKey}::${row._treeTabKey}`; + }, []); + + if (!tabSettingRows.length) { + return ( +
+ No tab visibility rows in this export. +
+ ); + } + + return ( + <> + setExpandedGroupIds(collectAllParentGroupKeys(treeRows))} + onCollapseAll={() => setExpandedGroupIds(new Set())} + /> + + (type === 'GROUP' ? TREE_ROW_HEIGHT_GROUP_PX : TREE_ROW_HEIGHT_LEAF_PX)} + /> + + {findingsModal && ( + setFindingsModal(null)} + findings={findingsModal.matches} + summaryLine={ + + {containerLabelById?.get(findingsModal.parentId) ?? findingsModal.parentId} + {' — '} + {findingsModal.matches.length} {findingsModal.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} + + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisUserAssignmentsTree.tsx b/libs/features/manage-permissions/src/PermissionAnalysisUserAssignmentsTree.tsx new file mode 100644 index 000000000..e7c1574cd --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisUserAssignmentsTree.tsx @@ -0,0 +1,890 @@ +import { css } from '@emotion/react'; +import { PermissionAnalysisExpandCollapseControls } from './PermissionAnalysisExpandCollapseControls'; +import { logger } from '@jetstream/shared/client-logger'; +import { query } from '@jetstream/shared/data'; +import { escapeSoqlString } from '@jetstream/shared/ui-utils'; +import type { SalesforceOrgUi } from '@jetstream/types'; +import type { RenderCellProps, RenderGroupCellProps } from '@jetstream/ui'; +import { + AutoFullHeightContainer, + Badge, + ColumnWithFilter, + DataTree, + getPermissionSetGroupSetupUrl, + getProfileOrPermSetSetupUrl, + getSalesforceUserManageSetupUrl, + Icon, + SalesforceLogin, + ScopedNotification, + setColumnFromType, + Spinner, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { Fragment, FunctionComponent, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import { PermissionAnalysisFindingsModal } from './PermissionAnalysisFindingsModal'; +import { permissionAnalysisAssignmentTypeLabelCss } from './permission-analysis-viewer-badge.styles'; +import { + buildContainerIdFindingSeverity, + buildPermissionSetGroupLabelMap, + buildPermissionSetIdLabelMap, + buildUserAssignmentsTreeRows, + listFindingsForExportContainer, + sortUserAssignmentsTreeRowsByUserDisplay, + type PermissionAnalysisFinding, + type PermissionExportRow, + type UserAssignmentsTreeRow, + type UserLicenseLeafRecord, +} from './permission-export-result-view'; + +const TREE_GROUP_BY = ['_treeUserGroupKey'] as const; + +const TREE_USER_GROUP_MIN_PX = 200; +const TREE_USER_GROUP_MAX_PX = 420; +const TREE_COL_USER = `minmax(${TREE_USER_GROUP_MIN_PX}px, min(${TREE_USER_GROUP_MAX_PX}px, 1.35fr))`; + +const ASSIGNMENT_COL_MIN_PX = 220; +const TREE_COL_ASSIGNMENT = `minmax(${ASSIGNMENT_COL_MIN_PX}px, 1fr)`; + +const USER_SOQL_CHUNK_SIZE = 200; + +const TREE_ROW_HEIGHT_LEAF_PX = 48; +const TREE_ROW_HEIGHT_GROUP_PX = 48; + +const OBJECT_TYPE_ACTION_BUTTON_CLASSNAME = 'slds-button slds-button_icon slds-button_icon-bare'; + +interface UserTreeDisplay { + name: string; + username: string; + isActive: boolean; + profileId: string | null; + profileName: string | null; +} + +function userRecordIsActive(record: { IsActive?: unknown }): boolean { + const value = record.IsActive; + if (value === false || value === 'false') { + return false; + } + return true; +} + +function readProfileFromUserRecord(record: { ProfileId?: unknown; Profile?: unknown }): { + profileId: string | null; + profileName: string | null; +} { + const profileId = typeof record.ProfileId === 'string' && record.ProfileId.trim() ? record.ProfileId.trim() : null; + const profileBlock = record.Profile; + const profileName = + profileBlock && + typeof profileBlock === 'object' && + 'Name' in profileBlock && + typeof (profileBlock as { Name?: unknown }).Name === 'string' + ? String((profileBlock as { Name: string }).Name).trim() + : null; + return { profileId, profileName: profileName && profileName.length > 0 ? profileName : null }; +} + +function collectUniqueUserIdsFromAssignments(assignments: PermissionExportRow[]): string[] { + const ids = new Set(); + for (const row of assignments) { + const assigneeId = row.AssigneeId; + if (typeof assigneeId === 'string' && assigneeId.trim().startsWith('005')) { + ids.add(assigneeId.trim()); + } + } + return [...ids].sort((a, b) => a.localeCompare(b)); +} + +type ContainerFindingsModalState = { + containerId: string; + columnLabel: string; + matches: PermissionAnalysisFinding[]; +}; + +function renderUserGroupCell( + userDisplayById: Map, + setupLogin: { org: SalesforceOrgUi; serverUrl: string; skipFrontDoorAuth: boolean }, + { groupKey, childRows, isExpanded, toggleGroup }: RenderGroupCellProps, +) { + const userId = String(groupKey); + const display = userDisplayById.get(userId); + const titleLabel = display ? `${display.name} — ${display.username}` : userId; + + return ( +
+ +
+ { + event.stopPropagation(); + }} + > + + +
+
+ ); +} + +export interface PermissionAnalysisUserAssignmentsTreeProps { + permissionSetAssignments: PermissionExportRow[]; + permissionSets: PermissionExportRow[]; + permissionSetGroupComponents: PermissionExportRow[]; + permissionSetGroups: PermissionExportRow[]; + org: SalesforceOrgUi; + serverUrl: string; + skipFrontdoorLogin: boolean; + defaultApiVersion: string; + findings?: PermissionAnalysisFinding[]; + containerLabelById?: Map; +} + +/** + * Assignments from the export, grouped by user: permission sets (from assignments), inferred permission set groups, + * Salesforce profile, and permission set licenses (from `PermissionSetLicenseAssign` when available). + */ +export const PermissionAnalysisUserAssignmentsTree: FunctionComponent = ({ + permissionSetAssignments, + permissionSets, + permissionSetGroupComponents, + permissionSetGroups, + org, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + findings = [], + containerLabelById, +}) => { + const [licensesByUserId, setLicensesByUserId] = useState>(() => new Map()); + + const baseTreeRows = useMemo( + () => + buildUserAssignmentsTreeRows({ + assignments: permissionSetAssignments, + permissionSets, + groupComponents: permissionSetGroupComponents, + groups: permissionSetGroups, + licensesByUserId, + }), + [permissionSetAssignments, permissionSets, permissionSetGroupComponents, permissionSetGroups, licensesByUserId], + ); + + const labelByPermissionSetId = useMemo(() => buildPermissionSetIdLabelMap(permissionSets), [permissionSets]); + const labelByGroupId = useMemo(() => buildPermissionSetGroupLabelMap(permissionSetGroups), [permissionSetGroups]); + const permissionSetRowById = useMemo(() => { + const map = new Map(); + for (const row of permissionSets) { + const id = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (id) { + map.set(id, row); + } + } + return map; + }, [permissionSets]); + + const [userDisplayById, setUserDisplayById] = useState>(() => new Map()); + const [userDisplayLoading, setUserDisplayLoading] = useState(false); + + useEffect(() => { + if (!org?.uniqueId) { + setUserDisplayById(new Map()); + setUserDisplayLoading(false); + return; + } + const ids = collectUniqueUserIdsFromAssignments(permissionSetAssignments); + if (ids.length === 0) { + setUserDisplayById(new Map()); + setUserDisplayLoading(false); + return; + } + + let cancelled = false; + setUserDisplayLoading(true); + + void (async () => { + try { + const merged = new Map(); + for (let index = 0; index < ids.length; index += USER_SOQL_CHUNK_SIZE) { + const chunk = ids.slice(index, index + USER_SOQL_CHUNK_SIZE); + const inList = chunk.map((id) => `'${escapeSoqlString(id)}'`).join(', '); + const soql = `SELECT Id, Name, Username, IsActive, ProfileId, Profile.Name FROM User WHERE Id IN (${inList})`; + const response = await query<{ + Id: string; + Name?: string; + Username?: string; + IsActive?: boolean; + ProfileId?: string; + Profile?: { Name?: string }; + }>(org, soql); + for (const record of response.queryResults.records ?? []) { + const recordId = typeof record.Id === 'string' ? record.Id.trim() : ''; + if (!recordId) { + continue; + } + const { profileId, profileName } = readProfileFromUserRecord(record); + merged.set(recordId, { + name: typeof record.Name === 'string' && record.Name.trim() ? record.Name.trim() : recordId, + username: typeof record.Username === 'string' && record.Username.trim() ? record.Username.trim() : '', + isActive: userRecordIsActive(record), + profileId, + profileName, + }); + } + } + if (!cancelled) { + setUserDisplayById(merged); + } + } catch (error) { + logger.warn('Failed to load User rows for assignments tree', error); + if (!cancelled) { + setUserDisplayById(new Map()); + } + } finally { + if (!cancelled) { + setUserDisplayLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [org, permissionSetAssignments]); + + useEffect(() => { + if (!org?.uniqueId) { + setLicensesByUserId(new Map()); + return; + } + const ids = collectUniqueUserIdsFromAssignments(permissionSetAssignments); + if (ids.length === 0) { + setLicensesByUserId(new Map()); + return; + } + + let cancelled = false; + + void (async () => { + try { + const merged = new Map(); + const seenKeys = new Set(); + for (let index = 0; index < ids.length; index += USER_SOQL_CHUNK_SIZE) { + const chunk = ids.slice(index, index + USER_SOQL_CHUNK_SIZE); + const inList = chunk.map((id) => `'${escapeSoqlString(id)}'`).join(', '); + const soql = `SELECT AssigneeId, PermissionSetLicenseId, PermissionSetLicense.MasterLabel, PermissionSetLicense.DeveloperName FROM PermissionSetLicenseAssign WHERE AssigneeId IN (${inList})`; + const response = await query<{ + AssigneeId?: string; + PermissionSetLicenseId?: string; + PermissionSetLicense?: { MasterLabel?: string; DeveloperName?: string }; + }>(org, soql); + for (const record of response.queryResults.records ?? []) { + const userId = typeof record.AssigneeId === 'string' ? record.AssigneeId.trim() : ''; + const licenseId = typeof record.PermissionSetLicenseId === 'string' ? record.PermissionSetLicenseId.trim() : ''; + if (!userId || !licenseId) { + continue; + } + const dedupeKey = `${userId}::${licenseId}`; + if (seenKeys.has(dedupeKey)) { + continue; + } + seenKeys.add(dedupeKey); + const licBlock = record.PermissionSetLicense; + const master = + licBlock && typeof licBlock.MasterLabel === 'string' && licBlock.MasterLabel.trim() ? licBlock.MasterLabel.trim() : ''; + const dev = + licBlock && typeof licBlock.DeveloperName === 'string' && licBlock.DeveloperName.trim() ? licBlock.DeveloperName.trim() : ''; + const label = master || dev || licenseId; + const list = merged.get(userId) ?? []; + list.push({ permissionSetLicenseId: licenseId, label }); + merged.set(userId, list); + } + } + if (!cancelled) { + setLicensesByUserId(merged); + } + } catch (error) { + logger.warn('Failed to load PermissionSetLicenseAssign rows for assignments tree', error); + if (!cancelled) { + setLicensesByUserId(new Map()); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [org, permissionSetAssignments]); + + const displayLabelByUserId = useMemo(() => { + const map = new Map(); + for (const [userId, display] of userDisplayById) { + map.set(userId, display.name); + } + return map; + }, [userDisplayById]); + + const treeRows = useMemo(() => { + const sorted = sortUserAssignmentsTreeRowsByUserDisplay(baseTreeRows, displayLabelByUserId); + return sorted.map((row) => { + if (row._leafKind !== 'profile') { + return row; + } + const profileId = userDisplayById.get(row._treeUserGroupKey)?.profileId ?? null; + if (!profileId) { + return row; + } + return { ...row, _profileId: profileId }; + }); + }, [baseTreeRows, displayLabelByUserId, userDisplayById]); + + const containerSeverity = useMemo(() => { + if (findings.length === 0) { + return null; + } + return buildContainerIdFindingSeverity(findings); + }, [findings]); + + const allExpandedGroupIds = useMemo(() => { + const ids = new Set(); + for (const row of treeRows) { + ids.add(row._treeUserGroupKey); + } + return ids; + }, [treeRows]); + + const [expandedGroupIds, setExpandedGroupIds] = useState>(() => new Set()); + useLayoutEffect(() => { + setExpandedGroupIds(new Set(allExpandedGroupIds)); + }, [allExpandedGroupIds]); + + const [findingsModal, setFindingsModal] = useState(null); + + const setupLogin = useMemo(() => ({ org, serverUrl, skipFrontDoorAuth: skipFrontdoorLogin }), [org, serverUrl, skipFrontdoorLogin]); + + const openFindingsForPermissionSet = useCallback( + (permissionSetId: string) => { + const id = permissionSetId.trim(); + if (!id || !containerSeverity?.has(id)) { + return; + } + const matches = listFindingsForExportContainer(findings, id); + if (matches.length === 0) { + return; + } + setFindingsModal({ + containerId: id, + columnLabel: 'Permission Set', + matches, + }); + }, + [containerSeverity, findings], + ); + + const columns = useMemo((): ColumnWithFilter[] => { + if (!treeRows.length) { + return []; + } + + const userCol: ColumnWithFilter = { + ...setColumnFromType('_treeUserGroupKey', 'text'), + name: 'User', + key: '_treeUserGroupKey', + field: '_treeUserGroupKey', + resizable: true, + width: TREE_COL_USER, + minWidth: TREE_USER_GROUP_MIN_PX, + maxWidth: TREE_USER_GROUP_MAX_PX, + // Group header spans the full row (clamped to the column count by the grid) so the label + actions + // lay out across the whole width instead of overflowing the narrow grouping column. + colSpan: ({ type }) => (type === 'GROUP' ? Number.MAX_SAFE_INTEGER : undefined), + renderGroupCell: (props) => renderUserGroupCell(userDisplayById, setupLogin, props), + getValue: ({ row }) => { + const userId = row._treeUserGroupKey; + const display = userDisplayById.get(userId); + if (display) { + return `${display.name} ${display.username} ${userId}`.trim(); + } + return userId; + }, + } as ColumnWithFilter; + + const assignmentCol: ColumnWithFilter = { + ...setColumnFromType('Id', 'text'), + name: 'Assignment', + key: 'Id', + field: 'Id', + resizable: true, + width: TREE_COL_ASSIGNMENT, + minWidth: ASSIGNMENT_COL_MIN_PX, + getValue: ({ row }) => { + const userId = row._treeUserGroupKey; + if (row._leafKind === 'permission_set' && row._permissionSetId) { + const label = labelByPermissionSetId.get(row._permissionSetId) ?? row._permissionSetId; + return `${label} ${row._permissionSetId} ${userId}`.trim(); + } + if (row._leafKind === 'permission_set_group' && row._permissionSetGroupId) { + const label = labelByGroupId.get(row._permissionSetGroupId) ?? row._permissionSetGroupId; + return `${label} permission set group ${userId}`.trim(); + } + if (row._leafKind === 'profile') { + const profileName = userDisplayById.get(userId)?.profileName ?? ''; + return `profile ${profileName} ${userId}`.trim(); + } + if (row._leafKind === 'permission_set_license') { + return `${row._licenseLabel ?? ''} license ${userId}`.trim(); + } + return ''; + }, + renderCell: (props: RenderCellProps) => { + const row = props.row; + if (!row) { + return null; + } + + if (userDisplayLoading && row._leafKind === 'profile') { + return ; + } + + if (row._leafKind === 'permission_set' && row._permissionSetId) { + const permissionSetId = row._permissionSetId; + const label = labelByPermissionSetId.get(permissionSetId) ?? permissionSetId; + const permSetRow = permissionSetRowById.get(permissionSetId); + const isProfileOwned = permSetRow?.IsOwnedByProfile === true; + const profileIdForSetup = + permSetRow && typeof permSetRow.ProfileId === 'string' && permSetRow.ProfileId.trim().length > 0 && isProfileOwned + ? permSetRow.ProfileId.trim() + : null; + const recordType = isProfileOwned && profileIdForSetup ? 'Profile' : 'PermissionSet'; + const setupTargetId = recordType === 'Profile' && profileIdForSetup ? profileIdForSetup : permissionSetId; + const returnUrl = getProfileOrPermSetSetupUrl(recordType, setupTargetId); + const severity = containerSeverity?.get(permissionSetId); + const openUserButton = ( + { + event.stopPropagation(); + }} + > + + + ); + + return ( +
+
+

+ Permission set +

+

{label}

+
+
+ {openUserButton} + {severity && ( + + )} +
+
+ ); + } + + if (row._leafKind === 'permission_set_group' && row._permissionSetGroupId) { + const groupId = row._permissionSetGroupId; + const label = labelByGroupId.get(groupId) ?? groupId; + return ( +
+
+

+ Permission set group +

+

{label}

+
+
+ { + event.stopPropagation(); + }} + > + + +
+
+ ); + } + + if (row._leafKind === 'profile') { + const userId = row._treeUserGroupKey; + const display = userDisplayById.get(userId); + const profileName = display?.profileName ?? '—'; + const profileId = row._profileId ?? display?.profileId ?? null; + const returnUrl = profileId ? getProfileOrPermSetSetupUrl('Profile', profileId) : null; + return ( +
+
+

+ Profile +

+

{profileName}

+
+ {returnUrl && ( +
+ { + event.stopPropagation(); + }} + > + + +
+ )} +
+ ); + } + + if (row._leafKind === 'permission_set_license') { + const label = row._licenseLabel ?? row._permissionSetLicenseId ?? ''; + const licenseId = row._permissionSetLicenseId; + const returnUrl = licenseId ? `/${licenseId}` : null; + return ( +
+
+

Permission set license

+

{label}

+
+ {returnUrl && ( +
+ { + event.stopPropagation(); + }} + > + + +
+ )} +
+ ); + } + + return null; + }, + } as ColumnWithFilter; + + return [userCol, assignmentCol]; + }, [ + treeRows, + userDisplayById, + userDisplayLoading, + labelByPermissionSetId, + labelByGroupId, + permissionSetRowById, + containerSeverity, + setupLogin, + openFindingsForPermissionSet, + ]); + + const getRowKey = useCallback((row: UserAssignmentsTreeRow) => row.Id, []); + + if (!permissionSetAssignments.length) { + return ( +
+ No permission set assignments in this export slice. +
+ ); + } + + if (!collectUniqueUserIdsFromAssignments(permissionSetAssignments).length) { + return ( +
+ No user assignments (User Ids) were found in this export. +
+ ); + } + + if (!treeRows.length) { + return ( +
+ No rows were available to build the assignments tree. +
+ ); + } + + return ( + <> + setExpandedGroupIds(new Set(allExpandedGroupIds))} + onCollapseAll={() => setExpandedGroupIds(new Set())} + /> + + (type === 'GROUP' ? TREE_ROW_HEIGHT_GROUP_PX : TREE_ROW_HEIGHT_LEAF_PX)} + /> + + {findingsModal && ( + setFindingsModal(null)} + findings={findingsModal.matches} + summaryLine={ + + {findingsModal.columnLabel} + {' · '} + {containerLabelById?.get(findingsModal.containerId) ?? findingsModal.containerId} — {findingsModal.matches.length}{' '} + {findingsModal.matches.length === 1 ? 'issue' : 'issues'} + + } + /> + )} + + + ); +}; diff --git a/libs/features/manage-permissions/src/PermissionAnalysisView.tsx b/libs/features/manage-permissions/src/PermissionAnalysisView.tsx new file mode 100644 index 000000000..8f71b51a7 --- /dev/null +++ b/libs/features/manage-permissions/src/PermissionAnalysisView.tsx @@ -0,0 +1,1123 @@ +import { css } from '@emotion/react'; +import { logger } from '@jetstream/shared/client-logger'; +import { describeGlobal, queryWithCache } from '@jetstream/shared/data'; +import { escapeSoqlString, formatNumber } from '@jetstream/shared/ui-utils'; +import { getErrorMessage, gzipDecode } from '@jetstream/shared/utils'; +import type { AsyncJob, PermissionExportAnalysisJob, PermissionExportFullResult } from '@jetstream/types'; +import { + AutoFullHeightContainer, + Icon, + ProgressIndicator, + ScopedNotification, + Tabs, + Toast, + Toolbar, + ToolbarItemActions, + ToolbarItemGroup, + Tooltip, +} from '@jetstream/ui'; +import { RequireMetadataApiBanner, jobsState } from '@jetstream/ui-core'; +import { applicationCookieState, selectSkipFrontdoorAuth, selectedOrgState } from '@jetstream/ui/app-state'; +import { dexieDb } from '@jetstream/ui/db'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { useAtomValue } from 'jotai'; +import { Fragment, FunctionComponent, useEffect, useMemo, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { PermissionAnalysisExportGrid } from './PermissionAnalysisExportGrid'; +import { PermissionAnalysisFieldPermissionsTree } from './PermissionAnalysisFieldPermissionsTree'; +import { PermissionAnalysisFindingsFiltersBar } from './PermissionAnalysisFindingsFiltersBar'; +import { PermissionAnalysisHistoryModal } from './PermissionAnalysisHistoryModal'; +import { PermissionAnalysisIssuesTab } from './PermissionAnalysisIssuesTab'; +import { PermissionAnalysisObjectPermissionsTree } from './PermissionAnalysisObjectPermissionsTree'; +import { PermissionAnalysisPermissionSetsTree } from './PermissionAnalysisPermissionSetsTree'; +import { PermissionAnalysisTabVisibilityTree } from './PermissionAnalysisTabVisibilityTree'; +import { PermissionAnalysisUserAssignmentsTree } from './PermissionAnalysisUserAssignmentsTree'; +import { PermissionAnalysisExportMetadataProvider } from './permission-analysis-export-metadata-context'; +import { usePermissionAnalysisIssuesFilters } from './permission-analysis-issues-filters'; +import { + buildPermissionSetIdLabelMap, + collectSobjectApiNamesFromPermissionExport, + collectTabSettingNamesFromPermissionExport, + fieldExportDetailLookupKey, + fieldPermissionQualifiedFieldShortApi, + filterPermissionSetExportRowsById, + parsePermissionExportRequestScope, + parsePermissionExportResult, + type FieldExportDetail, + type PermissionAnalysisFinding, + type PermissionExportRow, + type SobjectExportDetail, +} from './permission-export-result-view'; + +const EMPTY_PERMISSION_ANALYSIS_FINDINGS: PermissionAnalysisFinding[] = []; +const EMPTY_PERMISSION_EXPORT_ASSIGNMENT_ROWS: PermissionExportRow[] = []; + +const HEIGHT_BUFFER = 170; +const ENTITY_DEFINITION_CHUNK_SIZE = 40; +const TAB_DEFINITION_CHUNK_SIZE = 100; +const FIELD_DEFINITION_CHUNK_SIZE = 50; +/** Max concurrent per-object FieldDefinition Tooling queries (avoids unbounded parallel requests). */ +const FIELD_DEFINITION_OBJECT_CONCURRENCY = 5; + +/** True for local Vite dev; false in production builds — used to avoid exposing raw job payloads in prod. */ +const SHOW_RAW_JOB_JSON_UI = import.meta.env.DEV; + +async function mapPool(items: readonly T[], concurrency: number, fn: (item: T) => Promise): Promise { + if (items.length === 0) { + return; + } + const workerCount = Math.max(1, Math.min(concurrency, items.length)); + let nextIndex = 0; + async function worker(): Promise { + while (true) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + await fn(items[index]); + } + } + await Promise.all(Array.from({ length: workerCount }, () => worker())); +} + +interface ToolingEntityDefinitionRow { + QualifiedApiName: string; + Label?: string | null; + Description?: string | null; +} + +interface ToolingTabDefinitionRow { + Name: string; + Label?: string | null; +} + +interface ToolingFieldDefinitionRow { + QualifiedApiName: string; + Label?: string | null; + Description?: string | null; + DurableId?: string | null; +} + +function formatJobResult(result: unknown): string { + try { + return JSON.stringify(result, null, 2); + } catch { + return String(result); + } +} + +/** + * Lazily stringifies the result when the Raw JSON tab actually renders. Stringifying inline inside + * the `resultTabs` useMemo would re-run on every memo dep change even if the tab isn't active — + * for ~20 MB blobs in dev that adds noticeable jank when toggling filters. + */ +const RawJsonTabContent: FunctionComponent<{ result: unknown }> = ({ result }) => { + const formatted = useMemo(() => formatJobResult(result), [result]); + return ( +
+
+        {formatted}
+      
+
+ ); +}; + +/** + * Read-only analysis workspace. Subscribes to the in-flight job entry (jotai jobsState) for progress + * and to Dexie `analysis_job_history` for the terminal row; no HTTP polling. Result decoding happens + * once per Dexie row (gzip decompress) and is reshaped into the envelope the parser expects. + */ +export const PermissionAnalysisView: FunctionComponent = () => { + const selectedOrg = useAtomValue(selectedOrgState); + const { serverUrl, defaultApiVersion } = useAtomValue(applicationCookieState); + const skipFrontdoorLogin = useAtomValue(selectSkipFrontdoorAuth); + const jobs = useAtomValue(jobsState); + const [searchParams, setSearchParams] = useSearchParams(); + const jobId = searchParams.get('job'); + + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [sobjectExportDetails, setSobjectExportDetails] = useState>({}); + const [tabLabelBySettingName, setTabLabelBySettingName] = useState>(() => new Map()); + const [fieldExportDetails, setFieldExportDetails] = useState>({}); + const [decodedFullResult, setDecodedFullResult] = useState(null); + const [decodeError, setDecodeError] = useState(null); + + /** + * Live in-flight AsyncJob for this jobHistoryKey, when present. Drives the progress UI before the + * Dexie terminal row lands; the Jobs popover shows the same entry. + */ + const inFlightJob: AsyncJob | null = useMemo(() => { + if (!jobId) { + return null; + } + for (const candidate of Object.values(jobs)) { + if (candidate.type !== 'PermissionExportAnalysis') { + continue; + } + const meta = candidate.meta as PermissionExportAnalysisJob | undefined; + if (meta?.jobHistoryKey === jobId) { + return candidate as AsyncJob; + } + } + return null; + }, [jobs, jobId]); + + const inFlightStatus = inFlightJob?.status; + const isJobRunning = inFlightStatus === 'pending' || inFlightStatus === 'in-progress'; + + /** + * Terminal Dexie row for this jobHistoryKey, kept reactive via useLiveQuery so the view updates + * the moment the JobWorker writes the row. Scoped to the selected org: a row whose `org` does not + * match (bookmarked/copied key, or org switched while the URL still has an old key) resolves to + * `undefined` so we never show another org's analysis in this org's context. + */ + const selectedOrgId = selectedOrg?.uniqueId; + const historyRow = useLiveQuery(async () => { + if (!jobId || !selectedOrgId) { + return undefined; + } + const row = await dexieDb.analysis_job_history.get(jobId); + return row && row.org === selectedOrgId ? row : undefined; + }, [jobId, selectedOrgId]); + + useEffect(() => { + // Eagerly drop any prior decoded payload so switching between large completed runs doesn't keep both + // the old and new uncompressed blobs in memory while the new gunzip resolves. + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale decoded payload when job/row changes + setDecodedFullResult(null); + setDecodeError(null); + if (!historyRow || historyRow.status !== 'completed' || !historyRow.resultBlob) { + return; + } + let cancelled = false; + gzipDecode(historyRow.resultBlob) + .then((decoded) => { + if (!cancelled) { + setDecodedFullResult(decoded); + } + }) + .catch((ex) => { + if (!cancelled) { + logger.error('Failed to decode permission_export history blob', ex); + setDecodeError(getErrorMessage(ex)); + } + }); + return () => { + cancelled = true; + }; + }, [historyRow]); + + const jobStatusNormalized = useMemo(() => { + if (historyRow?.status === 'completed' || historyRow?.status === 'failed') { + return historyRow.status; + } + if (isJobRunning) { + return 'running'; + } + if (inFlightStatus === 'failed' || inFlightStatus === 'aborted') { + return 'failed'; + } + return null; + }, [historyRow?.status, isJobRunning, inFlightStatus]); + + const isTerminal = jobStatusNormalized === 'completed' || jobStatusNormalized === 'failed'; + const fetchError = decodeError; + const terminalErrorMessage = historyRow?.errorMessage ?? inFlightJob?.statusMessage ?? null; + const liveProgress = inFlightJob?.progress; + + /** + * The decoded blob from Dexie has the FLAT merged shape (counts, findings, permissionSets, etc. + * at the top level). The parser expects a nested envelope `{ ...summary, export: { permissionSets, ... } }`. + * Reshape once and feed both the parser and downstream consumers (request-scope lookups) the same root. + */ + const reshapedJobResult = useMemo(() => { + if (!decodedFullResult) { + return null; + } + return { + requestPayload: decodedFullResult.requestPayload, + phase: decodedFullResult.phase, + summary: decodedFullResult.summary, + truncated: decodedFullResult.truncated, + counts: decodedFullResult.counts, + findings: decodedFullResult.findings, + issueCodeSummary: decodedFullResult.issueCodeSummary, + export: { + permissionSets: decodedFullResult.permissionSets, + permissionSetAssignments: decodedFullResult.permissionSetAssignments, + permissionSetGroups: decodedFullResult.permissionSetGroups, + permissionSetGroupComponents: decodedFullResult.permissionSetGroupComponents, + mutingPermissionSets: decodedFullResult.mutingPermissionSets, + objectPermissions: decodedFullResult.objectPermissions, + fieldPermissions: decodedFullResult.fieldPermissions, + permissionSetTabSettings: decodedFullResult.permissionSetTabSettings, + }, + }; + }, [decodedFullResult]); + + const parsedExport = useMemo(() => { + if (!reshapedJobResult) { + return null; + } + return parsePermissionExportResult(reshapedJobResult); + }, [reshapedJobResult]); + + const issueScopeFilterContext = useMemo(() => { + if (!reshapedJobResult) { + return undefined; + } + const scope = parsePermissionExportRequestScope(reshapedJobResult); + const supportsExportScopeFilter = scope.profilePermissionSetIds.length > 0 && scope.permissionSetIds.length > 0; + return { + supportsExportScopeFilter, + profilePermissionSetIds: new Set(scope.profilePermissionSetIds), + permissionSetIds: new Set(scope.permissionSetIds), + }; + }, [reshapedJobResult]); + + const exportObjectScopeNames = useMemo(() => parsePermissionExportRequestScope(reshapedJobResult).objectApiNames, [reshapedJobResult]); + + const permissionAnalysisIssuesFilters = usePermissionAnalysisIssuesFilters({ + findings: parsedExport?.findings ?? EMPTY_PERMISSION_ANALYSIS_FINDINGS, + permissionSetAssignments: parsedExport?.export.permissionSetAssignments ?? EMPTY_PERMISSION_EXPORT_ASSIGNMENT_ROWS, + searchParams, + setSearchParams, + issueScopeFilterContext, + }); + const globallyFilteredFindings = permissionAnalysisIssuesFilters.filteredFindings; + + useEffect(() => { + const exportSnapshot = parsedExport; + + if (!selectedOrg?.uniqueId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset cached labels when org clears + setSobjectExportDetails({}); + return; + } + + if (!exportSnapshot) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset when job payload missing + setSobjectExportDetails({}); + return; + } + + const exportBundleForSobjects = exportSnapshot.export; + + let cancelled = false; + + async function loadSobjectExportDetails() { + /** Primary labels/descriptions from EntityDefinition; describeGlobal only for APIs missing from ED (odd/large orgs). */ + const details: Record = {}; + const returnedFromEntityDefinition = new Set(); + + if (cancelled) { + return; + } + + const apiNames = collectSobjectApiNamesFromPermissionExport(exportBundleForSobjects); + for (let offset = 0; offset < apiNames.length; offset += ENTITY_DEFINITION_CHUNK_SIZE) { + if (cancelled) { + return; + } + const chunk = apiNames.slice(offset, offset + ENTITY_DEFINITION_CHUNK_SIZE); + const inList = chunk.map((name) => `'${escapeSoqlString(name)}'`).join(', '); + const soql = `SELECT QualifiedApiName, Label, Description FROM EntityDefinition WHERE QualifiedApiName IN (${inList})`; + try { + const { data } = await queryWithCache(selectedOrg, soql, true); + const records = data?.queryResults?.records; + if (!Array.isArray(records)) { + continue; + } + for (const record of records) { + const api = record.QualifiedApiName; + if (typeof api !== 'string' || api.trim().length === 0) { + continue; + } + returnedFromEntityDefinition.add(api); + const existing = details[api]; + const descriptionFromEd = + record.Description != null && String(record.Description).trim().length > 0 + ? String(record.Description).trim() + : (existing?.description ?? null); + const labelFromEd = typeof record.Label === 'string' && record.Label.trim().length > 0 ? record.Label.trim() : api; + details[api] = { + apiName: api, + label: labelFromEd, + description: descriptionFromEd, + }; + } + } catch (entityDefinitionError) { + logger.warn('EntityDefinition query failed for permission analysis object metadata', entityDefinitionError); + } + } + + const missingFromEntityDefinition = apiNames.filter((api) => !returnedFromEntityDefinition.has(api)); + if (missingFromEntityDefinition.length > 0 && !cancelled) { + try { + const { data } = await describeGlobal(selectedOrg, false); + const sobjects = data?.sobjects; + if (Array.isArray(sobjects)) { + const byName = new Map(sobjects.map((s) => [s.name, s])); + for (const api of missingFromEntityDefinition) { + const described = byName.get(api); + if (described) { + const label = typeof described.label === 'string' && described.label.trim().length > 0 ? described.label.trim() : api; + details[api] = { + apiName: api, + label, + description: null, + }; + } + } + } + } catch (describeGlobalError) { + logger.warn('describeGlobal fallback failed for permission analysis object metadata', describeGlobalError); + } + } + + for (const api of apiNames) { + if (!details[api]) { + details[api] = { apiName: api, label: api, description: null }; + } + } + + if (!cancelled) { + setSobjectExportDetails(details); + } + } + + void loadSobjectExportDetails(); + + return () => { + cancelled = true; + }; + }, [selectedOrg, parsedExport]); + + useEffect(() => { + if (!selectedOrg?.uniqueId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset cached tab labels when org clears + setTabLabelBySettingName(new Map()); + return; + } + + if (!parsedExport) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset when export snapshot missing + setTabLabelBySettingName(new Map()); + return; + } + + const exportBundleForTabs = parsedExport.export; + let cancelled = false; + + async function loadTabDefinitionLabels() { + const tabNames = collectTabSettingNamesFromPermissionExport(exportBundleForTabs); + const labelMap = new Map(); + + for (let offset = 0; offset < tabNames.length; offset += TAB_DEFINITION_CHUNK_SIZE) { + if (cancelled) { + return; + } + const chunk = tabNames.slice(offset, offset + TAB_DEFINITION_CHUNK_SIZE); + const inList = chunk.map((name) => `'${escapeSoqlString(name)}'`).join(', '); + const soql = `SELECT Name, Label FROM TabDefinition WHERE Name IN (${inList})`; + try { + const { data } = await queryWithCache(selectedOrg, soql, true); + const records = data?.queryResults?.records; + if (!Array.isArray(records)) { + continue; + } + for (const record of records) { + const api = record.Name; + if (typeof api !== 'string' || api.trim().length === 0) { + continue; + } + const trimmedApi = api.trim(); + const labelFromTd = typeof record.Label === 'string' && record.Label.trim().length > 0 ? record.Label.trim() : trimmedApi; + labelMap.set(trimmedApi, labelFromTd); + } + } catch (tabDefinitionError) { + logger.warn('TabDefinition query failed for permission analysis tab labels', tabDefinitionError); + } + } + + if (!cancelled) { + setTabLabelBySettingName(labelMap); + } + } + + void loadTabDefinitionLabels(); + + return () => { + cancelled = true; + }; + }, [selectedOrg, parsedExport]); + + useEffect(() => { + if (!selectedOrg?.uniqueId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset cached field labels when org clears + setFieldExportDetails({}); + return; + } + + if (!parsedExport) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset when export snapshot missing + setFieldExportDetails({}); + return; + } + + const bundle = parsedExport.export; + let cancelled = false; + + async function loadFieldDefinitions() { + const fieldsByObject = new Map>(); + for (const row of bundle.fieldPermissions) { + const obj = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + if (!obj) { + continue; + } + const short = fieldPermissionQualifiedFieldShortApi(row); + if (!short) { + continue; + } + let fieldSet = fieldsByObject.get(obj); + if (!fieldSet) { + fieldSet = new Set(); + fieldsByObject.set(obj, fieldSet); + } + fieldSet.add(short); + } + + const details: Record = {}; + + const objectWorkItems = [...fieldsByObject.entries()].map(([objectApi, fieldSet]) => ({ + objectApi, + names: [...fieldSet].sort((a, b) => a.localeCompare(b)), + })); + + await mapPool(objectWorkItems, FIELD_DEFINITION_OBJECT_CONCURRENCY, async ({ objectApi, names }) => { + for (let offset = 0; offset < names.length; offset += FIELD_DEFINITION_CHUNK_SIZE) { + if (cancelled) { + return; + } + const chunk = names.slice(offset, offset + FIELD_DEFINITION_CHUNK_SIZE); + const inList = chunk.map((name) => `'${escapeSoqlString(name)}'`).join(', '); + const soql = `SELECT QualifiedApiName, Label, Description, DurableId FROM FieldDefinition WHERE EntityDefinition.QualifiedApiName = '${escapeSoqlString(objectApi)}' AND QualifiedApiName IN (${inList})`; + try { + const { data } = await queryWithCache(selectedOrg, soql, true); + const records = data?.queryResults?.records; + if (!Array.isArray(records)) { + continue; + } + for (const record of records) { + const qn = record.QualifiedApiName; + if (typeof qn !== 'string' || qn.trim().length === 0) { + continue; + } + const qualified = qn.trim(); + const key = fieldExportDetailLookupKey(objectApi, qualified); + const labelFromFd = typeof record.Label === 'string' && record.Label.trim().length > 0 ? record.Label.trim() : qualified; + const desc = + record.Description != null && String(record.Description).trim().length > 0 ? String(record.Description).trim() : null; + const durable = typeof record.DurableId === 'string' && record.DurableId.trim().length > 0 ? record.DurableId.trim() : null; + details[key] = { + objectApiName: objectApi, + qualifiedApiName: qualified, + label: labelFromFd, + description: desc, + durableId: durable, + }; + } + } catch (fieldDefinitionError) { + logger.warn('FieldDefinition query failed for permission analysis field metadata', fieldDefinitionError); + } + } + }); + + if (!cancelled) { + setFieldExportDetails(details); + } + } + + void loadFieldDefinitions(); + + return () => { + cancelled = true; + }; + }, [selectedOrg, parsedExport]); + + const findingsCount = parsedExport?.findings.length ?? 0; + + const permissionAnalysisExportMetadata = useMemo( + () => ({ fieldExportDetails, sobjectExportDetails, tabLabelBySettingName }), + [fieldExportDetails, sobjectExportDetails, tabLabelBySettingName], + ); + + const resultTabs = useMemo(() => { + if (!selectedOrg || !parsedExport) { + return null; + } + + const { export: exportBundle, truncated, findings: allFindings } = parsedExport; + const counts = parsedExport.counts; + + const containerLabelById = buildPermissionSetIdLabelMap(exportBundle.permissionSets); + + const gridProps = { + org: selectedOrg, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + sobjectExportDetails, + }; + + const requestScope = parsePermissionExportRequestScope(reshapedJobResult); + const hasExplicitScope = requestScope.profilePermissionSetIds.length > 0 || requestScope.permissionSetIds.length > 0; + const profileIdSet = new Set(requestScope.profilePermissionSetIds); + const permissionSetIdSet = new Set(requestScope.permissionSetIds); + const profilePermissionSetRows = filterPermissionSetExportRowsById(exportBundle.permissionSets, profileIdSet); + let standalonePermissionSetRows = filterPermissionSetExportRowsById(exportBundle.permissionSets, permissionSetIdSet); + let showProfilesTab = requestScope.profilePermissionSetIds.length > 0; + let showPermissionSetsTab = requestScope.permissionSetIds.length > 0; + + if (!hasExplicitScope && exportBundle.permissionSets.length > 0) { + showProfilesTab = false; + showPermissionSetsTab = true; + standalonePermissionSetRows = exportBundle.permissionSets; + } + + const showPermissionSetGroupsTab = + exportBundle.permissionSetGroups.length > 0 || + exportBundle.permissionSetGroupComponents.length > 0 || + exportBundle.mutingPermissionSets.length > 0; + + function renderTruncationNotice() { + if (!truncated) { + return null; + } + return ( + + Export hit the row limit; some Salesforce rows may be missing from these tables. + + ); + } + + const profilesTab = { + id: 'profiles', + title: ( + + + + + Profiles ({profilePermissionSetRows.length}) + + ), + titleText: 'Profiles', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }; + + const permissionSetsTab = { + id: 'permission-sets', + title: ( + + + + + Permission Sets ({standalonePermissionSetRows.length}) + + ), + titleText: 'Permission Sets', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }; + + return [ + ...(showProfilesTab ? [profilesTab] : []), + ...(showPermissionSetsTab ? [permissionSetsTab] : []), + { + id: 'assignments', + title: ( + + + + + Assignments{counts.permissionSetAssignments != null ? ` (${counts.permissionSetAssignments})` : ''} + + ), + titleText: 'Assignments', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }, + ...(showPermissionSetGroupsTab + ? [ + { + id: 'permission-set-groups', + title: ( + + + + + Permission Set Groups + {counts.permissionSetGroups != null ? ` (${counts.permissionSetGroups})` : ''} + + ), + titleText: 'Permission Set Groups', + content: ( +
+ {renderTruncationNotice()} +
+

Groups

+ +
+
+

Group components

+ +
+
+

Muting permission sets

+ +
+
+ ), + }, + ] + : []), + { + id: 'object-permissions', + title: ( + + + + + Object Permissions{counts.objectPermissions != null ? ` (${counts.objectPermissions})` : ''} + + ), + titleText: 'Object Permissions', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }, + { + id: 'tab-visibility', + title: ( + + + + + Tab Visibility{counts.permissionSetTabSettings != null ? ` (${counts.permissionSetTabSettings})` : ''} + + ), + titleText: 'Tab Visibility', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }, + { + id: 'field-permissions', + title: ( + + + + + Field Permissions{counts.fieldPermissions != null ? ` (${counts.fieldPermissions})` : ''} + + ), + titleText: 'Field Permissions', + content: ( +
+ {renderTruncationNotice()} + +
+ ), + }, + { + id: 'issues', + title: ( + + + + + Issues{findingsCount > 0 ? ` (${findingsCount})` : ''} + + ), + titleText: 'Issues', + content: ( + + ), + }, + ...(SHOW_RAW_JOB_JSON_UI + ? [ + { + id: 'raw-json', + title: ( + + + + + Raw JSON + + ), + titleText: 'Raw JSON', + content: , + }, + ] + : []), + ]; + }, [ + selectedOrg, + parsedExport, + serverUrl, + skipFrontdoorLogin, + defaultApiVersion, + reshapedJobResult, + findingsCount, + sobjectExportDetails, + permissionAnalysisIssuesFilters, + globallyFilteredFindings, + ]); + + return ( +
+ + +
+
+ + + + Go Back + + +
+
+ {jobId && !fetchError && isTerminal && jobStatusNormalized === 'completed' && parsedExport && selectedOrg ? ( +
+ +
+ ) : null} +
+
+ + + + + +
+
+
+ {isHistoryOpen && selectedOrg && ( + setIsHistoryOpen(false)} + onSelectJob={(nextJobId) => { + setSearchParams({ job: nextJobId }, { replace: true }); + }} + /> + )} + + {!jobId && ( +
+ + No analysis job is linked to this page. Use Continue on the selection screen to start a permission export job. + +
+ )} + {jobId && fetchError && {fetchError}} + {jobId && !fetchError && jobStatusNormalized === 'failed' && terminalErrorMessage != null && ( +
+ {terminalErrorMessage} +
+ )} + {jobId && !fetchError && !isTerminal && ( +
+

Permission analysis in progress…

+

+ {isJobRunning && liveProgress?.label ? liveProgress.label : 'Preparing'} + {isJobRunning && liveProgress && liveProgress.total > 0 + ? ` — step ${formatNumber(liveProgress.current)} of ${formatNumber(liveProgress.total)}` + : ''} +

+ +

+ You can leave this page, but keep this tab open — the job will keep running and you'll find it in the Background Jobs. +

+
+ )} + {jobId && !fetchError && isTerminal && jobStatusNormalized === 'completed' && parsedExport && selectedOrg && resultTabs && ( + + + {exportObjectScopeNames.length > 0 && ( +
+ + Object scope for object and field permissions ({exportObjectScopeNames.length} type + {exportObjectScopeNames.length === 1 ? '' : 's'} + ): + {exportObjectScopeNames.length <= 8 ? ( + <> {exportObjectScopeNames.join(', ')}. + ) : ( + <> + {' '} + {exportObjectScopeNames.slice(0, 8).join(', ')}… and {exportObjectScopeNames.length - 8} more. + + )}{' '} + Tab visibility and permission set lists are not filtered by object. + +
+ )} + {parsedExport.summary && ( +
+

{parsedExport.summary}

+
+ )} + tab.id).join('|')} + initialActiveId={resultTabs[0]?.id ?? 'assignments'} + tabs={resultTabs} + /> +
+
+ )} + {jobId && !fetchError && isTerminal && jobStatusNormalized === 'completed' && !parsedExport && reshapedJobResult && ( +
+ {SHOW_RAW_JOB_JSON_UI ? ( + + + This job result does not include a structured permission export payload. Showing raw JSON only. + + + {formatJobResult(reshapedJobResult)} + + ), + }, + ]} + /> + + ) : ( + + This job result does not include a structured permission export payload. The result cannot be shown in the analysis UI. + + )} +
+ )} +
+
+ ); +}; + +export default PermissionAnalysisView; diff --git a/libs/features/manage-permissions/src/__tests__/aggregate-permission-findings.spec.ts b/libs/features/manage-permissions/src/__tests__/aggregate-permission-findings.spec.ts new file mode 100644 index 000000000..fbd16650e --- /dev/null +++ b/libs/features/manage-permissions/src/__tests__/aggregate-permission-findings.spec.ts @@ -0,0 +1,23 @@ +import { PermissionExportFindingCode } from '@jetstream/shared/constants'; +import { describe, expect, it } from 'vitest'; +import { aggregatePermissionAnalysisFindings, type PermissionAnalysisFinding } from '../permission-export-result-view'; + +describe('aggregatePermissionAnalysisFindings', () => { + it('returns empty rollups for an empty list', () => { + expect(aggregatePermissionAnalysisFindings([])).toEqual({ byCode: [], byObject: [] }); + }); + + it('groups by code and object and excludes FINDINGS_TRUNCATED', () => { + const rows: PermissionAnalysisFinding[] = [ + { severity: 'error', code: 'FLS_READ_NO_OBJECT_READ', objectApiName: 'Account', message: 'a' }, + { severity: 'error', code: 'FLS_READ_NO_OBJECT_READ', objectApiName: 'Contact', message: 'b' }, + { severity: 'warning', code: 'OLS_READ_NO_FLS_ROWS', objectApiName: 'Account', message: 'c' }, + { severity: 'warning', code: PermissionExportFindingCode.FINDINGS_TRUNCATED, message: 'cap' }, + ]; + const agg = aggregatePermissionAnalysisFindings(rows); + expect(agg.byCode.map((r) => r.code)).toEqual(['FLS_READ_NO_OBJECT_READ', 'OLS_READ_NO_FLS_ROWS']); + expect(agg.byCode[0].count).toBe(2); + expect(agg.byCode[0].errorCount).toBe(2); + expect(agg.byObject.find((o) => o.objectApiName === 'Account')?.count).toBe(2); + }); +}); diff --git a/libs/features/manage-permissions/src/__tests__/export-grid-finding-cells.spec.ts b/libs/features/manage-permissions/src/__tests__/export-grid-finding-cells.spec.ts new file mode 100644 index 000000000..546d07064 --- /dev/null +++ b/libs/features/manage-permissions/src/__tests__/export-grid-finding-cells.spec.ts @@ -0,0 +1,280 @@ +import { PermissionExportFindingCode } from '@jetstream/shared/constants'; +import { describe, expect, it } from 'vitest'; +import { + FIELD_PERMISSION_OBJECT_SCOPE_MARKER, + buildContainerIdFindingSeverity, + buildFieldPermissionFindingCellHighlights, + buildPermissionSetAssigneeIdsByPermissionSetId, + buildPermissionSetAssignmentsTreeRows, + buildPermissionSetGroupLabelMap, + buildPermissionSetIdToGroupIdsMap, + buildUserAssignmentsTreeRows, + fieldPermissionCellSeverity, + fieldPermissionFindingRowKey, + listFindingsForExportContainer, + listFindingsForFieldPermissionCell, + sortUserAssignmentsTreeRowsByUserDisplay, +} from '../permission-export-result-view'; + +describe('buildFieldPermissionFindingCellHighlights', () => { + it('maps FLS read mismatch to PermissionsRead for the field row key', () => { + const parentId = '0PS1'; + const findings = [ + { + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + severity: 'error', + objectApiName: 'Account', + fieldApiName: 'Account.Name', + parentId, + permissionSetId: parentId, + }, + ]; + const map = buildFieldPermissionFindingCellHighlights(findings); + const rowKey = fieldPermissionFindingRowKey(parentId, 'Account', 'Account.Name'); + // Re-tiered: FLS without OLS is inert → warning (catalog severity, not the row's input severity). + expect(map.get(rowKey)?.get('PermissionsRead')).toBe('warning'); + }); + + it('maps FLS_WITHOUT_OLS_ROW to object scope marker and Read/Edit columns', () => { + const parentId = '0PS2'; + const findings = [ + { + code: PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW, + severity: 'error', + objectApiName: 'Contact', + parentId, + }, + ]; + const map = buildFieldPermissionFindingCellHighlights(findings); + const scopeKey = fieldPermissionFindingRowKey(parentId, 'Contact', FIELD_PERMISSION_OBJECT_SCOPE_MARKER); + expect(map.get(scopeKey)?.get('PermissionsRead')).toBe('warning'); + expect(map.get(scopeKey)?.get('PermissionsEdit')).toBe('warning'); + }); + + it('resolves fieldPermissionCellSeverity from scope marker for any field row on Read', () => { + const parentId = '0PS3'; + const highlights = buildFieldPermissionFindingCellHighlights([ + { + code: PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW, + severity: 'error', + objectApiName: 'Case', + parentId, + }, + ]); + const severity = fieldPermissionCellSeverity(highlights, parentId, 'Case', 'Case.Subject', 'PermissionsRead'); + expect(severity).toBe('warning'); + }); +}); + +describe('listFindingsForFieldPermissionCell', () => { + it('returns FLS_WITHOUT_OLS for any field on the object', () => { + const parentId = '0PS9'; + const findings = [ + { + code: PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW, + severity: 'error', + objectApiName: 'Lead', + parentId, + }, + ]; + const matches = listFindingsForFieldPermissionCell(findings, parentId, 'Lead', 'Lead.Company', 'PermissionsRead'); + expect(matches).toHaveLength(1); + }); + + it('requires field match for FLS read code', () => { + const parentId = '0PS8'; + const findings = [ + { + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + severity: 'error', + objectApiName: 'Account', + fieldApiName: 'Account.Name', + parentId, + }, + ]; + expect(listFindingsForFieldPermissionCell(findings, parentId, 'Account', 'Account.Name', 'PermissionsRead')).toHaveLength(1); + expect(listFindingsForFieldPermissionCell(findings, parentId, 'Account', 'Account.Other__c', 'PermissionsRead')).toHaveLength(0); + }); +}); + +describe('buildContainerIdFindingSeverity and listFindingsForExportContainer', () => { + it('aggregates max severity per container id', () => { + const id = '0PS55'; + const findings = [ + { code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, severity: 'warning', objectApiName: 'A', parentId: id }, + { + // Re-anchored on a genuine exposure code (error) now that FLS misalignment is a warning. + code: PermissionExportFindingCode.OBJECT_MODIFY_ALL_RECORDS, + severity: 'error', + objectApiName: 'B', + parentId: id, + }, + ]; + const sev = buildContainerIdFindingSeverity(findings); + expect(sev.get(id)).toBe('error'); + const listed = listFindingsForExportContainer(findings, id); + expect(listed).toHaveLength(2); + }); + + it('uses row severity when issue code is not in the catalog (forward-compatible jobs)', () => { + const id = '0PS77'; + const findings = [ + { + code: 'FUTURE_ISSUE_CODE_V1', + severity: 'error', + objectApiName: 'Account', + parentId: id, + permissionSetId: id, + }, + ]; + const sev = buildContainerIdFindingSeverity(findings); + expect(sev.get(id)).toBe('error'); + }); + + it('excludes FINDINGS_TRUNCATED from container drill-in', () => { + const id = '0PS66'; + const findings = [ + { code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, severity: 'error', objectApiName: 'X', parentId: id }, + { code: PermissionExportFindingCode.FINDINGS_TRUNCATED, severity: 'warning', message: 'cap' }, + ]; + expect(listFindingsForExportContainer(findings, id)).toHaveLength(1); + }); +}); + +describe('buildPermissionSetAssigneeIdsByPermissionSetId', () => { + it('groups only user assignees (005 prefix), dedupes, and sorts', () => { + const psId = '0PS000000000001'; + const map = buildPermissionSetAssigneeIdsByPermissionSetId([ + { PermissionSetId: psId, AssigneeId: '005000000000002' }, + { PermissionSetId: psId, AssigneeId: '005000000000001' }, + { PermissionSetId: psId, AssigneeId: '005000000000002' }, + { PermissionSetId: psId, AssigneeId: '00G000000000003' }, + ]); + expect(map.get(psId)).toEqual(['005000000000001', '005000000000002']); + }); +}); + +describe('buildPermissionSetAssignmentsTreeRows', () => { + it('emits one leaf per user and a placeholder when there are no user assignees', () => { + const psA = '0PS0000000000AA'; + const psB = '0PS0000000000BB'; + const permSets = [ + { Id: psA, Label: 'Alpha', Name: 'Alpha' }, + { Id: psB, Label: 'Beta', Name: 'Beta' }, + ]; + const assignments = [ + { PermissionSetId: psA, AssigneeId: '005000000000001' }, + { PermissionSetId: psA, AssigneeId: '005000000000002' }, + ]; + const leaves = buildPermissionSetAssignmentsTreeRows(permSets, assignments); + const byGroup = new Map(); + for (const row of leaves) { + const groupKey = String(row._treePermissionSetGroupKey); + const list = byGroup.get(groupKey) ?? []; + if (row._noDirectUserAssignments) { + list.push('placeholder'); + } else if (typeof row.AssigneeId === 'string') { + list.push(row.AssigneeId); + } + byGroup.set(groupKey, list); + } + expect(byGroup.get(psA)).toEqual(['005000000000001', '005000000000002']); + expect(byGroup.get(psB)).toEqual(['placeholder']); + }); + + it('orders permission set groups alphabetically by display label', () => { + const psA = '0PS0000000000AA'; + const psB = '0PS0000000000BB'; + const permSets = [ + { Id: psB, Label: 'Zebra', Name: 'Zebra' }, + { Id: psA, Label: 'Alpha', Name: 'Alpha' }, + ]; + const leaves = buildPermissionSetAssignmentsTreeRows(permSets, []); + const groupOrder = [...new Set(leaves.map((row) => row._treePermissionSetGroupKey))]; + expect(groupOrder).toEqual([psA, psB]); + }); +}); + +describe('buildUserAssignmentsTreeRows', () => { + it('groups by user: profile first, permission sets (alpha), inferred groups, then licenses', () => { + const u1 = '005000000000001'; + const u2 = '005000000000002'; + const ps1 = '0PS000000000001'; + const ps2 = '0PS000000000002'; + const g1 = '0PG000000000001'; + const assignments = [ + { PermissionSetId: ps1, AssigneeId: u1 }, + { PermissionSetId: ps2, AssigneeId: u1 }, + { PermissionSetId: ps1, AssigneeId: u2 }, + ]; + const permissionSets = [ + { Id: ps1, Label: 'B Perm', Name: 'B_Perm' }, + { Id: ps2, Label: 'A Perm', Name: 'A_Perm' }, + ]; + const groupComponents = [ + { PermissionSetId: ps1, PermissionSetGroupId: g1 }, + { PermissionSetId: ps2, PermissionSetGroupId: g1 }, + ]; + const groups = [{ Id: g1, MasterLabel: 'My Group', DeveloperName: 'My_Group' }]; + const licensesByUserId = new Map([ + [u1, [{ permissionSetLicenseId: '0PL000000000001', label: 'License B' }]], + [u2, [{ permissionSetLicenseId: '0PL000000000002', label: 'License A' }]], + ]); + + const rows = buildUserAssignmentsTreeRows({ + assignments, + permissionSets, + groupComponents: groupComponents, + groups, + licensesByUserId, + }); + + const kindsForUser = (userId: string) => rows.filter((row) => row._treeUserGroupKey === userId).map((row) => row._leafKind); + + expect(kindsForUser(u1)).toEqual(['profile', 'permission_set', 'permission_set', 'permission_set_group', 'permission_set_license']); + expect(kindsForUser(u2)).toEqual(['profile', 'permission_set', 'permission_set_group', 'permission_set_license']); + + const psLeavesU1 = rows.filter((row) => row._treeUserGroupKey === u1 && row._leafKind === 'permission_set'); + expect(psLeavesU1.map((row) => row._permissionSetId)).toEqual([ps2, ps1]); + + const groupLeaf = rows.find((row) => row._leafKind === 'permission_set_group' && row._treeUserGroupKey === u1); + expect(groupLeaf?._permissionSetGroupId).toBe(g1); + + const licenseLeaf = rows.find((row) => row._leafKind === 'permission_set_license' && row._treeUserGroupKey === u1); + expect(licenseLeaf?._licenseLabel).toBe('License B'); + }); +}); + +describe('buildPermissionSetIdToGroupIdsMap', () => { + it('maps permission set Ids to group Ids', () => { + const map = buildPermissionSetIdToGroupIdsMap([ + { PermissionSetId: '0PS1', PermissionSetGroupId: '0PG1' }, + { PermissionSetId: '0PS1', PermissionSetGroupId: '0PG2' }, + ]); + expect([...(map.get('0PS1') ?? [])].sort()).toEqual(['0PG1', '0PG2']); + }); +}); + +describe('buildPermissionSetGroupLabelMap', () => { + it('prefers MasterLabel over DeveloperName', () => { + const map = buildPermissionSetGroupLabelMap([{ Id: '0PG1', MasterLabel: 'Nice', DeveloperName: 'X' }]); + expect(map.get('0PG1')).toBe('Nice'); + }); +}); + +describe('sortUserAssignmentsTreeRowsByUserDisplay', () => { + it('orders user blocks by display label', () => { + const rows = [ + { Id: '1', _treeUserGroupKey: '005B', _leafKind: 'profile' as const }, + { Id: '2', _treeUserGroupKey: '005A', _leafKind: 'profile' as const }, + ]; + const sorted = sortUserAssignmentsTreeRowsByUserDisplay( + rows, + new Map([ + ['005A', 'Zebra'], + ['005B', 'Alpha'], + ]), + ); + expect(sorted.map((row) => row._treeUserGroupKey)).toEqual(['005B', '005A']); + }); +}); diff --git a/libs/features/manage-permissions/src/__tests__/object-permission-finding-cells.spec.ts b/libs/features/manage-permissions/src/__tests__/object-permission-finding-cells.spec.ts new file mode 100644 index 000000000..e83fd6dbf --- /dev/null +++ b/libs/features/manage-permissions/src/__tests__/object-permission-finding-cells.spec.ts @@ -0,0 +1,287 @@ +import { PermissionExportFindingCode } from '@jetstream/shared/constants'; +import { describe, expect, it } from 'vitest'; +import { + buildObjectPermissionFindingCellHighlights, + formatTabSettingVisibilityDisplay, + getFindingCodeDisplayParts, + getObjectPermissionHighlightColumnKeysForFindingCode, + listFindingsForObjectPermissionCell, + objectPermissionFindingRowKey, + sortFieldPermissionExportRowsForAnalysisTree, + sortObjectPermissionExportRowsForAnalysisTree, + sortTabSettingExportRowsForAnalysisTree, + type PermissionAnalysisFinding, +} from '../permission-export-result-view'; + +describe('buildObjectPermissionFindingCellHighlights', () => { + it('returns an empty map when there are no findings', () => { + expect(buildObjectPermissionFindingCellHighlights([]).size).toBe(0); + }); + + it('maps OLS read warning to PermissionsRead on the matching row key', () => { + const parentId = '0PSxx0000001'; + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, + severity: 'warning', + objectApiName: 'Account', + parentId, + }, + ]; + const map = buildObjectPermissionFindingCellHighlights(findings); + const rowKey = objectPermissionFindingRowKey(parentId, 'Account'); + expect(map.get(rowKey)?.get('PermissionsRead')).toBe('warning'); + }); + + it('maps FLS read without object read to read-path columns as warning (re-tiered: inert, not exposure)', () => { + const parentId = '0PSyy0000002'; + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + severity: 'error', + objectApiName: 'Contact', + permissionSetId: parentId, + }, + ]; + const map = buildObjectPermissionFindingCellHighlights(findings); + const rowKey = objectPermissionFindingRowKey(parentId, 'Contact'); + // Severity is derived from the catalog (the row's own severity is ignored for known codes). + expect(map.get(rowKey)?.get('PermissionsRead')).toBe('warning'); + expect(map.get(rowKey)?.get('PermissionsViewAllRecords')).toBe('warning'); + expect(map.get(rowKey)?.get('PermissionsModifyAllRecords')).toBe('warning'); + }); + + it('prefers error over warning when the same cell is targeted', () => { + const parentId = '0PSzz0000003'; + const rowKey = objectPermissionFindingRowKey(parentId, 'Case'); + // Both findings highlight PermissionsViewAllRecords; the Modify All (error) must win over View All (warning). + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.OBJECT_VIEW_ALL_RECORDS, + severity: 'warning', + objectApiName: 'Case', + parentId, + }, + { + code: PermissionExportFindingCode.OBJECT_MODIFY_ALL_RECORDS, + severity: 'error', + objectApiName: 'Case', + parentId, + }, + ]; + const map = buildObjectPermissionFindingCellHighlights(findings); + expect(map.get(rowKey)?.get('PermissionsViewAllRecords')).toBe('error'); + }); + + it('ignores FINDINGS_TRUNCATED', () => { + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.FINDINGS_TRUNCATED, + severity: 'warning', + message: 'cap', + }, + ]; + expect(buildObjectPermissionFindingCellHighlights(findings).size).toBe(0); + }); +}); + +describe('listFindingsForObjectPermissionCell', () => { + it('returns findings that highlight the requested column on the row', () => { + const parentId = '0PS000000001'; + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + severity: 'error', + objectApiName: 'Account', + parentId, + message: 'Field X read without object read', + fieldApiName: 'Account.MyField__c', + }, + { + code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, + severity: 'warning', + objectApiName: 'Account', + parentId, + message: 'OLS only', + }, + ]; + const forRead = listFindingsForObjectPermissionCell(findings, parentId, 'Account', 'PermissionsRead'); + expect(forRead).toHaveLength(2); + const forViewAll = listFindingsForObjectPermissionCell(findings, parentId, 'Account', 'PermissionsViewAllRecords'); + expect(forViewAll).toHaveLength(1); + expect(forViewAll[0]?.code).toBe(PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ); + }); + + it('returns empty when parent or object does not match', () => { + const findings: PermissionAnalysisFinding[] = [ + { + code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, + severity: 'warning', + objectApiName: 'Contact', + parentId: '0PS1', + }, + ]; + expect(listFindingsForObjectPermissionCell(findings, '0PS1', 'Account', 'PermissionsRead')).toEqual([]); + }); +}); + +describe('getFindingCodeDisplayParts', () => { + it('uses catalog label as title and keeps raw code for technical suffix', () => { + const parts = getFindingCodeDisplayParts(PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS); + expect(parts.technicalCode).toBe(PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS); + expect(parts.title).toContain('field permission'); + expect(parts.title).not.toMatch(/^OLS_/); + }); + + it('returns raw code as title when unknown', () => { + const parts = getFindingCodeDisplayParts('UNKNOWN_FUTURE_CODE'); + expect(parts.title).toBe('UNKNOWN_FUTURE_CODE'); + expect(parts.technicalCode).toBeNull(); + }); +}); + +describe('getObjectPermissionHighlightColumnKeysForFindingCode', () => { + it('returns read-path columns for FLS_READ_NO_OBJECT_READ', () => { + expect(getObjectPermissionHighlightColumnKeysForFindingCode(PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ)).toEqual([ + 'PermissionsRead', + 'PermissionsViewAllRecords', + 'PermissionsModifyAllRecords', + ]); + }); + + it('returns empty for codes that do not map to object-permission cells', () => { + expect(getObjectPermissionHighlightColumnKeysForFindingCode(PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW)).toEqual([]); + expect(getObjectPermissionHighlightColumnKeysForFindingCode('UNKNOWN')).toEqual([]); + }); +}); + +describe('sortObjectPermissionExportRowsForAnalysisTree', () => { + const psProfile = '0PSPROFILE000001'; + const psStandalone = '0PSSTAND00000001'; + + it('orders profile-owned parents before standalone permission sets, then by parent label', () => { + const permissionSetRows = [ + { + Id: psStandalone, + Label: 'Zebra Perm', + Name: 'Zebra', + IsOwnedByProfile: false, + }, + { + Id: psProfile, + Label: 'X00ignored', + Name: 'X', + IsOwnedByProfile: true, + Profile: { Name: 'Alpha Profile' }, + }, + ]; + const objectPermissionRows = [ + { ParentId: psStandalone, SobjectType: 'Account', Id: 'op1' }, + { ParentId: psProfile, SobjectType: 'Contact', Id: 'op2' }, + ]; + const sorted = sortObjectPermissionExportRowsForAnalysisTree(objectPermissionRows, permissionSetRows); + expect(sorted.map((row) => row.ParentId)).toEqual([psProfile, psStandalone]); + }); + + it('orders standalone permission sets alphabetically when neither is profile-owned', () => { + const psB = '0PSBBBB000000001'; + const psA = '0PSAAAA000000001'; + const permissionSetRows = [ + { Id: psB, Label: 'B Perm', Name: 'B', IsOwnedByProfile: false }, + { Id: psA, Label: 'A Perm', Name: 'A', IsOwnedByProfile: false }, + ]; + const objectPermissionRows = [ + { ParentId: psB, SobjectType: 'Account', Id: '1' }, + { ParentId: psA, SobjectType: 'Account', Id: '2' }, + ]; + const sorted = sortObjectPermissionExportRowsForAnalysisTree(objectPermissionRows, permissionSetRows); + expect(sorted.map((row) => row.ParentId)).toEqual([psA, psB]); + }); + + it('orders objects by metadata label within the same parent', () => { + const permissionSetRows = [{ Id: psStandalone, Label: 'P', Name: 'P', IsOwnedByProfile: false }]; + const sobjectExportDetails = { + Zebra__c: { apiName: 'Zebra__c', label: 'Z', description: null }, + Account: { apiName: 'Account', label: 'Account', description: null }, + }; + const objectPermissionRows = [ + { ParentId: psStandalone, SobjectType: 'Zebra__c', Id: '1' }, + { ParentId: psStandalone, SobjectType: 'Account', Id: '2' }, + ]; + const sorted = sortObjectPermissionExportRowsForAnalysisTree(objectPermissionRows, permissionSetRows, sobjectExportDetails); + expect(sorted.map((row) => row.SobjectType)).toEqual(['Account', 'Zebra__c']); + }); +}); + +describe('formatTabSettingVisibilityDisplay', () => { + it('maps DefaultOn to Visible and DefaultOff to Hidden', () => { + expect(formatTabSettingVisibilityDisplay('DefaultOn')).toBe('Visible'); + expect(formatTabSettingVisibilityDisplay('DefaultOff')).toBe('Hidden'); + }); + + it('returns em dash for empty values and raw string for other picklist values', () => { + expect(formatTabSettingVisibilityDisplay('')).toBe('—'); + expect(formatTabSettingVisibilityDisplay(null)).toBe('—'); + expect(formatTabSettingVisibilityDisplay('Available')).toBe('Available'); + }); +}); + +describe('sortFieldPermissionExportRowsForAnalysisTree', () => { + const psStandalone = '0PSSTAND00000001'; + + it('orders objects then fields within the same permission set parent', () => { + const permissionSetRows = [{ Id: psStandalone, Label: 'P', Name: 'P', IsOwnedByProfile: false }]; + const fieldRows = [ + { ParentId: psStandalone, SobjectType: 'Zebra__c', Field: 'Zebra__c.B__c', PermissionsRead: true, Id: '1' }, + { ParentId: psStandalone, SobjectType: 'Alpha__c', Field: 'Alpha__c.A__c', PermissionsRead: true, Id: '2' }, + { ParentId: psStandalone, SobjectType: 'Alpha__c', Field: 'Alpha__c.Z__c', PermissionsRead: true, Id: '3' }, + ]; + const sorted = sortFieldPermissionExportRowsForAnalysisTree(fieldRows, permissionSetRows); + expect(sorted.map((row) => row.Field)).toEqual(['Alpha__c.A__c', 'Alpha__c.Z__c', 'Zebra__c.B__c']); + }); +}); + +describe('sortTabSettingExportRowsForAnalysisTree', () => { + const psProfile = '0PSPROFILE000001'; + const psStandalone = '0PSSTAND00000001'; + + it('orders profile-owned parents before standalone permission sets', () => { + const permissionSetRows = [ + { Id: psStandalone, Label: 'Zebra', Name: 'Zebra', IsOwnedByProfile: false }, + { Id: psProfile, Label: 'X', Name: 'X', IsOwnedByProfile: true, Profile: { Name: 'Admin' } }, + ]; + const tabRows = [ + { ParentId: psStandalone, Name: 'TabA', Visibility: 'DefaultOn', Id: 't1' }, + { ParentId: psProfile, Name: 'TabB', Visibility: 'DefaultOff', Id: 't2' }, + ]; + const sorted = sortTabSettingExportRowsForAnalysisTree(tabRows, permissionSetRows); + expect(sorted.map((row) => row.ParentId)).toEqual([psProfile, psStandalone]); + }); + + it('orders tabs by Name within the same parent', () => { + const permissionSetRows = [{ Id: psStandalone, Label: 'P', Name: 'P', IsOwnedByProfile: false }]; + const tabRows = [ + { ParentId: psStandalone, Name: 'Zebra', Visibility: 'DefaultOn', Id: '1' }, + { ParentId: psStandalone, Name: 'Alpha', Visibility: 'DefaultOff', Id: '2' }, + ]; + const sorted = sortTabSettingExportRowsForAnalysisTree(tabRows, permissionSetRows); + expect(sorted.map((row) => row.Name)).toEqual(['Alpha', 'Zebra']); + }); + + it('orders tabs by TabDefinition label when a label map is provided', () => { + const permissionSetRows = [{ Id: psStandalone, Label: 'P', Name: 'P', IsOwnedByProfile: false }]; + const tabRows = [ + { ParentId: psStandalone, Name: 'standard-ZebraTab', Visibility: 'DefaultOn', Id: '1' }, + { ParentId: psStandalone, Name: 'standard-AlphaTab', Visibility: 'DefaultOn', Id: '2' }, + ]; + const sortedByApi = sortTabSettingExportRowsForAnalysisTree(tabRows, permissionSetRows); + expect(sortedByApi.map((row) => row.Name)).toEqual(['standard-AlphaTab', 'standard-ZebraTab']); + + const labelMap = new Map([ + ['standard-ZebraTab', 'Zebra label'], + ['standard-AlphaTab', 'Alpha label'], + ]); + const sortedByLabel = sortTabSettingExportRowsForAnalysisTree(tabRows, permissionSetRows, labelMap); + expect(sortedByLabel.map((row) => row.Name)).toEqual(['standard-AlphaTab', 'standard-ZebraTab']); + }); +}); diff --git a/libs/features/manage-permissions/src/analysis-job-status-display.ts b/libs/features/manage-permissions/src/analysis-job-status-display.ts new file mode 100644 index 000000000..e335f608d --- /dev/null +++ b/libs/features/manage-permissions/src/analysis-job-status-display.ts @@ -0,0 +1,25 @@ +/** + * Analysis job `status` from the API is stored lowercase; format for UI labels. + */ +export function formatAnalysisJobStatusForDisplay(status: string | null | undefined): string { + if (status == null || !String(status).trim()) { + return '—'; + } + const normalized = String(status).trim().toLowerCase(); + switch (normalized) { + case 'completed': + return 'Completed'; + case 'failed': + return 'Failed'; + case 'running': + return 'Running'; + case 'pending': + return 'Pending'; + default: + return String(status) + .trim() + .split(/[\s_-]+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + } +} diff --git a/libs/features/manage-permissions/src/index.ts b/libs/features/manage-permissions/src/index.ts index 0687cda97..5da3cf399 100644 --- a/libs/features/manage-permissions/src/index.ts +++ b/libs/features/manage-permissions/src/index.ts @@ -1,3 +1,10 @@ export * from './ManagePermissions'; export * from './ManagePermissionsEditor'; export * from './ManagePermissionsSelection'; +export * from './PermissionAnalysis'; +export * from './PermissionAnalysisSelection'; +export * from './PermissionAnalysisView'; +export * from './PermissionAnalysisHistoryModal'; +export { formatAnalysisJobStatusForDisplay } from './analysis-job-status-display'; +export { filterPermissionsSobjects } from './utils/permission-manager-utils'; +export * from './permission-export'; diff --git a/libs/features/manage-permissions/src/permission-analysis-export-metadata-context.tsx b/libs/features/manage-permissions/src/permission-analysis-export-metadata-context.tsx new file mode 100644 index 000000000..22d9b6369 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-analysis-export-metadata-context.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import type { FieldExportDetail, SobjectExportDetail } from './permission-export-result-view'; + +export interface PermissionAnalysisExportMetadataContextValue { + fieldExportDetails: Record; + sobjectExportDetails: Record; + tabLabelBySettingName: ReadonlyMap; +} + +const EMPTY_TAB_LABELS: ReadonlyMap = new Map(); + +const defaultPermissionAnalysisExportMetadata: PermissionAnalysisExportMetadataContextValue = { + fieldExportDetails: {}, + sobjectExportDetails: {}, + tabLabelBySettingName: EMPTY_TAB_LABELS, +}; + +const PermissionAnalysisExportMetadataContext = createContext( + defaultPermissionAnalysisExportMetadata, +); + +export function usePermissionAnalysisExportMetadata(): PermissionAnalysisExportMetadataContextValue { + return useContext(PermissionAnalysisExportMetadataContext); +} + +export interface PermissionAnalysisExportMetadataProviderProps { + value: PermissionAnalysisExportMetadataContextValue; + children: ReactNode; +} + +export function PermissionAnalysisExportMetadataProvider({ value, children }: PermissionAnalysisExportMetadataProviderProps) { + return {children}; +} diff --git a/libs/features/manage-permissions/src/permission-analysis-issues-filters.ts b/libs/features/manage-permissions/src/permission-analysis-issues-filters.ts new file mode 100644 index 000000000..ad589b89c --- /dev/null +++ b/libs/features/manage-permissions/src/permission-analysis-issues-filters.ts @@ -0,0 +1,305 @@ +import { useCallback, useMemo } from 'react'; +import type { SetURLSearchParams } from 'react-router-dom'; +import { + type PermissionAnalysisFinding, + type PermissionExportRow, + getFindingContainerId, + getPermissionSetIdsWithDirectUserAssignment, +} from './permission-export-result-view'; + +export type IssuesSeverityFilter = 'all' | 'errors' | 'warnings'; + +/** Valid values for `issueSeverity` query param (anything else is treated as `all`). */ +export function parseIssuesSeverityFilterFromSearchParams(searchParams: URLSearchParams): IssuesSeverityFilter { + const raw = searchParams.get('issueSeverity'); + if (raw === 'errors' || raw === 'warnings') { + return raw; + } + return 'all'; +} + +export type IssuesOlsFlsFilter = 'all' | 'ols' | 'fls'; + +/** Valid values for `issueOlsFls` query param (anything else is treated as `all`). */ +export function parseIssuesOlsFlsFilterFromSearchParams(searchParams: URLSearchParams): IssuesOlsFlsFilter { + const raw = searchParams.get('issueOlsFls'); + if (raw === 'ols' || raw === 'fls') { + return raw; + } + return 'all'; +} + +export type IssuesDirectAssignmentFilter = 'all' | 'assigned' | 'unassigned'; +export type IssuesGroupBy = 'none' | 'severity' | 'object' | 'code' | 'container'; + +/** Issues grid column keys (order matches default grid). */ +export const ISSUES_GRID_COLUMN_KEYS = [ + 'severity', + 'code', + 'objectApiName', + 'fieldApiName', + 'message', + 'permissionSetId', + 'parentId', + 'containerId', +] as const; + +export type IssuesGridColumnKey = (typeof ISSUES_GRID_COLUMN_KEYS)[number]; + +export const ISSUES_GRID_COLUMN_LABELS: Record = { + severity: 'Severity', + code: 'Issue', + objectApiName: 'Object', + fieldApiName: 'Field', + message: 'Message', + permissionSetId: 'Permission set Id', + parentId: 'Parent Id', + containerId: 'Container Id', +}; + +/** Comma-separated keys in `issueHiddenCols`; unknown segments ignored. */ +export function parseIssueHiddenColumnsFromSearchParams(searchParams: URLSearchParams): Set { + const raw = searchParams.get('issueHiddenCols'); + if (!raw) { + return new Set(); + } + const allowed = new Set(ISSUES_GRID_COLUMN_KEYS); + const result = new Set(); + for (const part of raw.split(',')) { + const key = part.trim(); + if (allowed.has(key)) { + result.add(key as IssuesGridColumnKey); + } + } + return result; +} + +export type IssuesScopeFilter = 'all' | 'profiles' | 'permissionSets'; + +/** Valid values for `issueScope` when the export used explicit profile vs permission set scope. */ +export function parseIssuesScopeFilterFromSearchParams(searchParams: URLSearchParams): IssuesScopeFilter { + const raw = searchParams.get('issueScope'); + if (raw === 'profiles' || raw === 'permissionSets') { + return raw; + } + return 'all'; +} + +export interface IssueScopeFilterContext { + /** + * True when the job selected both profile permission sets and standalone permission sets. + * Export Scope filtering (and the toolbar control) applies only in that case. + */ + supportsExportScopeFilter: boolean; + profilePermissionSetIds: ReadonlySet; + permissionSetIds: ReadonlySet; +} + +export function readPermissionAnalysisSearchParam(searchParams: URLSearchParams, key: string, fallback: string): string { + const value = searchParams.get(key); + return value && value.length > 0 ? value : fallback; +} + +export function mergePermissionAnalysisSearchParams( + searchParams: URLSearchParams, + updates: Record, +): URLSearchParams { + const next = new URLSearchParams(searchParams); + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === undefined || value === '') { + next.delete(key); + } else { + next.set(key, value); + } + } + return next; +} + +function normalizeSeverity(value: string | undefined): string { + return (value ?? '').toLowerCase(); +} + +export function isErrorSeverity(value: string | undefined): boolean { + const normalized = normalizeSeverity(value); + return normalized === 'error' || normalized === 'errors'; +} + +export function isWarningSeverity(value: string | undefined): boolean { + const normalized = normalizeSeverity(value); + return normalized === 'warning' || normalized === 'warnings'; +} + +/** + * Buckets an issue `code` for OLS vs FLS toolbar filters. + * Matches {@link PermissionExportFindingCode} prefixes (OLS_… / FLS_…). + * Meta codes such as FINDINGS_TRUNCATED and blank codes classify as `other` (visible only when filter is All). + */ +function findingCodeKind(code: string | undefined): 'ols' | 'fls' | 'other' { + const upper = (code ?? '').trim().toUpperCase(); + if (upper.startsWith('OLS')) { + return 'ols'; + } + if (upper.startsWith('FLS')) { + return 'fls'; + } + return 'other'; +} + +export interface UsePermissionAnalysisIssuesFiltersArgs { + findings: PermissionAnalysisFinding[]; + permissionSetAssignments: PermissionExportRow[]; + searchParams: URLSearchParams; + setSearchParams: SetURLSearchParams; + /** When set, issues can be narrowed to profile permission sets vs standalone permission sets from the job scope. */ + issueScopeFilterContext?: IssueScopeFilterContext; +} + +export interface UsePermissionAnalysisIssuesFiltersResult { + severityFilter: IssuesSeverityFilter; + olsFlsFilter: IssuesOlsFlsFilter; + directAssignmentFilter: IssuesDirectAssignmentFilter; + scopeFilter: IssuesScopeFilter; + /** Same reference passed into the hook; used by the toolbar for Export Scope visibility. */ + issueScopeFilterContext: IssueScopeFilterContext | undefined; + hiddenIssueGridColumns: ReadonlySet; + groupBy: IssuesGroupBy; + hasAssignmentData: boolean; + filteredFindings: PermissionAnalysisFinding[]; + errorTotal: number; + warningTotal: number; + errorFiltered: number; + warningFiltered: number; + updateParams: (updates: Record) => void; +} + +export function usePermissionAnalysisIssuesFilters({ + findings, + permissionSetAssignments, + searchParams, + setSearchParams, + issueScopeFilterContext, +}: UsePermissionAnalysisIssuesFiltersArgs): UsePermissionAnalysisIssuesFiltersResult { + const severityFilter = parseIssuesSeverityFilterFromSearchParams(searchParams); + const olsFlsFilter = parseIssuesOlsFlsFilterFromSearchParams(searchParams); + const scopeFilter = parseIssuesScopeFilterFromSearchParams(searchParams); + const hiddenIssueGridColumns = useMemo(() => parseIssueHiddenColumnsFromSearchParams(searchParams), [searchParams]); + const directAssignmentFilter = readPermissionAnalysisSearchParam( + searchParams, + 'issueDirectAssign', + 'all', + ) as IssuesDirectAssignmentFilter; + const groupBy = readPermissionAnalysisSearchParam(searchParams, 'cfGroup', 'none') as IssuesGroupBy; + + const updateParams = useCallback( + (updates: Record) => { + setSearchParams(mergePermissionAnalysisSearchParams(searchParams, updates), { replace: true }); + }, + [searchParams, setSearchParams], + ); + + const permissionSetsWithUsers = useMemo( + () => getPermissionSetIdsWithDirectUserAssignment(permissionSetAssignments), + [permissionSetAssignments], + ); + const hasAssignmentData = permissionSetAssignments.length > 0; + + const filteredFindings = useMemo(() => { + return findings.filter((finding) => { + const severityValue = finding.severity as string | undefined; + if (severityFilter === 'errors' && !isErrorSeverity(severityValue)) { + return false; + } + // Warnings-only: keep rows whose severity is warning/warnings (not errors, not unknown/other). + if (severityFilter === 'warnings' && !isWarningSeverity(severityValue)) { + return false; + } + if (olsFlsFilter === 'ols' && findingCodeKind(finding.code as string | undefined) !== 'ols') { + return false; + } + if (olsFlsFilter === 'fls' && findingCodeKind(finding.code as string | undefined) !== 'fls') { + return false; + } + if (directAssignmentFilter !== 'all' && hasAssignmentData) { + const containerId = getFindingContainerId(finding); + if (!containerId) { + return false; + } + const assigned = permissionSetsWithUsers.has(containerId); + if (directAssignmentFilter === 'assigned' && !assigned) { + return false; + } + if (directAssignmentFilter === 'unassigned' && assigned) { + return false; + } + } + if (issueScopeFilterContext?.supportsExportScopeFilter && scopeFilter !== 'all') { + const containerId = getFindingContainerId(finding); + if (!containerId) { + return false; + } + if (scopeFilter === 'profiles' && !issueScopeFilterContext.profilePermissionSetIds.has(containerId)) { + return false; + } + if (scopeFilter === 'permissionSets' && !issueScopeFilterContext.permissionSetIds.has(containerId)) { + return false; + } + } + return true; + }); + }, [ + findings, + severityFilter, + olsFlsFilter, + directAssignmentFilter, + hasAssignmentData, + permissionSetsWithUsers, + issueScopeFilterContext, + scopeFilter, + ]); + + const errorTotal = useMemo(() => findings.filter((f) => isErrorSeverity(f.severity as string | undefined)).length, [findings]); + const warningTotal = useMemo(() => findings.filter((f) => isWarningSeverity(f.severity as string | undefined)).length, [findings]); + const errorFiltered = useMemo( + () => filteredFindings.filter((f) => isErrorSeverity(f.severity as string | undefined)).length, + [filteredFindings], + ); + const warningFiltered = useMemo( + () => filteredFindings.filter((f) => isWarningSeverity(f.severity as string | undefined)).length, + [filteredFindings], + ); + + return useMemo( + () => ({ + severityFilter, + olsFlsFilter, + directAssignmentFilter, + scopeFilter, + issueScopeFilterContext, + hiddenIssueGridColumns, + groupBy, + hasAssignmentData, + filteredFindings, + errorTotal, + warningTotal, + errorFiltered, + warningFiltered, + updateParams, + }), + [ + severityFilter, + olsFlsFilter, + directAssignmentFilter, + scopeFilter, + issueScopeFilterContext, + hiddenIssueGridColumns, + groupBy, + hasAssignmentData, + filteredFindings, + errorTotal, + warningTotal, + errorFiltered, + warningFiltered, + updateParams, + ], + ); +} diff --git a/libs/features/manage-permissions/src/permission-analysis-tree-group-title.ts b/libs/features/manage-permissions/src/permission-analysis-tree-group-title.ts new file mode 100644 index 000000000..0c58751c1 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-analysis-tree-group-title.ts @@ -0,0 +1,13 @@ +/** + * Strip the redundant `Profile: ` prefix when showing a profile pill + title on two lines + * (tab visibility, field permissions, object permissions trees). + */ +export function permissionAnalysisPermissionContainerGroupTitleLine(exportLabel: string, isProfileOwned: boolean): string { + if (isProfileOwned && exportLabel.startsWith('Profile: ')) { + const rest = exportLabel.slice('Profile: '.length).trim(); + if (rest.length > 0) { + return rest; + } + } + return exportLabel; +} diff --git a/libs/features/manage-permissions/src/permission-analysis-viewer-badge.styles.ts b/libs/features/manage-permissions/src/permission-analysis-viewer-badge.styles.ts new file mode 100644 index 000000000..ff46cf58d --- /dev/null +++ b/libs/features/manage-permissions/src/permission-analysis-viewer-badge.styles.ts @@ -0,0 +1,187 @@ +import { css, SerializedStyles } from '@emotion/react'; + +export type PermissionScopeBadgeSurface = 'default' | 'onBrand'; + +/** + * Profile vs permission-set chip colors for Permission Analysis (history + scope filter). + * Palette matches the standalone permission matrix `viewer.html` / `viewer.css` used with export output: + * profiles use the blue column treatment; permission sets use the purple column treatment. + * + * If your viewer CSS uses different hex values, update them here so Jetstream stays in sync. + */ +const PROFILE_BG = '#d8edff'; +const PROFILE_BORDER = '#1589ee'; +const PROFILE_TEXT = '#032d60'; + +const PERM_SET_BG = '#eee5f7'; +const PERM_SET_BORDER = '#9050e9'; +const PERM_SET_TEXT = '#3e2497'; + +/** Teal treatment for permission set groups — distinct from profile (blue) and permission set (purple). */ +const PERM_SET_GROUP_BG = '#e3f7f5'; +const PERM_SET_GROUP_BORDER = '#06a59a'; +const PERM_SET_GROUP_TEXT = '#032f2e'; + +/** Neutral chip for non-permission scopes (e.g. analyzed Salesforce objects in field usage history). */ +const OBJECT_BG = '#f3f2f2'; +const OBJECT_BORDER = '#c9c9c9'; +const OBJECT_TEXT = '#080707'; + +const scopeBadgeTruncateCss = css` + max-width: 14rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: middle; + font-weight: 600; + border-radius: 0.25rem; + box-shadow: none; +`; + +const objectDefaultCss = css` + ${scopeBadgeTruncateCss} + background-color: ${OBJECT_BG}; + color: ${OBJECT_TEXT}; + border: 1px solid ${OBJECT_BORDER}; +`; + +const objectOnBrandCss = css` + ${scopeBadgeTruncateCss} + background-color: rgba(255, 255, 255, 0.2); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.55); +`; + +const profileDefaultCss = css` + ${scopeBadgeTruncateCss} + background-color: ${PROFILE_BG}; + color: ${PROFILE_TEXT}; + border: 1px solid ${PROFILE_BORDER}; +`; + +const permissionSetDefaultCss = css` + ${scopeBadgeTruncateCss} + background-color: ${PERM_SET_BG}; + color: ${PERM_SET_TEXT}; + border: 1px solid ${PERM_SET_BORDER}; +`; + +const permissionSetGroupDefaultCss = css` + ${scopeBadgeTruncateCss} + background-color: ${PERM_SET_GROUP_BG}; + color: ${PERM_SET_GROUP_TEXT}; + border: 1px solid ${PERM_SET_GROUP_BORDER}; +`; + +/** Selected filter row uses brand (blue) background — chips need light outline treatment. */ +const profileOnBrandCss = css` + ${scopeBadgeTruncateCss} + background-color: rgba(255, 255, 255, 0.22); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.65); +`; + +const permissionSetOnBrandCss = css` + ${scopeBadgeTruncateCss} + background-color: rgba(255, 255, 255, 0.18); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.55); +`; + +const permissionSetGroupOnBrandCss = css` + ${scopeBadgeTruncateCss} + background-color: rgba(255, 255, 255, 0.16); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.5); +`; + +/** Container kinds that use the permission-matrix palette (analysis history, assignment rows, etc.). */ +export type PermissionAnalysisContainerKind = 'profile' | 'permission_set' | 'permission_set_group'; + +const assignmentTypeLabelShellCss = css` + display: inline-block; + vertical-align: middle; + font-weight: 600; + border-radius: 0.25rem; + box-shadow: none; + padding: 0.0625rem 0.4rem; + line-height: 1.35; + max-width: 100%; +`; + +const profileAssignmentTypeLabelCss = css` + ${assignmentTypeLabelShellCss} + background-color: ${PROFILE_BG}; + color: ${PROFILE_TEXT}; + border: 1px solid ${PROFILE_BORDER}; +`; + +const permissionSetAssignmentTypeLabelCss = css` + ${assignmentTypeLabelShellCss} + background-color: ${PERM_SET_BG}; + color: ${PERM_SET_TEXT}; + border: 1px solid ${PERM_SET_BORDER}; +`; + +const permissionSetGroupAssignmentTypeLabelCss = css` + ${assignmentTypeLabelShellCss} + background-color: ${PERM_SET_GROUP_BG}; + color: ${PERM_SET_GROUP_TEXT}; + border: 1px solid ${PERM_SET_GROUP_BORDER}; +`; + +/** + * Compact type label (e.g. “Profile”) for assignment rows — same palette as {@link permissionScopeBadgeCss}. + */ +export function permissionAnalysisAssignmentTypeLabelCss(kind: PermissionAnalysisContainerKind): SerializedStyles { + if (kind === 'profile') { + return profileAssignmentTypeLabelCss; + } + if (kind === 'permission_set_group') { + return permissionSetGroupAssignmentTypeLabelCss; + } + return permissionSetAssignmentTypeLabelCss; +} + +/** + * @param kind Profile vs permission set vs analyzed object (field usage history). + * @param surface Use `onBrand` when the badge sits on `slds-button_brand` (selected filter row). + */ +export function permissionScopeBadgeCss( + kind: 'profile' | 'permission_set' | 'object', + surface: PermissionScopeBadgeSurface = 'default', +): SerializedStyles { + if (kind === 'object') { + return surface === 'onBrand' ? objectOnBrandCss : objectDefaultCss; + } + if (surface === 'onBrand') { + return kind === 'profile' ? profileOnBrandCss : permissionSetOnBrandCss; + } + return kind === 'profile' ? profileDefaultCss : permissionSetDefaultCss; +} + +/** + * Scope / filter chips when the list can include permission set groups (e.g. future history filters). + */ +export function permissionAnalysisContainerBadgeCss( + kind: PermissionAnalysisContainerKind, + surface: PermissionScopeBadgeSurface = 'default', +): SerializedStyles { + if (surface === 'onBrand') { + if (kind === 'profile') { + return profileOnBrandCss; + } + if (kind === 'permission_set_group') { + return permissionSetGroupOnBrandCss; + } + return permissionSetOnBrandCss; + } + if (kind === 'profile') { + return profileDefaultCss; + } + if (kind === 'permission_set_group') { + return permissionSetGroupDefaultCss; + } + return permissionSetDefaultCss; +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-columns-assignments.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-columns-assignments.ts new file mode 100644 index 000000000..bd7fd4031 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-columns-assignments.ts @@ -0,0 +1,495 @@ +import type { ColumnWithFilter, RowWithKey } from '@jetstream/ui'; +import { getRowTypeFromValue, setColumnFromType } from '@jetstream/ui'; +import type { BuildDynamicExportColumnsOptions, PermissionExportRow } from './export-result-types-labels'; +import { buildPermissionSetIdLabelMap } from './export-result-types-labels'; + +/** Salesforce User Id prefix (15- or 18-char Ids). */ +const USER_ID_PREFIX = '005'; + +const COLUMN_KEY_ORDER = ['Id', 'ParentId', 'PermissionSetId', 'PermissionSetGroupId', 'AssigneeId', 'SobjectType', 'Field']; + +/** Column key order for export grids and the object-permissions tree (stable, readable columns). */ +export function sortedExportColumnKeys(rows: PermissionExportRow[]): string[] { + const keys = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) { + keys.add(key); + } + } + const ordered: string[] = []; + for (const preferred of COLUMN_KEY_ORDER) { + if (keys.has(preferred)) { + ordered.push(preferred); + } + } + const rest = [...keys].filter((key) => !COLUMN_KEY_ORDER.includes(key)).sort((a, b) => a.localeCompare(b)); + ordered.push(...rest); + return ordered; +} + +/** + * Salesforce `ObjectPermissions` API fields in grid order: Create, Read, Edit, Delete, + * View All Records, Modify All Records, View All Fields. + */ +export const OBJECT_PERMISSION_COLUMN_KEY_ORDER = [ + 'PermissionsCreate', + 'PermissionsRead', + 'PermissionsEdit', + 'PermissionsDelete', + 'PermissionsViewAllRecords', + 'PermissionsModifyAllRecords', + 'PermissionsViewAllFields', +] as const; + +function isObjectPermissionsExportSample(sample: PermissionExportRow): boolean { + return 'PermissionsDelete' in sample; +} + +/** + * `Permissions*` keys present on rows, in {@link OBJECT_PERMISSION_COLUMN_KEY_ORDER}, then any extras (sorted). + */ +export function sortedObjectPermissionBooleanKeys(rows: PermissionExportRow[]): string[] { + if (!rows.length) { + return []; + } + const keySet = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) { + if (key.startsWith('Permissions')) { + keySet.add(key); + } + } + } + const orderSet = new Set(OBJECT_PERMISSION_COLUMN_KEY_ORDER); + const primary = OBJECT_PERMISSION_COLUMN_KEY_ORDER.filter((key) => keySet.has(key)); + const rest = [...keySet].filter((key) => !orderSet.has(key)).sort((a, b) => a.localeCompare(b)); + return [...primary, ...rest]; +} + +/** Reorders a full export key list so `Permissions*` on object-permission rows follow {@link OBJECT_PERMISSION_COLUMN_KEY_ORDER}. */ +export function reorderExportKeysForObjectPermissions(keys: string[]): string[] { + const nonPerm = keys.filter((key) => !key.startsWith('Permissions')); + const perm = keys.filter((key) => key.startsWith('Permissions')); + const orderSet = new Set(OBJECT_PERMISSION_COLUMN_KEY_ORDER); + const primary = OBJECT_PERMISSION_COLUMN_KEY_ORDER.filter((key) => perm.includes(key)); + const rest = perm.filter((key) => !orderSet.has(key)).sort((a, b) => a.localeCompare(b)); + return [...nonPerm, ...primary, ...rest]; +} + +/** Split PascalCase API suffixes into spaced words for column titles (e.g. `ViewAllRecords` → "View All Records"). */ +function splitPascalCaseToTitle(pascal: string): string { + return pascal + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .trim(); +} + +/** + * Human-readable DataTable header for export SOQL field names. + * Salesforce object/field permission booleans use `PermissionsRead`, `PermissionsEdit`, etc.; show "Read", "Edit", … + * + * @param fieldKey API field name from a row key. + * @returns Label for the column header; `fieldKey` unchanged when not a `Permissions*` column. + */ +const PERMISSIONS_COLUMN_MIN_WIDTH_PX = 200; +const PERMISSIONS_COLUMN_HEADER_CHAR_PX = 9; +const PERMISSIONS_COLUMN_HEADER_PADDING_PX = 48; + +function permissionsColumnWidthForLabel(headerLabel: string): number { + const fromLabel = Math.ceil(headerLabel.length * PERMISSIONS_COLUMN_HEADER_CHAR_PX + PERMISSIONS_COLUMN_HEADER_PADDING_PX); + return Math.max(PERMISSIONS_COLUMN_MIN_WIDTH_PX, fromLabel); +} + +export function getExportColumnHeaderLabel(fieldKey: string): string { + if (fieldKey === 'SobjectType') { + return 'Object'; + } + if (fieldKey === 'CreatedDate') { + return 'Created Date'; + } + if (fieldKey === 'LastModifiedDate') { + return 'Last Modified Date'; + } + if (fieldKey === 'CreatedBy') { + return 'Created By'; + } + if (fieldKey === 'LastModifiedBy') { + return 'Last Modified By'; + } + if (!fieldKey.startsWith('Permissions')) { + return fieldKey; + } + const suffix = fieldKey.slice('Permissions'.length); + if (!suffix) { + return fieldKey; + } + const explicitLabels: Record = { + Read: 'Read', + Create: 'Create', + Edit: 'Edit', + Delete: 'Delete', + ViewAllRecords: 'View All Records', + ModifyAllRecords: 'Modify All Records', + ViewAllFields: 'View All Fields', + }; + if (explicitLabels[suffix]) { + return explicitLabels[suffix]; + } + return splitPascalCaseToTitle(suffix); +} + +/** + * User Ids (`005…`) assigned to each permission set Id, deduped and sorted (for the Permission Sets tab). + */ +export function buildPermissionSetAssigneeIdsByPermissionSetId(assignments: PermissionExportRow[]): Map { + const idSets = new Map>(); + for (const row of assignments) { + const permissionSetId = row.PermissionSetId; + const assigneeId = row.AssigneeId; + if (typeof permissionSetId !== 'string' || typeof assigneeId !== 'string') { + continue; + } + const trimmedPermissionSetId = permissionSetId.trim(); + const trimmedAssigneeId = assigneeId.trim(); + if (!trimmedPermissionSetId || !trimmedAssigneeId.startsWith(USER_ID_PREFIX)) { + continue; + } + let assigneeSet = idSets.get(trimmedPermissionSetId); + if (!assigneeSet) { + assigneeSet = new Set(); + idSets.set(trimmedPermissionSetId, assigneeSet); + } + assigneeSet.add(trimmedAssigneeId); + } + const result = new Map(); + for (const [permissionSetId, assigneeSet] of idSets) { + result.set( + permissionSetId, + [...assigneeSet].sort((a, b) => a.localeCompare(b)), + ); + } + return result; +} + +/** Leaf rows for the Permission Sets analysis tree (user assignment or “no users” placeholder). */ +export type PermissionSetAssignmentsTreeRow = PermissionExportRow & { + _treePermissionSetGroupKey: string; + /** Present on a synthetic leaf when there are no direct user (`005…`) assignments. */ + _noDirectUserAssignments?: boolean; +}; + +/** User-assignment leaf (excludes the “no direct user assignments” placeholder row). */ +export type PermissionSetAssignmentsTreeUserLeafRow = PermissionSetAssignmentsTreeRow & { + AssigneeId: string; +}; + +/** + * Builds flat rows for a tree grouped by permission set Id: one leaf per assigned user, or one placeholder leaf. + * Permission sets are ordered alphabetically by the same display label as {@link buildPermissionSetIdLabelMap}. + */ +export function buildPermissionSetAssignmentsTreeRows( + permissionSetRows: PermissionExportRow[], + assignments: PermissionExportRow[], +): PermissionSetAssignmentsTreeRow[] { + const labelByPermissionSetId = buildPermissionSetIdLabelMap(permissionSetRows); + const permissionSetRowsAlphabetical = [...permissionSetRows].sort((a, b) => { + const idA = typeof a.Id === 'string' ? a.Id.trim() : ''; + const idB = typeof b.Id === 'string' ? b.Id.trim() : ''; + const labelA = idA ? (labelByPermissionSetId.get(idA) ?? idA) : ''; + const labelB = idB ? (labelByPermissionSetId.get(idB) ?? idB) : ''; + return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' }); + }); + const userIdsBySetId = buildPermissionSetAssigneeIdsByPermissionSetId(assignments); + const result: PermissionSetAssignmentsTreeRow[] = []; + for (const permSetRow of permissionSetRowsAlphabetical) { + const id = typeof permSetRow.Id === 'string' ? permSetRow.Id.trim() : ''; + if (!id) { + continue; + } + const userIds = userIdsBySetId.get(id) ?? []; + if (userIds.length === 0) { + result.push({ + _treePermissionSetGroupKey: id, + _noDirectUserAssignments: true, + Id: `__no_users__${id}`, + }); + } else { + for (const assigneeId of userIds) { + result.push({ + _treePermissionSetGroupKey: id, + PermissionSetId: id, + AssigneeId: assigneeId, + Id: `__user__${id}__${assigneeId}`, + }); + } + } + } + return result; +} + +export function isPermissionSetAssignmentsTreeUserLeaf(row: unknown): row is PermissionSetAssignmentsTreeUserLeafRow { + if (row === null || typeof row !== 'object') { + return false; + } + const record = row as PermissionSetAssignmentsTreeRow; + if (record._noDirectUserAssignments === true) { + return false; + } + const assigneeId = record.AssigneeId; + return typeof assigneeId === 'string' && assigneeId.startsWith(USER_ID_PREFIX); +} + +export function isPermissionSetAssignmentsTreePlaceholderLeaf(row: unknown): row is PermissionSetAssignmentsTreeRow { + if (row === null || typeof row !== 'object') { + return false; + } + return (row as PermissionSetAssignmentsTreeRow)._noDirectUserAssignments === true; +} + +/** Leaf kinds for the Assignments tab tree (grouped by user). */ +export type UserAssignmentTreeLeafKind = 'permission_set' | 'permission_set_group' | 'profile' | 'permission_set_license'; + +/** One permission set license row for {@link buildUserAssignmentsTreeRows}. */ +export interface UserLicenseLeafRecord { + permissionSetLicenseId: string; + label: string; +} + +/** Flat row for a tree grouped by Salesforce User Id (`005…`). */ +export type UserAssignmentsTreeRow = { + Id: string; + _treeUserGroupKey: string; + _leafKind: UserAssignmentTreeLeafKind; + /** Permission set Id when `_leafKind` is `permission_set`. */ + _permissionSetId?: string; + /** Permission set group Id when `_leafKind` is `permission_set_group`. */ + _permissionSetGroupId?: string; + /** Profile Id when `_leafKind` is `profile` (optional until enriched from `User`). */ + _profileId?: string; + /** Permission Set License definition Id when `_leafKind` is `permission_set_license`. */ + _permissionSetLicenseId?: string; + /** Display label for `permission_set_license` leaves. */ + _licenseLabel?: string; +}; + +export function buildPermissionSetGroupLabelMap(groups: PermissionExportRow[]): Map { + const map = new Map(); + for (const row of groups) { + const id = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (!id) { + continue; + } + const masterLabel = typeof row.MasterLabel === 'string' && row.MasterLabel.trim() ? row.MasterLabel.trim() : ''; + const developerName = typeof row.DeveloperName === 'string' && row.DeveloperName.trim() ? row.DeveloperName.trim() : ''; + map.set(id, masterLabel || developerName || id); + } + return map; +} + +export function buildPermissionSetIdToGroupIdsMap(components: PermissionExportRow[]): Map> { + const map = new Map>(); + for (const row of components) { + const permissionSetId = typeof row.PermissionSetId === 'string' ? row.PermissionSetId.trim() : ''; + const groupId = typeof row.PermissionSetGroupId === 'string' ? row.PermissionSetGroupId.trim() : ''; + if (!permissionSetId || !groupId) { + continue; + } + let groupSet = map.get(permissionSetId); + if (!groupSet) { + groupSet = new Set(); + map.set(permissionSetId, groupSet); + } + groupSet.add(groupId); + } + return map; +} + +function buildUserIdToAssignedPermissionSetIds(assignments: PermissionExportRow[]): Map> { + const map = new Map>(); + for (const row of assignments) { + const assigneeId = row.AssigneeId; + const permissionSetId = row.PermissionSetId; + if (typeof assigneeId !== 'string' || typeof permissionSetId !== 'string') { + continue; + } + const trimmedUserId = assigneeId.trim(); + const trimmedPermissionSetId = permissionSetId.trim(); + if (!trimmedUserId.startsWith(USER_ID_PREFIX) || !trimmedPermissionSetId) { + continue; + } + let permissionSetSet = map.get(trimmedUserId); + if (!permissionSetSet) { + permissionSetSet = new Set(); + map.set(trimmedUserId, permissionSetSet); + } + permissionSetSet.add(trimmedPermissionSetId); + } + return map; +} + +/** + * Builds flat rows for the Assignments analysis tree: group = user, leaves = profile first, then permission sets + * (alphabetically by display label), then permission set groups, then permission set licenses (per user). Users are + * ordered by `userId` here; use {@link sortUserAssignmentsTreeRowsByUserDisplay} after loading display names. + */ +export function buildUserAssignmentsTreeRows(options: { + assignments: PermissionExportRow[]; + permissionSets: PermissionExportRow[]; + groupComponents: PermissionExportRow[]; + groups: PermissionExportRow[]; + licensesByUserId?: ReadonlyMap; +}): UserAssignmentsTreeRow[] { + const { assignments, permissionSets, groupComponents, groups, licensesByUserId = new Map() } = options; + const labelByPermissionSetId = buildPermissionSetIdLabelMap(permissionSets); + const labelByGroupId = buildPermissionSetGroupLabelMap(groups); + const permissionSetIdToGroupIds = buildPermissionSetIdToGroupIdsMap(groupComponents); + const userToPermissionSetIds = buildUserIdToAssignedPermissionSetIds(assignments); + + const userIds = [...userToPermissionSetIds.keys()].sort((a, b) => a.localeCompare(b)); + const result: UserAssignmentsTreeRow[] = []; + + for (const userId of userIds) { + const permissionSetIds = [...(userToPermissionSetIds.get(userId) ?? [])].sort((firstId, secondId) => { + const labelA = labelByPermissionSetId.get(firstId) ?? firstId; + const labelB = labelByPermissionSetId.get(secondId) ?? secondId; + return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' }); + }); + + result.push({ + Id: `__profile__${userId}`, + _treeUserGroupKey: userId, + _leafKind: 'profile', + }); + + for (const permissionSetId of permissionSetIds) { + result.push({ + Id: `__ps__${userId}__${permissionSetId}`, + _treeUserGroupKey: userId, + _leafKind: 'permission_set', + _permissionSetId: permissionSetId, + }); + } + + const groupIdsForUser = new Set(); + for (const permissionSetId of permissionSetIds) { + for (const groupId of permissionSetIdToGroupIds.get(permissionSetId) ?? []) { + groupIdsForUser.add(groupId); + } + } + const sortedGroupIds = [...groupIdsForUser].sort((firstId, secondId) => { + const labelA = labelByGroupId.get(firstId) ?? firstId; + const labelB = labelByGroupId.get(secondId) ?? secondId; + return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' }); + }); + for (const groupId of sortedGroupIds) { + result.push({ + Id: `__psg__${userId}__${groupId}`, + _treeUserGroupKey: userId, + _leafKind: 'permission_set_group', + _permissionSetGroupId: groupId, + }); + } + + const licenseRecords = licensesByUserId.get(userId) ?? []; + const sortedLicenses = [...licenseRecords].sort((first, second) => + first.label.localeCompare(second.label, undefined, { sensitivity: 'base' }), + ); + for (const license of sortedLicenses) { + result.push({ + Id: `__psl__${userId}__${license.permissionSetLicenseId}`, + _treeUserGroupKey: userId, + _leafKind: 'permission_set_license', + _permissionSetLicenseId: license.permissionSetLicenseId, + _licenseLabel: license.label, + }); + } + } + + return result; +} + +/** + * Reorders {@link UserAssignmentsTreeRow} blocks so users appear alphabetically by display label. + */ +export function sortUserAssignmentsTreeRowsByUserDisplay( + rows: UserAssignmentsTreeRow[], + displayLabelByUserId: ReadonlyMap, +): UserAssignmentsTreeRow[] { + const rowsByUserId = new Map(); + for (const row of rows) { + const userId = row._treeUserGroupKey; + const list = rowsByUserId.get(userId) ?? []; + list.push(row); + rowsByUserId.set(userId, list); + } + const sortedUserIds = [...rowsByUserId.keys()].sort((a, b) => { + const labelA = displayLabelByUserId.get(a) ?? a; + const labelB = displayLabelByUserId.get(b) ?? b; + return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' }); + }); + const output: UserAssignmentsTreeRow[] = []; + for (const userId of sortedUserIds) { + output.push(...(rowsByUserId.get(userId) ?? [])); + } + return output; +} + +export function isUserAssignmentsTreePermissionSetLeaf( + row: unknown, +): row is UserAssignmentsTreeRow & { _leafKind: 'permission_set'; _permissionSetId: string } { + if (row === null || typeof row !== 'object') { + return false; + } + const record = row as UserAssignmentsTreeRow; + return record._leafKind === 'permission_set' && typeof record._permissionSetId === 'string' && record._permissionSetId.trim().length > 0; +} + +/** + * Builds read-only DataTable columns from heterogeneous SOQL rows (first row drives types). + */ +export function buildDynamicExportColumns( + rows: PermissionExportRow[], + options?: BuildDynamicExportColumnsOptions, +): ColumnWithFilter[] { + if (!rows.length) { + return []; + } + const omit = options?.omitColumnKeys ?? new Set(); + let keys = sortedExportColumnKeys(rows).filter((key) => !omit.has(key)); + const firstRow = rows[0]; + if (isObjectPermissionsExportSample(firstRow)) { + keys = reorderExportKeysForObjectPermissions(keys); + } + return keys.map((key) => { + const fieldType = key === 'Id' || key === 'ParentId' || key.endsWith('Id') ? 'salesforceId' : getRowTypeFromValue(firstRow[key], false); + const base = setColumnFromType(key, fieldType); + const headerLabel = getExportColumnHeaderLabel(key); + const permissionsWidthPx = key.startsWith('Permissions') ? permissionsColumnWidthForLabel(headerLabel) : undefined; + return { + ...base, + name: headerLabel, + key, + field: key, + resizable: true, + ...(permissionsWidthPx !== undefined ? { width: permissionsWidthPx, minWidth: permissionsWidthPx } : {}), + } as ColumnWithFilter; + }); +} + +/** + * Permission sets that have at least one direct assignment to a User (`AssigneeId` prefix `005`). + */ +export function getPermissionSetIdsWithDirectUserAssignment(assignments: PermissionExportRow[]): Set { + const result = new Set(); + for (const row of assignments) { + const permissionSetId = row.PermissionSetId; + const assigneeId = row.AssigneeId; + if (typeof permissionSetId !== 'string' || typeof assigneeId !== 'string') { + continue; + } + if (assigneeId.startsWith(USER_ID_PREFIX)) { + result.add(permissionSetId); + } + } + return result; +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-core.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-core.ts new file mode 100644 index 000000000..8fa7034c8 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-core.ts @@ -0,0 +1,4 @@ +export * from './export-result-types-labels'; +export * from './export-result-sorting'; +export * from './export-result-parse-collect'; +export * from './export-result-columns-assignments'; diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-findings.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-findings.ts new file mode 100644 index 000000000..183dd9bce --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-findings.ts @@ -0,0 +1,488 @@ +import { getPermissionExportFindingDefinition, PermissionExportFindingCode } from '@jetstream/shared/constants'; + +import { + FIELD_PERMISSION_BOOLEAN_COLUMN_KEYS, + type PermissionAnalysisFinding, + type PermissionExportRow, +} from './export-result-types-labels'; + +export function getFindingContainerId(finding: PermissionAnalysisFinding): string | null { + const candidates = [finding.permissionSetId, finding.parentId, finding.containerId]; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.length > 0) { + return candidate; + } + } + return null; +} + +/** Matches {@link buildPermissionExportFindings} row keying: permission-set container + object API name. */ +export function objectPermissionFindingRowKey(parentId: string, objectApiName: string): string { + return `${parentId}::${objectApiName}`; +} + +export type PermissionObjectFindingCellSeverity = 'error' | 'warning'; + +const OBJECT_FINDING_READ_PATH_COLUMNS = ['PermissionsRead', 'PermissionsViewAllRecords', 'PermissionsModifyAllRecords'] as const; +const OBJECT_FINDING_EDIT_PATH_COLUMNS = ['PermissionsEdit', 'PermissionsModifyAllRecords'] as const; + +/** + * Object-permission grid column keys highlighted for a given issue `code` (empty when none). + */ +export function getObjectPermissionHighlightColumnKeysForFindingCode(code: string): readonly string[] { + const trimmed = code.trim(); + if (!trimmed || trimmed === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + return []; + } + if (trimmed === PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS) { + return ['PermissionsRead']; + } + if (trimmed === PermissionExportFindingCode.OLS_EDIT_NO_FLS_ROWS) { + return ['PermissionsEdit']; + } + if (trimmed === PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ) { + return OBJECT_FINDING_READ_PATH_COLUMNS; + } + if (trimmed === PermissionExportFindingCode.FLS_EDIT_NO_OBJECT_EDIT) { + return OBJECT_FINDING_EDIT_PATH_COLUMNS; + } + // Modify All Records implies View All Records, so highlight both columns for the (error) modify finding. + if (trimmed === PermissionExportFindingCode.OBJECT_MODIFY_ALL_RECORDS) { + return ['PermissionsModifyAllRecords', 'PermissionsViewAllRecords']; + } + if (trimmed === PermissionExportFindingCode.OBJECT_VIEW_ALL_RECORDS) { + return ['PermissionsViewAllRecords']; + } + return []; +} + +function severityForObjectPermissionFindingCode(code: string): PermissionObjectFindingCellSeverity | null { + const def = getPermissionExportFindingDefinition(code); + if (!def) { + return null; + } + return def.severity === 'error' ? 'error' : 'warning'; +} + +/** + * Issues that contribute to highlighting this leaf row cell on the Object Permissions tree. + */ +export function listFindingsForObjectPermissionCell( + findings: readonly PermissionAnalysisFinding[], + parentId: string, + objectApiName: string, + columnKey: string, +): PermissionAnalysisFinding[] { + const normalizedParent = parentId.trim(); + const normalizedObject = objectApiName.trim(); + const normalizedColumn = columnKey.trim(); + const matches: PermissionAnalysisFinding[] = []; + for (const finding of findings) { + const findingParent = getFindingContainerId(finding)?.trim() ?? ''; + const findingObject = typeof finding.objectApiName === 'string' ? finding.objectApiName.trim() : ''; + if (!findingParent || !findingObject) { + continue; + } + if (findingParent !== normalizedParent || findingObject !== normalizedObject) { + continue; + } + const codeRaw = typeof finding.code === 'string' ? finding.code.trim() : ''; + const highlightColumns = getObjectPermissionHighlightColumnKeysForFindingCode(codeRaw); + if (!highlightColumns.includes(normalizedColumn)) { + continue; + } + matches.push(finding); + } + return matches; +} + +/** + * Maps object-permission export rows (ParentId + SobjectType) to permission boolean columns that + * should be highlighted on the Object Permissions tree from analysis issues. + * + * @param findings Parsed `analysis_job.result.findings` (same issue rows as the Issues tab). + * @returns Outer key: {@link objectPermissionFindingRowKey}; inner key: `Permissions*` column name. + */ +export function buildObjectPermissionFindingCellHighlights( + findings: PermissionAnalysisFinding[], +): Map> { + const result = new Map>(); + + const mergeCell = (rowKey: string, columnKey: string, severity: PermissionObjectFindingCellSeverity): void => { + let columnMap = result.get(rowKey); + if (!columnMap) { + columnMap = new Map(); + result.set(rowKey, columnMap); + } + const existing = columnMap.get(columnKey); + const next: PermissionObjectFindingCellSeverity = existing === 'error' || severity === 'error' ? 'error' : severity; + columnMap.set(columnKey, next); + }; + + for (const finding of findings) { + const codeRaw = typeof finding.code === 'string' ? finding.code.trim() : ''; + const objectApi = typeof finding.objectApiName === 'string' ? finding.objectApiName.trim() : ''; + const parentId = getFindingContainerId(finding)?.trim() ?? ''; + if (!codeRaw || !objectApi || !parentId) { + continue; + } + const highlightColumns = getObjectPermissionHighlightColumnKeysForFindingCode(codeRaw); + if (highlightColumns.length === 0) { + continue; + } + const severity = severityForObjectPermissionFindingCode(codeRaw); + if (!severity) { + continue; + } + const rowKey = objectPermissionFindingRowKey(parentId, objectApi); + for (const columnKey of highlightColumns) { + mergeCell(rowKey, columnKey, severity); + } + } + + return result; +} + +/** + * Sentinel field segment for {@link fieldPermissionFindingRowKey} when an issue applies to every + * field-permission row for the same permission set + object (e.g. {@link PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW}). + */ +export const FIELD_PERMISSION_OBJECT_SCOPE_MARKER = '__FIELD_PERM_OBJECT_SCOPE__'; + +/** Row key for field-permission export highlights (`ParentId::SobjectType::Field` or scope marker). */ +export function fieldPermissionFindingRowKey(parentId: string, objectApiName: string, fieldSegment: string): string { + return `${parentId.trim()}::${objectApiName.trim()}::${fieldSegment.trim()}`; +} + +/** + * Field-permission grid column keys highlighted for a given issue `code` (empty when none on this surface). + */ +export function getFieldPermissionHighlightColumnKeysForFindingCode(code: string): readonly string[] { + const trimmed = code.trim(); + if (!trimmed || trimmed === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + return []; + } + if (trimmed === PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ) { + return ['PermissionsRead']; + } + if (trimmed === PermissionExportFindingCode.FLS_EDIT_NO_OBJECT_EDIT) { + return ['PermissionsEdit']; + } + if (trimmed === PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW) { + return [...FIELD_PERMISSION_BOOLEAN_COLUMN_KEYS]; + } + return []; +} + +function severityForFieldPermissionFindingCode(code: string): PermissionObjectFindingCellSeverity | null { + return severityForObjectPermissionFindingCode(code); +} + +/** + * Issues that highlight this field-permission export cell (same `ParentId` / `SobjectType` / `Field` row). + */ +export function listFindingsForFieldPermissionCell( + findings: readonly PermissionAnalysisFinding[], + parentId: string, + objectApiName: string, + fieldApiName: string, + columnKey: string, +): PermissionAnalysisFinding[] { + const normalizedParent = parentId.trim(); + const normalizedObject = objectApiName.trim(); + const normalizedField = fieldApiName.trim(); + const normalizedColumn = columnKey.trim(); + const matches: PermissionAnalysisFinding[] = []; + for (const finding of findings) { + const findingParent = getFindingContainerId(finding)?.trim() ?? ''; + const findingObject = typeof finding.objectApiName === 'string' ? finding.objectApiName.trim() : ''; + if (!findingParent || !findingObject || findingParent !== normalizedParent || findingObject !== normalizedObject) { + continue; + } + const codeRaw = typeof finding.code === 'string' ? finding.code.trim() : ''; + const highlightColumns = getFieldPermissionHighlightColumnKeysForFindingCode(codeRaw); + if (!highlightColumns.includes(normalizedColumn)) { + continue; + } + if (codeRaw === PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW) { + matches.push(finding); + continue; + } + const findingField = typeof finding.fieldApiName === 'string' ? finding.fieldApiName.trim() : ''; + if (findingField === normalizedField) { + matches.push(finding); + } + } + return matches; +} + +/** + * Maps field-permission export rows to cells that should highlight from analysis issues. + * + * @returns Outer key: {@link fieldPermissionFindingRowKey}; inner key: column name (e.g. `PermissionsRead`). + */ +export function buildFieldPermissionFindingCellHighlights( + findings: PermissionAnalysisFinding[], +): Map> { + const result = new Map>(); + + const mergeCell = (rowKey: string, columnKey: string, severity: PermissionObjectFindingCellSeverity): void => { + let columnMap = result.get(rowKey); + if (!columnMap) { + columnMap = new Map(); + result.set(rowKey, columnMap); + } + const existing = columnMap.get(columnKey); + const next: PermissionObjectFindingCellSeverity = existing === 'error' || severity === 'error' ? 'error' : severity; + columnMap.set(columnKey, next); + }; + + for (const finding of findings) { + const codeRaw = typeof finding.code === 'string' ? finding.code.trim() : ''; + const objectApi = typeof finding.objectApiName === 'string' ? finding.objectApiName.trim() : ''; + const parentId = getFindingContainerId(finding)?.trim() ?? ''; + if (!codeRaw || !objectApi || !parentId) { + continue; + } + const highlightColumns = getFieldPermissionHighlightColumnKeysForFindingCode(codeRaw); + if (highlightColumns.length === 0) { + continue; + } + const severity = severityForFieldPermissionFindingCode(codeRaw); + if (!severity) { + continue; + } + const fieldPart = + codeRaw === PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW + ? FIELD_PERMISSION_OBJECT_SCOPE_MARKER + : typeof finding.fieldApiName === 'string' + ? finding.fieldApiName.trim() + : ''; + if (!fieldPart) { + continue; + } + const rowKey = fieldPermissionFindingRowKey(parentId, objectApi, fieldPart); + for (const columnKey of highlightColumns) { + mergeCell(rowKey, columnKey, severity); + } + } + + return result; +} + +export function fieldPermissionCellSeverity( + highlights: Map>, + parentId: string, + objectApiName: string, + fieldApiName: string, + columnKey: string, +): PermissionObjectFindingCellSeverity | undefined { + const specificKey = fieldPermissionFindingRowKey(parentId, objectApiName, fieldApiName); + const scopeKey = fieldPermissionFindingRowKey(parentId, objectApiName, FIELD_PERMISSION_OBJECT_SCOPE_MARKER); + const fromSpecific = highlights.get(specificKey)?.get(columnKey); + const fromScope = highlights.get(scopeKey)?.get(columnKey); + if (fromSpecific === 'error' || fromScope === 'error') { + return 'error'; + } + return fromSpecific ?? fromScope; +} + +function isErrorLikeSeverity(value: unknown): boolean { + const normalized = String(value ?? '').toLowerCase(); + return normalized === 'error' || normalized === 'errors'; +} + +function isWarningLikeSeverity(value: unknown): boolean { + const normalized = String(value ?? '').toLowerCase(); + return normalized === 'warning' || normalized === 'warnings'; +} + +/** + * Severity used for container-level badges (permission set / profile rows), from catalog definition or row payload. + */ +function containerSeverityFromFinding(finding: PermissionAnalysisFinding, codeRaw: string): PermissionObjectFindingCellSeverity | null { + const def = getPermissionExportFindingDefinition(codeRaw); + if (def) { + return def.severity === 'error' ? 'error' : 'warning'; + } + if (isErrorLikeSeverity(finding.severity)) { + return 'error'; + } + if (isWarningLikeSeverity(finding.severity)) { + return 'warning'; + } + return null; +} + +/** + * Max severity per permission-set container Id for profile / permission-set / assignment export rows. + */ +export function buildContainerIdFindingSeverity( + findings: readonly PermissionAnalysisFinding[], +): Map { + const result = new Map(); + for (const finding of findings) { + const codeRaw = typeof finding.code === 'string' ? finding.code.trim() : ''; + if (!codeRaw || codeRaw === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + continue; + } + const containerId = getFindingContainerId(finding)?.trim() ?? ''; + if (!containerId) { + continue; + } + const next = containerSeverityFromFinding(finding, codeRaw); + if (!next) { + continue; + } + const existing = result.get(containerId); + const merged: PermissionObjectFindingCellSeverity = existing === 'error' || next === 'error' ? 'error' : next; + result.set(containerId, merged); + } + return result; +} + +/** + * All issues for a permission-set container (same list as Issues tab), excluding truncation rows. + */ +export function listFindingsForExportContainer( + findings: readonly PermissionAnalysisFinding[], + containerId: string, +): PermissionAnalysisFinding[] { + const id = containerId.trim(); + if (!id) { + return []; + } + return findings.filter((finding) => { + const code = String(finding.code ?? '').trim(); + if (code === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + return false; + } + return getFindingContainerId(finding)?.trim() === id; + }); +} + +/** Column keys on permission-set export rows that open the container issues modal (first match wins). */ +export function pickPermissionSetExportClickableColumnKeys(sample: PermissionExportRow): string[] { + const preferred = ['Label', 'Name', 'MasterLabel', 'DeveloperName', 'Profile', 'Id'] as const; + return preferred.filter((key) => key in sample); +} + +/** Column keys on assignment rows that open issues for the related permission set. */ +export function pickAssignmentExportClickableColumnKeys(sample: PermissionExportRow): string[] { + const preferred = ['PermissionSetId', 'AssigneeId', 'Id'] as const; + return preferred.filter((key) => key in sample); +} + +/** Column keys on PermissionSetTabSetting rows (`ParentId` = permission set). */ +export function pickTabVisibilityExportClickableColumnKeys(sample: PermissionExportRow): string[] { + const preferred = ['ParentId', 'Name', 'Visibility', 'Id'] as const; + return preferred.filter((key) => key in sample); +} + +export function getFindingLabelForCode(code: string | undefined): string { + if (!code) { + return ''; + } + return getPermissionExportFindingDefinition(code)?.label ?? ''; +} + +export interface FindingCodeDisplayParts { + /** Catalog label when the code is known; otherwise the raw value (or "(no code)"). */ + title: string; + /** Raw exporter `code` for muted parentheses when a catalog label exists. */ + technicalCode: string | null; +} + +/** + * Splits an issue `code` into a user-facing title vs optional technical identifier. + */ +export function getFindingCodeDisplayParts(code: string | undefined): FindingCodeDisplayParts { + const raw = typeof code === 'string' ? code.trim() : ''; + if (!raw) { + return { title: '(no code)', technicalCode: null }; + } + const catalogLabel = getFindingLabelForCode(raw); + if (catalogLabel.length > 0) { + return { title: catalogLabel, technicalCode: raw }; + } + return { title: raw, technicalCode: null }; +} + +export interface PermissionFindingCodeRollup { + code: string; + count: number; + errorCount: number; + warningCount: number; + label: string; +} + +export interface PermissionFindingObjectRollup { + objectApiName: string; + count: number; + errorCount: number; + warningCount: number; +} + +export interface AggregatePermissionFindingsResult { + byCode: PermissionFindingCodeRollup[]; + byObject: PermissionFindingObjectRollup[]; +} + +/** Rolls up the current issue list for summary tiles. */ +export function aggregatePermissionAnalysisFindings(findings: PermissionAnalysisFinding[]): AggregatePermissionFindingsResult { + const byCodeMap = new Map(); + const byObjectMap = new Map(); + + for (const row of findings) { + const codeRaw = String(row.code ?? '').trim(); + const code = codeRaw.length > 0 ? codeRaw : '(no code)'; + if (code === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + continue; + } + const objectKeyRaw = String(row.objectApiName ?? '').trim(); + const objectKey = objectKeyRaw.length > 0 ? objectKeyRaw : '(no object)'; + const isError = isErrorLikeSeverity(row.severity); + const isWarning = isWarningLikeSeverity(row.severity); + + const codeAgg = byCodeMap.get(code) ?? { count: 0, errors: 0, warnings: 0 }; + codeAgg.count += 1; + if (isError) { + codeAgg.errors += 1; + } + if (isWarning) { + codeAgg.warnings += 1; + } + byCodeMap.set(code, codeAgg); + + const objectAgg = byObjectMap.get(objectKey) ?? { count: 0, errors: 0, warnings: 0 }; + objectAgg.count += 1; + if (isError) { + objectAgg.errors += 1; + } + if (isWarning) { + objectAgg.warnings += 1; + } + byObjectMap.set(objectKey, objectAgg); + } + + const byCode: PermissionFindingCodeRollup[] = [...byCodeMap.entries()] + .map(([code, value]) => ({ + code, + count: value.count, + errorCount: value.errors, + warningCount: value.warnings, + label: getFindingLabelForCode(code === '(no code)' ? undefined : code), + })) + .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code)); + + const byObject: PermissionFindingObjectRollup[] = [...byObjectMap.entries()] + .map(([objectApiName, value]) => ({ + objectApiName, + count: value.count, + errorCount: value.errors, + warningCount: value.warnings, + })) + .sort((a, b) => b.count - a.count || a.objectApiName.localeCompare(b.objectApiName)); + + return { byCode, byObject }; +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-parse-collect.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-parse-collect.ts new file mode 100644 index 000000000..38d5e75c3 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-parse-collect.ts @@ -0,0 +1,137 @@ +import type { + ParsedPermissionExportResult, + PermissionAnalysisFinding, + PermissionExportBundle, + PermissionExportRequestScope, + PermissionExportRow, +} from './export-result-types-labels'; + +export function collectSobjectApiNamesFromPermissionExport(exportBundle: PermissionExportBundle): string[] { + const names = new Set(); + for (const row of exportBundle.objectPermissions) { + const value = row.SobjectType; + if (typeof value === 'string' && value.trim().length > 0) { + names.add(value.trim()); + } + } + for (const row of exportBundle.fieldPermissions) { + const value = row.SobjectType; + if (typeof value === 'string' && value.trim().length > 0) { + names.add(value.trim()); + } + } + return [...names].sort((a, b) => a.localeCompare(b)); +} + +/** + * Unique tab API names from `PermissionSetTabSetting` rows (for Tooling `TabDefinition` enrichment). + */ +export function collectTabSettingNamesFromPermissionExport(exportBundle: PermissionExportBundle): string[] { + const names = new Set(); + for (const row of exportBundle.permissionSetTabSettings) { + const value = row.Name; + if (typeof value === 'string' && value.trim().length > 0) { + names.add(value.trim()); + } + } + return [...names].sort((a, b) => a.localeCompare(b)); +} + +function asRecord(value: unknown): Record | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return null; +} + +function stringIdArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((id): id is string => typeof id === 'string'); +} + +export function parsePermissionExportRequestScope(jobResult: unknown): PermissionExportRequestScope { + const root = asRecord(jobResult); + if (!root) { + return { profilePermissionSetIds: [], permissionSetIds: [], objectApiNames: [] }; + } + const payload = asRecord(root.requestPayload); + if (!payload) { + return { profilePermissionSetIds: [], permissionSetIds: [], objectApiNames: [] }; + } + return { + profilePermissionSetIds: stringIdArray(payload.profileIds), + permissionSetIds: stringIdArray(payload.permissionSetIds), + objectApiNames: stringIdArray(payload.objectApiNames), + }; +} + +export function filterPermissionSetExportRowsById( + rows: PermissionExportRow[], + permissionSetIds: ReadonlySet, +): PermissionExportRow[] { + if (permissionSetIds.size === 0) { + return []; + } + return rows.filter((row) => typeof row.Id === 'string' && permissionSetIds.has(row.Id)); +} + +function asRowArray(value: unknown): PermissionExportRow[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((row): row is PermissionExportRow => row !== null && typeof row === 'object' && !Array.isArray(row)); +} + +/** + * Normalizes `analysis_job.result` JSON for permission export jobs. + */ +export function parsePermissionExportResult(jobResult: unknown): ParsedPermissionExportResult | null { + const root = asRecord(jobResult); + if (!root) { + return null; + } + + const exportBlock = asRecord(root.export); + if (!exportBlock) { + return null; + } + + const countsRaw = asRecord(root.counts); + const counts: Record = {}; + if (countsRaw) { + for (const [key, value] of Object.entries(countsRaw)) { + if (typeof value === 'number' && Number.isFinite(value)) { + counts[key] = value; + } + } + } + + const findingsRaw = root.findings; + const findings: PermissionAnalysisFinding[] = Array.isArray(findingsRaw) + ? findingsRaw.filter((item): item is PermissionAnalysisFinding => item !== null && typeof item === 'object') + : []; + + return { + phase: root.phase != null ? String(root.phase) : null, + summary: root.summary != null ? String(root.summary) : null, + truncated: Boolean(root.truncated), + counts, + export: { + permissionSets: asRowArray(exportBlock.permissionSets), + permissionSetAssignments: asRowArray(exportBlock.permissionSetAssignments), + permissionSetGroups: asRowArray(exportBlock.permissionSetGroups), + permissionSetGroupComponents: asRowArray(exportBlock.permissionSetGroupComponents), + mutingPermissionSets: asRowArray(exportBlock.mutingPermissionSets), + objectPermissions: asRowArray(exportBlock.objectPermissions), + fieldPermissions: asRowArray(exportBlock.fieldPermissions), + permissionSetTabSettings: asRowArray(exportBlock.permissionSetTabSettings), + }, + findings, + issueCodeSummary: + root.issueCodeSummary != null && typeof root.issueCodeSummary === 'object' && !Array.isArray(root.issueCodeSummary) + ? (root.issueCodeSummary as Record) + : null, + }; +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-sorting.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-sorting.ts new file mode 100644 index 000000000..8df0e5cdf --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-sorting.ts @@ -0,0 +1,226 @@ +import type { PermissionExportRow, SobjectExportDetail } from './export-result-types-labels'; +import { buildPermissionSetIdLabelMap, fieldPermissionQualifiedFieldShortApi } from './export-result-types-labels'; + +function isProfileOwnedPermissionSetRow(row: PermissionExportRow | undefined): boolean { + return row?.IsOwnedByProfile === true; +} + +/** + * Sorts `ObjectPermissions` export rows for the analysis tree: profile-owned parents first (label order), + * then other permission sets (label order), then rows for the same parent by object label (metadata label + * when {@link sobjectExportDetails} has it, else `SobjectType` API name). + */ +export function sortObjectPermissionExportRowsForAnalysisTree( + objectPermissionRows: PermissionExportRow[], + permissionSetRows: PermissionExportRow[], + sobjectExportDetails?: Readonly>, +): PermissionExportRow[] { + const labelByParentId = buildPermissionSetIdLabelMap(permissionSetRows); + const permissionSetById = new Map(); + for (const row of permissionSetRows) { + const id = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (id) { + permissionSetById.set(id, row); + } + } + + function parentTier(parentId: string): number { + if (!parentId) { + return 2; + } + return isProfileOwnedPermissionSetRow(permissionSetById.get(parentId)) ? 0 : 1; + } + + function parentLabelCompareKey(parentId: string): string { + return labelByParentId.get(parentId) ?? parentId; + } + + function objectSortCompareKey(sobjectType: string): string { + const api = sobjectType.trim(); + if (!api) { + return ''; + } + const detail = sobjectExportDetails?.[api]; + const label = detail?.label?.trim() ? detail.label.trim() : api; + const primary = label.toLocaleLowerCase(); + const secondary = api.toLocaleLowerCase(); + return `${primary}\0${secondary}`; + } + + return [...objectPermissionRows].sort((rowA, rowB) => { + const parentA = typeof rowA.ParentId === 'string' ? rowA.ParentId.trim() : ''; + const parentB = typeof rowB.ParentId === 'string' ? rowB.ParentId.trim() : ''; + if (parentA !== parentB) { + const tierA = parentTier(parentA); + const tierB = parentTier(parentB); + if (tierA !== tierB) { + return tierA - tierB; + } + const labelCmp = parentLabelCompareKey(parentA).localeCompare(parentLabelCompareKey(parentB), undefined, { + sensitivity: 'base', + }); + if (labelCmp !== 0) { + return labelCmp; + } + return parentA.localeCompare(parentB, undefined, { sensitivity: 'base' }); + } + const objA = typeof rowA.SobjectType === 'string' ? rowA.SobjectType.trim() : ''; + const objB = typeof rowB.SobjectType === 'string' ? rowB.SobjectType.trim() : ''; + return objectSortCompareKey(objA).localeCompare(objectSortCompareKey(objB), undefined, { sensitivity: 'base' }); + }); +} + +/** + * Sorts `FieldPermissions` export rows: profile-owned parents first, then permission sets (by parent label + * from the export rows themselves), then object by `SobjectType` API name, then field by qualified `Field`. + * + * Sort keys are derived from row data only (no metadata) so the resulting order is stable across async + * label loads — async metadata responses update displayed labels in cells but never trigger a re-sort. + * + * Uses a decorate-sort-undecorate (Schwartzian) pattern — sort keys are computed once per row + * (O(N) trims/lookups/lowercases) and the comparator only compares primitives. + */ +export function sortFieldPermissionExportRowsForAnalysisTree( + fieldPermissionRows: PermissionExportRow[], + permissionSetRows: PermissionExportRow[], +): PermissionExportRow[] { + const labelByParentId = buildPermissionSetIdLabelMap(permissionSetRows); + const permissionSetById = new Map(); + for (const row of permissionSetRows) { + const id = typeof row.Id === 'string' ? row.Id : ''; + if (id) { + permissionSetById.set(id, row); + } + } + + const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); + + interface DecoratedRow { + row: PermissionExportRow; + tier: number; + parentId: string; + parentLabel: string; + objKey: string; + fieldKey: string; + } + + const decorated: DecoratedRow[] = new Array(fieldPermissionRows.length); + for (let i = 0; i < fieldPermissionRows.length; i++) { + const row = fieldPermissionRows[i]; + const parentId = typeof row.ParentId === 'string' ? row.ParentId : ''; + const tier = !parentId ? 2 : isProfileOwnedPermissionSetRow(permissionSetById.get(parentId)) ? 0 : 1; + const parentLabel = (labelByParentId.get(parentId) ?? parentId).toLocaleLowerCase(); + + const sobjectType = typeof row.SobjectType === 'string' ? row.SobjectType : ''; + const objKey = sobjectType.toLocaleLowerCase(); + + const fieldFull = typeof row.Field === 'string' ? row.Field : ''; + const fieldShort = fieldPermissionQualifiedFieldShortApi(row); + const fieldKey = (fieldShort || fieldFull).toLocaleLowerCase(); + + decorated[i] = { row, tier, parentId, parentLabel, objKey, fieldKey }; + } + + decorated.sort((a, b) => { + if (a.parentId !== b.parentId) { + if (a.tier !== b.tier) { + return a.tier - b.tier; + } + const labelCmp = collator.compare(a.parentLabel, b.parentLabel); + if (labelCmp !== 0) { + return labelCmp; + } + return collator.compare(a.parentId, b.parentId); + } + const objCmp = collator.compare(a.objKey, b.objKey); + if (objCmp !== 0) { + return objCmp; + } + return collator.compare(a.fieldKey, b.fieldKey); + }); + + const sorted: PermissionExportRow[] = new Array(decorated.length); + for (let i = 0; i < decorated.length; i++) { + sorted[i] = decorated[i].row; + } + return sorted; +} + +/** + * User-facing copy for `PermissionSetTabSetting.Visibility` on analysis grids. + */ +export function formatTabSettingVisibilityDisplay(value: unknown): string { + const raw = typeof value === 'string' ? value.trim() : ''; + if (raw === 'DefaultOn') { + return 'Visible'; + } + if (raw === 'DefaultOff') { + return 'Hidden'; + } + if (raw.length === 0) { + return '—'; + } + return raw; +} + +/** + * Sorts `PermissionSetTabSetting` export rows for the analysis tree: profile-owned parents first (label order), + * then other permission sets (label order), then by tab display label (or {@link Name}) within the same {@link ParentId}. + * + * @param tabLabelBySettingName Optional `TabDefinition.Name` → `Label` map from Tooling (enriches sort when loaded). + */ +export function sortTabSettingExportRowsForAnalysisTree( + tabSettingRows: PermissionExportRow[], + permissionSetRows: PermissionExportRow[], + tabLabelBySettingName?: ReadonlyMap, +): PermissionExportRow[] { + const labelByParentId = buildPermissionSetIdLabelMap(permissionSetRows); + const permissionSetById = new Map(); + for (const row of permissionSetRows) { + const id = typeof row.Id === 'string' ? row.Id.trim() : ''; + if (id) { + permissionSetById.set(id, row); + } + } + + function parentTier(parentId: string): number { + if (!parentId) { + return 2; + } + return isProfileOwnedPermissionSetRow(permissionSetById.get(parentId)) ? 0 : 1; + } + + function parentLabelCompareKey(parentId: string): string { + return labelByParentId.get(parentId) ?? parentId; + } + + function tabSortCompareKey(row: PermissionExportRow): string { + const name = typeof row.Name === 'string' ? row.Name.trim() : ''; + const label = tabLabelBySettingName?.get(name)?.trim(); + const primary = (label && label.length > 0 ? label : name).toLocaleLowerCase(); + const secondary = name.toLocaleLowerCase(); + return `${primary}\0${secondary}`; + } + + return [...tabSettingRows].sort((rowA, rowB) => { + const parentA = typeof rowA.ParentId === 'string' ? rowA.ParentId.trim() : ''; + const parentB = typeof rowB.ParentId === 'string' ? rowB.ParentId.trim() : ''; + if (parentA !== parentB) { + const tierA = parentTier(parentA); + const tierB = parentTier(parentB); + if (tierA !== tierB) { + return tierA - tierB; + } + const labelCmp = parentLabelCompareKey(parentA).localeCompare(parentLabelCompareKey(parentB), undefined, { + sensitivity: 'base', + }); + if (labelCmp !== 0) { + return labelCmp; + } + return parentA.localeCompare(parentB, undefined, { sensitivity: 'base' }); + } + const keyA = tabSortCompareKey(rowA); + const keyB = tabSortCompareKey(rowB); + return keyA.localeCompare(keyB, undefined, { sensitivity: 'base' }); + }); +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-types-labels.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-types-labels.ts new file mode 100644 index 000000000..1fc6073dc --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/export-result-types-labels.ts @@ -0,0 +1,140 @@ +export type PermissionExportRow = Record; + +export interface PermissionExportBundle { + permissionSets: PermissionExportRow[]; + permissionSetAssignments: PermissionExportRow[]; + permissionSetGroups: PermissionExportRow[]; + permissionSetGroupComponents: PermissionExportRow[]; + mutingPermissionSets: PermissionExportRow[]; + objectPermissions: PermissionExportRow[]; + fieldPermissions: PermissionExportRow[]; + permissionSetTabSettings: PermissionExportRow[]; +} + +/** Describe + EntityDefinition metadata for permission export SobjectType cells. */ +export interface SobjectExportDetail { + apiName: string; + label: string; + description: string | null; +} + +/** Tooling `FieldDefinition` metadata for permission export field cells (label, setup link). */ +export interface FieldExportDetail { + objectApiName: string; + qualifiedApiName: string; + label: string; + description: string | null; + /** Tooling `FieldDefinition.DurableId` for Lightning Fields & Relationships deep link when present. */ + durableId: string | null; +} + +/** + * Stable lookup key for {@link FieldExportDetail} maps: `objectApiName::qualifiedApiName` (short field API name). + */ +export function fieldExportDetailLookupKey(objectApiName: string, qualifiedApiName: string): string { + return `${objectApiName.trim()}::${qualifiedApiName.trim()}`; +} + +/** + * Short field API name from a `FieldPermissions` export row (`Field` is usually `ObjectApi.FieldApi`). + */ +export function fieldPermissionQualifiedFieldShortApi(row: PermissionExportRow): string { + const obj = typeof row.SobjectType === 'string' ? row.SobjectType.trim() : ''; + const full = typeof row.Field === 'string' ? row.Field.trim() : ''; + if (!obj || !full) { + return ''; + } + const prefix = `${obj}.`; + if (full.startsWith(prefix)) { + return full.slice(prefix.length); + } + const dot = full.lastIndexOf('.'); + return dot >= 0 ? full.slice(dot + 1) : full; +} + +/** Field-level permission booleans in the same order as the object-permissions subset (Read, Edit). */ +export const FIELD_PERMISSION_BOOLEAN_COLUMN_KEYS: readonly string[] = ['PermissionsRead', 'PermissionsEdit']; + +/** Object column copy in issue detail modals when describe metadata exists. */ +export function formatObjectLabelForModalSummary( + apiName: string, + sobjectExportDetails: Record | undefined, +): { displayLabel: string; showApiInParens: boolean } { + const api = apiName.trim(); + if (!api) { + return { displayLabel: '', showApiInParens: false }; + } + const metadataLabel = sobjectExportDetails?.[api]?.label?.trim(); + if (metadataLabel && metadataLabel !== api) { + return { displayLabel: metadataLabel, showApiInParens: true }; + } + return { displayLabel: api, showApiInParens: false }; +} + +function permissionSetExportRowLabel(row: PermissionExportRow): string { + const label = typeof row.Label === 'string' && row.Label.trim() ? row.Label.trim() : null; + const name = typeof row.Name === 'string' && row.Name.trim() ? row.Name.trim() : null; + const profileBlock = row.Profile; + const profileName = + profileBlock && + typeof profileBlock === 'object' && + typeof (profileBlock as { Name?: unknown }).Name === 'string' + ? String((profileBlock as { Name: string }).Name).trim() + : null; + const isProfile = row.IsOwnedByProfile === true; + if (isProfile && profileName) { + return `Profile: ${profileName}`; + } + return label ?? name ?? (typeof row.Id === 'string' ? row.Id : 'Permission set'); +} + +/** Map permission set `Id` to a short display label (profiles, permission sets tab, modals). */ +export function buildPermissionSetIdLabelMap(permissionSetRows: PermissionExportRow[]): Map { + const map = new Map(); + for (const row of permissionSetRows) { + const id = row.Id; + if (typeof id !== 'string' || id.trim().length === 0) { + continue; + } + map.set(id.trim(), permissionSetExportRowLabel(row)); + } + return map; +} + +export interface PermissionAnalysisFinding { + severity?: string; + code?: string; + message?: string; + objectApiName?: string; + fieldApiName?: string; + permissionSetId?: string; + parentId?: string; + containerId?: string; + [key: string]: unknown; +} + +export interface ParsedPermissionExportResult { + phase: string | null; + summary: string | null; + truncated: boolean; + counts: Record; + export: PermissionExportBundle; + findings: PermissionAnalysisFinding[]; + issueCodeSummary: Record | null; +} + +/** + * IDs from the job's `requestPayload` (see permission export analysis jobs). + * `profileIds` are the profile **PermissionSet** Ids chosen on the selection screen, not Profile Ids. + */ +export interface PermissionExportRequestScope { + profilePermissionSetIds: string[]; + permissionSetIds: string[]; + /** When non-empty, the export job limited ObjectPermissions / FieldPermissions rows to these `SobjectType` values. */ + objectApiNames: string[]; +} + +export interface BuildDynamicExportColumnsOptions { + /** Row keys to exclude from the grid (e.g. REST `attributes`, `Id`, `ParentId` on object permission rows). */ + omitColumnKeys?: ReadonlySet; +} diff --git a/libs/features/manage-permissions/src/permission-export-result-view-modules/index.ts b/libs/features/manage-permissions/src/permission-export-result-view-modules/index.ts new file mode 100644 index 000000000..f6a35f5d5 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view-modules/index.ts @@ -0,0 +1,2 @@ +export * from './export-result-core'; +export * from './export-result-findings'; diff --git a/libs/features/manage-permissions/src/permission-export-result-view.ts b/libs/features/manage-permissions/src/permission-export-result-view.ts new file mode 100644 index 000000000..594783d13 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export-result-view.ts @@ -0,0 +1,5 @@ +/** + * Vite resolves `./permission-export-result-view` to this file before the directory. + * Implementation modules live under `./permission-export-result-view-modules/`. + */ +export * from './permission-export-result-view-modules/index'; diff --git a/libs/features/manage-permissions/src/permission-export/__tests__/build-permission-export-findings.spec.ts b/libs/features/manage-permissions/src/permission-export/__tests__/build-permission-export-findings.spec.ts new file mode 100644 index 000000000..2396888ee --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/__tests__/build-permission-export-findings.spec.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; +import { buildIssueCodeSummary, buildPermissionExportFindings } from '../build-permission-export-findings'; + +describe('buildPermissionExportFindings', () => { + it('returns empty when there are no permission rows', () => { + expect(buildPermissionExportFindings([], [])).toEqual([]); + }); + + it('emits FLS_READ_NO_OBJECT_READ when field Read is true but object does not grant effective read', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + PermissionsRead: false, + PermissionsViewAllRecords: false, + PermissionsModifyAllRecords: false, + }, + ]; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + Field: 'Account.Name', + PermissionsRead: true, + }, + ]; + const findings = buildPermissionExportFindings(objectPermissions, fieldPermissions); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe('FLS_READ_NO_OBJECT_READ'); + // Re-tiered: inert FLS-without-OLS is a warning, not an error (errors are reserved for real exposure). + expect(findings[0].severity).toBe('warning'); + expect(findings[0].objectApiName).toBe('Account'); + expect(findings[0].fieldApiName).toBe('Account.Name'); + }); + + it('does not emit FLS_READ_NO_OBJECT_READ when object grants View All Records (but flags the broad access)', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + PermissionsRead: false, + PermissionsViewAllRecords: true, + }, + ]; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + Field: 'Account.Name', + PermissionsRead: true, + }, + ]; + const codes = buildPermissionExportFindings(objectPermissions, fieldPermissions).map((f) => f.code); + expect(codes).not.toContain('FLS_READ_NO_OBJECT_READ'); + expect(codes).toEqual(['OBJECT_VIEW_ALL_RECORDS']); + }); + + it('emits FLS_EDIT_NO_OBJECT_EDIT when field Edit is true but object does not grant effective edit', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Contact', + PermissionsEdit: false, + PermissionsModifyAllRecords: false, + }, + ]; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Contact', + Field: 'Contact.FirstName', + PermissionsEdit: true, + }, + ]; + const findings = buildPermissionExportFindings(objectPermissions, fieldPermissions); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe('FLS_EDIT_NO_OBJECT_EDIT'); + }); + + it('does not emit FLS_EDIT_NO_OBJECT_EDIT when object grants Modify All Records (but flags the broad access)', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + PermissionsEdit: false, + PermissionsModifyAllRecords: true, + }, + ]; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + Field: 'Account.Name', + PermissionsEdit: true, + }, + ]; + const codes = buildPermissionExportFindings(objectPermissions, fieldPermissions).map((f) => f.code); + expect(codes).not.toContain('FLS_EDIT_NO_OBJECT_EDIT'); + expect(codes).toEqual(['OBJECT_MODIFY_ALL_RECORDS']); + }); + + it('emits FLS_WITHOUT_OLS_ROW when field rows exist but there is no object permission row', () => { + const parentId = '0PS000000000001'; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Case', + Field: 'Case.Subject', + PermissionsRead: true, + }, + ]; + const findings = buildPermissionExportFindings([], fieldPermissions); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe('FLS_WITHOUT_OLS_ROW'); + expect(findings[0].objectApiName).toBe('Case'); + }); + + it('does not emit per-field FLS_READ when missing OLS row (FLS_WITHOUT_OLS_ROW covers the case)', () => { + const parentId = '0PS000000000001'; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Lead', + Field: 'Lead.Name', + PermissionsRead: true, + }, + ]; + const findings = buildPermissionExportFindings([], fieldPermissions); + expect(findings.map((f) => f.code)).toEqual(['FLS_WITHOUT_OLS_ROW']); + }); + + it('emits OLS_READ_NO_FLS_ROWS when object Read is true and there are no field rows for that parent+object', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Opportunity', + PermissionsRead: true, + }, + ]; + const findings = buildPermissionExportFindings(objectPermissions, []); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe('OLS_READ_NO_FLS_ROWS'); + expect(findings[0].severity).toBe('warning'); + }); + + it('emits OLS_EDIT_NO_FLS_ROWS when object Edit is true and there are no field rows', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Task', + PermissionsEdit: true, + PermissionsRead: false, + }, + ]; + const findings = buildPermissionExportFindings(objectPermissions, []); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe('OLS_EDIT_NO_FLS_ROWS'); + }); + + it('does not emit OLS_READ_NO_FLS_ROWS when at least one field permission exists for the object', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + PermissionsRead: true, + }, + ]; + const fieldPermissions = [ + { + ParentId: parentId, + SobjectType: 'Account', + Field: 'Account.Name', + PermissionsRead: false, + }, + ]; + expect(buildPermissionExportFindings(objectPermissions, fieldPermissions)).toHaveLength(0); + }); +}); + +describe('buildPermissionExportFindings — group-aware suppression', () => { + it('suppresses FLS_WITHOUT_OLS_ROW when a sibling permission set in the same group supplies object read', () => { + const flsOnly = '0PS00000000FLS1'; + const olsOnly = '0PS00000000OLS1'; + const objectPermissions = [{ ParentId: olsOnly, SobjectType: 'Account', PermissionsRead: true }]; + const fieldPermissions = [{ ParentId: flsOnly, SobjectType: 'Account', Field: 'Account.Name', PermissionsRead: true }]; + const context = { + permissionSetGroupComponents: [ + { PermissionSetGroupId: '0PG1', PermissionSetId: flsOnly }, + { PermissionSetGroupId: '0PG1', PermissionSetId: olsOnly }, + ], + }; + const codes = buildPermissionExportFindings(objectPermissions, fieldPermissions, context).map((f) => f.code); + // The FLS-only building block is satisfied by the OLS-only sibling in group 0PG1 → no false positive. + expect(codes).not.toContain('FLS_WITHOUT_OLS_ROW'); + expect(codes).not.toContain('FLS_READ_NO_OBJECT_READ'); + }); + + it('still flags (softened) when the group contains a muting permission set', () => { + const flsOnly = '0PS00000000FLS1'; + const olsOnly = '0PS00000000OLS1'; + const objectPermissions = [{ ParentId: olsOnly, SobjectType: 'Account', PermissionsRead: true }]; + const fieldPermissions = [{ ParentId: flsOnly, SobjectType: 'Account', Field: 'Account.Name', PermissionsRead: true }]; + const context = { + permissionSetGroupComponents: [ + { PermissionSetGroupId: '0PG1', PermissionSetId: flsOnly }, + { PermissionSetGroupId: '0PG1', PermissionSetId: olsOnly }, + ], + mutingPermissionSets: [{ PermissionSetGroupId: '0PG1' }], + }; + const findings = buildPermissionExportFindings(objectPermissions, fieldPermissions, context); + const flsFinding = findings.find((f) => f.code === 'FLS_WITHOUT_OLS_ROW'); + expect(flsFinding).toBeDefined(); + expect(flsFinding?.partOfGroupId).toBe('0PG1'); + expect(String(flsFinding?.message)).toContain('muting'); + }); +}); + +describe('buildPermissionExportFindings — read+edit dedup', () => { + it('emits only the edit finding when a field is misaligned on both read and edit', () => { + const parentId = '0PS000000000001'; + const objectPermissions = [{ ParentId: parentId, SobjectType: 'Account', PermissionsRead: false, PermissionsEdit: false }]; + const fieldPermissions = [ + { ParentId: parentId, SobjectType: 'Account', Field: 'Account.Name', PermissionsRead: true, PermissionsEdit: true }, + ]; + const codes = buildPermissionExportFindings(objectPermissions, fieldPermissions).map((f) => f.code); + expect(codes).toEqual(['FLS_EDIT_NO_OBJECT_EDIT']); + }); +}); + +describe('buildPermissionExportFindings — new findings', () => { + it('flags high-risk and elevated system permissions', () => { + const context = { + permissionSets: [ + { Id: '0PS1', Label: 'Power PS', IsOwnedByProfile: false, PermissionsModifyAllData: true, PermissionsExportReport: true }, + ], + // Avoid an orphaned-permset finding muddying the assertion. + permissionSetAssignments: [{ PermissionSetId: '0PS1', AssigneeId: '005000000000001' }], + }; + const findings = buildPermissionExportFindings([], [], context); + const byCode = findings.map((f) => f.code); + expect(byCode).toContain('SYSTEM_PERM_HIGH_RISK'); + expect(byCode).toContain('SYSTEM_PERM_ELEVATED'); + expect(findings.find((f) => f.code === 'SYSTEM_PERM_HIGH_RISK')?.severity).toBe('error'); + expect(findings.find((f) => f.code === 'SYSTEM_PERM_ELEVATED')?.severity).toBe('warning'); + }); + + it('flags orphaned permission sets (no assignment, not in a group, not a profile)', () => { + const context = { + permissionSets: [ + { Id: '0PS_ORPHAN', Label: 'Unused', IsOwnedByProfile: false }, + { Id: '0PS_PROFILE', Label: 'Admin', IsOwnedByProfile: true }, + ], + permissionSetAssignments: [], + }; + const codes = buildPermissionExportFindings([], [], context).map((f) => f.code); + expect(codes).toContain('PERMSET_NO_ASSIGNMENTS'); + // Profile-owned sets are never flagged as orphaned. + expect(codes.filter((c) => c === 'PERMSET_NO_ASSIGNMENTS')).toHaveLength(1); + }); + + it('does not flag orphaned permission sets when assignment data was truncated', () => { + const context = { + permissionSets: [{ Id: '0PS_ORPHAN', Label: 'Unused', IsOwnedByProfile: false }], + permissionSetAssignments: [], + truncatedCategories: ['permissionSetAssignments'], + }; + const codes = buildPermissionExportFindings([], [], context).map((f) => f.code); + expect(codes).not.toContain('PERMSET_NO_ASSIGNMENTS'); + }); + + it('flags a visible tab with no object read, and ignores tabs whose object has read', () => { + const parentId = '0PS1'; + const objectPermissions = [{ ParentId: parentId, SobjectType: 'Contact', PermissionsRead: true }]; + const context = { + permissionSetTabSettings: [ + { ParentId: parentId, Name: 'standard-Account', Visibility: 'DefaultOn' }, + { ParentId: parentId, Name: 'standard-Contact', Visibility: 'DefaultOn' }, + { ParentId: parentId, Name: 'My_VF_Tab', Visibility: 'DefaultOn' }, + ], + }; + const findings = buildPermissionExportFindings(objectPermissions, [], context).filter((f) => f.code === 'TAB_VISIBLE_NO_OBJECT_READ'); + expect(findings).toHaveLength(1); + expect(findings[0].objectApiName).toBe('Account'); + }); +}); + +describe('buildIssueCodeSummary', () => { + it('aggregates counts by code', () => { + const summary = buildIssueCodeSummary([ + { code: 'FLS_READ_NO_OBJECT_READ', severity: 'error' }, + { code: 'FLS_READ_NO_OBJECT_READ', severity: 'error' }, + { code: 'OLS_READ_NO_FLS_ROWS', severity: 'warning' }, + ]); + expect(summary.FLS_READ_NO_OBJECT_READ).toEqual({ count: 2, errors: 2, warnings: 0 }); + expect(summary.OLS_READ_NO_FLS_ROWS).toEqual({ count: 1, errors: 0, warnings: 1 }); + }); +}); diff --git a/libs/features/manage-permissions/src/permission-export/__tests__/run-permission-export.spec.ts b/libs/features/manage-permissions/src/permission-export/__tests__/run-permission-export.spec.ts new file mode 100644 index 000000000..f07589a88 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/__tests__/run-permission-export.spec.ts @@ -0,0 +1,183 @@ +import type { SalesforceOrgUi } from '@jetstream/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { runPermissionExport } from '../run-permission-export'; + +const { mockedQuery, mockedQueryMore } = vi.hoisted(() => ({ + mockedQuery: vi.fn(), + mockedQueryMore: vi.fn(), +})); + +vi.mock('@jetstream/shared/data', async (importOriginal) => { + const actual = await importOriginal(); + async function queryWithRecordBudget( + org: unknown, + soql: string, + isTooling: boolean, + budget: { remaining: number }, + onPage: (records: Record[]) => void, + ): Promise<{ truncated: boolean }> { + let response = await mockedQuery(org, soql, isTooling); + while (true) { + const records = response.queryResults.records as Record[]; + if (budget.remaining <= 0) { + return { truncated: true }; + } + if (records.length > budget.remaining) { + onPage(records.slice(0, budget.remaining)); + budget.remaining = 0; + return { truncated: true }; + } + onPage(records); + budget.remaining -= records.length; + if (response.queryResults.done) { + break; + } + const nextUrl = response.queryResults.nextRecordsUrl; + if (!nextUrl) { + break; + } + response = await mockedQueryMore(org, nextUrl, isTooling); + } + return { truncated: false }; + } + return { + ...actual, + query: mockedQuery, + queryMore: mockedQueryMore, + queryWithRecordBudget, + }; +}); + +function done(records: T[]) { + return { + queryResults: { + records, + done: true, + totalSize: records.length, + }, + } as any; +} + +const PROFILE_PERM_SET_ID = '0PS000000000001'; +const PERM_SET_ID = '0PS000000000002'; +const GROUP_ID = '0PG000000000001'; + +const ORG = { uniqueId: 'org-1' } as unknown as SalesforceOrgUi; + +describe('runPermissionExport', () => { + beforeEach(() => { + mockedQuery.mockReset(); + mockedQueryMore.mockReset(); + }); + + it('returns an empty merged result when no valid parent ids are provided', async () => { + const result = await runPermissionExport(ORG, [], []); + expect(mockedQuery).not.toHaveBeenCalled(); + expect(result.truncated).toBe(false); + expect(result.full.counts).toEqual({ + permissionSets: 0, + permissionSetAssignments: 0, + permissionSetGroups: 0, + permissionSetGroupComponents: 0, + mutingPermissionSets: 0, + objectPermissions: 0, + fieldPermissions: 0, + permissionSetTabSettings: 0, + }); + expect(result.full.findings).toEqual([]); + expect(result.full.summary).toContain('Exported 0 permission sets'); + }); + + it('aggregates query results and builds the merged PermissionExportFullResult shape', async () => { + const permissionSetRows = [ + { Id: PROFILE_PERM_SET_ID, Name: 'Admin Profile', IsOwnedByProfile: true }, + { Id: PERM_SET_ID, Name: 'CustomPermSet', IsOwnedByProfile: false }, + ]; + const objectPermissionRows = [ + { + Id: '01p000000000001', + ParentId: PERM_SET_ID, + SobjectType: 'Account', + PermissionsRead: true, + PermissionsCreate: true, + PermissionsEdit: true, + PermissionsDelete: false, + PermissionsViewAllRecords: false, + PermissionsModifyAllRecords: false, + PermissionsViewAllFields: false, + }, + ]; + const fieldPermissionRows = [ + { + Id: '01k000000000001', + ParentId: PERM_SET_ID, + SobjectType: 'Account', + Field: 'Account.Name', + PermissionsRead: true, + PermissionsEdit: true, + }, + ]; + const tabSettingRows = [{ Id: '0t0000000000001', ParentId: PERM_SET_ID, Name: 'Account', Visibility: 'DefaultOn' }]; + const assignmentRows = [{ Id: '0Pa000000000001', PermissionSetId: PERM_SET_ID, AssigneeId: '005000000000001' }]; + const componentRows = [{ Id: '0PC000000000001', PermissionSetGroupId: GROUP_ID, PermissionSetId: PERM_SET_ID }]; + const groupRows = [{ Id: GROUP_ID, DeveloperName: 'GroupA', MasterLabel: 'Group A' }]; + const mutingRows: Record[] = []; + + // The order of queries matches the algorithm: permission sets, then (per parent chunk) object, + // field, tabs, assignments, components, then (per group chunk) group + muting. + mockedQuery + .mockResolvedValueOnce(done(permissionSetRows)) + .mockResolvedValueOnce(done(objectPermissionRows)) + .mockResolvedValueOnce(done(fieldPermissionRows)) + .mockResolvedValueOnce(done(tabSettingRows)) + .mockResolvedValueOnce(done(assignmentRows)) + .mockResolvedValueOnce(done(componentRows)) + .mockResolvedValueOnce(done(groupRows)) + .mockResolvedValueOnce(done(mutingRows)); + + const onProgress = vi.fn(); + + const result = await runPermissionExport(ORG, [PROFILE_PERM_SET_ID], [PERM_SET_ID], { onProgress }); + + expect(result.truncated).toBe(false); + expect(result.full.counts).toEqual({ + permissionSets: 2, + permissionSetAssignments: 1, + permissionSetGroups: 1, + permissionSetGroupComponents: 1, + mutingPermissionSets: 0, + objectPermissions: 1, + fieldPermissions: 1, + permissionSetTabSettings: 1, + }); + + expect(result.full.permissionSets).toEqual(permissionSetRows); + expect(result.full.objectPermissions).toEqual(objectPermissionRows); + expect(result.full.fieldPermissions).toEqual(fieldPermissionRows); + expect(result.full.permissionSetTabSettings).toEqual(tabSettingRows); + expect(result.full.permissionSetAssignments).toEqual(assignmentRows); + expect(result.full.permissionSetGroupComponents).toEqual(componentRows); + expect(result.full.permissionSetGroups).toEqual(groupRows); + expect(result.full.mutingPermissionSets).toEqual(mutingRows); + + expect(result.full.phase).toBe('permission_export_v1'); + expect(result.full.summary).toBe( + 'Exported 2 permission sets, 1 assignments, 1 permission set groups (1 components, 0 muting permission sets), 1 object permission rows, 1 field permission rows, 1 tab settings (truncated=false). 0 issue(s).', + ); + + expect(result.full.requestPayload).toEqual({ + profileIds: [PROFILE_PERM_SET_ID], + permissionSetIds: [PERM_SET_ID], + }); + + expect(onProgress).toHaveBeenCalled(); + const lastProgressCall = onProgress.mock.calls.at(-1)?.[0]; + expect(lastProgressCall?.label).toBe('Complete'); + expect(lastProgressCall?.percent).toBe(100); + }); + + it('throws when isCanceled returns true before queries run', async () => { + await expect(runPermissionExport(ORG, [PROFILE_PERM_SET_ID], [], { isCanceled: () => true })).rejects.toThrow('Job canceled'); + expect(mockedQuery).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/features/manage-permissions/src/permission-export/__tests__/soql-templates.spec.ts b/libs/features/manage-permissions/src/permission-export/__tests__/soql-templates.spec.ts new file mode 100644 index 000000000..23c5de4d2 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/__tests__/soql-templates.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + buildFieldPermissionsByParentSoql, + buildMutingPermissionSetsByGroupSoql, + buildObjectPermissionsByParentSoql, + buildPermissionSetAssignmentsByPermissionSetSoql, + buildPermissionSetByIdSoql, + buildPermissionSetGroupByIdSoql, + buildPermissionSetGroupComponentsByPermissionSetSoql, + buildTabSettingsByParentSoql, +} from '../soql-templates'; + +describe('soql-templates permission export', () => { + it('buildPermissionSetByIdSoql composes a PermissionSet IN clause', () => { + const soql = buildPermissionSetByIdSoql(['p1', 'p2']); + expect(soql).toContain('FROM PermissionSet'); + expect(soql).toContain("Id IN ('p1', 'p2')"); + }); + + it('buildObjectPermissionsByParentSoql composes parent IN with no object filter', () => { + const soql = buildObjectPermissionsByParentSoql(['p1']); + expect(soql).toContain('FROM ObjectPermissions'); + expect(soql).toContain("ParentId IN ('p1')"); + expect(soql).not.toContain('SobjectType IN'); + }); + + it('buildObjectPermissionsByParentSoql adds optional SobjectType filter', () => { + const soql = buildObjectPermissionsByParentSoql(['p1'], ['Account', 'Case']); + expect(soql).toContain("ParentId IN ('p1')"); + expect(soql).toContain("SobjectType IN ('Account', 'Case')"); + expect(soql).toContain(' AND '); + }); + + it('buildFieldPermissionsByParentSoql adds optional SobjectType filter', () => { + const soql = buildFieldPermissionsByParentSoql(['p1'], ['Foo__c']); + expect(soql).toContain('FROM FieldPermissions'); + expect(soql).toContain("ParentId IN ('p1')"); + expect(soql).toContain("SobjectType IN ('Foo__c')"); + }); + + it('buildTabSettingsByParentSoql composes a PermissionSetTabSetting query', () => { + const soql = buildTabSettingsByParentSoql(['p1', 'p2']); + expect(soql).toContain('FROM PermissionSetTabSetting'); + expect(soql).toContain("ParentId IN ('p1', 'p2')"); + }); + + it('buildPermissionSetAssignmentsByPermissionSetSoql composes assignment query', () => { + const soql = buildPermissionSetAssignmentsByPermissionSetSoql(['p1']); + expect(soql).toContain('FROM PermissionSetAssignment'); + expect(soql).toContain("PermissionSetId IN ('p1')"); + }); + + it('buildPermissionSetGroupComponentsByPermissionSetSoql composes group component query', () => { + const soql = buildPermissionSetGroupComponentsByPermissionSetSoql(['p1']); + expect(soql).toContain('FROM PermissionSetGroupComponent'); + expect(soql).toContain("PermissionSetId IN ('p1')"); + }); + + it('buildPermissionSetGroupByIdSoql composes group query', () => { + const soql = buildPermissionSetGroupByIdSoql(['g1']); + expect(soql).toContain('FROM PermissionSetGroup'); + expect(soql).toContain("Id IN ('g1')"); + }); + + it('buildMutingPermissionSetsByGroupSoql composes muting permission set query', () => { + const soql = buildMutingPermissionSetsByGroupSoql(['g1']); + expect(soql).toContain('FROM MutingPermissionSet'); + expect(soql).toContain("PermissionSetGroupId IN ('g1')"); + }); +}); diff --git a/libs/features/manage-permissions/src/permission-export/build-permission-export-findings.ts b/libs/features/manage-permissions/src/permission-export/build-permission-export-findings.ts new file mode 100644 index 000000000..d73571043 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/build-permission-export-findings.ts @@ -0,0 +1,548 @@ +/** Derives permission analysis issues from exported permission rows (ObjectPermissions, FieldPermissions, …). */ + +import { + HIGH_RISK_SYSTEM_PERMISSIONS, + PERMISSION_EXPORT_FINDING_DEFINITIONS, + PermissionExportFindingCode, +} from '@jetstream/shared/constants'; + +export const MAX_PERMISSION_EXPORT_FINDINGS = 8_000; + +/** Direct User assignment ids start with `005`; permission set groups / queues do not. */ +const USER_ID_PREFIX = '005'; + +export type PermissionExportFindingRecord = Record; + +/** + * Optional context that unlocks group-aware suppression and the broader finding set. Every field is + * optional so legacy 2-argument calls keep their original behavior (no group suppression, no new findings). + */ +export interface PermissionExportFindingsContext { + permissionSets?: Record[]; + permissionSetAssignments?: Record[]; + permissionSetGroupComponents?: Record[]; + mutingPermissionSets?: Record[]; + permissionSetTabSettings?: Record[]; + /** Categories that hit their row cap — used to suppress false "orphaned" calls when assignments are incomplete. */ + truncatedCategories?: ReadonlySet | readonly string[]; +} + +function readTrimmedString(row: Record, key: string): string { + const value = row[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +function readBooleanTrue(row: Record, key: string): boolean { + const value = row[key]; + return value === true || value === 'true'; +} + +function objectPermissionKey(parentId: string, sobjectType: string): string { + return `${parentId}::${sobjectType}`; +} + +/** Object-level read path for FLS alignment: Read, View All Records, or Modify All Records. */ +function objectGrantsEffectiveRead(row: Record): boolean { + return ( + readBooleanTrue(row, 'PermissionsRead') || + readBooleanTrue(row, 'PermissionsViewAllRecords') || + readBooleanTrue(row, 'PermissionsModifyAllRecords') + ); +} + +/** Object-level edit path for FLS alignment: Edit or Modify All Records. */ +function objectGrantsEffectiveEdit(row: Record): boolean { + return readBooleanTrue(row, 'PermissionsEdit') || readBooleanTrue(row, 'PermissionsModifyAllRecords'); +} + +/** + * Group-effective access lookup. A permission set that grants only FLS (or only OLS) is a valid building + * block when combined in a Permission Set Group — so an FLS/OLS misalignment on a group member is a false + * positive if a sibling member in the same group supplies the missing access. + */ +interface GroupContext { + /** permissionSetId → group ids it belongs to. */ + readonly groupsByMember: Map>; + /** group id → member permissionSetIds. */ + readonly membersByGroup: Map>; + /** group ids that contain at least one muting permission set (effective access not fully evaluated). */ + readonly mutingGroupIds: Set; + /** permissionSetIds that are a component of any group (i.e. potentially assigned via a group). */ + readonly groupMemberIds: Set; +} + +function addToSetMap(map: Map>, key: string, value: string): void { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + set.add(value); +} + +function buildGroupContext(context: PermissionExportFindingsContext | undefined): GroupContext { + const groupsByMember = new Map>(); + const membersByGroup = new Map>(); + const mutingGroupIds = new Set(); + const groupMemberIds = new Set(); + + for (const row of context?.permissionSetGroupComponents ?? []) { + if (!row || typeof row !== 'object') { + continue; + } + const groupId = readTrimmedString(row, 'PermissionSetGroupId'); + const permissionSetId = readTrimmedString(row, 'PermissionSetId'); + if (!groupId || !permissionSetId) { + continue; + } + groupMemberIds.add(permissionSetId); + addToSetMap(groupsByMember, permissionSetId, groupId); + addToSetMap(membersByGroup, groupId, permissionSetId); + } + + for (const row of context?.mutingPermissionSets ?? []) { + if (!row || typeof row !== 'object') { + continue; + } + const groupId = readTrimmedString(row, 'PermissionSetGroupId'); + if (groupId) { + mutingGroupIds.add(groupId); + } + } + + return { groupsByMember, membersByGroup, mutingGroupIds, groupMemberIds }; +} + +function permissionSetIdsWithDirectUserAssignment(context: PermissionExportFindingsContext | undefined): Set { + const assigned = new Set(); + for (const row of context?.permissionSetAssignments ?? []) { + if (!row || typeof row !== 'object') { + continue; + } + const permissionSetId = readTrimmedString(row, 'PermissionSetId'); + const assigneeId = readTrimmedString(row, 'AssigneeId'); + if (permissionSetId && assigneeId.startsWith(USER_ID_PREFIX)) { + assigned.add(permissionSetId); + } + } + return assigned; +} + +function categoryTruncated(context: PermissionExportFindingsContext | undefined, category: string): boolean { + const categories = context?.truncatedCategories; + if (!categories) { + return false; + } + return categories instanceof Set ? categories.has(category) : (categories as readonly string[]).includes(category); +} + +/** + * Resolves the object API name a PermissionSetTabSetting refers to, or `null` for tabs not backed by a + * queryable object (Visualforce / web / Lightning page tabs). Standard tabs are `standard-`; + * custom-object tabs use the object API name (often `__c`). + */ +function tabSettingObjectApiName(tabName: string): string | null { + const name = tabName.trim(); + if (!name) { + return null; + } + if (name.startsWith('standard-')) { + return name.slice('standard-'.length); + } + if (name.endsWith('__c')) { + return name; + } + return null; +} + +/** + * Builds deterministic issue rows from SOQL export payloads. + * + * @param objectPermissions ObjectPermissions rows keyed by ParentId + SobjectType. + * @param fieldPermissions FieldPermissions rows for the same permission sets. + * @param context Optional extra rows (permission sets, assignments, group components, muting, tabs) that + * enable group-aware suppression and the broader finding set. + * @returns Flat list suitable for `analysis_job.result.findings`. + */ +export function buildPermissionExportFindings( + objectPermissions: Record[], + fieldPermissions: Record[], + context?: PermissionExportFindingsContext, +): PermissionExportFindingRecord[] { + const findings: PermissionExportFindingRecord[] = []; + let suppressedAfterCap = 0; + + const tryPush = (finding: PermissionExportFindingRecord): void => { + if (findings.length < MAX_PERMISSION_EXPORT_FINDINGS) { + findings.push(finding); + return; + } + suppressedAfterCap += 1; + }; + + const group = buildGroupContext(context); + + const objectRowByKey = new Map>(); + for (const row of objectPermissions) { + if (!row || typeof row !== 'object') { + continue; + } + const parentId = readTrimmedString(row, 'ParentId'); + const sobjectType = readTrimmedString(row, 'SobjectType'); + if (!parentId || !sobjectType) { + continue; + } + objectRowByKey.set(objectPermissionKey(parentId, sobjectType), row); + } + + /** Whether a sibling permission set in a shared group supplies the object access this parent lacks. */ + const siblingSuppliesAccess = (parentId: string, sobjectType: string, mode: 'read' | 'edit'): boolean => { + const groupIds = group.groupsByMember.get(parentId); + if (!groupIds) { + return false; + } + for (const groupId of groupIds) { + for (const memberId of group.membersByGroup.get(groupId) ?? []) { + if (memberId === parentId) { + continue; + } + const row = objectRowByKey.get(objectPermissionKey(memberId, sobjectType)); + if (row && (mode === 'read' ? objectGrantsEffectiveRead(row) : objectGrantsEffectiveEdit(row))) { + return true; + } + } + } + return false; + }; + + const firstGroupId = (parentId: string): string | undefined => { + const groupIds = group.groupsByMember.get(parentId); + return groupIds ? [...groupIds][0] : undefined; + }; + + const parentInGroupWithMuting = (parentId: string): boolean => { + for (const groupId of group.groupsByMember.get(parentId) ?? []) { + if (group.mutingGroupIds.has(groupId)) { + return true; + } + } + return false; + }; + + /** + * Emits an FLS/OLS-alignment finding unless a group sibling already supplies the access. When the + * group also contains muting permission sets we cannot be sure, so we emit a softened finding instead + * of suppressing it (fail safe — show it, annotated). + */ + const pushGroupAwareFlsFinding = ( + base: PermissionExportFindingRecord, + parentId: string, + sobjectType: string, + mode: 'read' | 'edit', + ): void => { + const groupId = firstGroupId(parentId); + if (siblingSuppliesAccess(parentId, sobjectType, mode)) { + if (!parentInGroupWithMuting(parentId)) { + return; // satisfied by the group — not a real issue + } + tryPush({ + ...base, + ...(groupId ? { partOfGroupId: groupId } : {}), + message: `${String(base.message ?? '')} It may be provided by another permission set in group ${groupId}, but a muting permission set is present so effective access was not fully evaluated.`, + }); + return; + } + tryPush({ ...base, ...(groupId ? { partOfGroupId: groupId } : {}) }); + }; + + const fieldCountByParentObject = new Map(); + const fieldParentObjectKeys = new Set(); + for (const row of fieldPermissions) { + if (!row || typeof row !== 'object') { + continue; + } + const parentId = readTrimmedString(row, 'ParentId'); + const sobjectType = readTrimmedString(row, 'SobjectType'); + if (!parentId || !sobjectType) { + continue; + } + const key = objectPermissionKey(parentId, sobjectType); + fieldParentObjectKeys.add(key); + fieldCountByParentObject.set(key, (fieldCountByParentObject.get(key) ?? 0) + 1); + } + + for (const key of fieldParentObjectKeys) { + if (objectRowByKey.has(key)) { + continue; + } + const separatorIdx = key.indexOf('::'); + // Reject keys with no separator (indexOf === -1), an empty parentId, or an empty sobjectType + // (separator at/after the second-to-last char leaves nothing after `::`). + if (separatorIdx <= 0 || separatorIdx + 2 >= key.length) { + continue; + } + const parentId = key.slice(0, separatorIdx); + const sobjectType = key.slice(separatorIdx + 2); + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW]; + pushGroupAwareFlsFinding( + { + severity: def.severity, + code: PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW, + message: `Field permissions exist for ${sobjectType}, but there is no ObjectPermissions row for the same permission set and object.`, + objectApiName: sobjectType, + parentId, + permissionSetId: parentId, + containerId: parentId, + }, + parentId, + sobjectType, + 'read', + ); + } + + for (const fRow of fieldPermissions) { + if (!fRow || typeof fRow !== 'object') { + continue; + } + const parentId = readTrimmedString(fRow, 'ParentId'); + const sobjectType = readTrimmedString(fRow, 'SobjectType'); + const field = readTrimmedString(fRow, 'Field'); + if (!parentId || !sobjectType || !field) { + continue; + } + const objectRow = objectRowByKey.get(objectPermissionKey(parentId, sobjectType)); + if (!objectRow) { + continue; + } + const editMisaligned = readBooleanTrue(fRow, 'PermissionsEdit') && !objectGrantsEffectiveEdit(objectRow); + const readMisaligned = readBooleanTrue(fRow, 'PermissionsRead') && !objectGrantsEffectiveRead(objectRow); + + if (editMisaligned) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.FLS_EDIT_NO_OBJECT_EDIT]; + pushGroupAwareFlsFinding( + { + severity: def.severity, + code: PermissionExportFindingCode.FLS_EDIT_NO_OBJECT_EDIT, + message: `Field ${field} on ${sobjectType} has Edit at field level, but the object permission does not grant Edit or Modify All Records.`, + objectApiName: sobjectType, + fieldApiName: field, + parentId, + permissionSetId: parentId, + containerId: parentId, + }, + parentId, + sobjectType, + 'edit', + ); + } + // Skip the read finding when edit is also misaligned for the same field — same root cause, avoids + // double-counting one misconfiguration as two findings. + if (readMisaligned && !editMisaligned) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ]; + pushGroupAwareFlsFinding( + { + severity: def.severity, + code: PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ, + message: `Field ${field} on ${sobjectType} has Read at field level, but the object permission does not grant Read, View All Records, or Modify All Records.`, + objectApiName: sobjectType, + fieldApiName: field, + parentId, + permissionSetId: parentId, + containerId: parentId, + }, + parentId, + sobjectType, + 'read', + ); + } + } + + for (const oRow of objectPermissions) { + if (!oRow || typeof oRow !== 'object') { + continue; + } + const parentId = readTrimmedString(oRow, 'ParentId'); + const sobjectType = readTrimmedString(oRow, 'SobjectType'); + if (!parentId || !sobjectType) { + continue; + } + const key = objectPermissionKey(parentId, sobjectType); + const fieldCount = fieldCountByParentObject.get(key) ?? 0; + + if (readBooleanTrue(oRow, 'PermissionsRead') && fieldCount === 0) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS, + message: `Object read is on for ${sobjectType}, but there are no field permission rows for this object on the same permission set (default field access applies).`, + objectApiName: sobjectType, + parentId, + permissionSetId: parentId, + containerId: parentId, + }); + } + if (readBooleanTrue(oRow, 'PermissionsEdit') && fieldCount === 0) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.OLS_EDIT_NO_FLS_ROWS]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.OLS_EDIT_NO_FLS_ROWS, + message: `Object edit is on for ${sobjectType}, but there are no field permission rows for this object on the same permission set (default field access applies).`, + objectApiName: sobjectType, + parentId, + permissionSetId: parentId, + containerId: parentId, + }); + } + + // Broad record access that bypasses the sharing model. Modify All Records implies View All Records, so + // only the (higher-severity) Modify All finding is emitted when both are present. + if (readBooleanTrue(oRow, 'PermissionsModifyAllRecords')) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.OBJECT_MODIFY_ALL_RECORDS]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.OBJECT_MODIFY_ALL_RECORDS, + message: `Modify All Records is granted for ${sobjectType} — this bypasses the sharing model (edit/delete every record).`, + objectApiName: sobjectType, + parentId, + permissionSetId: parentId, + containerId: parentId, + }); + } else if (readBooleanTrue(oRow, 'PermissionsViewAllRecords')) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.OBJECT_VIEW_ALL_RECORDS]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.OBJECT_VIEW_ALL_RECORDS, + message: `View All Records is granted for ${sobjectType} — this bypasses the sharing model (read every record).`, + objectApiName: sobjectType, + parentId, + permissionSetId: parentId, + containerId: parentId, + }); + } + } + + // High-risk system permissions + orphaned permission sets (require the permission set rows). + const permissionSets = context?.permissionSets ?? []; + const assignedSet = permissionSetIdsWithDirectUserAssignment(context); + const assignmentsTruncated = categoryTruncated(context, 'permissionSetAssignments'); + for (const psRow of permissionSets) { + if (!psRow || typeof psRow !== 'object') { + continue; + } + const id = readTrimmedString(psRow, 'Id'); + if (!id) { + continue; + } + const isProfile = readBooleanTrue(psRow, 'IsOwnedByProfile'); + const label = readTrimmedString(psRow, 'Label') || readTrimmedString(psRow, 'Name') || id; + const containerNoun = isProfile ? 'Profile' : 'Permission set'; + + for (const perm of HIGH_RISK_SYSTEM_PERMISSIONS) { + if (!readBooleanTrue(psRow, perm.field)) { + continue; + } + const code = perm.tier === 1 ? PermissionExportFindingCode.SYSTEM_PERM_HIGH_RISK : PermissionExportFindingCode.SYSTEM_PERM_ELEVATED; + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[code]; + tryPush({ + severity: def.severity, + code, + message: `${containerNoun} "${label}" grants the system permission "${perm.label}".`, + systemPermission: perm.field, + parentId: id, + permissionSetId: id, + containerId: id, + }); + } + + // Orphaned permission set: not profile-owned, no direct user assignment, and not a member of any group + // (a group member may be assigned via its group). Skip when assignment data was truncated. + if (!isProfile && !assignmentsTruncated && !assignedSet.has(id) && !group.groupMemberIds.has(id)) { + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.PERMSET_NO_ASSIGNMENTS]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.PERMSET_NO_ASSIGNMENTS, + message: `Permission set "${label}" has no direct user assignments and is not part of a permission set group — it may be safe to delete.`, + parentId: id, + permissionSetId: id, + containerId: id, + }); + } + } + + // Tab visible without object read. + for (const tabRow of context?.permissionSetTabSettings ?? []) { + if (!tabRow || typeof tabRow !== 'object') { + continue; + } + const parentId = readTrimmedString(tabRow, 'ParentId'); + const tabName = readTrimmedString(tabRow, 'Name'); + const visibility = readTrimmedString(tabRow, 'Visibility'); + if (!parentId || !tabName || visibility === '' || visibility === 'None' || visibility === 'Hidden') { + continue; + } + const objectApiName = tabSettingObjectApiName(tabName); + if (!objectApiName) { + continue; // VF / web / Lightning page tab — no queryable object to check + } + const objectRow = objectRowByKey.get(objectPermissionKey(parentId, objectApiName)); + if (objectRow && objectGrantsEffectiveRead(objectRow)) { + continue; + } + const def = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.TAB_VISIBLE_NO_OBJECT_READ]; + tryPush({ + severity: def.severity, + code: PermissionExportFindingCode.TAB_VISIBLE_NO_OBJECT_READ, + message: `Tab "${tabName}" is visible (${visibility}), but the permission set grants no read access to ${objectApiName}.`, + objectApiName, + parentId, + permissionSetId: parentId, + containerId: parentId, + }); + } + + if (suppressedAfterCap > 0) { + const truncatedDef = PERMISSION_EXPORT_FINDING_DEFINITIONS[PermissionExportFindingCode.FINDINGS_TRUNCATED]; + findings.push({ + severity: truncatedDef.severity, + code: PermissionExportFindingCode.FINDINGS_TRUNCATED, + message: `${suppressedAfterCap.toLocaleString()} additional issues were not included so the job result stays under ${MAX_PERMISSION_EXPORT_FINDINGS.toLocaleString()} rows. Narrow the permission set selection and re-run if you need full coverage.`, + objectApiName: undefined, + fieldApiName: undefined, + parentId: undefined, + permissionSetId: undefined, + containerId: undefined, + }); + } + + return findings; +} + +export interface IssueCodeSummaryEntry { + count: number; + errors: number; + warnings: number; +} + +/** + * Rolls up issues by `code` for `analysis_job.result.issueCodeSummary`. + */ +export function buildIssueCodeSummary(findings: PermissionExportFindingRecord[]): Record { + const summary: Record = {}; + for (const row of findings) { + const codeRaw = row.code; + const code = typeof codeRaw === 'string' && codeRaw.trim().length > 0 ? codeRaw.trim() : ''; + if (!code || code === PermissionExportFindingCode.FINDINGS_TRUNCATED) { + continue; + } + const existing = summary[code] ?? { count: 0, errors: 0, warnings: 0 }; + existing.count += 1; + const severity = String(row.severity ?? '').toLowerCase(); + if (severity === 'error' || severity === 'errors') { + existing.errors += 1; + } else if (severity === 'warning' || severity === 'warnings') { + existing.warnings += 1; + } + summary[code] = existing; + } + return summary; +} diff --git a/libs/features/manage-permissions/src/permission-export/index.ts b/libs/features/manage-permissions/src/permission-export/index.ts new file mode 100644 index 000000000..4a80137f2 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/index.ts @@ -0,0 +1,5 @@ +export * from './build-permission-export-findings'; +export * from './permission-export-query-runner'; +export * from './run-permission-export'; +export * from './soql-templates'; +export * from './substitute-soql-placeholders'; diff --git a/libs/features/manage-permissions/src/permission-export/permission-export-query-runner.ts b/libs/features/manage-permissions/src/permission-export/permission-export-query-runner.ts new file mode 100644 index 000000000..7ce21e06a --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/permission-export-query-runner.ts @@ -0,0 +1,20 @@ +import { substituteSoqlPlaceholders } from './substitute-soql-placeholders'; + +export type PermissionExportQueryKind = 'data' | 'tooling'; + +/** + * Prepares SOQL from a template string for execution via Jetstream HTTP (not CLI). + * Server-side export jobs run equivalent queries from `apps/api/.../soql-templates.ts` + * (see `soql/*.soql` in this folder for reviewer-friendly copies). + * + * @param template Raw SOQL with `{{placeholders}}`. + * @param vars Placeholder values. + * @param kind Whether the query targets the Data or Tooling API (caller chooses transport). + */ +export function buildPermissionExportSoql( + template: string, + vars: Record, + _kind: PermissionExportQueryKind = 'data', +): string { + return substituteSoqlPlaceholders(template, vars); +} diff --git a/libs/features/manage-permissions/src/permission-export/run-permission-export.ts b/libs/features/manage-permissions/src/permission-export/run-permission-export.ts new file mode 100644 index 000000000..2f15120fa --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/run-permission-export.ts @@ -0,0 +1,416 @@ +import { queryWithRecordBudget } from '@jetstream/shared/data'; +import { sanitizeSobjectApiNames, splitArrayToMaxSize, uniqueSalesforceIds } from '@jetstream/shared/utils'; +import type { PermissionExportFullResult, SalesforceOrgUi } from '@jetstream/types'; +import { buildIssueCodeSummary, buildPermissionExportFindings } from './build-permission-export-findings'; +import { + buildFieldPermissionsByParentSoql, + buildMutingPermissionSetsByGroupSoql, + buildObjectPermissionsByParentSoql, + buildPermissionSetAssignmentsByPermissionSetSoql, + buildPermissionSetByIdSoql, + buildPermissionSetGroupByIdSoql, + buildPermissionSetGroupComponentsByPermissionSetSoql, + buildTabSettingsByParentSoql, +} from './soql-templates'; + +const PARENT_ID_CHUNK_SIZE = 200; +const GROUP_ID_CHUNK_SIZE = 200; +/** Keeps `SobjectType IN (...)` clauses within typical SOQL length limits when many objects are selected. */ +const OBJECT_SOBJECT_TYPE_IN_CHUNK_SIZE = 80; + +/** + * Per-category row caps. Each category has its own independent budget so that one heavy category + * (e.g. FieldPermissions on a large org) cannot silently starve later categories (Tab settings, + * Assignments, Groups, MutingPermissionSets) of their budget. Caps sum to ~200K worst case but in + * practice categories like Groups and TabSettings rarely use more than a few thousand rows. + */ +const CATEGORY_BUDGETS = { + permissionSets: 10_000, + objectPermissions: 50_000, + fieldPermissions: 100_000, + permissionSetTabSettings: 10_000, + permissionSetAssignments: 100_000, + permissionSetGroupComponents: 10_000, + permissionSetGroups: 10_000, + mutingPermissionSets: 10_000, +} as const; + +type ExportCategory = keyof typeof CATEGORY_BUDGETS; + +const CATEGORY_LABELS: Record = { + permissionSets: 'PermissionSet', + objectPermissions: 'ObjectPermissions', + fieldPermissions: 'FieldPermissions', + permissionSetTabSettings: 'PermissionSetTabSetting', + permissionSetAssignments: 'PermissionSetAssignment', + permissionSetGroupComponents: 'PermissionSetGroupComponent', + permissionSetGroups: 'PermissionSetGroup', + mutingPermissionSets: 'MutingPermissionSet', +}; + +export interface RunPermissionExportProgress { + current: number; + total: number; + percent: number; + label: string; +} + +export interface RunPermissionExportOptions { + /** Optional sobject scope for ObjectPermissions/FieldPermissions */ + objectApiNames?: unknown; + /** Called periodically with progress info; safe to be a no-op */ + onProgress?: (progress: RunPermissionExportProgress) => void; + /** Returns true if the caller wants to cancel; checked at chunk boundaries */ + isCanceled?: () => boolean; +} + +export interface RunPermissionExportResult { + truncated: boolean; + full: PermissionExportFullResult; +} + +function emptyResult(requestPayload?: PermissionExportFullResult['requestPayload']): RunPermissionExportResult { + const counts = { + permissionSets: 0, + permissionSetAssignments: 0, + permissionSetGroups: 0, + permissionSetGroupComponents: 0, + mutingPermissionSets: 0, + objectPermissions: 0, + fieldPermissions: 0, + permissionSetTabSettings: 0, + }; + return { + truncated: false, + full: { + ...(requestPayload ? { requestPayload } : {}), + phase: 'permission_export_v1', + summary: + 'Exported 0 permission sets, 0 assignments, 0 permission set groups (0 components, 0 muting permission sets), 0 object permission rows, 0 field permission rows, 0 tab settings (truncated=false). 0 issue(s).', + truncated: false, + counts, + findings: [], + issueCodeSummary: {}, + permissionSets: [], + permissionSetAssignments: [], + permissionSetGroups: [], + permissionSetGroupComponents: [], + mutingPermissionSets: [], + objectPermissions: [], + fieldPermissions: [], + permissionSetTabSettings: [], + }, + }; +} + +function throwIfCanceled(isCanceled: (() => boolean) | undefined): void { + if (isCanceled?.()) { + throw new Error('Job canceled'); + } +} + +/** + * Browser-side implementation of the permission export job. Issues many SOQL queries through + * `query`/`queryMore` (via `queryWithRecordBudget`), aggregates the rows in memory, and computes + * the same findings + issue-code summary the server processor used to produce. + * + * Mirrors the original server `runPermissionExportSoql` algorithm: PermissionSet first, then per + * parent-id chunk of 200 we fetch ObjectPermissions/FieldPermissions (optionally re-chunked by + * sobject type), Tab settings, Assignments, and Group components. Group ids harvested from the + * components query then drive PermissionSetGroup + MutingPermissionSet queries. + */ +export async function runPermissionExport( + org: SalesforceOrgUi, + profilePermissionSetIds: string[], + permissionSetIds: string[], + options?: RunPermissionExportOptions, +): Promise { + const requestPayload: PermissionExportFullResult['requestPayload'] = { + profileIds: profilePermissionSetIds, + permissionSetIds: permissionSetIds, + ...(options?.objectApiNames !== undefined ? { objectApiNames: options.objectApiNames } : {}), + }; + + const parentIds = uniqueSalesforceIds([...profilePermissionSetIds, ...permissionSetIds]); + const objectScope = sanitizeSobjectApiNames(options?.objectApiNames); + const objectTypeChunks: (string[] | undefined)[] = + objectScope.length === 0 ? [undefined] : splitArrayToMaxSize(objectScope, OBJECT_SOBJECT_TYPE_IN_CHUNK_SIZE); + + if (parentIds.length === 0) { + return emptyResult(requestPayload); + } + + const parentIdChunks = splitArrayToMaxSize(parentIds, PARENT_ID_CHUNK_SIZE); + + const budgets: Record = { + permissionSets: { remaining: CATEGORY_BUDGETS.permissionSets }, + objectPermissions: { remaining: CATEGORY_BUDGETS.objectPermissions }, + fieldPermissions: { remaining: CATEGORY_BUDGETS.fieldPermissions }, + permissionSetTabSettings: { remaining: CATEGORY_BUDGETS.permissionSetTabSettings }, + permissionSetAssignments: { remaining: CATEGORY_BUDGETS.permissionSetAssignments }, + permissionSetGroupComponents: { remaining: CATEGORY_BUDGETS.permissionSetGroupComponents }, + permissionSetGroups: { remaining: CATEGORY_BUDGETS.permissionSetGroups }, + mutingPermissionSets: { remaining: CATEGORY_BUDGETS.mutingPermissionSets }, + }; + const truncatedCategories = new Set(); + + const permissionSets: Record[] = []; + const permissionSetAssignments: Record[] = []; + const permissionSetGroupComponents: Record[] = []; + const permissionSetGroups: Record[] = []; + const mutingPermissionSets: Record[] = []; + const objectPermissions: Record[] = []; + const fieldPermissions: Record[] = []; + const permissionSetTabSettings: Record[] = []; + + // 1 for the initial PermissionSet query + 5 steps per parent chunk + 2 steps per group chunk (estimated upfront). + const initialTotal = 1 + parentIdChunks.length * 5; + let currentStep = 0; + let totalSteps = initialTotal; + + const emitProgress = (label: string): void => { + const percent = totalSteps === 0 ? 0 : Math.min(100, Math.round((currentStep / totalSteps) * 100)); + options?.onProgress?.({ current: currentStep, total: totalSteps, percent, label }); + }; + + emitProgress(`Loading ${parentIds.length} permission set(s)`); + + throwIfCanceled(options?.isCanceled); + const permSetResult = await queryWithRecordBudget>( + org, + buildPermissionSetByIdSoql(parentIds), + false, + budgets.permissionSets, + (page) => { + permissionSets.push(...page); + }, + ); + if (permSetResult.truncated) { + truncatedCategories.add('permissionSets'); + } + currentStep += 1; + emitProgress(`Loaded ${permissionSets.length} permission set(s)`); + + const permissionSetGroupIds = new Set(); + + for (let parentChunkIndex = 0; parentChunkIndex < parentIdChunks.length; parentChunkIndex++) { + const parentIdChunk = parentIdChunks[parentChunkIndex]; + const parentChunkLabel = parentIdChunks.length > 1 ? ` (batch ${parentChunkIndex + 1} of ${parentIdChunks.length})` : ''; + throwIfCanceled(options?.isCanceled); + + emitProgress(`Querying object permissions${parentChunkLabel}`); + for (const objectTypeChunk of objectTypeChunks) { + throwIfCanceled(options?.isCanceled); + if (budgets.objectPermissions.remaining <= 0) { + truncatedCategories.add('objectPermissions'); + break; + } + const objectResult = await queryWithRecordBudget>( + org, + buildObjectPermissionsByParentSoql(parentIdChunk, objectTypeChunk), + false, + budgets.objectPermissions, + (page) => { + objectPermissions.push(...page); + }, + ); + if (objectResult.truncated) { + truncatedCategories.add('objectPermissions'); + } + } + currentStep += 1; + + emitProgress(`Querying field permissions${parentChunkLabel}`); + for (const objectTypeChunk of objectTypeChunks) { + throwIfCanceled(options?.isCanceled); + if (budgets.fieldPermissions.remaining <= 0) { + truncatedCategories.add('fieldPermissions'); + break; + } + const fieldResult = await queryWithRecordBudget>( + org, + buildFieldPermissionsByParentSoql(parentIdChunk, objectTypeChunk), + false, + budgets.fieldPermissions, + (page) => { + fieldPermissions.push(...page); + }, + ); + if (fieldResult.truncated) { + truncatedCategories.add('fieldPermissions'); + } + } + currentStep += 1; + + throwIfCanceled(options?.isCanceled); + emitProgress(`Querying tab visibility settings${parentChunkLabel}`); + if (budgets.permissionSetTabSettings.remaining > 0) { + const tabResult = await queryWithRecordBudget>( + org, + buildTabSettingsByParentSoql(parentIdChunk), + false, + budgets.permissionSetTabSettings, + (page) => { + permissionSetTabSettings.push(...page); + }, + ); + if (tabResult.truncated) { + truncatedCategories.add('permissionSetTabSettings'); + } + } else { + truncatedCategories.add('permissionSetTabSettings'); + } + currentStep += 1; + + throwIfCanceled(options?.isCanceled); + emitProgress(`Querying user/group assignments${parentChunkLabel}`); + if (budgets.permissionSetAssignments.remaining > 0) { + const assignmentResult = await queryWithRecordBudget>( + org, + buildPermissionSetAssignmentsByPermissionSetSoql(parentIdChunk), + false, + budgets.permissionSetAssignments, + (page) => { + permissionSetAssignments.push(...page); + }, + ); + if (assignmentResult.truncated) { + truncatedCategories.add('permissionSetAssignments'); + } + } else { + truncatedCategories.add('permissionSetAssignments'); + } + currentStep += 1; + + throwIfCanceled(options?.isCanceled); + emitProgress(`Querying permission set group memberships${parentChunkLabel}`); + if (budgets.permissionSetGroupComponents.remaining > 0) { + const componentResult = await queryWithRecordBudget>( + org, + buildPermissionSetGroupComponentsByPermissionSetSoql(parentIdChunk), + false, + budgets.permissionSetGroupComponents, + (page) => { + for (const row of page) { + permissionSetGroupComponents.push(row); + const groupId = row.PermissionSetGroupId; + if (typeof groupId === 'string' && groupId.length > 0) { + permissionSetGroupIds.add(groupId); + } + } + }, + ); + if (componentResult.truncated) { + truncatedCategories.add('permissionSetGroupComponents'); + } + } else { + truncatedCategories.add('permissionSetGroupComponents'); + } + currentStep += 1; + } + + const sortedGroupIds = uniqueSalesforceIds([...permissionSetGroupIds]); + const groupIdChunks = sortedGroupIds.length === 0 ? [] : splitArrayToMaxSize(sortedGroupIds, GROUP_ID_CHUNK_SIZE); + // Now that we know how many group chunks there are, extend the total so progress reaches 100% accurately. + totalSteps = initialTotal + groupIdChunks.length * 2; + + for (let groupChunkIndex = 0; groupChunkIndex < groupIdChunks.length; groupChunkIndex++) { + const groupIdChunk = groupIdChunks[groupChunkIndex]; + const groupChunkLabel = groupIdChunks.length > 1 ? ` (batch ${groupChunkIndex + 1} of ${groupIdChunks.length})` : ''; + throwIfCanceled(options?.isCanceled); + + emitProgress(`Loading permission set group details${groupChunkLabel}`); + if (budgets.permissionSetGroups.remaining > 0) { + const groupResult = await queryWithRecordBudget>( + org, + buildPermissionSetGroupByIdSoql(groupIdChunk), + false, + budgets.permissionSetGroups, + (page) => { + permissionSetGroups.push(...page); + }, + ); + if (groupResult.truncated) { + truncatedCategories.add('permissionSetGroups'); + } + } else { + truncatedCategories.add('permissionSetGroups'); + } + currentStep += 1; + + throwIfCanceled(options?.isCanceled); + emitProgress(`Loading muting permission sets${groupChunkLabel}`); + if (budgets.mutingPermissionSets.remaining > 0) { + const mutingResult = await queryWithRecordBudget>( + org, + buildMutingPermissionSetsByGroupSoql(groupIdChunk), + false, + budgets.mutingPermissionSets, + (page) => { + mutingPermissionSets.push(...page); + }, + ); + if (mutingResult.truncated) { + truncatedCategories.add('mutingPermissionSets'); + } + } else { + truncatedCategories.add('mutingPermissionSets'); + } + currentStep += 1; + } + + const counts = { + permissionSets: permissionSets.length, + permissionSetAssignments: permissionSetAssignments.length, + permissionSetGroups: permissionSetGroups.length, + permissionSetGroupComponents: permissionSetGroupComponents.length, + mutingPermissionSets: mutingPermissionSets.length, + objectPermissions: objectPermissions.length, + fieldPermissions: fieldPermissions.length, + permissionSetTabSettings: permissionSetTabSettings.length, + }; + + const findings = buildPermissionExportFindings(objectPermissions, fieldPermissions, { + permissionSets, + permissionSetAssignments, + permissionSetGroupComponents, + mutingPermissionSets, + permissionSetTabSettings, + truncatedCategories, + }); + const issueCodeSummary = buildIssueCodeSummary(findings); + + const truncated = truncatedCategories.size > 0; + const truncatedLabel = truncated + ? `truncated=true (${[...truncatedCategories].map((category) => CATEGORY_LABELS[category]).join(', ')} hit row cap)` + : 'truncated=false'; + const summary = + `Exported ${counts.permissionSets} permission sets, ${counts.permissionSetAssignments} assignments, ` + + `${counts.permissionSetGroups} permission set groups (${counts.permissionSetGroupComponents} components, ` + + `${counts.mutingPermissionSets} muting permission sets), ${counts.objectPermissions} object permission rows, ` + + `${counts.fieldPermissions} field permission rows, ${counts.permissionSetTabSettings} tab settings ` + + `(${truncatedLabel}). ${findings.length} issue(s).`; + + emitProgress('Complete'); + + return { + truncated, + full: { + requestPayload, + phase: 'permission_export_v1', + summary, + truncated, + counts, + findings, + issueCodeSummary, + permissionSets, + permissionSetAssignments, + permissionSetGroups, + permissionSetGroupComponents, + mutingPermissionSets, + objectPermissions, + fieldPermissions, + permissionSetTabSettings, + }, + }; +} diff --git a/libs/features/manage-permissions/src/permission-export/soql-templates.ts b/libs/features/manage-permissions/src/permission-export/soql-templates.ts new file mode 100644 index 000000000..81cf8de0e --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql-templates.ts @@ -0,0 +1,182 @@ +import { HIGH_RISK_SYSTEM_PERMISSIONS } from '@jetstream/shared/constants'; +import { composeQuery, getField, WhereClause } from '@jetstreamapp/soql-parser-js'; + +function whereSobjectTypeIn(objectTypes?: string[]): WhereClause | undefined { + if (!objectTypes || objectTypes.length === 0) { + return undefined; + } + return { + left: { + field: 'SobjectType', + operator: 'IN', + value: objectTypes, + literalType: 'STRING', + }, + }; +} + +export function buildPermissionSetByIdSoql(ids: string[]): string { + return composeQuery({ + fields: [ + getField('Id'), + getField('Name'), + getField('Label'), + getField('Description'), + getField('IsOwnedByProfile'), + getField('ProfileId'), + getField('Profile.Name'), + getField('CreatedDate'), + getField('LastModifiedDate'), + getField('CreatedBy.Name'), + getField('LastModifiedBy.Name'), + // High-risk system permissions, surfaced as findings (Modify All Data, View All Data, etc.). + ...HIGH_RISK_SYSTEM_PERMISSIONS.map((perm) => getField(perm.field)), + ], + sObject: 'PermissionSet', + where: { + left: { + field: 'Id', + operator: 'IN', + value: ids, + literalType: 'STRING', + }, + }, + }); +} + +export function buildObjectPermissionsByParentSoql(parentIds: string[], objectTypes?: string[]): string { + const objectTypeWhere = whereSobjectTypeIn(objectTypes); + return composeQuery({ + fields: [ + getField('Id'), + getField('ParentId'), + getField('SobjectType'), + getField('PermissionsRead'), + getField('PermissionsCreate'), + getField('PermissionsEdit'), + getField('PermissionsDelete'), + getField('PermissionsViewAllRecords'), + getField('PermissionsModifyAllRecords'), + getField('PermissionsViewAllFields'), + ], + sObject: 'ObjectPermissions', + where: { + left: { + field: 'ParentId', + operator: 'IN', + value: parentIds, + literalType: 'STRING', + }, + ...(objectTypeWhere + ? { + operator: 'AND', + right: objectTypeWhere, + } + : {}), + }, + }); +} + +export function buildFieldPermissionsByParentSoql(parentIds: string[], objectTypes?: string[]): string { + const objectTypeWhere = whereSobjectTypeIn(objectTypes); + return composeQuery({ + fields: [ + getField('Id'), + getField('ParentId'), + getField('SobjectType'), + getField('Field'), + getField('PermissionsRead'), + getField('PermissionsEdit'), + ], + sObject: 'FieldPermissions', + where: { + left: { + field: 'ParentId', + operator: 'IN', + value: parentIds, + literalType: 'STRING', + }, + ...(objectTypeWhere + ? { + operator: 'AND', + right: objectTypeWhere, + } + : {}), + }, + }); +} + +export function buildTabSettingsByParentSoql(parentIds: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('ParentId'), getField('Name'), getField('Visibility')], + sObject: 'PermissionSetTabSetting', + where: { + left: { + field: 'ParentId', + operator: 'IN', + value: parentIds, + literalType: 'STRING', + }, + }, + }); +} + +export function buildPermissionSetAssignmentsByPermissionSetSoql(permissionSetIds: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('PermissionSetId'), getField('AssigneeId')], + sObject: 'PermissionSetAssignment', + where: { + left: { + field: 'PermissionSetId', + operator: 'IN', + value: permissionSetIds, + literalType: 'STRING', + }, + }, + }); +} + +export function buildPermissionSetGroupComponentsByPermissionSetSoql(permissionSetIds: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('PermissionSetGroupId'), getField('PermissionSetId')], + sObject: 'PermissionSetGroupComponent', + where: { + left: { + field: 'PermissionSetId', + operator: 'IN', + value: permissionSetIds, + literalType: 'STRING', + }, + }, + }); +} + +export function buildPermissionSetGroupByIdSoql(groupIds: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('DeveloperName'), getField('MasterLabel')], + sObject: 'PermissionSetGroup', + where: { + left: { + field: 'Id', + operator: 'IN', + value: groupIds, + literalType: 'STRING', + }, + }, + }); +} + +export function buildMutingPermissionSetsByGroupSoql(groupIds: string[]): string { + return composeQuery({ + fields: [getField('Id'), getField('PermissionSetGroupId'), getField('DeveloperName'), getField('MasterLabel')], + sObject: 'MutingPermissionSet', + where: { + left: { + field: 'PermissionSetGroupId', + operator: 'IN', + value: groupIds, + literalType: 'STRING', + }, + }, + }); +} diff --git a/libs/features/manage-permissions/src/permission-export/soql/field-permissions-by-parent.soql b/libs/features/manage-permissions/src/permission-export/soql/field-permissions-by-parent.soql new file mode 100644 index 000000000..9dbef7979 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/field-permissions-by-parent.soql @@ -0,0 +1,6 @@ +-- Mirrors apps/api/src/app/lib/permission-export/soql-templates.ts (buildFieldPermissionsByParentSoql). +-- Server may append: AND SobjectType IN (...) when the job payload includes objectApiNames. +SELECT Id, ParentId, SobjectType, Field, + PermissionsRead, PermissionsEdit +FROM FieldPermissions +WHERE ParentId IN ({{PARENT_IDS}}) diff --git a/libs/features/manage-permissions/src/permission-export/soql/muting-permission-sets-by-group.soql b/libs/features/manage-permissions/src/permission-export/soql/muting-permission-sets-by-group.soql new file mode 100644 index 000000000..764dddaea --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/muting-permission-sets-by-group.soql @@ -0,0 +1,3 @@ +SELECT Id, PermissionSetGroupId, DeveloperName, MasterLabel +FROM MutingPermissionSet +WHERE PermissionSetGroupId IN (:groupIds) diff --git a/libs/features/manage-permissions/src/permission-export/soql/object-permissions-by-parent.soql b/libs/features/manage-permissions/src/permission-export/soql/object-permissions-by-parent.soql new file mode 100644 index 000000000..65940e89d --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/object-permissions-by-parent.soql @@ -0,0 +1,7 @@ +-- Mirrors apps/api/src/app/lib/permission-export/soql-templates.ts (buildObjectPermissionsByParentSoql). +-- Server may append: AND SobjectType IN (...) when the job payload includes objectApiNames. +SELECT Id, ParentId, SobjectType, + PermissionsRead, PermissionsCreate, PermissionsEdit, PermissionsDelete, + PermissionsViewAllRecords, PermissionsModifyAllRecords, PermissionsViewAllFields +FROM ObjectPermissions +WHERE ParentId IN ({{PARENT_IDS}}) diff --git a/libs/features/manage-permissions/src/permission-export/soql/permission-set-assignments-by-permission-set.soql b/libs/features/manage-permissions/src/permission-export/soql/permission-set-assignments-by-permission-set.soql new file mode 100644 index 000000000..84efa5873 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/permission-set-assignments-by-permission-set.soql @@ -0,0 +1,3 @@ +SELECT Id, PermissionSetId, AssigneeId +FROM PermissionSetAssignment +WHERE PermissionSetId IN (:parentIds) diff --git a/libs/features/manage-permissions/src/permission-export/soql/permission-set-by-id.soql b/libs/features/manage-permissions/src/permission-export/soql/permission-set-by-id.soql new file mode 100644 index 000000000..3ee328bb8 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/permission-set-by-id.soql @@ -0,0 +1,5 @@ +-- Mirrors apps/api/src/app/lib/permission-export/soql-templates.ts (buildPermissionSetByIdSoql). +SELECT Id, Name, Label, Description, IsOwnedByProfile, ProfileId, Profile.Name, + CreatedDate, LastModifiedDate, CreatedBy.Name, LastModifiedBy.Name +FROM PermissionSet +WHERE Id IN ({{PARENT_IDS}}) diff --git a/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-by-id.soql b/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-by-id.soql new file mode 100644 index 000000000..f4d5f11d1 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-by-id.soql @@ -0,0 +1,3 @@ +SELECT Id, DeveloperName, MasterLabel +FROM PermissionSetGroup +WHERE Id IN (:groupIds) diff --git a/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-components-by-permission-set.soql b/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-components-by-permission-set.soql new file mode 100644 index 000000000..6a5f24b21 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/permission-set-group-components-by-permission-set.soql @@ -0,0 +1,3 @@ +SELECT Id, PermissionSetGroupId, PermissionSetId +FROM PermissionSetGroupComponent +WHERE PermissionSetId IN (:parentIds) diff --git a/libs/features/manage-permissions/src/permission-export/soql/tab-settings-by-parent.soql b/libs/features/manage-permissions/src/permission-export/soql/tab-settings-by-parent.soql new file mode 100644 index 000000000..57f0c8a70 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/soql/tab-settings-by-parent.soql @@ -0,0 +1,5 @@ +-- Mirrors apps/api/src/app/lib/permission-export/soql-templates.ts (buildTabSettingsByParentSoql). +-- PermissionSetTabSetting: tab visibility (Name + Visibility) per permission set parent. +SELECT Id, ParentId, Name, Visibility +FROM PermissionSetTabSetting +WHERE ParentId IN ({{PARENT_IDS}}) diff --git a/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.spec.ts b/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.spec.ts new file mode 100644 index 000000000..d9f49eb61 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { substituteSoqlPlaceholders } from './substitute-soql-placeholders'; + +describe('substituteSoqlPlaceholders', () => { + it('replaces simple tokens', () => { + const soql = 'SELECT Id FROM PermissionSet WHERE Name IN ({{ permissionSetNames }})'; + const out = substituteSoqlPlaceholders(soql, { permissionSetNames: "'Foo','Bar'" }); + expect(out).toBe("SELECT Id FROM PermissionSet WHERE Name IN ('Foo','Bar')"); + }); +}); diff --git a/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.ts b/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.ts new file mode 100644 index 000000000..0ab9ff5a5 --- /dev/null +++ b/libs/features/manage-permissions/src/permission-export/substitute-soql-placeholders.ts @@ -0,0 +1,16 @@ +/** + * Replaces `{{token}}` placeholders in SOQL templates (legacy `sf_permissions_analysis` style). + * + * @param soql SOQL string possibly containing `{{name}}` style placeholders. + * @param vars Map of placeholder name (without braces) to replacement text (already escaped for SOQL if needed). + */ +export function substituteSoqlPlaceholders(soql: string, vars: Record): string { + return Object.entries(vars).reduce((acc, [key, value]) => { + const pattern = new RegExp(`\\{\\{\\s*${escapeRegExp(key)}\\s*\\}\\}`, 'g'); + return acc.replace(pattern, value); + }, soql); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/libs/features/manage-permissions/vite.config.ts b/libs/features/manage-permissions/vite.config.ts index 2d3261da1..02980f7f6 100644 --- a/libs/features/manage-permissions/vite.config.ts +++ b/libs/features/manage-permissions/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig(() => ({ watch: false, globals: true, environment: 'jsdom', + setupFiles: ['../../test-utils/src/test-setup-dom.ts'], include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], reporters: ['default'], passWithNoTests: true, diff --git a/libs/features/query/src/QueryResults/QueryResults.tsx b/libs/features/query/src/QueryResults/QueryResults.tsx index dde932fab..633069e5c 100644 --- a/libs/features/query/src/QueryResults/QueryResults.tsx +++ b/libs/features/query/src/QueryResults/QueryResults.tsx @@ -175,14 +175,39 @@ export const QueryResults = React.memo(() => { navigate('', { replace: true, state: window.history.state.state }); return; } - // Fallback to session state if browser history is not available (e.g. browser extension) + // Fallback: sessionStorage (same tab / extension) then localStorage (cross-tab handoff from window.open — new tabs + // do not inherit the opener's sessionStorage). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let potentialState: any = null; + let handoffFromLocalStorage = false; try { - const potentialState = JSON.parse(sessionStorage.getItem('query') || ''); - if (isString(potentialState.soql)) { - navigate('', { replace: true, state: potentialState }); + const sessionRaw = sessionStorage.getItem('query'); + if (sessionRaw) { + potentialState = JSON.parse(sessionRaw); + } + } catch { + // ignore invalid session payload + } + if (!isString(potentialState?.soql)) { + try { + const localRaw = localStorage.getItem('query'); + if (localRaw) { + potentialState = JSON.parse(localRaw); + handoffFromLocalStorage = true; + } + } catch { + // ignore invalid local payload + } + } + if (isString(potentialState?.soql)) { + navigate('', { replace: true, state: potentialState }); + if (handoffFromLocalStorage) { + try { + localStorage.removeItem('query'); + } catch { + // ignore + } } - } catch (ex) { - // could not parse session, ignore } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/libs/icon-factory/src/lib/icon-factory.tsx b/libs/icon-factory/src/lib/icon-factory.tsx index b4398d08c..bd6c2b1d3 100644 --- a/libs/icon-factory/src/lib/icon-factory.tsx +++ b/libs/icon-factory/src/lib/icon-factory.tsx @@ -23,7 +23,10 @@ import StandardIcon_Apex from './icons/standard/Apex'; import StandardIcon_AssetRelationship from './icons/standard/AssetRelationship'; import StandardIcon_Billing from './icons/standard/Billing'; import StandardIcon_BundleConfig from './icons/standard/BundleConfig'; +import StandardIcon_Chart from './icons/standard/Chart'; import StandardIcon_ConnectedApps from './icons/standard/ConnectedApps'; +import StandardIcon_Customers from './icons/standard/Customers'; +import StandardIcon_CustomerPortalUsers from './icons/standard/CustomerPortalUsers'; import StandardIcon_DataStreams from './icons/standard/DataStreams'; import StandardIcon_EmployeeOrganization from './icons/standard/EmployeeOrganization'; import StandardIcon_Entity from './icons/standard/Entity'; @@ -32,6 +35,8 @@ import StandardIcon_Feed from './icons/standard/Feed'; import StandardIcon_Feedback from './icons/standard/Feedback'; import StandardIcon_Form from './icons/standard/Form'; import StandardIcon_Formula from './icons/standard/Formula'; +import StandardIcon_Groups from './icons/standard/Groups'; +import StandardIcon_Incident from './icons/standard/Incident'; import StandardIcon_MultiPicklist from './icons/standard/MultiPicklist'; import StandardIcon_Opportunity from './icons/standard/Opportunity'; import StandardIcon_Outcome from './icons/standard/Outcome'; @@ -56,6 +61,8 @@ import UtilityIcon_Announcement from './icons/utility/Announcement'; import UtilityIcon_Apex from './icons/utility/Apex'; import UtilityIcon_ApexPlugin from './icons/utility/ApexPlugin'; import UtilityIcon_Archive from './icons/utility/Archive'; +import UtilityIcon_ArrowLeft from './icons/utility/ArrowLeft'; +import UtilityIcon_ArrowRight from './icons/utility/ArrowRight'; import UtilityIcon_Arrowdown from './icons/utility/Arrowdown'; import UtilityIcon_Arrowup from './icons/utility/Arrowup'; import UtilityIcon_Back from './icons/utility/Back'; @@ -90,6 +97,7 @@ import UtilityIcon_ExpandAll from './icons/utility/ExpandAll'; import UtilityIcon_ExpandAlt from './icons/utility/ExpandAlt'; import UtilityIcon_Fallback from './icons/utility/Fallback'; import UtilityIcon_Favorite from './icons/utility/Favorite'; +import UtilityIcon_Feed from './icons/utility/Feed'; import UtilityIcon_File from './icons/utility/File'; import UtilityIcon_Filter from './icons/utility/Filter'; import UtilityIcon_FilterList from './icons/utility/FilterList'; @@ -203,7 +211,10 @@ const standardIcons = { asset_relationship: StandardIcon_AssetRelationship, billing: StandardIcon_Billing, bundle_config: StandardIcon_BundleConfig, + chart: StandardIcon_Chart, connected_apps: StandardIcon_ConnectedApps, + customers: StandardIcon_Customers, + customer_portal_users: StandardIcon_CustomerPortalUsers, data_streams: StandardIcon_DataStreams, employee_organization: StandardIcon_EmployeeOrganization, entity: StandardIcon_Entity, @@ -212,6 +223,8 @@ const standardIcons = { feedback: StandardIcon_Feedback, form: StandardIcon_Form, formula: StandardIcon_Formula, + groups: StandardIcon_Groups, + incident: StandardIcon_Incident, multi_picklist: StandardIcon_MultiPicklist, opportunity: StandardIcon_Opportunity, outcome: StandardIcon_Outcome, @@ -254,6 +267,8 @@ const utilityIcons = { apex_plugin: UtilityIcon_ApexPlugin, apex: UtilityIcon_Apex, archive: UtilityIcon_Archive, + arrow_left: UtilityIcon_ArrowLeft, + arrow_right: UtilityIcon_ArrowRight, arrowdown: UtilityIcon_Arrowdown, arrowup: UtilityIcon_Arrowup, back: UtilityIcon_Back, @@ -288,6 +303,7 @@ const utilityIcons = { expand_alt: UtilityIcon_ExpandAlt, fallback: UtilityIcon_Fallback, favorite: UtilityIcon_Favorite, + feed: UtilityIcon_Feed, file: UtilityIcon_File, filter: UtilityIcon_Filter, filterList: UtilityIcon_FilterList, diff --git a/libs/shared/constants/src/index.ts b/libs/shared/constants/src/index.ts index bf1c6bd1a..7cf2df655 100644 --- a/libs/shared/constants/src/index.ts +++ b/libs/shared/constants/src/index.ts @@ -1 +1,2 @@ +export * from './lib/permission-export-finding-codes'; export * from './lib/shared-constants'; diff --git a/libs/shared/constants/src/lib/permission-export-finding-codes.ts b/libs/shared/constants/src/lib/permission-export-finding-codes.ts new file mode 100644 index 000000000..e388ec211 --- /dev/null +++ b/libs/shared/constants/src/lib/permission-export-finding-codes.ts @@ -0,0 +1,147 @@ +/** + * Permission export analysis issue codes and severities (shared by the analysis job runner and UI). + * + * Severity model (2-tier, intentionally — the issues UI counts only error/warning): + * - `error` = EXPOSURE: real over-access a user can exploit (Modify All Records, Modify All Data, …). + * - `warning` = INCONSISTENCY / DEAD-CONFIG / INCOMPLETE-SETUP / CLEANUP. Visible but not alarming. + * + * Note: field-level access that the object can never satisfy (FLS without OLS) is INERT — Salesforce + * silently ignores it — so it is a `warning`, not an `error`. Errors are reserved for true exposure. + */ + +export const PermissionExportFindingSeverity = { + Error: 'error', + Warning: 'warning', +} as const; + +export type PermissionExportFindingSeverityValue = (typeof PermissionExportFindingSeverity)[keyof typeof PermissionExportFindingSeverity]; + +/** + * Stored on each analysis row as `code` and referenced by `issueCodeSummary` keys. + */ +export const PermissionExportFindingCode = { + // Inert / inconsistency (warning) — field access the object can't satisfy. + FLS_EDIT_NO_OBJECT_EDIT: 'FLS_EDIT_NO_OBJECT_EDIT', + FLS_READ_NO_OBJECT_READ: 'FLS_READ_NO_OBJECT_READ', + FLS_WITHOUT_OLS_ROW: 'FLS_WITHOUT_OLS_ROW', + // Incomplete setup (warning). + OLS_READ_NO_FLS_ROWS: 'OLS_READ_NO_FLS_ROWS', + OLS_EDIT_NO_FLS_ROWS: 'OLS_EDIT_NO_FLS_ROWS', + // Exposure (error) / broad access (warning) — bypasses the sharing model for an object. + OBJECT_MODIFY_ALL_RECORDS: 'OBJECT_MODIFY_ALL_RECORDS', + OBJECT_VIEW_ALL_RECORDS: 'OBJECT_VIEW_ALL_RECORDS', + // System permissions. + SYSTEM_PERM_HIGH_RISK: 'SYSTEM_PERM_HIGH_RISK', + SYSTEM_PERM_ELEVATED: 'SYSTEM_PERM_ELEVATED', + // Cleanup / hygiene (warning). + PERMSET_NO_ASSIGNMENTS: 'PERMSET_NO_ASSIGNMENTS', + TAB_VISIBLE_NO_OBJECT_READ: 'TAB_VISIBLE_NO_OBJECT_READ', + // Meta. + FINDINGS_TRUNCATED: 'FINDINGS_TRUNCATED', +} as const; + +export type PermissionExportFindingCodeValue = (typeof PermissionExportFindingCode)[keyof typeof PermissionExportFindingCode]; + +export type PermissionExportFindingDefinition = { + readonly severity: PermissionExportFindingSeverityValue; + /** Short label for Issue Codes / aggregated UI. */ + readonly label: string; +}; + +export const PERMISSION_EXPORT_FINDING_DEFINITIONS: Record = { + [PermissionExportFindingCode.FLS_READ_NO_OBJECT_READ]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Field read granted, but the object grants no read — field access has no effect.', + }, + [PermissionExportFindingCode.FLS_EDIT_NO_OBJECT_EDIT]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Field edit granted, but the object grants no edit — field access has no effect.', + }, + [PermissionExportFindingCode.FLS_WITHOUT_OLS_ROW]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Field permissions exist for the object, but there is no object permissions row.', + }, + [PermissionExportFindingCode.OLS_READ_NO_FLS_ROWS]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Object read granted with no field permissions configured (default field access applies).', + }, + [PermissionExportFindingCode.OLS_EDIT_NO_FLS_ROWS]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Object edit granted with no field permissions configured (default field access applies).', + }, + [PermissionExportFindingCode.OBJECT_MODIFY_ALL_RECORDS]: { + severity: PermissionExportFindingSeverity.Error, + label: 'Modify All Records — bypasses the sharing model (edit/delete every record) for this object.', + }, + [PermissionExportFindingCode.OBJECT_VIEW_ALL_RECORDS]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'View All Records — bypasses the sharing model (read every record) for this object.', + }, + [PermissionExportFindingCode.SYSTEM_PERM_HIGH_RISK]: { + severity: PermissionExportFindingSeverity.Error, + label: 'High-risk system permission granted.', + }, + [PermissionExportFindingCode.SYSTEM_PERM_ELEVATED]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Elevated system permission granted.', + }, + [PermissionExportFindingCode.PERMSET_NO_ASSIGNMENTS]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Permission set has no direct user assignments and is not part of a permission set group.', + }, + [PermissionExportFindingCode.TAB_VISIBLE_NO_OBJECT_READ]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Tab is visible, but the permission set grants no read on the underlying object.', + }, + [PermissionExportFindingCode.FINDINGS_TRUNCATED]: { + severity: PermissionExportFindingSeverity.Warning, + label: 'Additional issues were omitted to keep the export result size bounded.', + }, +}; + +export function isPermissionExportFindingCode(value: string): value is PermissionExportFindingCodeValue { + return (Object.values(PermissionExportFindingCode) as readonly string[]).includes(value); +} + +export function getPermissionExportFindingDefinition(code: string): PermissionExportFindingDefinition | undefined { + return isPermissionExportFindingCode(code) ? PERMISSION_EXPORT_FINDING_DEFINITIONS[code] : undefined; +} + +/** + * Curated catalog of high-risk `PermissionSet.Permissions*` boolean fields surfaced as findings. + * + * Tier 1 → {@link PermissionExportFindingCode.SYSTEM_PERM_HIGH_RISK} (error): full data access / code authoring. + * Tier 2/3 → {@link PermissionExportFindingCode.SYSTEM_PERM_ELEVATED} (warning): privilege escalation, + * user/config control, and data egress. + * + * Shared by the PermissionSet SOQL builder (which columns to SELECT) and the findings builder. + */ +export interface HighRiskSystemPermission { + /** `PermissionSet` boolean field API name. */ + readonly field: string; + /** Human label for the finding message. */ + readonly label: string; + readonly tier: 1 | 2 | 3; +} + +export const HIGH_RISK_SYSTEM_PERMISSIONS: readonly HighRiskSystemPermission[] = [ + // Tier 1 — full data access / arbitrary code. + { field: 'PermissionsModifyAllData', label: 'Modify All Data', tier: 1 }, + { field: 'PermissionsViewAllData', label: 'View All Data', tier: 1 }, + { field: 'PermissionsAuthorApex', label: 'Author Apex', tier: 1 }, + // Tier 2 — privilege escalation / user & config control. + { field: 'PermissionsManageUsers', label: 'Manage Users', tier: 2 }, + { field: 'PermissionsManageInternalUsers', label: 'Manage Internal Users', tier: 2 }, + { field: 'PermissionsManageProfilesPermissionsets', label: 'Manage Profiles and Permission Sets', tier: 2 }, + { field: 'PermissionsAssignPermissionSets', label: 'Assign Permission Sets', tier: 2 }, + { field: 'PermissionsManageRoles', label: 'Manage Roles', tier: 2 }, + { field: 'PermissionsCustomizeApplication', label: 'Customize Application', tier: 2 }, + { field: 'PermissionsManageSharing', label: 'Manage Sharing', tier: 2 }, + // Tier 3 — data egress / setup visibility. + { field: 'PermissionsExportReport', label: 'Export Reports', tier: 3 }, + { field: 'PermissionsApiEnabled', label: 'API Enabled', tier: 3 }, + { field: 'PermissionsViewAllUsers', label: 'View All Users', tier: 3 }, + { field: 'PermissionsManageDataIntegrations', label: 'Manage Data Integrations', tier: 3 }, + { field: 'PermissionsPasswordNeverExpires', label: 'Password Never Expires', tier: 3 }, + { field: 'PermissionsViewSetup', label: 'View Setup and Configuration', tier: 3 }, +]; diff --git a/libs/shared/constants/src/lib/shared-constants.ts b/libs/shared/constants/src/lib/shared-constants.ts index 29c72a07c..7990494e2 100644 --- a/libs/shared/constants/src/lib/shared-constants.ts +++ b/libs/shared/constants/src/lib/shared-constants.ts @@ -355,6 +355,8 @@ export const TITLES = { FORMULA_EVALUATOR: 'Formula Evaluator | Jetstream', LOAD: 'Load | Jetstream', MANAGE_PERMISSIONS: 'Manage Permissions | Jetstream', + PERMISSION_ANALYSIS: 'Permission Analysis | Jetstream', + DATA_ANALYSIS: 'Data Analysis | Jetstream', MASS_UPDATE_RECORDS: 'Update Records | Jetstream', ORG_GROUPS: 'Manage Org Groups | Jetstream', PLATFORM_EVENTS: 'Platform Events | Jetstream', diff --git a/libs/shared/data/src/index.ts b/libs/shared/data/src/index.ts index 654e1fb4e..e4459bcca 100644 --- a/libs/shared/data/src/index.ts +++ b/libs/shared/data/src/index.ts @@ -2,4 +2,5 @@ export * from './lib/client-data'; export * from './lib/client-data-cache'; export { AxiosAdapterConfig, createMultipartFromFormData, getCsrfTokenFromCookie } from './lib/client-data-data-helper'; export * from './lib/client-socket-data'; +export * from './lib/data-utils'; export * from './lib/middleware'; diff --git a/libs/shared/data/src/lib/client-data.ts b/libs/shared/data/src/lib/client-data.ts index 387a836ee..b5aa32fba 100644 --- a/libs/shared/data/src/lib/client-data.ts +++ b/libs/shared/data/src/lib/client-data.ts @@ -1442,3 +1442,4 @@ export async function updatePermissionSetRecords( }), ]); } + diff --git a/libs/shared/data/src/lib/data-utils.ts b/libs/shared/data/src/lib/data-utils.ts new file mode 100644 index 000000000..9d1c62bed --- /dev/null +++ b/libs/shared/data/src/lib/data-utils.ts @@ -0,0 +1,46 @@ +import type { SalesforceOrgUi } from '@jetstream/types'; +import { query, queryMore } from './client-data'; + +/** + * Runs a SOQL query and follows `nextRecordsUrl` until done or the shared row budget is exhausted. + * Each page of records is passed to `onPage` and then discarded — callers that need to retain + * records should accumulate them inside the callback. + * + * @param budget.remaining Decremented per record passed to `onPage`. + * @returns `truncated` true when the budget ran out before all Salesforce rows were read. + */ +export async function queryWithRecordBudget>( + org: SalesforceOrgUi, + soql: string, + isTooling: boolean, + budget: { remaining: number }, + onPage: (records: T[]) => void, +): Promise<{ truncated: boolean }> { + let response = await query(org, soql, isTooling); + + while (true) { + const records = response.queryResults.records; + if (budget.remaining <= 0) { + return { truncated: true }; + } + if (records.length > budget.remaining) { + const allowed = records.slice(0, budget.remaining); + onPage(allowed); + budget.remaining = 0; + return { truncated: true }; + } + onPage(records); + budget.remaining -= records.length; + + if (response.queryResults.done) { + break; + } + const nextUrl = response.queryResults.nextRecordsUrl; + if (!nextUrl) { + break; + } + response = await queryMore(org, nextUrl, isTooling); + } + + return { truncated: false }; +} diff --git a/libs/shared/ui-app-state/src/lib/ui-app-state.ts b/libs/shared/ui-app-state/src/lib/ui-app-state.ts index 420ae170b..1cc9076e0 100644 --- a/libs/shared/ui-app-state/src/lib/ui-app-state.ts +++ b/libs/shared/ui-app-state/src/lib/ui-app-state.ts @@ -51,6 +51,7 @@ export const DEFAULT_PROFILE: UserProfileUi = { chromeExtension: false, desktop: false, recordSync: false, + analysisTools: false, }, subscriptions: [], } as UserProfileUi; @@ -266,6 +267,23 @@ export const googleDriveAccessState = atom((get) => { }; }); +/** + * Access to the paid-only Analysis Tools (Field Usage + Permission Analysis). Granted by the dedicated + * `analysisTools` entitlement (for future per-org/trial control) OR any active paid plan, so existing + * paid users keep access before the entitlement is provisioned. Processing is browser-only, so this is + * the sole gate — there is no server-side enforcement. + */ +export const analysisToolsAccessState = atom((get) => { + const ability = get(abilityState); + const hasPaidPlan = get(hasPaidPlanState); + const hasAnalysisToolsAccess = ability.can('access', 'AnalysisTools') || hasPaidPlan; + return { + hasAnalysisToolsAccess, + // Only prompt upgrade on web; desktop/extension/canvas are paid tiers and granted via ability. + analysisShowUpgradeToPro: !hasAnalysisToolsAccess && !get(isBrowserExtensionState) && !isDesktop() && !isCanvasApp(), + }; +}); + export const orgGroupsAsyncState = atom | OrgGroup[]>(fetchOrgGroups()); // Unwrapped value to simplify derived state export const orgGroupsState = unwrap(orgGroupsAsyncState, (prev) => prev ?? []); diff --git a/libs/shared/ui-core/src/app/AnalysisToolsPaywall.tsx b/libs/shared/ui-core/src/app/AnalysisToolsPaywall.tsx new file mode 100644 index 000000000..a2ecc21a1 --- /dev/null +++ b/libs/shared/ui-core/src/app/AnalysisToolsPaywall.tsx @@ -0,0 +1,50 @@ +import { css } from '@emotion/react'; +import { Icon, UpgradeToProButton } from '@jetstream/ui'; +import { FunctionComponent } from 'react'; +import { useAmplitude } from '../analytics'; + +export interface AnalysisToolsPaywallProps { + /** Feature name shown in the heading, e.g. "Field Usage Analysis". Defaults to "Analysis Tools". */ + featureLabel?: string; +} + +/** + * In-page paywall shown when a user without the {@link analysisToolsAccessState} entitlement reaches an + * analysis route directly. Discovery is also gated (nav + home cards are hidden), so this is the fallback + * for bookmarked/shared URLs. Processing is browser-only, so there is no server-side enforcement. + */ +export const AnalysisToolsPaywall: FunctionComponent = ({ featureLabel = 'Analysis Tools' }) => { + const { trackEvent } = useAmplitude(); + return ( +
+
+ +

{featureLabel} is a paid feature

+

+ Field Usage and Permission Analysis are available on a paid Jetstream plan. Upgrade to scan field population + across your objects, find unused fields, and audit profile and permission-set coverage. +

+
+ +
+
+
+ ); +}; + +export default AnalysisToolsPaywall; diff --git a/libs/shared/ui-core/src/app/AppHome/AppHome.tsx b/libs/shared/ui-core/src/app/AppHome/AppHome.tsx index 0b21d4602..4cbc20bb7 100644 --- a/libs/shared/ui-core/src/app/AppHome/AppHome.tsx +++ b/libs/shared/ui-core/src/app/AppHome/AppHome.tsx @@ -2,14 +2,27 @@ import { css } from '@emotion/react'; import { IconName, IconType } from '@jetstream/icon-factory'; import { APP_ROUTES } from '@jetstream/shared/ui-router'; import { Badge, Icon, ScopedNotification } from '@jetstream/ui'; +import { analysisToolsAccessState } from '@jetstream/ui/app-state'; import classNames from 'classnames'; +import { useAtomValue } from 'jotai'; import { Fragment } from 'react'; import { Link } from 'react-router-dom'; +import { ProBadge } from '../ProBadge'; import { AppHomeAlternativeApplicationFormats } from './AppHomeAlternativeApplicationFormats'; import { AppHomeOrgExpirationBanner } from './AppHomeOrgExpirationBanner'; import { AppHomeOrganizations } from './AppHomeOrganizations'; -const HOME_ITEMS = [ +type HomeRouteItem = (typeof APP_ROUTES)[keyof typeof APP_ROUTES]; + +interface HomeCard { + title: string; + icon: { type: string; icon: string }; + items: HomeRouteItem[]; + /** Paid-only Analysis Tools: the card stays visible but is locked with an upgrade prompt when not entitled. */ + requiresPro?: boolean; +} + +const HOME_ITEMS: HomeCard[] = [ { title: 'Query', icon: { type: 'standard', icon: 'record_lookup' }, @@ -30,6 +43,12 @@ const HOME_ITEMS = [ icon: { type: 'standard', icon: 'portal' }, items: [APP_ROUTES.PERMISSION_MANAGER], }, + { + title: 'Analysis', + icon: { type: 'standard', icon: 'data_streams' }, + items: [APP_ROUTES.PERMISSION_ANALYSIS, APP_ROUTES.DATA_ANALYSIS], + requiresPro: true, + }, { title: 'Deploy', icon: { type: 'standard', icon: 'asset_relationship' }, @@ -56,6 +75,7 @@ interface AppHomeProps { } export const AppHome = ({ showAlternativeAppFormats, hideConnectedAppBanner = false }: AppHomeProps) => { + const { hasAnalysisToolsAccess } = useAtomValue(analysisToolsAccessState); return (
- {HOME_ITEMS.map((card) => ( -
-
-
- {card.icon && ( - - )} -
-
-

- {card.title} -

-
-
- {card.items.map(({ DESCRIPTION, ROUTE, SEARCH_PARAM, TITLE, DOCS, NEW_UNTIL }) => ( - -
- - {TITLE} - - {NEW_UNTIL && NEW_UNTIL >= CURRENT_TIME && ( - - NEW - + {HOME_ITEMS.map((card) => { + const locked = !!card.requiresPro && !hasAnalysisToolsAccess; + return ( +
+
+
+ {card.icon && ( + + )} +
+
+

+ {card.title} + {card.requiresPro && } +

+
+
+ {card.items.map(({ DESCRIPTION, ROUTE, SEARCH_PARAM, TITLE, DOCS, NEW_UNTIL }) => ( + +
+ {locked ? ( + {TITLE} + ) : ( + + {TITLE} + + )} + {NEW_UNTIL && NEW_UNTIL >= CURRENT_TIME && ( + + NEW + + )} +
+
{DESCRIPTION}
+ {!locked && DOCS && ( + + Documentation + + )} -
-
{DESCRIPTION}
- {DOCS && ( - - Documentation - +
+ ))} +
+ {locked && ( + + )} +
-
- -
- ))} + + + ); + })} {showAlternativeAppFormats ? : null} diff --git a/libs/shared/ui-core/src/app/HeaderNavbar.tsx b/libs/shared/ui-core/src/app/HeaderNavbar.tsx index e0bfb740e..ce02cb363 100644 --- a/libs/shared/ui-core/src/app/HeaderNavbar.tsx +++ b/libs/shared/ui-core/src/app/HeaderNavbar.tsx @@ -26,8 +26,8 @@ import { RecordSearchPopover } from '../record/RecordSearchPopover'; import { UserSearchPopover } from '../record/UserSearchPopover'; import HeaderDonatePopover from './HeaderDonatePopover'; import HeaderHelpPopover from './HeaderHelpPopover'; -import { HeaderNavbarItems } from './HeaderNavbarItems'; -import { HeaderNavbarBillingUserItems } from './HeaderNavbarReadOnlyUserItems'; +import { useHeaderNavbarItems } from './HeaderNavbarItems'; +import { headerNavbarBillingUserItems } from './HeaderNavbarReadOnlyUserItems'; import HeaderUpdateNotification from './HeaderUpdateNotification'; import LogoPro from './jetstream-logo-pro-200w.png'; import Logo from './jetstream-logo-v1-200w.png'; @@ -149,6 +149,7 @@ export const HeaderNavbar = ({ const colorScheme: ColorScheme = userPreferences?.colorScheme ?? 'light'; const [enableNotifications, setEnableNotifications] = useState(false); const [userMenuItems, setUserMenuItems] = useState([]); + const navbarItems = useHeaderNavbarItems(); function handleUserMenuSelection(id: string) { switch (id) { @@ -311,10 +312,7 @@ export const HeaderNavbar = ({ isEmbeddedApp={isEmbeddedApp} onUserMenuItemSelected={handleUserMenuSelection} > - - {isReadOnlyUser && } - {!isReadOnlyUser && } - + ); diff --git a/libs/shared/ui-core/src/app/HeaderNavbarItems.tsx b/libs/shared/ui-core/src/app/HeaderNavbarItems.tsx index 12faadeb9..9cde1596a 100644 --- a/libs/shared/ui-core/src/app/HeaderNavbarItems.tsx +++ b/libs/shared/ui-core/src/app/HeaderNavbarItems.tsx @@ -1,20 +1,31 @@ import { APP_ROUTES } from '@jetstream/shared/ui-router'; -import { NavbarItem, NavbarItemWaffle, NavbarMenuItems } from '@jetstream/ui'; +import type { NavbarItemConfig } from '@jetstream/ui'; +import { useMemo } from 'react'; -export const HeaderNavbarItems = () => { - return ( - <> - - - - ( + () => [ + { + id: 'home', + type: 'waffle', + path: APP_ROUTES.HOME.ROUTE, + search: APP_ROUTES.HOME.SEARCH_PARAM, + title: 'Home', + assistiveText: 'Home Page', + }, + { + id: 'query', + type: 'item', + path: APP_ROUTES.QUERY.ROUTE, + search: APP_ROUTES.QUERY.SEARCH_PARAM, + title: APP_ROUTES.QUERY.DESCRIPTION, + label: APP_ROUTES.QUERY.TITLE, + }, + { + id: 'load-records', + type: 'menu', + label: 'Load Records', + items: [ { id: 'load', path: APP_ROUTES.LOAD.ROUTE, @@ -43,25 +54,51 @@ export const HeaderNavbarItems = () => { title: APP_ROUTES.LOAD_CREATE_RECORD.DESCRIPTION, label: APP_ROUTES.LOAD_CREATE_RECORD.TITLE, }, - ]} - /> - - - - - { title: APP_ROUTES.FORMULA_EVALUATOR.DESCRIPTION, label: APP_ROUTES.FORMULA_EVALUATOR.TITLE, }, - ]} - /> - - { title: APP_ROUTES.PLATFORM_EVENT_MONITOR.DESCRIPTION, label: APP_ROUTES.PLATFORM_EVENT_MONITOR.TITLE, }, - ]} - /> - { title: 'Documentation', label: 'Documentation', }, - ]} - /> - + ], + }, + ], + [], ); -}; +} diff --git a/libs/shared/ui-core/src/app/HeaderNavbarReadOnlyUserItems.tsx b/libs/shared/ui-core/src/app/HeaderNavbarReadOnlyUserItems.tsx index 2d90c2ef5..9d91da733 100644 --- a/libs/shared/ui-core/src/app/HeaderNavbarReadOnlyUserItems.tsx +++ b/libs/shared/ui-core/src/app/HeaderNavbarReadOnlyUserItems.tsx @@ -1,30 +1,37 @@ import { APP_ROUTES } from '@jetstream/shared/ui-router'; -import { NavbarItem, NavbarItemWaffle } from '@jetstream/ui'; +import type { NavbarItemConfig } from '@jetstream/ui'; -export const HeaderNavbarBillingUserItems = () => { - return ( - <> - - - - - - - - ); -}; +export const headerNavbarBillingUserItems: NavbarItemConfig[] = [ + { + id: 'home', + type: 'waffle', + path: APP_ROUTES.HOME.ROUTE, + search: APP_ROUTES.HOME.SEARCH_PARAM, + title: 'Home', + assistiveText: 'Home Page', + }, + { + id: 'profile', + type: 'item', + path: APP_ROUTES.PROFILE.ROUTE, + search: APP_ROUTES.PROFILE.SEARCH_PARAM, + title: APP_ROUTES.PROFILE.DESCRIPTION, + label: APP_ROUTES.PROFILE.TITLE, + }, + { + id: 'billing', + type: 'item', + path: APP_ROUTES.BILLING.ROUTE, + search: APP_ROUTES.BILLING.SEARCH_PARAM, + title: APP_ROUTES.BILLING.DESCRIPTION, + label: APP_ROUTES.BILLING.TITLE, + }, + { + id: 'team-dashboard', + type: 'item', + path: APP_ROUTES.TEAM_DASHBOARD.ROUTE, + search: APP_ROUTES.TEAM_DASHBOARD.SEARCH_PARAM, + title: APP_ROUTES.TEAM_DASHBOARD.DESCRIPTION, + label: APP_ROUTES.TEAM_DASHBOARD.TITLE, + }, +]; diff --git a/libs/shared/ui-core/src/app/ProBadge.tsx b/libs/shared/ui-core/src/app/ProBadge.tsx new file mode 100644 index 000000000..fbbc15ef1 --- /dev/null +++ b/libs/shared/ui-core/src/app/ProBadge.tsx @@ -0,0 +1,24 @@ +import { css } from '@emotion/react'; +import classNames from 'classnames'; +import { FunctionComponent } from 'react'; + +export interface ProBadgeProps { + className?: string; + title?: string; +} + +/** Small teal "Pro" pill marking a paid-only (Jetstream Pro) feature; matches the Jetstream Pro logo gradient. */ +export const ProBadge: FunctionComponent = ({ className, title = 'Jetstream Pro feature' }) => ( + + Pro + +); + +export default ProBadge; diff --git a/libs/shared/ui-core/src/create-fields/create-fields-load-utils.ts b/libs/shared/ui-core/src/create-fields/create-fields-load-utils.ts index 96fea3a1f..81da23b1b 100644 --- a/libs/shared/ui-core/src/create-fields/create-fields-load-utils.ts +++ b/libs/shared/ui-core/src/create-fields/create-fields-load-utils.ts @@ -1,3 +1,4 @@ +import { parseCustomFieldApiNameForTooling } from '@jetstream/shared/utils'; import { Field } from '@jetstream/types'; import { CustomFieldMetadata, FieldValues, SalesforceFieldType } from './create-fields-types'; import { getInitialValues } from './create-fields-utils'; @@ -338,11 +339,11 @@ export interface ExistingFieldRow { /** * Returns the namespace prefix from a custom field API name, or null if non-namespaced. - * e.g. "ns__MyField__c" => "ns", "MyField__c" => null + * Uses {@link parseCustomFieldApiNameForTooling} so unmanaged names like `My_Field__c` are not mistaken for packaged fields. */ function getFieldNamespacePrefix(fieldName: string): string | null { - const match = /^([A-Za-z0-9]+)__.+__c$/.exec(fieldName); - return match ? match[1] : null; + const parsed = parseCustomFieldApiNameForTooling(fieldName); + return parsed?.namespacePrefix ?? null; } /** diff --git a/libs/shared/ui-core/src/index.ts b/libs/shared/ui-core/src/index.ts index 659aaebd7..31fb4e083 100644 --- a/libs/shared/ui-core/src/index.ts +++ b/libs/shared/ui-core/src/index.ts @@ -1,4 +1,5 @@ export * from './analytics'; +export * from './app/AnalysisToolsPaywall'; export * from './app/AppHome/AppHome'; export * from './app/AppHome/AppHomeBillingUser'; export * from './app/AppLoading'; @@ -14,6 +15,7 @@ export * from './app/HeaderNavbar'; export * from './app/HeaderUpdateNotification'; export * from './app/MonacoEditor'; export * from './app/NotificationsRequestModal'; +export * from './app/ProBadge'; export * from './app/PromptNavigation'; export * from './app/RequireMetadataApiBanner'; export * from './app/ThemeApplier'; @@ -41,6 +43,7 @@ export * from './icons/JetstreamProLogo'; export * from './jetstream-events'; export * from './jobs/Job'; export * from './jobs/Jobs'; +export * from './jobs/jobs.state'; export * from './jobs/JobWorker'; export * from './load-records-results/LoadRecordsBulkApiResultsTable'; export * from './load-records-results/LoadRecordsBulkApiResultsTableRow'; diff --git a/libs/shared/ui-core/src/jobs/Job.tsx b/libs/shared/ui-core/src/jobs/Job.tsx index e65a3ba36..7a0747618 100644 --- a/libs/shared/ui-core/src/jobs/Job.tsx +++ b/libs/shared/ui-core/src/jobs/Job.tsx @@ -5,12 +5,18 @@ import { formatDate } from 'date-fns/format'; import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; import isString from 'lodash/isString'; import { FunctionComponent } from 'react'; +import { useNavigate } from 'react-router-dom'; import { downloadJob } from './job-utils'; const JOBS_WITH_DOWNLOAD = new Set(['BulkDelete', 'BulkUndelete']); -const JOBS_WITH_CANCEL = new Set(['BulkDownload', 'RetrievePackageZip']); +const JOBS_WITH_CANCEL = new Set(['BulkDownload', 'RetrievePackageZip', 'PermissionExportAnalysis', 'FieldUsageAnalysis']); const JOBS_WITH_LINK = new Set(['BulkDownload', 'UploadToGoogle', 'RetrievePackageZip']); -const JOBS_WITH_TIMESTAMP_UPDATE = new Set(['RetrievePackageZip', 'BulkDownload']); +const JOBS_WITH_TIMESTAMP_UPDATE = new Set([ + 'RetrievePackageZip', + 'BulkDownload', + 'PermissionExportAnalysis', + 'FieldUsageAnalysis', +]); const JOBS_WITH_FILE_ACTIONS = new Set(['DesktopFileDownload']); export interface JobProps { @@ -20,6 +26,7 @@ export interface JobProps { } export const Job: FunctionComponent = ({ job, cancelJob, dismiss }) => { + const navigate = useNavigate(); const status = job.statusMessage || job.status; let message; let timestamp; @@ -144,13 +151,23 @@ export const Job: FunctionComponent = ({ job, cancelJob, dismiss }) => {inProgress && JOBS_WITH_CANCEL.has(job.type) && ( -
-
- -
+
+ +
+ )} + {job.viewUrl && ( +
+
)} {!inProgress && ( diff --git a/libs/shared/ui-core/src/jobs/JobWorker.ts b/libs/shared/ui-core/src/jobs/JobWorker.ts index f15c59e26..6897e0775 100644 --- a/libs/shared/ui-core/src/jobs/JobWorker.ts +++ b/libs/shared/ui-core/src/jobs/JobWorker.ts @@ -1,5 +1,11 @@ /// /* eslint-disable @typescript-eslint/no-explicit-any */ +import { + computeFieldUsageWhereUsed, + FIELD_USAGE_MAX_ROWS_PER_OBJECT, + runFieldUsageQueryForObjects, +} from '@jetstream/feature/data-analysis'; +import { runPermissionExport } from '@jetstream/feature/manage-permissions'; import { logger } from '@jetstream/shared/client-logger'; import { MIME_TYPES } from '@jetstream/shared/constants'; import { @@ -30,16 +36,21 @@ import { getIdFromRecordUrl, getMapOfBaseAndSubqueryRecords, getSObjectFromRecordUrl, + gzipEncode, replaceSubqueryQueryResultsWithRecords, splitArrayToMaxSize, } from '@jetstream/shared/utils'; import type { + AnalysisJobHistoryItem, AsyncJobType, AsyncJobWorkerMessagePayload, AsyncJobWorkerMessageResponse, BulkDownloadJob, CancelJob, DesktopFileDownloadJob, + FieldUsageAnalysisJob, + FieldUsageFullResult, + PermissionExportAnalysisJob, RetrievePackageFromListMetadataJob, RetrievePackageFromManifestJob, RetrievePackageFromPackageNamesJob, @@ -48,9 +59,33 @@ import type { UploadToGoogleJob, WorkerMessage, } from '@jetstream/types'; +import { dexieDb } from '@jetstream/ui/db'; import clamp from 'lodash/clamp'; import isString from 'lodash/isString'; +const ANALYSIS_HISTORY_PER_ORG_TYPE_CAP = 10; + +async function pruneAnalysisJobHistory(orgUniqueId: string, jobType: AnalysisJobHistoryItem['jobType']): Promise { + try { + // Wrap read + delete in a single transaction so a concurrent write (pin/unpin from another tab, or + // another job finishing on the same org+type) cannot race between the sortBy and the bulkDelete. + await dexieDb.transaction('rw', dexieDb.analysis_job_history, async () => { + // sortBy returns ascending by createdAt; reverse the array to put newest first so slice(N) drops the older rows. + const rowsAscending = await dexieDb.analysis_job_history + .where('[org+jobType+createdAt]') + .between([orgUniqueId, jobType, new Date(0)], [orgUniqueId, jobType, new Date(8640000000000000)]) + .sortBy('createdAt'); + const rowsNewestFirst = rowsAscending.slice().reverse(); + const overCap = rowsNewestFirst.filter((row) => !row.pinned).slice(ANALYSIS_HISTORY_PER_ORG_TYPE_CAP); + if (overCap.length > 0) { + await dexieDb.analysis_job_history.bulkDelete(overCap.map((row) => row.key)); + } + }); + } catch (ex) { + logger.warn('[JOB][ANALYSIS] Failed to prune analysis_job_history', ex); + } +} + /** * This class mimics a web-worker based on what the application uses for these methods */ @@ -342,6 +377,249 @@ export class JobWorker { } break; } + case 'PermissionExportAnalysis': { + const { org, job } = payloadData as AsyncJobWorkerMessagePayload; + const { jobHistoryKey, profileIds, permissionSetIds, objectApiNames } = job.meta; + const canceledRef = this.canceledJobIds; + try { + const { full } = await runPermissionExport(org, profileIds, permissionSetIds, { + objectApiNames, + onProgress: (progress) => { + const response: AsyncJobWorkerMessageResponse = { + job, + lastActivityUpdate: true, + results: { progress }, + }; + this.replyToMessage(name, response); + }, + isCanceled: () => canceledRef.has(job.id), + }); + + // Compress + Dexie write can take seconds on large results; keep the UI from sitting silently at 100%. + this.replyToMessage(name, { + job, + lastActivityUpdate: true, + results: { progress: { current: 1, total: 1, percent: 100, label: 'Compressing and saving results…' } }, + }); + + const blob = await gzipEncode(full); + const now = new Date(); + const historyRow: AnalysisJobHistoryItem = { + key: jobHistoryKey, + org: org.uniqueId, + jobType: 'permission_export', + status: 'completed', + requestPayload: full.requestPayload, + createdAt: now, + updatedAt: now, + errorMessage: null, + pinned: false, + summary: full.summary, + resultBlob: blob, + resultBlobSize: blob.byteLength, + }; + await dexieDb.analysis_job_history.put(historyRow); + await pruneAnalysisJobHistory(org.uniqueId, 'permission_export'); + + const response: AsyncJobWorkerMessageResponse = { + job, + results: { jobHistoryKey, summary: full.summary }, + }; + this.replyToMessage(name, response); + } catch (ex) { + const errorMessage = getErrorMessage(ex); + const wasCanceled = this.canceledJobIds.has(job.id); + if (!wasCanceled) { + try { + const now = new Date(); + const failedRow: AnalysisJobHistoryItem = { + key: jobHistoryKey, + org: org.uniqueId, + jobType: 'permission_export', + status: 'failed', + requestPayload: { + profileIds, + permissionSetIds, + ...(objectApiNames !== undefined ? { objectApiNames } : {}), + }, + createdAt: now, + updatedAt: now, + errorMessage, + pinned: false, + summary: null, + resultBlob: null, + resultBlobSize: 0, + }; + await dexieDb.analysis_job_history.put(failedRow); + await pruneAnalysisJobHistory(org.uniqueId, 'permission_export'); + } catch (writeEx) { + logger.warn('[JOB][PERMISSION_EXPORT] Failed to record failed analysis_job_history row', writeEx); + } + } + + const response: AsyncJobWorkerMessageResponse = { job }; + this.replyToMessage(name, response, errorMessage); + } finally { + // Always clear the cancel flag (success, failure, or cancel) so the Set doesn't accumulate stale ids. + canceledRef.delete(job.id); + } + break; + } + case 'FieldUsageAnalysis': { + const { org, job } = payloadData as AsyncJobWorkerMessagePayload; + const { jobHistoryKey, objectApiNames, loadFullScan } = job.meta; + const canceledRef = this.canceledJobIds; + try { + const queryOutcome = await runFieldUsageQueryForObjects(org, objectApiNames, { + loadFullScan, + onProgress: (progress) => { + const response: AsyncJobWorkerMessageResponse = { + job, + lastActivityUpdate: true, + results: { progress }, + }; + this.replyToMessage(name, response); + }, + isCanceled: () => canceledRef.has(job.id), + }); + + // The where-used phase has no per-item progress; tell the UI what's happening so it doesn't appear frozen. + this.replyToMessage(name, { + job, + lastActivityUpdate: true, + results: { + progress: { + current: objectApiNames.length, + total: objectApiNames.length, + percent: 100, + label: 'Resolving field dependencies (Where Used)…', + }, + }, + }); + + let whereUsed: Record = {}; + let whereUsedResolvedFieldKeys: string[] = []; + let whereUsedComputed = true; + try { + const whereUsedOutcome = await computeFieldUsageWhereUsed(org, queryOutcome.objects); + whereUsed = whereUsedOutcome.whereUsed as unknown as Record; + whereUsedResolvedFieldKeys = whereUsedOutcome.resolvedFieldKeys; + } catch (whereUsedEx) { + logger.warn('[JOB][FIELD_USAGE] where-used lookup failed; continuing without map', whereUsedEx); + whereUsed = {}; + whereUsedResolvedFieldKeys = []; + whereUsedComputed = false; + } + // Where-used can take a while; respect a cancel requested during that phase before we persist a "completed" row. + if (canceledRef.has(job.id)) { + throw new Error('Job canceled'); + } + + const okObjectCount = objectApiNames.filter( + (objectApiName) => !queryOutcome.failedObjects.includes(objectApiName) && !queryOutcome.objects[objectApiName]?.error, + ).length; + const summaryParts = [ + `Field Usage for ${okObjectCount}/${objectApiNames.length} Object(s).${loadFullScan ? ' No per-object row cap.' : ''}`, + queryOutcome.anyQueryTruncated + ? loadFullScan + ? 'Some objects may still show truncated scans for very large data sets or API limits.' + : `Row scan capped at ${String(FIELD_USAGE_MAX_ROWS_PER_OBJECT)} rows per Object where noted.` + : '', + queryOutcome.failedObjects.length > 0 ? `Failed: ${queryOutcome.failedObjects.join(', ')}.` : '', + ].filter(Boolean); + const summary = summaryParts.join(' '); + + const full: FieldUsageFullResult = { + requestPayload: { + objectApiNames, + ...(loadFullScan !== undefined ? { loadFullScan } : {}), + }, + phase: 'field_usage_v1', + summary, + truncated: queryOutcome.anyQueryTruncated, + failedObjects: queryOutcome.failedObjects, + whereUsedComputed, + whereUsedResolvedFieldKeys, + objects: queryOutcome.objects as unknown as FieldUsageFullResult['objects'], + whereUsed: whereUsed as unknown as FieldUsageFullResult['whereUsed'], + }; + + // Compress + Dexie write can take seconds on large results; keep the UI from sitting silently at 100%. + this.replyToMessage(name, { + job, + lastActivityUpdate: true, + results: { + progress: { + current: objectApiNames.length, + total: objectApiNames.length, + percent: 100, + label: 'Compressing and saving results…', + }, + }, + }); + + const blob = await gzipEncode(full); + const now = new Date(); + const historyRow: AnalysisJobHistoryItem = { + key: jobHistoryKey, + org: org.uniqueId, + jobType: 'field_usage', + status: 'completed', + requestPayload: full.requestPayload, + createdAt: now, + updatedAt: now, + errorMessage: null, + pinned: false, + summary, + resultBlob: blob, + resultBlobSize: blob.byteLength, + }; + await dexieDb.analysis_job_history.put(historyRow); + await pruneAnalysisJobHistory(org.uniqueId, 'field_usage'); + + const response: AsyncJobWorkerMessageResponse = { + job, + results: { jobHistoryKey, summary }, + }; + this.replyToMessage(name, response); + } catch (ex) { + const errorMessage = getErrorMessage(ex); + const wasCanceled = this.canceledJobIds.has(job.id); + if (!wasCanceled) { + try { + const now = new Date(); + const failedRow: AnalysisJobHistoryItem = { + key: jobHistoryKey, + org: org.uniqueId, + jobType: 'field_usage', + status: 'failed', + requestPayload: { + objectApiNames, + ...(loadFullScan !== undefined ? { loadFullScan } : {}), + }, + createdAt: now, + updatedAt: now, + errorMessage, + pinned: false, + summary: null, + resultBlob: null, + resultBlobSize: 0, + }; + await dexieDb.analysis_job_history.put(failedRow); + await pruneAnalysisJobHistory(org.uniqueId, 'field_usage'); + } catch (writeEx) { + logger.warn('[JOB][FIELD_USAGE] Failed to record failed analysis_job_history row', writeEx); + } + } + + const response: AsyncJobWorkerMessageResponse = { job }; + this.replyToMessage(name, response, errorMessage); + } finally { + // Always clear the cancel flag (success, failure, or cancel) so the Set doesn't accumulate stale ids. + canceledRef.delete(job.id); + } + break; + } case 'DesktopFileDownload': { try { const { org, job } = payloadData as AsyncJobWorkerMessagePayload; diff --git a/libs/shared/ui-core/src/jobs/Jobs.tsx b/libs/shared/ui-core/src/jobs/Jobs.tsx index 59a387e40..2ccb760ca 100644 --- a/libs/shared/ui-core/src/jobs/Jobs.tsx +++ b/libs/shared/ui-core/src/jobs/Jobs.tsx @@ -555,6 +555,53 @@ export const Jobs: FunctionComponent = () => { } break; } + case 'PermissionExportAnalysis': + case 'FieldUsageAnalysis': { + try { + let newJob = { ...data.job }; + if (error) { + newJob = { + ...newJob, + finished: new Date(), + lastActivity: new Date(), + status: 'failed', + statusMessage: error, + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + notifyUser(`${name === 'PermissionExportAnalysis' ? 'Permission export' : 'Field usage'} analysis failed`, { + body: newJob.statusMessage, + tag: name, + }); + } else if (data.lastActivityUpdate) { + const progressUpdate = (data.results as { progress?: AsyncJob['progress'] } | undefined)?.progress; + newJob = { + ...newJob, + lastActivity: new Date(), + ...(progressUpdate ? { progress: progressUpdate } : {}), + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + } else { + const { summary } = (data.results || {}) as { jobHistoryKey?: string; summary?: string }; + newJob = { + ...newJob, + results: data.results, + finished: new Date(), + lastActivity: new Date(), + status: 'success', + statusMessage: summary || 'Analysis complete', + progress: undefined, + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + notifyUser(`${name === 'PermissionExportAnalysis' ? 'Permission export' : 'Field usage'} analysis finished`, { + body: newJob.statusMessage, + tag: name, + }); + } + } catch (ex) { + logger.error('[ERROR][JOB] Error processing analysis job results', ex); + } + break; + } case 'DesktopFileDownload': { try { let newJob = { ...data.job }; diff --git a/libs/shared/ui-db/src/index.ts b/libs/shared/ui-db/src/index.ts index 76aefd01f..26fc521c7 100644 --- a/libs/shared/ui-db/src/index.ts +++ b/libs/shared/ui-db/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/analysis-job-history-retention'; export * from './lib/api-request-history.db'; export * from './lib/client-data.db'; export * from './lib/query-history-object.db'; diff --git a/libs/shared/ui-db/src/lib/analysis-job-history-retention.ts b/libs/shared/ui-db/src/lib/analysis-job-history-retention.ts new file mode 100644 index 000000000..13d78eff4 --- /dev/null +++ b/libs/shared/ui-db/src/lib/analysis-job-history-retention.ts @@ -0,0 +1,52 @@ +import { logger } from '@jetstream/shared/client-logger'; +import { dexieDb } from './ui-db'; + +const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000; +const MAX_ROWS_PER_ORG_AND_JOB_TYPE = 10; +const JOB_TYPES = ['permission_export', 'field_usage'] as const; + +/** + * Prune `analysis_job_history` rows that exceed the retention policy: + * 1) drop unpinned rows older than 14 days + * 2) for each (org, jobType), keep at most the 10 most-recent unpinned rows + * + * Pinned rows are always preserved. Errors are logged and swallowed so a failed sweep + * never blocks app initialization. + */ +export async function pruneAnalysisJobHistory(): Promise { + try { + await dexieDb.transaction('rw', dexieDb.analysis_job_history, async () => { + const fourteenDaysAgo = new Date(Date.now() - FOURTEEN_DAYS_MS); + await dexieDb.analysis_job_history + .where('createdAt') + .below(fourteenDaysAgo) + .filter((row) => !row.pinned) + .delete(); + + const allOrgs = await dexieDb.analysis_job_history.orderBy('org').uniqueKeys(); + for (const orgKey of allOrgs) { + const org = String(orgKey); + for (const jobType of JOB_TYPES) { + // sortBy returns ascending by createdAt (Dexie always re-sorts in-memory; chained reverse() is a no-op). + // Reverse the array in JS to put newest first, then drop the first N to retain the newest N. + const rowsAscending = await dexieDb.analysis_job_history + .where('[org+jobType+createdAt]') + .between([org, jobType, new Date(0)], [org, jobType, new Date(8.64e15)]) + .sortBy('createdAt'); + const rowsNewestFirst = rowsAscending.slice().reverse(); + + const keysToDelete = rowsNewestFirst + .filter((row) => !row.pinned) + .slice(MAX_ROWS_PER_ORG_AND_JOB_TYPE) + .map((row) => row.key); + + if (keysToDelete.length > 0) { + await dexieDb.analysis_job_history.bulkDelete(keysToDelete); + } + } + } + }); + } catch (ex) { + logger.warn('[DB][ANALYSIS_JOB_HISTORY][PRUNE] Failed to prune analysis_job_history', ex); + } +} diff --git a/libs/shared/ui-db/src/lib/ui-db.ts b/libs/shared/ui-db/src/lib/ui-db.ts index f27af7870..ff2c1003d 100644 --- a/libs/shared/ui-db/src/lib/ui-db.ts +++ b/libs/shared/ui-db/src/lib/ui-db.ts @@ -1,6 +1,13 @@ /// import { logger } from '@jetstream/shared/client-logger'; -import type { ApiHistoryItem, LoadSavedMappingItem, QueryHistoryItem, QueryHistoryObject, RecentHistoryItem } from '@jetstream/types'; +import type { + AnalysisJobHistoryItem, + ApiHistoryItem, + LoadSavedMappingItem, + QueryHistoryItem, + QueryHistoryObject, + RecentHistoryItem, +} from '@jetstream/types'; import Dexie, { type EntityTable } from 'dexie'; import 'dexie-observable'; import 'dexie-syncable'; @@ -36,6 +43,17 @@ export const SyncableTables = { }, } as const; +/** + * Local-only Dexie tables. Not synced cross-device; the sync layer pulls only from `SyncableTables`. + * Kept separate from `SyncableTables` so the sync code stays type-tight without an extra prefix case. + */ +export const LocalOnlyTables = { + analysis_job_history: { + name: 'analysis_job_history', + keyPrefix: 'aj', + }, +} as const; + const isWebExtension = () => { try { return !!globalThis.__IS_BROWSER_EXTENSION__ || !!window?.chrome?.runtime?.id; @@ -70,6 +88,7 @@ export const dexieDb = new Dexie(DEXIE_DB_NAME) as Dexie & { load_saved_mapping: EntityTable; recent_history_item: EntityTable; api_request_history: EntityTable; + analysis_job_history: EntityTable; }; export const SyncableEntities = new Set(Object.keys(SyncableTables) as Array); @@ -89,6 +108,10 @@ dexieDb.version(3).stores({ api_request_history: 'key,org,lastRun,isFavorite,[org+isFavorite]', }); +dexieDb.version(4).stores({ + analysis_job_history: 'key,org,jobType,createdAt,pinned,[org+jobType+createdAt]', +}); + export const dexieDataSync = { connect: async () => { const status = await dexieDb.syncable.getStatus('/'); diff --git a/libs/shared/ui-router/src/lib/ui-router.ts b/libs/shared/ui-router/src/lib/ui-router.ts index 3e654d2ec..48ccf6dec 100644 --- a/libs/shared/ui-router/src/lib/ui-router.ts +++ b/libs/shared/ui-router/src/lib/ui-router.ts @@ -10,6 +10,8 @@ type RouteKey = | 'LOAD_CREATE_RECORD' | 'AUTOMATION_CONTROL' | 'PERMISSION_MANAGER' + | 'PERMISSION_ANALYSIS' + | 'DATA_ANALYSIS' | 'DEPLOY_METADATA' | 'CREATE_FIELDS' | 'FORMULA_EVALUATOR' @@ -127,6 +129,16 @@ export const APP_ROUTES: RouteMap = { TITLE: 'Manage Permissions', DESCRIPTION: 'View and update object and field permissions', }, + PERMISSION_ANALYSIS: { + ...getRoutePath('/permission-analysis'), + TITLE: 'Permission Analysis', + DESCRIPTION: 'Read and export permission coverage for profiles and permission sets', + }, + DATA_ANALYSIS: { + ...getRoutePath('/data-analysis'), + TITLE: 'Data Analysis', + DESCRIPTION: 'Field Usage and data coverage for selected Objects', + }, DEPLOY_METADATA: { ...getRoutePath('/deploy-metadata'), DOCS: 'https://docs.getjetstream.app/deploy-metadata', diff --git a/libs/shared/ui-utils/src/lib/shared-ui-utils.ts b/libs/shared/ui-utils/src/lib/shared-ui-utils.ts index 54b96c7ee..854294b52 100644 --- a/libs/shared/ui-utils/src/lib/shared-ui-utils.ts +++ b/libs/shared/ui-utils/src/lib/shared-ui-utils.ts @@ -237,10 +237,10 @@ export function polyfillFieldDefinition(field: Field): string { let value = ''; if (calculated && calculatedFormula) { - prefix = 'Formula('; + prefix = 'Formula ('; suffix = ')'; } else if (calculated) { - prefix = 'Roll-Up Summary('; + prefix = 'Roll-Up Summary ('; suffix = ')'; } else if (externalId) { suffix = ' (External Id)'; @@ -253,36 +253,36 @@ export function polyfillFieldDefinition(field: Field): string { } else if (nameField) { value = 'Name'; } else if (type === 'textarea' && extraTypeInfo === 'plaintextarea') { - value = `${length > 255 ? 'Long ' : ''}Text Area(${length})`; + value = `${length > 255 ? 'Long ' : ''}Text Area (${length})`; } else if (type === 'textarea' && extraTypeInfo === 'richtextarea') { - value = `Rich Text Area(${length})`; + value = `Rich Text Area (${length})`; } else if (isRelationshipField(field)) { // includes text/reference if referenceTo has data - value = `Lookup(${(referenceTo || []).join(',')})`; + value = `Lookup (${(referenceTo || []).join(',')})`; } else if (type === 'string') { - value = `Text(${length})`; + value = `Text (${length})`; } else if (type === 'boolean') { value = 'Checkbox'; } else if (type === 'datetime') { value = 'Date/Time'; } else if (type === 'currency') { - value = `Currency(${precision}, ${scale})`; + value = `Currency (${precision}, ${scale})`; } else if (calculated && !calculatedFormula && !autoNumber) { value = `Number`; } else if (type === 'double' || type === 'int') { if (calculated) { value = `Number`; } else { - value = `Number(${precision}, ${scale})`; + value = `Number (${precision}, ${scale})`; } } else if (type === 'encryptedstring') { - value = `Text (Encrypted)(${length})`; + value = `Text (Encrypted) (${length})`; } else if (type === 'location') { value = 'Geolocation'; } else if (type === 'percent') { - value = `Percent(${precision}, ${scale})`; + value = `Percent (${precision}, ${scale})`; } else if (type === 'url') { - value = `URL(${length})`; + value = `URL (${length})`; } else { // Address, Email, Date, Time, picklist, phone value = `${type[0].toUpperCase()}${type.substring(1)}`; diff --git a/libs/shared/utils/src/index.ts b/libs/shared/utils/src/index.ts index b5adb249e..34b0200d1 100644 --- a/libs/shared/utils/src/index.ts +++ b/libs/shared/utils/src/index.ts @@ -1,3 +1,7 @@ +export * from './lib/compression'; +export * from './lib/custom-field-tooling-names'; +export * from './lib/dedupe-field-usage-where-used'; export * from './lib/regex'; +export * from './lib/sobject-api-name-utils'; export * from './lib/utils'; export * from './lib/password-validation'; diff --git a/libs/shared/utils/src/lib/__tests__/compression.spec.ts b/libs/shared/utils/src/lib/__tests__/compression.spec.ts new file mode 100644 index 000000000..11f137f31 --- /dev/null +++ b/libs/shared/utils/src/lib/__tests__/compression.spec.ts @@ -0,0 +1,39 @@ +// @vitest-environment node +// gzip helpers use Blob.stream()/CompressionStream, which jsdom does not implement; Node's Web Streams do. +import { describe, expect, it } from 'vitest'; +import { gzipDecode, gzipEncode } from '../compression'; + +describe('gzipEncode / gzipDecode', () => { + it('round-trips a simple object', async () => { + const value = { phase: 'field_usage_v1', truncated: false, count: 3 }; + const encoded = await gzipEncode(value); + expect(encoded).toBeInstanceOf(Uint8Array); + expect(encoded.byteLength).toBeGreaterThan(0); + await expect(gzipDecode(encoded)).resolves.toEqual(value); + }); + + it('round-trips deeply nested structures and arrays', async () => { + const value = { + objects: { + Account: { fieldUsage: { Name__c: { filled: 10, pct: 50, latestFilledRowModified: null } } }, + }, + whereUsed: { 'Account.Name__c': [{ type: 'ApexClass', name: 'Foo', kind: 'apex' }] }, + failedObjects: ['Contact', 'Lead'], + }; + await expect(gzipDecode(await gzipEncode(value))).resolves.toEqual(value); + }); + + it('handles large payloads (many records) without loss', async () => { + const value = { + rows: Array.from({ length: 5000 }, (_, index) => ({ id: index, name: `Field_${index}__c`, pct: index % 100 })), + }; + const decoded = await gzipDecode(await gzipEncode(value)); + expect(decoded.rows).toHaveLength(5000); + expect(decoded.rows[4999]).toEqual({ id: 4999, name: 'Field_4999__c', pct: 99 }); + }); + + it('preserves unicode content', async () => { + const value = { label: 'Coût — naïve 日本語 😀' }; + await expect(gzipDecode(await gzipEncode(value))).resolves.toEqual(value); + }); +}); diff --git a/libs/shared/utils/src/lib/__tests__/custom-field-tooling-names.spec.ts b/libs/shared/utils/src/lib/__tests__/custom-field-tooling-names.spec.ts new file mode 100644 index 000000000..68283dc5d --- /dev/null +++ b/libs/shared/utils/src/lib/__tests__/custom-field-tooling-names.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + customFieldApiNameHasNamespacePrefix, + isCustomFieldApiName, + isUnmanagedCustomFieldApiName, + parseCustomFieldApiNameForTooling, +} from '../custom-field-tooling-names'; + +describe('parseCustomFieldApiNameForTooling', () => { + it('parses unmanaged custom fields (DeveloperName is API name without __c)', () => { + expect(parseCustomFieldApiNameForTooling('Amount__c')).toEqual({ + namespacePrefix: null, + developerName: 'Amount', + }); + expect(parseCustomFieldApiNameForTooling('Custom_Field__c')).toEqual({ + namespacePrefix: null, + developerName: 'Custom_Field', + }); + }); + + it('parses namespaced packaged fields into NamespacePrefix + DeveloperName', () => { + expect(parseCustomFieldApiNameForTooling('acme__Amount__c')).toEqual({ + namespacePrefix: 'acme', + developerName: 'Amount', + }); + expect(parseCustomFieldApiNameForTooling('ns__My_Custom_Field__c')).toEqual({ + namespacePrefix: 'ns', + developerName: 'My_Custom_Field', + }); + }); + + it('returns null for non-custom API names', () => { + expect(parseCustomFieldApiNameForTooling('Name')).toBeNull(); + expect(parseCustomFieldApiNameForTooling('Lookup__r')).toBeNull(); + }); +}); + +describe('isCustomFieldApiName', () => { + it('is true for custom field API names parseable for Tooling', () => { + expect(isCustomFieldApiName('Amount__c')).toBe(true); + expect(isCustomFieldApiName('acme__Amount__c')).toBe(true); + }); + + it('is false for standard and relationship suffixes', () => { + expect(isCustomFieldApiName('Name')).toBe(false); + expect(isCustomFieldApiName('Lookup__r')).toBe(false); + }); +}); + +describe('isUnmanagedCustomFieldApiName', () => { + it('is true only when there is no namespace prefix', () => { + expect(isUnmanagedCustomFieldApiName('Amount__c')).toBe(true); + expect(isUnmanagedCustomFieldApiName('My_Field__c')).toBe(true); + expect(isUnmanagedCustomFieldApiName('acme__Amount__c')).toBe(false); + expect(isUnmanagedCustomFieldApiName('Name')).toBe(false); + }); +}); + +describe('customFieldApiNameHasNamespacePrefix', () => { + it('is false for unmanaged custom fields', () => { + expect(customFieldApiNameHasNamespacePrefix('Amount__c')).toBe(false); + expect(customFieldApiNameHasNamespacePrefix('My_Field__c')).toBe(false); + }); + + it('is true for namespaced packaged custom fields', () => { + expect(customFieldApiNameHasNamespacePrefix('acme__Amount__c')).toBe(true); + expect(customFieldApiNameHasNamespacePrefix('ns__My_Custom_Field__c')).toBe(true); + }); + + it('is false for non-custom API names', () => { + expect(customFieldApiNameHasNamespacePrefix('Name')).toBe(false); + }); +}); diff --git a/libs/shared/utils/src/lib/__tests__/dedupe-field-usage-where-used.spec.ts b/libs/shared/utils/src/lib/__tests__/dedupe-field-usage-where-used.spec.ts new file mode 100644 index 000000000..c9d8f30fc --- /dev/null +++ b/libs/shared/utils/src/lib/__tests__/dedupe-field-usage-where-used.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { dedupeFieldUsageWhereUsedRows, flowLikeAutomationDedupeKey, sortFieldUsageWhereUsedRows } from '../dedupe-field-usage-where-used'; + +describe('dedupeFieldUsageWhereUsedRows', () => { + it('merges Flow and FlowDefinition with the same base name', () => { + const rows = dedupeFieldUsageWhereUsedRows([ + { type: 'Flow', name: 'My_Flow', kind: 'automation' }, + { type: 'FlowDefinition', name: 'My_Flow', kind: 'automation' }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0].type).toBe('FlowDefinition'); + expect(rows[0].name).toBe('My_Flow'); + }); + + it('merges Flow rows that only differ by a trailing version suffix', () => { + const rows = dedupeFieldUsageWhereUsedRows([ + { type: 'Flow', name: 'My_Flow-1', kind: 'automation' }, + { type: 'Flow', name: 'My_Flow-2', kind: 'automation' }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0].type).toBe('Flow'); + expect(rows[0].name).toBe('My_Flow-1'); + }); + + it('keeps distinct flows whose names do not normalize to the same key', () => { + const rows = dedupeFieldUsageWhereUsedRows([ + { type: 'Flow', name: 'Alpha_Flow', kind: 'automation' }, + { type: 'Flow', name: 'Beta_Flow', kind: 'automation' }, + ]); + expect(rows).toHaveLength(2); + }); + + it('does not merge ApexTrigger or WorkflowRule rows', () => { + const rows = dedupeFieldUsageWhereUsedRows([ + { type: 'ApexTrigger', name: 'T1', kind: 'automation' }, + { type: 'ApexTrigger', name: 'T1', kind: 'automation' }, + ]); + expect(rows).toHaveLength(2); + }); + + it('leaves apex, layout, and other kinds untouched', () => { + const rows = dedupeFieldUsageWhereUsedRows([ + { type: 'Flow', name: 'F', kind: 'automation' }, + { type: 'ApexClass', name: 'C', kind: 'apex' }, + { type: 'Layout', name: 'L', kind: 'layout' }, + ]); + expect(rows).toHaveLength(3); + }); +}); + +describe('flowLikeAutomationDedupeKey', () => { + it('strips trailing -digits', () => { + expect(flowLikeAutomationDedupeKey('X-12')).toBe('X'); + expect(flowLikeAutomationDedupeKey('My_Flow-3')).toBe('My_Flow'); + }); +}); + +describe('sortFieldUsageWhereUsedRows', () => { + it('orders by kind then type then name', () => { + const sorted = sortFieldUsageWhereUsedRows([ + { type: 'Layout', name: 'Z', kind: 'layout' }, + { type: 'Flow', name: 'A', kind: 'automation' }, + { type: 'ApexClass', name: 'B', kind: 'apex' }, + ]); + expect(sorted.map((r) => r.kind)).toEqual(['automation', 'apex', 'layout']); + }); +}); diff --git a/libs/shared/utils/src/lib/__tests__/sobject-api-name-utils.spec.ts b/libs/shared/utils/src/lib/__tests__/sobject-api-name-utils.spec.ts new file mode 100644 index 000000000..156dab685 --- /dev/null +++ b/libs/shared/utils/src/lib/__tests__/sobject-api-name-utils.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { isValidSalesforceId, sanitizeSobjectApiNames, uniqueSalesforceIds } from '../sobject-api-name-utils'; + +describe('sobject-api-name-utils', () => { + it('validates 15- and 18-character Salesforce ids', () => { + expect(isValidSalesforceId('00e000000000001')).toBe(true); + expect(isValidSalesforceId('00e000000000001AAA')).toBe(true); + expect(isValidSalesforceId('bad')).toBe(false); + // 16- and 17-char are not valid Salesforce ids + expect(isValidSalesforceId('00e0000000000001')).toBe(false); + expect(isValidSalesforceId('00e00000000000001')).toBe(false); + }); + + it('uniqueSalesforceIds filters invalid and duplicates', () => { + expect(uniqueSalesforceIds(['00e000000000001', '00e000000000001', '', 'nope', '00e000000000002'])).toEqual([ + '00e000000000001', + '00e000000000002', + ]); + }); + + it('sanitizeSobjectApiNames filters invalid and dedupes', () => { + expect(sanitizeSobjectApiNames(['Account', ' Account ', 'Account', 'bad name', '', 12, 'ns__Obj__c'])).toEqual([ + 'Account', + 'ns__Obj__c', + ]); + }); + + it('sanitizeSobjectApiNames returns empty for non-array', () => { + expect(sanitizeSobjectApiNames(null)).toEqual([]); + }); + + it('sanitizeSobjectApiNames rejects names with SOQL injection characters', () => { + expect(sanitizeSobjectApiNames(["Account', 'Contact", 'Account); --', 'Account/*', "Account'"])).toEqual([]); + }); + + it('sanitizeSobjectApiNames caps the list length', () => { + const input = Array.from({ length: 600 }, (_, i) => `Obj${i}__c`); + expect(sanitizeSobjectApiNames(input).length).toBe(500); + }); +}); diff --git a/libs/shared/utils/src/lib/compression.ts b/libs/shared/utils/src/lib/compression.ts new file mode 100644 index 000000000..b4eb16a7b --- /dev/null +++ b/libs/shared/utils/src/lib/compression.ts @@ -0,0 +1,15 @@ +const TEXT_ENCODER = new TextEncoder(); +const TEXT_DECODER = new TextDecoder(); + +export async function gzipEncode(value: unknown): Promise> { + const json = JSON.stringify(value); + const stream = new Blob([TEXT_ENCODER.encode(json) as BlobPart]).stream().pipeThrough(new CompressionStream('gzip')); + const buffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(buffer); +} + +export async function gzipDecode(bytes: Uint8Array): Promise { + const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new DecompressionStream('gzip')); + const text = TEXT_DECODER.decode(await new Response(stream).arrayBuffer()); + return JSON.parse(text) as T; +} diff --git a/libs/shared/utils/src/lib/custom-field-tooling-names.ts b/libs/shared/utils/src/lib/custom-field-tooling-names.ts new file mode 100644 index 000000000..3d513ba3a --- /dev/null +++ b/libs/shared/utils/src/lib/custom-field-tooling-names.ts @@ -0,0 +1,60 @@ +/** + * Tooling CustomField uses DeveloperName (no trailing __c) and optional NamespacePrefix. + * + * @see https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_customfield.htm + */ +export interface CustomFieldToolingNameParts { + namespacePrefix: string | null; + developerName: string; +} + +/** + * Maps a custom field API name to Tooling CustomField query filters. + * Unmanaged: Amount__c → DeveloperName Amount, NamespacePrefix null. + * Managed: acme__Amount__c → DeveloperName Amount, NamespacePrefix acme. + */ +export function parseCustomFieldApiNameForTooling(fieldApiName: string): CustomFieldToolingNameParts | null { + const trimmed = fieldApiName.trim(); + if (!trimmed.endsWith('__c')) { + return null; + } + const withoutSuffix = trimmed.slice(0, -3); + const separatorIndex = withoutSuffix.indexOf('__'); + if (separatorIndex === -1) { + return { namespacePrefix: null, developerName: withoutSuffix }; + } + const namespacePrefix = withoutSuffix.slice(0, separatorIndex); + const developerName = withoutSuffix.slice(separatorIndex + 2); + if (!developerName) { + return null; + } + return { + namespacePrefix: namespacePrefix.length > 0 ? namespacePrefix : null, + developerName, + }; +} + +/** + * True when the API name parses as a custom field (trailing `__c` with a usable developer name). + * False for standard fields, `__r` relationship suffixes, and malformed names. + */ +export function isCustomFieldApiName(fieldApiName: string): boolean { + return parseCustomFieldApiNameForTooling(fieldApiName) != null; +} + +/** + * True for unmanaged custom fields (`Amount__c`). False for packaged (`acme__Amount__c`) and non-custom names. + */ +export function isUnmanagedCustomFieldApiName(fieldApiName: string): boolean { + const parsed = parseCustomFieldApiNameForTooling(fieldApiName); + return parsed != null && parsed.namespacePrefix == null; +} + +/** + * True when the field API name includes a namespace prefix (e.g. packaged `acme__Amount__c`). + * Unmanaged custom fields (`Amount__c`) return false. Non-custom API names return false. + */ +export function customFieldApiNameHasNamespacePrefix(fieldApiName: string): boolean { + const parsed = parseCustomFieldApiNameForTooling(fieldApiName); + return parsed != null && parsed.namespacePrefix != null; +} diff --git a/libs/shared/utils/src/lib/dedupe-field-usage-where-used.ts b/libs/shared/utils/src/lib/dedupe-field-usage-where-used.ts new file mode 100644 index 000000000..e60f857ab --- /dev/null +++ b/libs/shared/utils/src/lib/dedupe-field-usage-where-used.ts @@ -0,0 +1,104 @@ +/** + * Tooling `MetadataComponentDependency` often returns multiple rows for the same logical Flow + * (e.g. FlowDefinition plus Flow, or several Flow rows with version suffixes). + * We collapse those to one row per automation for counts and drill-down. + */ + +const FLOW_DEDUPE_METADATA_TYPES = new Set(['Flow', 'FlowDefinition']); + +export interface FieldUsageWhereUsedRowKind { + type: string; + name: string; + kind: 'automation' | 'apex' | 'layout' | 'other'; +} + +function kindSortOrder(kind: FieldUsageWhereUsedRowKind['kind']): number { + if (kind === 'automation') { + return 0; + } + if (kind === 'apex') { + return 1; + } + if (kind === 'layout') { + return 2; + } + return 3; +} + +/** + * Normalizes Flow metadata names so `My_Flow` and `My_Flow-3` share one key. + * Heuristic: trailing `-\d+` is treated as a version suffix (common in Tooling names). + */ +export function flowLikeAutomationDedupeKey(metadataComponentName: string): string { + return metadataComponentName.trim().replace(/-\d+$/, ''); +} + +function flowNameLooksVersioned(metadataComponentName: string): boolean { + return /-\d+$/.test(metadataComponentName.trim()); +} + +function preferFlowLikeRow(a: T, b: T): T { + const at = a.type.trim(); + const bt = b.type.trim(); + if (at === 'FlowDefinition' && bt !== 'FlowDefinition') { + return a; + } + if (bt === 'FlowDefinition' && at !== 'FlowDefinition') { + return b; + } + const aVersioned = flowNameLooksVersioned(a.name); + const bVersioned = flowNameLooksVersioned(b.name); + if (aVersioned !== bVersioned) { + return aVersioned ? b : a; + } + const an = (a.name || '').trim(); + const bn = (b.name || '').trim(); + return an.toLowerCase().localeCompare(bn.toLowerCase()) <= 0 ? a : b; +} + +/** + * Drops duplicate Flow / FlowDefinition rows that refer to the same logical flow. + * Other automation types (triggers, workflow, process) are left as returned by Tooling. + */ +export function dedupeFieldUsageWhereUsedRows(rows: T[]): T[] { + const flowLike = new Map(); + const rest: T[] = []; + + for (const row of rows) { + if (row.kind !== 'automation') { + rest.push(row); + continue; + } + const t = row.type.trim(); + if (!FLOW_DEDUPE_METADATA_TYPES.has(t)) { + rest.push(row); + continue; + } + const key = flowLikeAutomationDedupeKey(row.name); + const existing = flowLike.get(key); + if (!existing) { + flowLike.set(key, row); + } else { + flowLike.set(key, preferFlowLikeRow(row, existing)); + } + } + + return [...rest, ...flowLike.values()]; +} + +/** + * Same ordering as field-usage Tooling dependency lists (kind, then type, then name). + */ +export function sortFieldUsageWhereUsedRows(rows: T[]): T[] { + return [...rows].sort((a, b) => { + const ka = kindSortOrder(a.kind); + const kb = kindSortOrder(b.kind); + if (ka !== kb) { + return ka - kb; + } + return ( + (a.type || '').toLowerCase().localeCompare((b.type || '').toLowerCase()) || + (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()) + ); + }); +} diff --git a/libs/shared/utils/src/lib/regex.ts b/libs/shared/utils/src/lib/regex.ts index 2d28fbc82..f66a6794c 100644 --- a/libs/shared/utils/src/lib/regex.ts +++ b/libs/shared/utils/src/lib/regex.ts @@ -28,6 +28,6 @@ export const REGEX = { ENDS_WITH_NON_ALPHANUMERIC: /[^0-9a-zA-Z]+$/, CONSECUTIVE_UNDERSCORES: /_+/g, STARTS_WITH_NUMBER: /^[0-9]/, - SFDC_ID: /^([0-9a-zA-Z]{16}|[0-9a-zA-Z]{18})$/, + SFDC_ID: /^([0-9a-zA-Z]{15}|[0-9a-zA-Z]{18})$/, FILE_EXTENSION: /\.[a-z0-9]+$/i, }; diff --git a/libs/shared/utils/src/lib/sobject-api-name-utils.ts b/libs/shared/utils/src/lib/sobject-api-name-utils.ts new file mode 100644 index 000000000..3c74b1635 --- /dev/null +++ b/libs/shared/utils/src/lib/sobject-api-name-utils.ts @@ -0,0 +1,39 @@ +import { REGEX } from './regex'; + +export function isValidSalesforceId(id: string): boolean { + return REGEX.SFDC_ID.test(id.trim()); +} + +export function uniqueSalesforceIds(ids: string[]): string[] { + return Array.from(new Set(ids.filter((id) => REGEX.SFDC_ID.test(id)))); +} + +const OBJECT_API_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_.]*$/; +const MAX_SOBJECT_API_NAMES_PER_REQUEST = 500; + +/** + * Normalizes sobject API names from analysis job payloads for use in SOQL filters. + * Drops invalid tokens, dedupes, and caps list length to defend against SOQL injection. + */ +export function sanitizeSobjectApiNames(raw: unknown): string[] { + if (!Array.isArray(raw)) { + return []; + } + const seen = new Set(); + const output: string[] = []; + for (const item of raw) { + if (typeof item !== 'string') { + continue; + } + const name = item.trim(); + if (name.length === 0 || name.length > 255 || !OBJECT_API_NAME_PATTERN.test(name) || seen.has(name)) { + continue; + } + seen.add(name); + output.push(name); + if (output.length >= MAX_SOBJECT_API_NAMES_PER_REQUEST) { + break; + } + } + return output; +} diff --git a/libs/test-utils/src/test-setup-dom.ts b/libs/test-utils/src/test-setup-dom.ts index f5e4a46a2..d6ace9cd4 100644 --- a/libs/test-utils/src/test-setup-dom.ts +++ b/libs/test-utils/src/test-setup-dom.ts @@ -8,6 +8,8 @@ */ if (typeof globalThis.ResizeObserver === 'undefined') { class ResizeObserverShim { + // Accept (and ignore) the callback so the shim matches the real `ResizeObserver(callback)` signature. + constructor(_callback?: ResizeObserverCallback) {} observe(): void {} unobserve(): void {} disconnect(): void {} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index f28b0c6c7..7fd9ab96b 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/analysis-job-types'; export * from './lib/billing.types'; export * from './lib/jobs/types'; export * from './lib/salesforce/apex.types'; diff --git a/libs/types/src/lib/analysis-job-types.ts b/libs/types/src/lib/analysis-job-types.ts new file mode 100644 index 000000000..137965dcf --- /dev/null +++ b/libs/types/src/lib/analysis-job-types.ts @@ -0,0 +1,129 @@ +import { z } from 'zod'; + +/** + * Schemas + inferred types for client-side analysis jobs (permission_export and field_usage). + * + * Jobs run entirely in the browser via the JobWorker pattern and persist their results in Dexie + * (`analysis_job_history` table) as gzip-compressed blobs. The legacy split between a small + * `result` JSONB column and a heavy `resultData` JSONB column on the server is gone; everything + * is stored as a single merged shape per (org, jobType) row. + */ + +export const analysisJobTypeSchema = z.enum(['permission_export', 'field_usage']); +export type AnalysisJobType = z.infer; + +export const analysisJobRequestPayloadSchema = z.record(z.string(), z.unknown()); +export type AnalysisJobRequestPayload = z.infer; + +export const analysisJobIssueCodeSummaryEntrySchema = z.object({ + count: z.number(), + errors: z.number(), + warnings: z.number(), +}); +export type AnalysisJobIssueCodeSummaryEntry = z.infer; + +export const analysisJobFindingRecordSchema = z.record(z.string(), z.unknown()); +export type AnalysisJobFindingRecord = z.infer; + +export const permissionExportJobCountsSchema = z.object({ + permissionSets: z.number(), + permissionSetAssignments: z.number(), + permissionSetGroups: z.number(), + permissionSetGroupComponents: z.number(), + mutingPermissionSets: z.number(), + objectPermissions: z.number(), + fieldPermissions: z.number(), + permissionSetTabSettings: z.number(), +}); +export type PermissionExportJobCounts = z.infer; + +/** + * Small "metadata" portion of a permission_export result — the summary, counts, findings, etc. + * + * NOTE: The existing client-side parser `parsePermissionExportResult` reads heavy export rows from + * `result.export.*`. The Dexie row stores the merged shape (see `permissionExportFullResultSchema` + * below), and the view code reshapes back to `{ ...summary, export: { permissionSets, ... } }` for + * the parser. Parser migration is scheduled for Wave 5. + */ +export const permissionExportJobResultSchema = z.object({ + requestPayload: analysisJobRequestPayloadSchema.optional(), + phase: z.literal('permission_export_v1'), + summary: z.string(), + truncated: z.boolean(), + counts: permissionExportJobCountsSchema, + findings: z.array(analysisJobFindingRecordSchema), + issueCodeSummary: z.record(z.string(), analysisJobIssueCodeSummaryEntrySchema), +}); +export type PermissionExportJobResult = z.infer; + +/** Heavy export rows produced by the permission_export job. */ +export const permissionExportJobResultDataSchema = z.object({ + permissionSets: z.array(z.record(z.string(), z.unknown())), + permissionSetAssignments: z.array(z.record(z.string(), z.unknown())), + permissionSetGroups: z.array(z.record(z.string(), z.unknown())), + permissionSetGroupComponents: z.array(z.record(z.string(), z.unknown())), + mutingPermissionSets: z.array(z.record(z.string(), z.unknown())), + objectPermissions: z.array(z.record(z.string(), z.unknown())), + fieldPermissions: z.array(z.record(z.string(), z.unknown())), + permissionSetTabSettings: z.array(z.record(z.string(), z.unknown())), +}); +export type PermissionExportJobResultData = z.infer; + +/** Full permission_export result as stored in Dexie — small metadata + heavy export rows in one shape. */ +export const permissionExportFullResultSchema = permissionExportJobResultSchema.merge(permissionExportJobResultDataSchema); +export type PermissionExportFullResult = z.infer; + +export const fieldUsageJobResultSchema = z.object({ + requestPayload: analysisJobRequestPayloadSchema.optional(), + phase: z.literal('field_usage_v1'), + summary: z.string(), + truncated: z.boolean(), + failedObjects: z.array(z.string()), + /** + * False when the Tooling where-used dependency lookup failed entirely (so `whereUsed` is empty not + * because there are no dependencies, but because they could not be determined). Optional for + * backward-compatibility with rows written before this flag existed (treat absent as `true`). + */ + whereUsedComputed: z.boolean().optional(), + /** + * `Object.Field__c` keys whose metadata dependencies were FULLY determined (Tooling Id resolved AND + * dependency query succeeded). Only these may be treated as "0 dependencies = no references" for + * delete-eligibility. Absent on rows written before this existed — callers fall back to + * {@link whereUsedComputed} (whole-run) for legacy rows. + */ + whereUsedResolvedFieldKeys: z.array(z.string()).optional(), +}); +export type FieldUsageJobResult = z.infer; + +export const fieldUsageJobResultDataSchema = z.object({ + /** Keyed by sobject API name. Value shape matches the per-object payload produced by the field-usage runner. */ + objects: z.record(z.string(), z.record(z.string(), z.unknown())), + /** Tooling dependency rows keyed by `ObjectApi.FieldApi`. */ + whereUsed: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))), +}); +export type FieldUsageJobResultData = z.infer; + +/** Full field_usage result as stored in Dexie — small metadata + heavy object/whereUsed maps in one shape. */ +export const fieldUsageFullResultSchema = fieldUsageJobResultSchema.merge(fieldUsageJobResultDataSchema); +export type FieldUsageFullResult = z.infer; + +/** + * Dexie row shape for `analysis_job_history`. Stores the gzip-compressed JSON blob of the full + * result (decoded lazily when a view loads). `running` is intentionally absent from `status` — + * in-flight job state lives in jotai, not Dexie. + */ +export const analysisJobHistoryItemSchema = z.object({ + key: z.string(), + org: z.string(), + jobType: analysisJobTypeSchema, + status: z.enum(['completed', 'failed']), + requestPayload: analysisJobRequestPayloadSchema.optional(), + createdAt: z.date(), + updatedAt: z.date(), + errorMessage: z.string().nullable(), + pinned: z.boolean().default(false), + summary: z.string().nullable(), + resultBlob: z.instanceof(Uint8Array).nullable(), + resultBlobSize: z.number().default(0), +}); +export type AnalysisJobHistoryItem = z.infer; diff --git a/libs/types/src/lib/billing.types.ts b/libs/types/src/lib/billing.types.ts index 5e17b27c9..166c63a2a 100644 --- a/libs/types/src/lib/billing.types.ts +++ b/libs/types/src/lib/billing.types.ts @@ -6,6 +6,7 @@ export const EntitlementsAccessSchema = z.object({ desktop: z.boolean().optional().default(false), recordSync: z.boolean().optional().default(false), chromeExtension: z.boolean().optional().default(false), + analysisTools: z.boolean().optional().default(false), }); export type EntitlementsAccess = z.infer; export type Entitlements = keyof EntitlementsAccess; diff --git a/libs/types/src/lib/types.ts b/libs/types/src/lib/types.ts index c71ca513d..4546d3465 100644 --- a/libs/types/src/lib/types.ts +++ b/libs/types/src/lib/types.ts @@ -173,6 +173,7 @@ const EntitlementsSchema = z.object({ chromeExtension: z.boolean().default(false), desktop: z.boolean().default(false), recordSync: z.boolean().default(false), + analysisTools: z.boolean().default(false), }); export const UserProfileUiSchema = z.object({ diff --git a/libs/types/src/lib/ui/types.ts b/libs/types/src/lib/ui/types.ts index 4f944c85b..84673cd04 100644 --- a/libs/types/src/lib/ui/types.ts +++ b/libs/types/src/lib/ui/types.ts @@ -427,7 +427,9 @@ export type AsyncJobType = | 'RetrievePackageZip' | 'UploadToGoogle' | 'DesktopFileDownload' - | 'CancelJob'; + | 'CancelJob' + | 'PermissionExportAnalysis' + | 'FieldUsageAnalysis'; export type AsyncJobStatus = 'pending' | 'in-progress' | 'success' | 'finished-warning' | 'failed' | 'aborted'; @@ -454,6 +456,33 @@ export interface AsyncJob { progress?: AsyncJobProgress; // optional progress information meta: T; results?: R; + /** + * Optional in-app navigation target shown as a "View" button in the Jobs popover when set. + * Used by jobs that produce results rendered in a dedicated route (e.g. analysis jobs). + */ + viewUrl?: string; +} + +/** + * Meta payload for the in-browser `PermissionExportAnalysis` job. The `jobHistoryKey` is the + * Dexie row id that will receive the final result blob; views derive the route from it. + */ +export interface PermissionExportAnalysisJob { + jobHistoryKey: string; + orgUniqueId: string; + profileIds: string[]; + permissionSetIds: string[]; + objectApiNames?: string[]; +} + +/** + * Meta payload for the in-browser `FieldUsageAnalysis` job. + */ +export interface FieldUsageAnalysisJob { + jobHistoryKey: string; + orgUniqueId: string; + objectApiNames: string[]; + loadFullScan?: boolean; } export interface AsyncJobWorkerMessagePayload { diff --git a/libs/ui/src/lib/card/Card.tsx b/libs/ui/src/lib/card/Card.tsx index 3a5f65f09..7c79ade60 100644 --- a/libs/ui/src/lib/card/Card.tsx +++ b/libs/ui/src/lib/card/Card.tsx @@ -51,10 +51,17 @@ export const Card = forwardRef(
-

{titleContent}

+

+ {titleContent} +

{actions && ( > defaultColumnOptions?: DefaultColumnOptions; className?: string; 'aria-label'?: string; + /** Opt-in: rows wrap and grow to fit their content (DOM-measured, virtualization preserved). Disables + * column virtualization, so use only for grids whose columns fit without horizontal scrolling. */ + autoRowHeight?: boolean; } /** Map the legacy DataTable/DataTree prop surface onto DataTableV2 props (selection Set↔Record, etc.). */ diff --git a/libs/ui/src/lib/data-table/grid/DataTableV2.tsx b/libs/ui/src/lib/data-table/grid/DataTableV2.tsx index c7dc933f0..08b8bc204 100644 --- a/libs/ui/src/lib/data-table/grid/DataTableV2.tsx +++ b/libs/ui/src/lib/data-table/grid/DataTableV2.tsx @@ -66,6 +66,9 @@ export interface DataTableV2Props; /** Right-click context menu action handler (must be stable). */ contextMenuAction?: (item: ContextMenuItem, data: ContextMenuActionData) => void; + /** Opt-in: rows wrap and size to their content (DOM-measured); disables column virtualization so every + * row's height accounts for all cells. Use only for grids whose columns fit without horizontal scroll. */ + autoRowHeight?: boolean; } function DataTableV2Inner(props: DataTableV2Props, ref: React.Ref>) { @@ -105,6 +108,7 @@ function DataTableV2Inner(props: DataTableV2Pr summaryRowHeight, contextMenuItems, contextMenuAction, + autoRowHeight, } = props; const { table, gridId, orderedColumns, filters, filterSetValues, updateFilter, registerEditedValues } = useJetstreamTable({ @@ -196,6 +200,7 @@ function DataTableV2Inner(props: DataTableV2Pr summaryRowHeight={summaryRowHeight} contextMenuItems={contextMenuItems} contextMenuAction={contextMenuAction} + autoRowHeight={autoRowHeight} /> diff --git a/libs/ui/src/lib/data-table/grid/components/GridBody.tsx b/libs/ui/src/lib/data-table/grid/components/GridBody.tsx index ecc2c1321..4ecf424de 100644 --- a/libs/ui/src/lib/data-table/grid/components/GridBody.tsx +++ b/libs/ui/src/lib/data-table/grid/components/GridBody.tsx @@ -42,6 +42,10 @@ export interface GridBodyProps { rowClass?: (row: TRow) => string | undefined; onStartEdit?: (rowId: string, columnId: string) => void; onCommitRow?: (updatedRow: TRow, rowId: string, columnId: string) => void; + /** When true, rows size to their content (cells wrap) and are DOM-measured by the virtualizer instead of + * pinned to `rowHeight`. The caller must also render every column (no horizontal virtualization) so a + * row's measured height reflects all its cells — `GridContainer` does this when `autoRowHeight` is set. */ + autoRowHeight?: boolean; } // In-cell controls are removed from the page tab order (tabindex="-1") so the grid is a single tab stop. @@ -75,6 +79,7 @@ export function GridBody({ rowClass, onStartEdit, onCommitRow, + autoRowHeight, }: GridBodyProps) { const { rows } = table.getRowModel(); const leafColumns = table.getVisibleLeafColumns(); @@ -100,7 +105,11 @@ export function GridBody({ }, overscan, getItemKey: (index) => rows[index].id, + // In auto-height mode the estimate above is only the initial guess; the virtualizer measures each + // rendered row's real height (rows wrap to content) and corrects the offsets, keeping virtualization. + ...(autoRowHeight ? { measureElement: (el: Element | null) => el?.getBoundingClientRect().height ?? DEFAULT_ROW_HEIGHT } : {}), }); + const measureRowRef = autoRowHeight ? rowVirtualizer.measureElement : undefined; // Resolve the active cell to a DOM node: scroll its row into view, then focus the cell (navigation) // or the first focusable inside it (actionable). Runs only when the coordinate/mode changes — NOT on @@ -235,6 +244,8 @@ export function GridBody({ height={virtualRow.size} activeCell={rowActiveCell} onCellMouseDown={onCellMouseDown} + autoHeight={autoRowHeight} + measureRef={measureRowRef} /> ); } @@ -261,6 +272,8 @@ export function GridBody({ onCellContextMenu={onCellContextMenu} onStartEdit={onStartEdit} onCommitRow={onCommitRow} + autoHeight={autoRowHeight} + measureRef={measureRowRef} /> ); })} diff --git a/libs/ui/src/lib/data-table/grid/components/GridContainer.tsx b/libs/ui/src/lib/data-table/grid/components/GridContainer.tsx index 971394e3c..63c4cf36a 100644 --- a/libs/ui/src/lib/data-table/grid/components/GridContainer.tsx +++ b/libs/ui/src/lib/data-table/grid/components/GridContainer.tsx @@ -70,6 +70,9 @@ export interface GridContainerProps { contextMenuAction?: (item: ContextMenuItem, data: ContextMenuActionData) => void; /** Slot for editor popovers / context menu portals rendered as siblings of the grid. */ children?: ReactNode; + /** When true, rows size to their content (cells wrap, DOM-measured) and ALL columns render (horizontal + * virtualization off) so each row's measured height accounts for every cell. Opt-in per grid. */ + autoRowHeight?: boolean; } export function GridContainer({ @@ -90,6 +93,7 @@ export function GridContainer({ contextMenuItems, contextMenuAction, children, + autoRowHeight, }: GridContainerProps) { const scrollRef = useRef(null); const gridRef = useRef(null); @@ -270,13 +274,17 @@ export function GridContainer({ }); return indexes; }, [leafColumns]); - const visibleColumnIndexes = useMemo(() => { + const windowedColumnIndexes = useMemo(() => { const indexes = new Set(frozenColumnIndexes); for (const virtualColumn of virtualColumns) { indexes.add(virtualColumn.index); } return Array.from(indexes).sort((a, b) => a - b); }, [virtualColumns, frozenColumnIndexes]); + const allColumnIndexes = useMemo(() => leafColumns.map((_, index) => index), [leafColumns]); + // Auto-height rows are DOM-measured, so every column must render (otherwise a row's measured height + // would miss cells outside the horizontal window). Trade horizontal virtualization for correct heights. + const visibleColumnIndexes = autoRowHeight ? allColumnIndexes : windowedColumnIndexes; // Scroll the active column into view (mirrors the active-row logic in GridBody) so keyboard // navigation to off-screen columns brings them into the window before focus resolves. @@ -548,6 +556,7 @@ export function GridContainer({ rowClass={rowClass} onStartEdit={handleStartEdit} onCommitRow={handleCellCommit} + autoRowHeight={autoRowHeight} />
diff --git a/libs/ui/src/lib/data-table/grid/components/GridGroupRow.tsx b/libs/ui/src/lib/data-table/grid/components/GridGroupRow.tsx index 230d1505c..f081119e7 100644 --- a/libs/ui/src/lib/data-table/grid/components/GridGroupRow.tsx +++ b/libs/ui/src/lib/data-table/grid/components/GridGroupRow.tsx @@ -19,6 +19,10 @@ export interface GridGroupRowProps { height: number; activeCell?: ActiveCell | null; onCellMouseDown?: (rowId: string, columnId: string, shiftKey: boolean, button?: number) => void; + /** When true the group row sizes to its content instead of being pinned to `height`. */ + autoHeight?: boolean; + /** Virtualizer `measureElement` ref; attached in auto-height mode so the row's real height is measured. */ + measureRef?: (el: HTMLElement | null) => void; } // `row.getLeafRows()` walks + maps the entire group on every call; cache per TanStack row instance @@ -51,6 +55,8 @@ export function GridGroupRow({ height, activeCell, onCellMouseDown, + autoHeight, + measureRef, }: GridGroupRowProps) { const isExpanded = row.getIsExpanded(); const firstColumnId = columns[0]?.id; @@ -66,17 +72,20 @@ export function GridGroupRow({ /> ); - const rowStyle: CSSProperties = { transform: `translateY(${virtualStart}px)`, blockSize: height, gridTemplateColumns }; + const rowStyle: CSSProperties = autoHeight + ? { transform: `translateY(${virtualStart}px)`, gridTemplateColumns } + : { transform: `translateY(${virtualStart}px)`, blockSize: height, gridTemplateColumns }; const anyGroupCell = columns.some((column) => column.columnDef.meta?.jetstream?.renderGroupCell); const baseRowProps = { + ref: autoHeight ? measureRef : undefined, role: 'row' as const, 'aria-rowindex': ariaRowIndex, 'aria-level': (row.depth ?? 0) + 1, 'aria-expanded': isExpanded, 'data-row-id': row.id, 'data-index': rowIndex, - className: 'jgrid-group-row', + className: autoHeight ? 'jgrid-group-row jgrid-row-auto-height' : 'jgrid-group-row', }; // Fallback: single full-width group header. @@ -119,7 +128,7 @@ export function GridGroupRow({ // Group rows resolve span via the GROUP discriminant so a column can span the header (e.g. the // grouping column owning the toggle + label) without widening that column on data rows. Clamp to the // remaining tracks so an over-large span can't overrun the row. - const requestedSpan = meta?.colSpan?.({ type: 'GROUP', row: representativeRow }) ?? 1; + const requestedSpan = meta?.colSpan?.({ type: 'GROUP', row: representativeRow, groupingColumnId: row.groupingColumnId }) ?? 1; const span = Math.max(1, Math.min(requestedSpan, columns.length - index)); // A spanning cell is visible when any of the tracks it covers is in the visible window. let cellIsVisible = false; diff --git a/libs/ui/src/lib/data-table/grid/components/GridRow.tsx b/libs/ui/src/lib/data-table/grid/components/GridRow.tsx index 1c3b2ea59..b8fe9e4bf 100644 --- a/libs/ui/src/lib/data-table/grid/components/GridRow.tsx +++ b/libs/ui/src/lib/data-table/grid/components/GridRow.tsx @@ -41,6 +41,10 @@ export interface GridRowProps { onCellContextMenu?: (event: React.MouseEvent, rowId: string, columnId: string) => void; onStartEdit?: (rowId: string, columnId: string) => void; onCommitRow?: (updatedRow: TRow, rowId: string, columnId: string) => void; + /** When true the row sizes to its content (cells wrap) instead of being pinned to `height`. */ + autoHeight?: boolean; + /** Virtualizer `measureElement` ref; attached to the row in auto-height mode so its real height is measured. */ + measureRef?: (el: HTMLElement | null) => void; } function GridRowComponent({ @@ -63,16 +67,17 @@ function GridRowComponent({ onCellContextMenu, onStartEdit, onCommitRow, + autoHeight, + measureRef, }: GridRowProps) { const original = row.original as TRow & Partial; const consumerRowClass = rowClass?.(row.original); const cells = row.getVisibleCells(); - const style: CSSProperties = { - gridTemplateColumns, - blockSize: height, - transform: `translateY(${virtualStart}px)`, - }; + // Auto-height: let the row grow to its (wrapping) content and be DOM-measured; don't pin `blockSize`. + const style: CSSProperties = autoHeight + ? { gridTemplateColumns, transform: `translateY(${virtualStart}px)` } + : { gridTemplateColumns, blockSize: height, transform: `translateY(${virtualStart}px)` }; // ROW colSpan support (e.g. deploy's "No metadata found" message spanning several tracks). Resolve // span ownership across ALL columns so it stays stable as the horizontal window moves, then render @@ -131,6 +136,7 @@ function GridRowComponent({ return (
0 ? row.depth + 1 : undefined} @@ -138,9 +144,14 @@ function GridRowComponent({ aria-selected={row.getCanSelect() ? isSelected : undefined} data-row-id={row.id} data-index={rowIndex} - className={classNames('jgrid-row', { 'jgrid-row-selected': isSelected, 'jgrid-row-last': isLastRow }, consumerRowClass, { - 'save-error': !!(original as Partial)?._saveError, - })} + className={classNames( + 'jgrid-row', + { 'jgrid-row-selected': isSelected, 'jgrid-row-last': isLastRow, 'jgrid-row-auto-height': autoHeight }, + consumerRowClass, + { + 'save-error': !!(original as Partial)?._saveError, + }, + )} style={style} > {renderedCells} @@ -169,7 +180,8 @@ function gridRowPropsAreEqual(prev: GridRowProps, next: GridRowProps): prev.isExpanded === next.isExpanded && prev.isLastRow === next.isLastRow && prev.selectionColRange === next.selectionColRange && - prev.rowClass === next.rowClass + prev.rowClass === next.rowClass && + prev.autoHeight === next.autoHeight ); } diff --git a/libs/ui/src/lib/data-table/grid/data-table-grid.css b/libs/ui/src/lib/data-table/grid/data-table-grid.css index e3e8dbc2d..06fca8030 100644 --- a/libs/ui/src/lib/data-table/grid/data-table-grid.css +++ b/libs/ui/src/lib/data-table/grid/data-table-grid.css @@ -195,6 +195,21 @@ background-color: var(--jgrid-row-hover); } +/* Auto-height rows (opt-in `autoRowHeight`): the virtualizer measures each row, so it grows to fit wrapped + cell content instead of being pinned. Cells wrap, top-align, and don't clip. */ +.jgrid-row-auto-height { + grid-auto-rows: auto; + min-block-size: 1.875rem; +} +.jgrid-row-auto-height .jgrid-cell { + white-space: normal; + overflow: visible; + text-overflow: clip; + align-items: flex-start; + overflow-wrap: anywhere; + padding-block: 0.25rem; +} + .jgrid-row-selected, .jgrid-row-selected:hover { background-color: var(--jgrid-row-selected); @@ -359,3 +374,26 @@ .jgrid-empty-cell { grid-column: 1 / -1; } + +/* Permission Analysis findings highlighting (consumer row/cell classes from the manage-permissions feature). + Body cells use `background-color: inherit`, so the row-level rule tints the whole row through its cells. + A solid fill (no per-cell border) is used so adjacent flagged cells merge into one continuous block — a + per-cell inset border can't bridge the grid's divider lines, which left a seam between neighboring cells. */ +.jgrid-row.permission-finding-row--error { + background-color: #ffd5da; +} + +/* Warning findings: amber cell (Issues severity column + object-permission boolean cells). */ +.jgrid-cell.permission-finding-severity-cell--warning { + background-color: #ffe3b3; +} + +/* Error findings on object-permission boolean columns (FLS vs OLS mismatch). */ +.jgrid-cell.permission-finding-cell--error { + background-color: #ffd5da; +} + +/* Object-permission cells that open the findings drill-down modal. */ +.jgrid-cell.permission-finding-cell--clickable { + cursor: pointer; +} diff --git a/libs/ui/src/lib/data-table/grid/grid-types.ts b/libs/ui/src/lib/data-table/grid/grid-types.ts index bbdc82acb..78cb98c6a 100644 --- a/libs/ui/src/lib/data-table/grid/grid-types.ts +++ b/libs/ui/src/lib/data-table/grid/grid-types.ts @@ -182,7 +182,9 @@ export type ColSpanArgs = | { type: 'HEADER'; row?: undefined } | { type: 'ROW'; row: TRow } | { type: 'SUMMARY'; row: TRow } - | { type: 'GROUP'; row?: TRow }; + // `groupingColumnId` is the column that grouped THIS header's level — lets a column span the header only + // at its own level (e.g. multi-level grouping where each level's grouping column spans the full row). + | { type: 'GROUP'; row?: TRow; groupingColumnId?: string }; // ───────────────────────────────────────────────────────────────────────────── // The public, author-facing column definition (detached from react-data-grid `Column`) diff --git a/libs/ui/src/lib/nav/Navbar.tsx b/libs/ui/src/lib/nav/Navbar.tsx index 423005f1c..c639341c1 100644 --- a/libs/ui/src/lib/nav/Navbar.tsx +++ b/libs/ui/src/lib/nav/Navbar.tsx @@ -1,14 +1,166 @@ -import { FunctionComponent } from 'react'; +import { css } from '@emotion/react'; +import { FunctionComponent, useLayoutEffect, useRef, useState } from 'react'; +import { NavbarItem, NavbarItemProps } from './NavbarItem'; +import { NavbarItemWaffle, NavbarItemWaffleProps } from './NavbarItemWaffle'; +import { NavbarMenuItem, NavbarMenuItems, NavbarMenuItemsProps } from './NavbarMenuItems'; + +export type NavbarItemConfig = + | ({ id: string; type: 'waffle' } & NavbarItemWaffleProps) + | ({ id: string; type: 'item' } & NavbarItemProps) + | ({ id: string; type: 'menu' } & NavbarMenuItemsProps); export interface NavbarProps { - children?: React.ReactNode; + items: NavbarItemConfig[]; +} + +function renderNavbarItem(config: NavbarItemConfig) { + switch (config.type) { + case 'waffle': + return ; + case 'item': + return ; + case 'menu': + return ; + default: + return null; + } +} + +/** + * Flatten the items that no longer fit into a single list for the "More" dropdown. Dropdown menus + * keep their label as a section heading, while plain items become standalone links - matching the + * Salesforce overflow pattern where nested menus are flattened into headed sections. + */ +function buildOverflowMenuItems(overflowConfigs: NavbarItemConfig[]): NavbarMenuItem[] { + const overflowMenuItems: NavbarMenuItem[] = []; + overflowConfigs.forEach((config) => { + switch (config.type) { + case 'menu': + config.items.forEach((item, index) => { + overflowMenuItems.push(index === 0 ? { ...item, heading: config.label } : item); + }); + break; + case 'item': + overflowMenuItems.push({ + id: config.id, + path: config.path, + search: config.search, + title: config.title, + label: config.label, + }); + break; + // 'waffle' is always kept visible, so it never reaches the overflow menu + } + }); + return overflowMenuItems; +} + +function computeVisibleCount(itemWidths: number[], moreWidth: number, availableWidth: number): number { + const totalWidth = itemWidths.reduce((total, width) => total + width, 0); + if (totalWidth <= availableWidth) { + return itemWidths.length; + } + // Everything no longer fits, so reserve room for the "More" trigger and fit as many leading items as possible + let usedWidth = moreWidth; + let visibleCount = 0; + for (const width of itemWidths) { + if (usedWidth + width > availableWidth) { + break; + } + usedWidth += width; + visibleCount += 1; + } + // Always keep at least the first item (the Home/waffle) visible + return Math.max(1, visibleCount); } -export const Navbar: FunctionComponent = ({ children }) => { +/** + * Responsive navigation bar that mirrors the Salesforce context bar: items render inline until they no + * longer fit the available width, at which point the overflow collapses into a "More" dropdown. + * + * Widths are read from a hidden, inert copy of the full item list so that each item's natural width is + * always known - even while it is collapsed into the overflow menu - which keeps the calculation stable + * and avoids the layout feedback loop of measuring the same elements we are adding/removing. + */ +export const Navbar: FunctionComponent = ({ items }) => { + const containerRef = useRef(null); + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(items.length); + + useLayoutEffect(() => { + const container = containerRef.current; + const measure = measureRef.current; + if (!container || !measure) { + return; + } + + const recompute = () => { + // The measurement row renders every item followed by the "More" trigger, so the trailing child is "More" + const measuredChildren = Array.from(measure.children) as HTMLElement[]; + if (measuredChildren.length === 0) { + return; + } + const moreWidth = measuredChildren[measuredChildren.length - 1].offsetWidth; + const itemWidths = measuredChildren.slice(0, items.length).map((element) => element.offsetWidth); + setVisibleCount(computeVisibleCount(itemWidths, moreWidth, container.clientWidth)); + }; + + const resizeObserver = new ResizeObserver(recompute); + resizeObserver.observe(container); + recompute(); + return () => resizeObserver.disconnect(); + }, [items]); + + const hasOverflow = visibleCount < items.length; + const visibleItems = hasOverflow ? items.slice(0, visibleCount) : items; + const overflowMenuItems = hasOverflow ? buildOverflowMenuItems(items.slice(visibleCount)) : []; + return (
-
); diff --git a/libs/ui/src/lib/nav/NavbarMenuItems.tsx b/libs/ui/src/lib/nav/NavbarMenuItems.tsx index 92bb183d5..4f9c5a17d 100644 --- a/libs/ui/src/lib/nav/NavbarMenuItems.tsx +++ b/libs/ui/src/lib/nav/NavbarMenuItems.tsx @@ -247,7 +247,8 @@ export const NavbarMenuItems: FunctionComponent = ({ label return ( {item.heading && ( -
  • + // Skip the top divider/spacing on the first row so a leading heading doesn't render a stray border +
  • 0 })} role="separator"> {item.heading}
  • )} diff --git a/libs/ui/src/lib/popover/Popover.tsx b/libs/ui/src/lib/popover/Popover.tsx index a80cdb56e..283aa77b1 100644 --- a/libs/ui/src/lib/popover/Popover.tsx +++ b/libs/ui/src/lib/popover/Popover.tsx @@ -170,7 +170,7 @@ const PopoverComponent = ({ }; }, [isOpen, refs.floating, refs.domReference, isInPortal]); - const { as: TriggerElement = 'button', ...restButtonProps } = buttonProps || {}; + const { as: TriggerElement = 'button', style: triggerStyleFromButtonProps, ...restButtonProps } = buttonProps || {}; const mergedButtonProps = { ...getReferenceProps(), @@ -183,7 +183,7 @@ const PopoverComponent = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any 'onClick' in restButtonProps && typeof restButtonProps.onClick === 'function' && restButtonProps.onClick?.(ev as any); }, - style: buttonStyle, + style: { ...triggerStyleFromButtonProps, ...buttonStyle }, }; const triggerProps = TriggerElement === 'button' ? { ...mergedButtonProps, type: 'button' as const } : mergedButtonProps; diff --git a/libs/ui/src/lib/sobject-field-list/useWhereIsThisUsed.tsx b/libs/ui/src/lib/sobject-field-list/useWhereIsThisUsed.tsx index cafe4d6aa..02c557c40 100644 --- a/libs/ui/src/lib/sobject-field-list/useWhereIsThisUsed.tsx +++ b/libs/ui/src/lib/sobject-field-list/useWhereIsThisUsed.tsx @@ -1,5 +1,6 @@ import { logger } from '@jetstream/shared/client-logger'; import { clearCacheForOrg, queryWithCache } from '@jetstream/shared/data'; +import { parseCustomFieldApiNameForTooling } from '@jetstream/shared/utils'; import { useReducerFetchFn } from '@jetstream/shared/ui-utils'; import { ListItem, SalesforceOrgUi } from '@jetstream/types'; import orderBy from 'lodash/orderBy'; @@ -19,18 +20,19 @@ export interface MetadataDependency { MetadataComponentType: string; } -function getEntityDefinitionQuery(sobject: string, field: string) { - let namespace: string | undefined = undefined; - if (field.includes('__')) { - const [_namespace, fieldWithoutNamespace] = field.split('__'); - namespace = _namespace; - field = fieldWithoutNamespace; +function getEntityDefinitionQuery(sobject: string, fieldApiName: string) { + const parsed = parseCustomFieldApiNameForTooling(fieldApiName); + if (!parsed) { + return `SELECT Id, DeveloperName, EntityDefinitionId, TableEnumOrId FROM CustomField WHERE Id = NULL LIMIT 1`; } + const nsClause = + parsed.namespacePrefix != null && parsed.namespacePrefix.length > 0 + ? ` AND NamespacePrefix = '${parsed.namespacePrefix}'` + : ' AND NamespacePrefix = null'; return `SELECT Id, DeveloperName, EntityDefinitionId, TableEnumOrId FROM CustomField WHERE EntityDefinition.QualifiedApiName = '${sobject}' - AND DeveloperName = '${field}' - ${namespace ? `AND NamespacePrefix = '${namespace}'` : ''} + AND DeveloperName = '${parsed.developerName}'${nsClause} LIMIT 1`; } diff --git a/libs/ui/src/lib/sobject-list/ConnectedSobjectListMultiSelect.tsx b/libs/ui/src/lib/sobject-list/ConnectedSobjectListMultiSelect.tsx index 51da35cc8..05c95a7bf 100644 --- a/libs/ui/src/lib/sobject-list/ConnectedSobjectListMultiSelect.tsx +++ b/libs/ui/src/lib/sobject-list/ConnectedSobjectListMultiSelect.tsx @@ -44,6 +44,8 @@ export interface ConnectedSobjectListMultiSelectProps { onSobjects: (sobjects: DescribeGlobalSObjectResult[] | null) => void; onSelectedSObjects: (selectedSObjects: string[]) => void; onRefresh?: () => void; + /** When true, object list is read-only (selection and refresh disabled). */ + disabled?: boolean; } export const ConnectedSobjectListMultiSelect = forwardRef( @@ -61,6 +63,7 @@ export const ConnectedSobjectListMultiSelect = forwardRef { @@ -132,10 +135,10 @@ export const ConnectedSobjectListMultiSelect = forwardRef { - if (selectedOrg && !loading && !errorMessage && !sobjects) { + if (!disabled && selectedOrg && !loading && !errorMessage && !sobjects) { loadObjects().then(NOOP); } - }, [selectedOrg, loading, errorMessage, sobjects, onSobjects, loadObjects]); + }, [disabled, selectedOrg, loading, errorMessage, sobjects, onSobjects, loadObjects]); async function handleRefresh() { try { @@ -162,7 +165,11 @@ export const ConnectedSobjectListMultiSelect = forwardRef
    - @@ -174,6 +181,7 @@ export const ConnectedSobjectListMultiSelect = forwardRef { if (value) { selectedSObjectSet.add(item.name); @@ -135,10 +141,11 @@ export const SobjectListMultiSelect: FunctionComponent ({ label: item, value: item }))} onClearAll={() => onSelected([])} onClearItem={handleSelection} @@ -158,6 +165,7 @@ export const SobjectListMultiSelect: FunctionComponent ; filteredSobjects: DescribeGlobalSObjectResult[]; searchTerm: string; @@ -208,13 +219,17 @@ interface SobjectListContentProps { } const SobjectListContent = forwardRef( - ({ selectedSObjectSet, filteredSobjects, searchTerm, onSelected }: SobjectListContentProps, ref: RefObject) => { + ( + { disabled = false, selectedSObjectSet, filteredSobjects, searchTerm, onSelected }: SobjectListContentProps, + ref: RefObject, + ) => { return ( <> selectedSObjectSet.has(item.name)} onSelected={onSelected} getContent={(item: DescribeGlobalSObjectResult) => ({ diff --git a/libs/ui/src/lib/widgets/ItemSelectionSummary.tsx b/libs/ui/src/lib/widgets/ItemSelectionSummary.tsx index 59edd00a2..a367620a7 100644 --- a/libs/ui/src/lib/widgets/ItemSelectionSummary.tsx +++ b/libs/ui/src/lib/widgets/ItemSelectionSummary.tsx @@ -28,6 +28,9 @@ export const ItemSelectionSummary: FunctionComponent } function handleClearItem(item: string) { + if (disabled) { + return; + } onClearItem(item); if (items.length === 1) { popoverRef.current?.close(); @@ -55,7 +58,11 @@ export const ItemSelectionSummary: FunctionComponent

    Click on an item to de-select

      {items.map((item, i) => ( -
    • handleClearItem(item.value)}> +
    • handleClearItem(item.value)} + >
      {item.label}
      diff --git a/libs/ui/src/lib/widgets/ProfileOrPermSetPopover.tsx b/libs/ui/src/lib/widgets/ProfileOrPermSetPopover.tsx index 6b5cc81fc..2e6dbc07d 100644 --- a/libs/ui/src/lib/widgets/ProfileOrPermSetPopover.tsx +++ b/libs/ui/src/lib/widgets/ProfileOrPermSetPopover.tsx @@ -46,6 +46,18 @@ export function getProfileOrPermSetSetupUrl(recordType: ProfileOrPermSetRecordTy return `/lightning/setup/${basePath}/page?address=${encodeURIComponent(`/${recordId}?noredirect=1`)}`; } +/** Setup → Permission Set Groups → group detail. */ +export function getPermissionSetGroupSetupUrl(permissionSetGroupId: string): string { + const trimmed = permissionSetGroupId.trim(); + return `/lightning/setup/PermSetGroups/page?address=${encodeURIComponent(`/${trimmed}?noredirect=1`)}`; +} + +/** Setup → Users → user detail (expects a Salesforce User Id, prefix `005`). */ +export function getSalesforceUserManageSetupUrl(userId: string): string { + const trimmed = userId.trim(); + return `/lightning/setup/ManageUsers/page?address=${encodeURIComponent(`/${trimmed}?noredirect=1`)}`; +} + // Escape backslash, single quote, percent, and underscore in one pass so SOQL LIKE treats user input literally. function escapeSoqlLike(value: string): string { if (!value) { diff --git a/prisma/migrations/20260622001956_add_analysis_tools_entitlement/migration.sql b/prisma/migrations/20260622001956_add_analysis_tools_entitlement/migration.sql new file mode 100644 index 000000000..005d75e38 --- /dev/null +++ b/prisma/migrations/20260622001956_add_analysis_tools_entitlement/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "entitlement" ADD COLUMN "analysisTools" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "team_entitlement" ADD COLUMN "analysisTools" BOOLEAN NOT NULL DEFAULT false; + +-- Backfill: existing paid users (identified by the chromeExtension entitlement) also get Analysis Tools. +UPDATE "entitlement" SET "analysisTools" = true WHERE "chromeExtension" = true; +UPDATE "team_entitlement" SET "analysisTools" = true WHERE "chromeExtension" = true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 15228bac5..69a61cb0f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -248,6 +248,7 @@ model Entitlement { googleDrive Boolean @default(false) recordSync Boolean @default(false) desktop Boolean @default(false) + analysisTools Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -264,6 +265,7 @@ model TeamEntitlement { googleDrive Boolean @default(false) recordSync Boolean @default(false) desktop Boolean @default(false) + analysisTools Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/tsconfig.base.json b/tsconfig.base.json index 20d1dcd42..74f30f013 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -36,6 +36,7 @@ "@jetstream/feature/load-records": ["./libs/features/load-records/src/index.ts"], "@jetstream/feature/load-records-multi-object": ["./libs/features/load-records-multi-object/src/index.ts"], "@jetstream/feature/manage-permissions": ["./libs/features/manage-permissions/src/index.ts"], + "@jetstream/feature/data-analysis": ["./libs/features/data-analysis/src/index.ts"], "@jetstream/feature/orgGroups": ["./libs/features/org-groups/src/index.ts"], "@jetstream/feature/platform-event-monitor": ["./libs/features/platform-event-monitor/src/index.ts"], "@jetstream/feature/query": ["./libs/features/query/src/index.ts"],