Generate-IntuneVisualizationReport.ps1
<#PSScriptInfo .VERSION 0.1 .GUID 66a23821-6af0-4989-bd26-5851e247e7ac .AUTHOR Roy .COMPANYNAME .COPYRIGHT .TAGS .LICENSEURI .PROJECTURI .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES .PRIVATEDATA #> <# .DESCRIPTION Visualizing the Intune Enrollment #> param( [Parameter(Mandatory = $false, ParameterSetName = "Interactive")] [Parameter(Mandatory = $false, ParameterSetName = "ClientSecret")] [Parameter(Mandatory = $false, ParameterSetName = "Certificate")] [Parameter(Mandatory = $false, ParameterSetName = "Identity")] [Parameter(Mandatory = $false, ParameterSetName = "AccessToken")] [string[]]$RequiredScopes = @("User.Read.All", "Group.Read.All", "DeviceManagementConfiguration.Read.All", "DeviceManagementApps.Read.All", "DeviceManagementManagedDevices.Read.All", "Device.Read.All", "Mail.Send"), [Parameter(Mandatory = $true, ParameterSetName = "ClientSecret")] [Parameter(Mandatory = $true, ParameterSetName = "Certificate")] [Parameter(Mandatory = $false, ParameterSetName = "Identity")] [Parameter(Mandatory = $true, ParameterSetName = "AccessToken")] [Parameter(Mandatory = $false, ParameterSetName = "Interactive")] [string]$TenantId, [Parameter(Mandatory = $true, ParameterSetName = "ClientSecret")] [Parameter(Mandatory = $true, ParameterSetName = "Certificate")] [Parameter(Mandatory = $false, ParameterSetName = "Interactive")] [string]$ClientId, [Parameter(Mandatory = $true, ParameterSetName = "ClientSecret")] [string]$ClientSecret, [Parameter(Mandatory = $true, ParameterSetName = "Certificate")] [string]$CertificateThumbprint, [Parameter(Mandatory = $true, ParameterSetName = "Identity")] [switch]$Identity, [Parameter(Mandatory = $true, ParameterSetName = "AccessToken")] [string]$AccessToken, [Parameter(Mandatory = $false)] [switch]$IncludeFilterDetails, [Parameter(Mandatory = $false)] [switch]$ExportToCsv, [Parameter(Mandatory = $false)] [string]$ExportPath, [Parameter(Mandatory = $false)] [switch]$GenerateHtmlReport, [Parameter(Mandatory = $false)] [string]$HtmlReportPath, [Parameter(Mandatory = $false)] [switch]$DebugMode ) function Test-PSVersion { [CmdletBinding()] param() $minimumVersion = [Version]"7.0.0" $currentVersion = $PSVersionTable.PSVersion if ($currentVersion -lt $minimumVersion) { Write-Host "Error: This script requires PowerShell 7.0 or higher." -ForegroundColor Red Write-Host "Current PowerShell version: $($currentVersion)" -ForegroundColor Red Write-Host "Please install PowerShell 7 from https://github.com/PowerShell/PowerShell/releases" -ForegroundColor Yellow # Check if pwsh is installed but script was run in older version if (Get-Command pwsh -ErrorAction SilentlyContinue) { Write-Host "`nPowerShell 7 appears to be installed. You can run this script with:" -ForegroundColor Cyan Write-Host "pwsh -File `"$($MyInvocation.PSCommandPath)`"" -ForegroundColor Cyan # Ask if they want to relaunch with PowerShell 7 do { $response = Read-Host "Would you like to run this script using PowerShell 7 now? (Y/N)" } while ($response -notmatch '^(Y|y|N|n)$') if ($response -match '^(Y|y)$') { Start-Process -FilePath "pwsh" -ArgumentList "-File `"$($MyInvocation.PSCommandPath)`"" -NoNewWindow } else { Write-Host "Script execution stopped." -ForegroundColor Yellow } } # Stop script execution exit } Write-Host "PowerShell version check passed: Running PowerShell $currentVersion" -ForegroundColor Green } # Check PowerShell version before proceeding Test-PSVersion function Connect-ToMgGraph { [CmdletBinding(DefaultParameterSetName = "Interactive")] param ( [Parameter(Mandatory = $false, ParameterSetName = "Interactive")] [Parameter(Mandatory = $false, ParameterSetName = "ClientSecret")] [Parameter(Mandatory = $false, ParameterSetName = "Certificate")] [Parameter(Mandatory = $false, ParameterSetName = "Identity")] [Parameter(Mandatory = $false, ParameterSetName = "AccessToken")] [string[]]$RequiredScopes = @("User.Read"), [Parameter(Mandatory = $true, ParameterSetName = "ClientSecret")] [Parameter(Mandatory = $true, ParameterSetName = "Certificate")] [Parameter(Mandatory = $false, ParameterSetName = "Identity")] [Parameter(Mandatory = $true, ParameterSetName = "AccessToken")] [string]$TenantId, [Parameter(Mandatory = $true, ParameterSetName = "ClientSecret")] [Parameter(Mandatory = $true, ParameterSetName = "Certificate")] [string]$ClientId, [Parameter(Mandatory = $true, ParameterSetName = "ClientSecret")] [string]$ClientSecret, [Parameter(Mandatory = $true, ParameterSetName = "Certificate")] [string]$CertificateThumbprint, [Parameter(Mandatory = $true, ParameterSetName = "Identity")] [switch]$Identity, [Parameter(Mandatory = $true, ParameterSetName = "AccessToken")] [string]$AccessToken ) # Check if Microsoft Graph module is installed and import it Install-Requirements # Determine authentication method based on parameters $AuthMethod = $PSCmdlet.ParameterSetName Write-Verbose "Using authentication method: $AuthMethod" # Check if already connected $contextInfo = Get-MgContext -ErrorAction SilentlyContinue $reconnect = $false if ($contextInfo) { # Check if we have all the required permissions for interactive auth if ($AuthMethod -eq "Interactive") { $currentScopes = $contextInfo.Scopes $missingScopes = $RequiredScopes | Where-Object { $_ -notin $currentScopes } if ($missingScopes) { Write-Verbose "Missing required scopes: $($missingScopes -join ', '). Reconnecting..." Disconnect-MgGraph -ErrorAction SilentlyContinue $reconnect = $true } else { Write-Verbose "Already connected with required scopes." } } else { # For other auth methods, disconnect and reconnect with the new credentials Write-Verbose "Switching to $AuthMethod authentication method..." Disconnect-MgGraph -ErrorAction SilentlyContinue $reconnect = $true } } else { $reconnect = $true } if ($reconnect) { try { # Define connection parameters based on authentication method switch ($AuthMethod) { "Interactive" { $connectParams = @{ Scopes = $RequiredScopes NoWelcome = $true } if ($TenantId) { $connectParams.TenantId = $TenantId } if ($ClientId) { $connectParams.ClientId = $ClientId } Connect-MgGraph @connectParams } "ClientSecret" { $secureSecret = ConvertTo-SecureString $ClientSecret -AsPlainText -Force $credential = New-Object System.Management.Automation.PSCredential($ClientId, $secureSecret) Connect-MgGraph -TenantId $TenantId -ClientSecretCredential $credential -NoWelcome } "Certificate" { Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint -NoWelcome } "Identity" { $connectParams = @{ Identity = $true NoWelcome = $true } if ($TenantId) { $connectParams.TenantId = $TenantId } Connect-MgGraph @connectParams } "AccessToken" { $secureToken = ConvertTo-SecureString $AccessToken -AsPlainText -Force Connect-MgGraph -AccessToken $secureToken -NoWelcome } } # Verify connection $newContext = Get-MgContext if ($newContext) { Write-Verbose "Successfully connected to Microsoft Graph as $($newContext.Account)" return $newContext } else { throw "Connection attempt completed but unable to confirm connection" } } catch { Write-Error "Error connecting to Microsoft Graph: $_" return $null } } return $contextInfo } function Install-Requirements { $PsVersion = $PSVersionTable.PSVersion.Major $requiredModules = @( "Microsoft.Graph.Authentication" ) foreach ($module in $requiredModules) { if ($PsVersion -ge 7) { # For PowerShell 7+, install the most recent version $moduleInstalled = Get-Module -ListAvailable -Name $module if (-not $moduleInstalled) { Write-Host "Installing latest version of module: $module" -ForegroundColor Cyan Install-Module -Name $module -Scope CurrentUser -Force -AllowClobber -SkipPublisherCheck } else { Write-Host "Module $module is already installed." -ForegroundColor Green } } else { # For PowerShell 5.1, use version 2.25.0 $moduleVersion = "2.25.0" $moduleInstalled = Get-Module -ListAvailable -Name $module | Where-Object { $_.Version -eq $moduleVersion } if (-not $moduleInstalled) { Write-Host "Installing module: $module version $moduleVersion" -ForegroundColor Cyan Install-Module -Name $module -Scope CurrentUser -Force -AllowClobber -RequiredVersion $moduleVersion -SkipPublisherCheck } else { Write-Host "Module $module version $moduleVersion is already installed." -ForegroundColor Green } } } # Import the required modules foreach ($module in $requiredModules) { if ($PsVersion -ge 7) { # For PowerShell 7+, import the latest available version $importedModule = Get-Module -Name $module if (-not $importedModule) { try { Import-Module -Name $module -Force -ErrorAction Stop Write-Host "Latest version of module $module imported successfully." -ForegroundColor Green } catch { Write-Host "Failed to import module $module. Error: $_" -ForegroundColor Red throw } } else { Write-Host "Module $module was already imported." -ForegroundColor Green } } else { # For PowerShell 5.1, import version 2.25.0 $moduleVersion = "2.25.0" $importedModule = Get-Module -Name $module | Where-Object { $_.Version -eq $moduleVersion } if (-not $importedModule) { try { Import-Module -Name $module -RequiredVersion $moduleVersion -Force -ErrorAction Stop Write-Host "Module $module version $moduleVersion imported successfully." -ForegroundColor Green } catch { Write-Host "Failed to import module $module version $moduleVersion. Error: $_" -ForegroundColor Red throw } } else { Write-Host "Module $module version $moduleVersion was already imported." -ForegroundColor Green } } } } function Invoke-GraphRequestWithPaging { param ( [string]$Uri, [string]$Method = "GET" ) $results = @() $currentUri = $Uri do { try { $response = Invoke-MgGraphRequest -Uri $currentUri -Method $Method -OutputType PSObject -ErrorAction Stop # Add the current page of results if ($response.value) { $results += $response.value } # Get the next page URL if it exists $currentUri = $response.'@odata.nextLink' } catch { #write-warning "Error in GraphRequestWithPaging for URI $currentUri : $_" return $null } } while ($currentUri) return $results } function Get-IntuneEntities { param ( [Parameter(Mandatory = $true)] [string]$EntityType, [Parameter(Mandatory = $false)] [string]$Filter = "", [Parameter(Mandatory = $false)] [string]$Select = "", [Parameter(Mandatory = $false)] [string]$Expand = "" ) # Handle special cases for app management and specific deviceManagement endpoints if ($EntityType -like "deviceAppManagement/*" -or $EntityType -eq "deviceManagement/templates" -or $EntityType -eq "deviceManagement/intents") { $baseUri = "https://graph.microsoft.com/beta" $actualEntityType = $EntityType } else { $baseUri = "https://graph.microsoft.com/beta/deviceManagement" $actualEntityType = "$EntityType" } $currentUri = "$baseUri/$actualEntityType" if ($Filter) { $currentUri += "?`$filter=$Filter" } if ($Select) { $currentUri += $(if ($Filter) { "&" }else { "?" }) + "`$select=$Select" } if ($Expand) { $currentUri += $(if ($Filter -or $Select) { "&" }else { "?" }) + "`$expand=$Expand" } # Use the custom paging function instead of manual pagination $entities = Invoke-GraphRequestWithPaging -Uri $currentUri -Method "GET" if ($entities) { return $entities } else { return @() } } function Get-GroupInfo { param ( [Parameter(Mandatory = $true)] [string]$GroupId ) # Use a script-level cache for group info to avoid repeated API calls if (-not $script:GroupCache) { $script:GroupCache = @{} } # Check if we already have this group cached if ($script:GroupCache.ContainsKey($GroupId)) { return $script:GroupCache[$GroupId] } try { $groupUri = "https://graph.microsoft.com/v1.0/groups/$GroupId" $group = Invoke-MgGraphRequest -Uri $groupUri -Method Get $groupInfo = @{ Id = $group.id DisplayName = $group.displayName Success = $true } # Cache the result $script:GroupCache[$GroupId] = $groupInfo return $groupInfo } catch { $groupInfo = @{ Id = $GroupId DisplayName = "Unknown Group" Success = $false } # Cache the error result too to avoid repeated failed lookups $script:GroupCache[$GroupId] = $groupInfo return $groupInfo } } function Get-IntuneDeviceData { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$DeviceName ) # Get device from Microsoft Intune $deviceUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=deviceName eq '$DeviceName'" $deviceResponse = Invoke-GraphRequestWithPaging -Uri $deviceUri -Method GET if ($deviceResponse -and $deviceResponse.Count -gt 0) { $device = $deviceResponse[0] # Create a custom object with all the properties needed for filtering $deviceData = [PSCustomObject]@{ Id = $device.id DeviceName = $device.deviceName UserPrincipalName = $device.userPrincipalName AzureAdDeviceId = $device.azureAdDeviceId OperatingSystem = $device.operatingSystem OSVersion = $device.osVersion DeviceType = $device.deviceType ComplianceState = $device.complianceState JoinType = $device.joinType ManagementAgent = $device.managementAgent OwnerType = $device.ownerType EnrollmentProfileName = $device.enrollmentProfileName AutopilotEnrolled = $device.autopilotEnrolled Manufacturer = $device.manufacturer Model = $device.model SerialNumber = $device.serialNumber ProcessorArchitecture = $device.processorArchitecture EthernetMacAddress = $device.ethernetMacAddress WiFiMacAddress = $device.wiFiMacAddress TotalStorageSpaceInBytes = $device.totalStorageSpaceInBytes FreeStorageSpaceInBytes = $device.freeStorageSpaceInBytes PhysicalMemoryInBytes = $device.physicalMemoryInBytes IsEncrypted = $device.isEncrypted IsSupervised = $device.isSupervised JailBroken = $device.jailBroken AzureAdRegistered = $device.azureAdRegistered DeviceEnrollmentType = $device.deviceEnrollmentType ChassisType = $device.chassisType EnrolledDateTime = $device.enrolledDateTime LastSyncDateTime = $device.lastSyncDateTime ManagementState = $device.managementState DeviceRegistrationState = $device.deviceRegistrationState DeviceCategory = $device.deviceCategory DeviceCategoryDisplayName = $device.deviceCategoryDisplayName EmailAddress = $device.emailAddress UserDisplayName = $device.userDisplayName UserId = $device.userId } return $deviceData } else { #write-warning "Device '$DeviceName' not found in Intune" return $null } } function Test-IntuneFilter { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$FilterRule, [Parameter(Mandatory = $true)] [PSCustomObject]$DeviceProperties ) # Helper function to evaluate a single condition function Test-Condition { param ( [string]$PropertyPath, [string]$Operator, [string]$Value ) # Get the property from the device object $property = $PropertyPath.Trim() # Handle nested properties (e.g., device.enrollmentProfileName) if ($property.StartsWith("device.")) { $property = $property.Substring(7) # Remove "device." prefix } # Get the actual property value $actualValue = $null if ($DeviceProperties.PSObject.Properties.Name -contains $property) { $actualValue = $DeviceProperties.$property } else { Write-Verbose "Property '$property' not found in device properties" return $false } # Handle null values if ($null -eq $actualValue) { Write-Verbose "Property '$property' is null" return $false } # Evaluate based on operator switch -Regex ($Operator.Trim()) { "-eq" { return $actualValue -eq $Value } "-ne" { return $actualValue -ne $Value } "-contains" { return $actualValue -contains $Value } "-notContains" { return $actualValue -notcontains $Value } "-startsWith" { return $actualValue.StartsWith($Value) } "-notStartsWith" { return -not $actualValue.StartsWith($Value) } "-endsWith" { return $actualValue.EndsWith($Value) } "-notEndsWith" { return -not $actualValue.EndsWith($Value) } "-match" { return $actualValue -match $Value } "-notMatch" { return $actualValue -notmatch $Value } "-in" { $valueArray = $Value -split ',' return $valueArray -contains $actualValue } "-notIn" { $valueArray = $Value -split ',' return $valueArray -notcontains $actualValue } default { #write-warning "Unsupported operator: $Operator" return $false } } } # Split the rule into individual conditions $filterRule = $FilterRule -replace '\s+', ' ' # Normalize whitespace # Initial parsing to separate by "or" and "and" operators $orConditions = @() $sections = $filterRule -split ' or ' foreach ($section in $sections) { $andConditions = @() $andSections = $section -split ' and ' foreach ($andSection in $andSections) { # Extract the condition components using a simpler approach to avoid regex issues # Remove outer parentheses if they exist $cleanSection = $andSection.Trim() if ($cleanSection.StartsWith("(") -and $cleanSection.EndsWith(")")) { $cleanSection = $cleanSection.Substring(1, $cleanSection.Length - 2).Trim() } # Split by spaces to get the parts $parts = $cleanSection -split '\s+', 3 if ($parts.Count -ge 3) { $propertyPath = $parts[0] $operator = $parts[1] # Remove any surrounding quotes from the value $value = $parts[2] -replace '^["\'']|["\'']$', '' # Store the condition for evaluation $andConditions += @{ PropertyPath = $propertyPath Operator = $operator Value = $value } } else { #write-warning "Could not parse condition: $andSection" } } $orConditions += @{ AndConditions = $andConditions } } # Evaluate the conditions foreach ($orCondition in $orConditions) { $allAndConditionsTrue = $true foreach ($andCondition in $orCondition.AndConditions) { $result = Test-Condition -PropertyPath $andCondition.PropertyPath -Operator $andCondition.Operator -Value $andCondition.Value Write-Verbose "Evaluated: $($andCondition.PropertyPath) $($andCondition.Operator) '$($andCondition.Value)' = $result" if (-not $result) { $allAndConditionsTrue = $false break } } if ($allAndConditionsTrue) { return $true } } return $false } function Test-DevicePolicyAssignment { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$PolicyId, [Parameter(Mandatory = $true)] [string]$DeviceName, [Parameter(Mandatory = $false)] [switch]$ShowVerbose ) # Get device information using Invoke-GraphRequestWithPaging if ($ShowVerbose) { Write-Host "Retrieving device information for $DeviceName..." -ForegroundColor Cyan } $deviceUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=deviceName eq '$DeviceName'" $deviceResponse = Invoke-GraphRequestWithPaging -Uri $deviceUri -Method GET if (-not $deviceResponse -or $deviceResponse.Count -eq 0) { Write-Host "Device not found in Intune." -ForegroundColor Red return $false } $device = $deviceResponse[0] $deviceId = $device.id if ($ShowVerbose) { Write-Host "Found device with ID: $deviceId" -ForegroundColor Green } # Get basic device properties needed for filter evaluation $deviceProperties = Get-IntuneDeviceData -DeviceName $device.deviceName # Get user's group memberships if needed for group-based assignments $userPrincipalName = $deviceProperties.UserPrincipalName $userGroupIds = @() if ($userPrincipalName) { if ($ShowVerbose) { Write-Host "Retrieving group memberships for user: $userPrincipalName..." -ForegroundColor Cyan } try { $userUri = "https://graph.microsoft.com/v1.0/users/$userPrincipalName" $userResponse = Invoke-MgGraphRequest -Method GET -Uri $userUri if ($userResponse) { $userGroupsUri = "https://graph.microsoft.com/v1.0/users/$($userResponse.id)/transitiveMemberOf?`$select=id" $userGroupsData = Invoke-GraphRequestWithPaging -Uri $userGroupsUri -Method "GET" $userGroupIds = $userGroupsData.id if ($ShowVerbose) { Write-Host "User is a member of $($userGroupIds.Count) groups" -ForegroundColor Green } } } catch { Write-Host "Error retrieving user group memberships: $_" -ForegroundColor Yellow } } # Get Azure AD device group memberships $deviceGroupIds = @() if ($deviceProperties.AzureAdDeviceId) { if ($ShowVerbose) { Write-Host "Retrieving group memberships for Azure AD device..." -ForegroundColor Cyan } try { # Get the Azure AD device $azureAdDeviceUri = "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$($deviceProperties.AzureAdDeviceId)'" $azureAdDeviceResponse = Invoke-GraphRequestWithPaging -Uri $azureAdDeviceUri -Method GET if ($azureAdDeviceResponse -and $azureAdDeviceResponse.Count -gt 0) { # Get device group memberships $deviceGroupsUri = "https://graph.microsoft.com/v1.0/devices/$($azureAdDeviceResponse[0].id)/transitiveMemberOf?`$select=id" $deviceGroupsResponse = Invoke-GraphRequestWithPaging -Uri $deviceGroupsUri -Method GET $deviceGroupIds = $deviceGroupsResponse.id if ($ShowVerbose) { Write-Host "Device is a member of $($deviceGroupIds.Count) groups" -ForegroundColor Green } } else { Write-Host "Azure AD device not found for device ID: $($deviceProperties.AzureAdDeviceId)" -ForegroundColor Yellow } } catch { Write-Host "Error retrieving device group memberships: $_" -ForegroundColor Yellow } } # Get all Intune filters if ($ShowVerbose) { Write-Host "Retrieving all Intune filters..." -ForegroundColor Cyan } $filtersUri = "https://graph.microsoft.com/beta/deviceManagement/assignmentFilters?`$filter=platform eq 'windows10AndLater'" $intuneFilters = Invoke-GraphRequestWithPaging -Uri $filtersUri -Method GET # Get policy assignments if ($ShowVerbose) { Write-Host "Getting assignments for policy ID: $PolicyId" -ForegroundColor Blue } $assignmentsUri = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$PolicyId')/assignments" $assignmentResponse = Invoke-GraphRequestWithPaging -Uri $assignmentsUri -Method GET $isAssigned = $false # Check each assignment to see if it applies to this device foreach ($assignment in $assignmentResponse) { $targetType = $assignment.target.'@odata.type' $filterId = $assignment.target.deviceAndAppManagementAssignmentFilterId $filterType = $assignment.target.deviceAndAppManagementAssignmentFilterType # Check if base assignment applies (without filter) $baseApplicable = $false switch ($targetType) { "#microsoft.graph.allDevicesAssignmentTarget" { $baseApplicable = $true if ($ShowVerbose) { Write-Host " Assignment targets all devices" -ForegroundColor Gray } } "#microsoft.graph.allLicensedUsersAssignmentTarget" { $baseApplicable = ($null -ne $userPrincipalName) if ($ShowVerbose) { Write-Host " Assignment targets all licensed users" -ForegroundColor Gray } } "#microsoft.graph.groupAssignmentTarget" { $groupId = $assignment.target.groupId # Check if device or user is a member of this group $baseApplicable = ($userGroupIds -contains $groupId) -or ($deviceGroupIds -contains $groupId) if ($ShowVerbose) { Write-Host " Assignment targets group ID: $groupId (Device/User in group: $baseApplicable)" -ForegroundColor Gray } } } # Final assignment result after applying filter (if any) $assignmentApplies = $baseApplicable # Handle filter logic if present if ($filterId) { $filter = $intuneFilters | Where-Object id -EQ $filterId if ($filter) { if ($ShowVerbose) { Write-Host " Evaluating filter: $($filter.displayName)" -ForegroundColor Gray } $filterMatched = Test-IntuneFilter -FilterRule $filter.rule -DeviceProperties $deviceProperties if ($ShowVerbose) { Write-Host " Filter match result: $filterMatched" -ForegroundColor $(if ($filterMatched) { "Green" } else { "Yellow" }) } # Apply filter logic based on include/exclude type if ($filterType -eq "include") { $assignmentApplies = $baseApplicable -and $filterMatched } elseif ($filterType -eq "exclude") { $assignmentApplies = $baseApplicable -and (-not $filterMatched) } } else { # Filter not found - consider as not matching $assignmentApplies = $false if ($ShowVerbose) { Write-Host " Filter not found with ID: $filterId" -ForegroundColor Red } } } if ($ShowVerbose) { Write-Host " Assignment applies: $assignmentApplies" -ForegroundColor $(if ($assignmentApplies) { "Green" } else { "Yellow" }) } # If any assignment applies, the policy is assigned to this device if ($assignmentApplies) { $isAssigned = $true # Can break here since we only need to know if at least one assignment applies break } } if ($ShowVerbose) { Write-Host "Policy $(if ($isAssigned) { "IS" } else { "IS NOT" }) assigned to device $DeviceName" -ForegroundColor $(if ($isAssigned) { "Green" } else { "Yellow" }) } return $isAssigned } function Get-DetailedPolicyAssignments { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$EntityType, [Parameter(Mandatory = $true)] [string]$EntityId, [Parameter(Mandatory = $true)] [string]$PolicyName ) # Determine the correct assignments URI based on EntityType $assignmentsUri = $null if ($EntityType -eq "deviceAppManagement/managedAppPolicies") { # For generic App Protection Policies, determine the specific policy type first $policyDetailsUri = "https://graph.microsoft.com/beta/deviceAppManagement/managedAppPolicies/$EntityId" try { $policyDetailsResponse = Invoke-MgGraphRequest -Uri $policyDetailsUri -Method Get $policyODataType = $policyDetailsResponse.'@odata.type' $specificPolicyTypePath = switch ($policyODataType) { "#microsoft.graph.androidManagedAppProtection" { "androidManagedAppProtections" } "#microsoft.graph.iosManagedAppProtection" { "iosManagedAppProtections" } "#microsoft.graph.windowsManagedAppProtection" { "windowsManagedAppProtections" } default { $null } } if ($specificPolicyTypePath) { $assignmentsUri = "https://graph.microsoft.com/beta/deviceAppManagement/$specificPolicyTypePath('$EntityId')/assignments" } } catch { #write-warning "Error fetching details for App Protection Policy '$EntityId': $($_.Exception.Message)" return @() } } elseif ($EntityType -eq "mobileAppConfigurations") { $assignmentsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileAppConfigurations('$EntityId')/assignments" } elseif ($EntityType -like "deviceAppManagement/*ManagedAppProtections") { $assignmentsUri = "https://graph.microsoft.com/beta/$EntityType('$EntityId')/assignments" } elseif ($EntityType -eq "deviceManagement/intents") { $assignmentsUri = "https://graph.microsoft.com/beta/deviceManagement/intents('$EntityId')/assignments" } else { # General device management entities $assignmentsUri = "https://graph.microsoft.com/beta/deviceManagement/$EntityType('$EntityId')/assignments" } if (-not $assignmentsUri) { #write-warning "Could not determine assignments URI for EntityType '$EntityType' and EntityId '$EntityId'" return @() } try { # Get all assignments for the policy $assignments = Invoke-GraphRequestWithPaging -Uri $assignmentsUri -Method "GET" # Get all filters for reference $filtersUri = "https://graph.microsoft.com/beta/deviceManagement/assignmentFilters" $allFilters = Invoke-GraphRequestWithPaging -Uri $filtersUri -Method "GET" # Process each assignment $detailedAssignments = foreach ($assignment in $assignments) { $targetType = $assignment.target.'@odata.type' $filterId = $assignment.target.deviceAndAppManagementAssignmentFilterId $filterType = $assignment.target.deviceAndAppManagementAssignmentFilterType # Get basic assignment info $assignmentInfo = switch ($targetType) { "#microsoft.graph.allDevicesAssignmentTarget" { @{ AssignmentType = "All Devices" TargetName = "All Devices" TargetId = $null GroupId = $null } } "#microsoft.graph.allLicensedUsersAssignmentTarget" { @{ AssignmentType = "All Users" TargetName = "All Users" TargetId = $null GroupId = $null } } "#microsoft.graph.groupAssignmentTarget" { $groupId = $assignment.target.groupId $groupInfo = Get-GroupInfo -GroupId $groupId @{ AssignmentType = "Group (Include)" TargetName = $groupInfo.DisplayName TargetId = $groupId GroupId = $groupId } } "#microsoft.graph.exclusionGroupAssignmentTarget" { $groupId = $assignment.target.groupId $groupInfo = Get-GroupInfo -GroupId $groupId @{ AssignmentType = "Group (Exclude)" TargetName = $groupInfo.DisplayName TargetId = $groupId GroupId = $groupId } } default { @{ AssignmentType = "Unknown" TargetName = "Unknown" TargetId = $null GroupId = $null } } } # Get filter information if present $filterInfo = if ($filterId) { $filter = $allFilters | Where-Object { $_.id -eq $filterId } if ($filter) { @{ FilterId = $filter.id FilterName = $filter.displayName FilterType = $filterType FilterRule = $filter.rule FilterPlatform = $filter.platform } } else { @{ FilterId = $filterId FilterName = "Filter Not Found" FilterType = $filterType FilterRule = "Unknown" FilterPlatform = "Unknown" } } } else { @{ FilterId = $null FilterName = "No Filter" FilterType = "None" FilterRule = $null FilterPlatform = $null } } # Create combined assignment object [PSCustomObject]@{ PolicyName = $PolicyName PolicyId = $EntityId PolicyType = $EntityType AssignmentType = $assignmentInfo.AssignmentType TargetName = $assignmentInfo.TargetName TargetId = $assignmentInfo.TargetId GroupId = $assignmentInfo.GroupId FilterId = $filterInfo.FilterId FilterName = $filterInfo.FilterName FilterType = $filterInfo.FilterType FilterRule = $filterInfo.FilterRule FilterPlatform = $filterInfo.FilterPlatform AssignmentId = $assignment.id } } return $detailedAssignments } catch { #write-warning "Error getting detailed assignments for policy '$PolicyName': $($_.Exception.Message)" return @() } } function Get-DetailedPolicyAssignmentsFromExpanded { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [object]$Policy, [Parameter(Mandatory = $true)] [string]$PolicyName, [Parameter(Mandatory = $true)] [object]$PolicyType ) try { $detailedAssignments = @() if ($Policy.assignments -and $Policy.assignments.Count -gt 0) { foreach ($assignment in $Policy.assignments) { $targetType = "Unknown" $targetName = "Unknown" $targetId = $null $groupId = $null $filterInfo = @{ FilterId = $null FilterName = "No Filter" FilterType = "None" FilterRule = $null FilterPlatform = $null } # Process target information if ($assignment.target) { $targetType = switch ($assignment.target.'@odata.type') { "#microsoft.graph.allLicensedUsersAssignmentTarget" { "All Users" } "#microsoft.graph.allDevicesAssignmentTarget" { "All Devices" } "#microsoft.graph.groupAssignmentTarget" { "Group" } "#microsoft.graph.exclusionGroupAssignmentTarget" { "Group (Exclude)" } default { $assignment.target.'@odata.type' } } if ($assignment.target.groupId) { $targetId = $assignment.target.groupId $groupId = $assignment.target.groupId # Get group information try { $group = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/$($assignment.target.groupId)" -Method GET $targetName = $group.displayName } catch { $targetName = "Group (ID: $($assignment.target.groupId))" } } else { $targetName = $targetType } # Handle assignment filters if ($assignment.target.deviceAndAppManagementAssignmentFilterId) { $filterId = $assignment.target.deviceAndAppManagementAssignmentFilterId $filterType = switch ($assignment.target.deviceAndAppManagementAssignmentFilterType) { "include" { "include" } "exclude" { "exclude" } default { "include" } } # Get filter information try { $filter = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/assignmentFilters/$filterId" -Method GET $filterInfo = @{ FilterId = $filterId FilterName = $filter.displayName FilterType = $filterType FilterRule = $filter.rule FilterPlatform = $filter.platform } } catch { $filterInfo = @{ FilterId = $filterId FilterName = "Filter Not Found" FilterType = $filterType FilterRule = $null FilterPlatform = $null } } } } # Get assignment intent (Required/Available/Uninstall) for applications $assignmentIntent = "Unknown" if ($assignment.intent) { $assignmentIntent = switch ($assignment.intent) { "required" { "Required" } "available" { "Available" } "uninstall" { "Uninstall" } "availableWithoutEnrollment" { "Available (No Enrollment)" } default { $assignment.intent } } } $detailedAssignments += [PSCustomObject]@{ PolicyName = $PolicyName PolicyId = $Policy.id PolicyType = $PolicyType.EntityType AssignmentType = $targetType AssignmentIntent = $assignmentIntent TargetName = $targetName TargetId = $targetId GroupId = $groupId FilterId = $filterInfo.FilterId FilterName = $filterInfo.FilterName FilterType = $filterInfo.FilterType FilterRule = $filterInfo.FilterRule FilterPlatform = $filterInfo.FilterPlatform AssignmentId = $assignment.id } } } return $detailedAssignments } catch { #write-warning "Error processing expanded assignments for policy '$PolicyName': $($_.Exception.Message)" return @() } } function Get-AllIntunePoliciesWithAssignments { [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [switch]$IncludeFilterDetails, [Parameter(Mandatory = $false)] [switch]$ExportToCsv, [Parameter(Mandatory = $false)] [string]$ExportPath ) Write-Host "Starting comprehensive Intune policy and assignment retrieval..." -ForegroundColor Green # Initialize collections $allPolicyAssignments = [System.Collections.ArrayList]::new() $GraphEndpoint = "https://graph.microsoft.com" # Define policy types to retrieve $policyTypes = @( @{ Name = "Device Configuration"; EntityType = "deviceConfigurations"; DisplayName = "Device Configuration Profiles" }, @{ Name = "Settings Catalog"; EntityType = "configurationPolicies"; DisplayName = "Settings Catalog Policies" }, @{ Name = "Administrative Templates"; EntityType = "groupPolicyConfigurations"; DisplayName = "Administrative Templates" }, @{ Name = "Compliance Policies"; EntityType = "deviceCompliancePolicies"; DisplayName = "Compliance Policies" }, @{ Name = "App Protection"; EntityType = "deviceAppManagement/managedAppPolicies"; DisplayName = "App Protection Policies" }, @{ Name = "App Configuration"; EntityType = "mobileAppConfigurations"; DisplayName = "App Configuration Policies" }, @{ Name = "Applications"; EntityType = "deviceAppManagement/mobileApps"; DisplayName = "Applications" }, @{ Name = "Platform Scripts"; EntityType = "deviceManagementScripts"; DisplayName = "Platform Scripts" }, @{ Name = "Remediation Scripts"; EntityType = "deviceHealthScripts"; DisplayName = "Proactive Remediation Scripts" }, @{ Name = "Autopilot Profiles"; EntityType = "windowsAutopilotDeploymentProfiles"; DisplayName = "Autopilot Deployment Profiles" }, @{ Name = "ESP Profiles"; EntityType = "deviceEnrollmentConfigurations"; DisplayName = "Enrollment Status Page Profiles" }, @{ Name = "Endpoint Security"; EntityType = "deviceManagement/intents"; DisplayName = "Endpoint Security Policies" } ) # Get all Intune filters upfront for reference Write-Host "Retrieving all Intune assignment filters..." -ForegroundColor Yellow $filtersUri = "$GraphEndpoint/beta/deviceManagement/assignmentFilters" $allFilters = Invoke-GraphRequestWithPaging -Uri $filtersUri -Method "GET" Write-Host "Retrieved $($allFilters.Count) assignment filters" -ForegroundColor Green # Process each policy type - using sequential processing for Graph API calls to avoid module/auth issues # We'll use parallel processing for data processing tasks later foreach ($policyType in $policyTypes) { Write-Host "`nProcessing: $($policyType.DisplayName)" -ForegroundColor Cyan try { # Get all policies of this type # Use expand for mobile apps to get assignments in a single call if ($policyType.Name -eq "Applications") { $policies = Get-IntuneEntities -EntityType $policyType.EntityType -Expand "assignments" } else { $policies = Get-IntuneEntities -EntityType $policyType.EntityType } Write-Host "Found $($policies.Count) policies of type: $($policyType.Name)" -ForegroundColor Yellow # Process policies with optimized sequential processing for Graph API calls if ($policies.Count -gt 0) { Write-Host " Processing $($policies.Count) policies with optimized sequential processing..." -ForegroundColor Gray foreach ($policy in $policies) { $policyName = if ($policy.displayName) { $policy.displayName } elseif ($policy.name) { $policy.name } else { "Unnamed Policy" } # Get detailed assignments for this policy try { # Handle mobile apps with expanded assignments differently if ($policyType.Name -eq "Applications" -and $policy.assignments) { $detailedAssignments = Get-DetailedPolicyAssignmentsFromExpanded -Policy $policy -PolicyName $policyName -PolicyType $policyType } else { $detailedAssignments = Get-DetailedPolicyAssignments -EntityType $policyType.EntityType -EntityId $policy.id -PolicyName $policyName } if ($detailedAssignments.Count -eq 0) { # Add entry for unassigned policy $unassignedPolicy = [PSCustomObject]@{ PolicyCategory = $policyType.Name PolicyName = $policyName PolicyId = $policy.id PolicyType = $policyType.EntityType AssignmentType = "Not Assigned" TargetName = "Not Assigned" TargetId = $null GroupId = $null FilterId = $null FilterName = "No Filter" FilterType = "None" FilterRule = $null FilterPlatform = $null AssignmentId = $null CreatedDateTime = $policy.createdDateTime LastModifiedDateTime = $policy.lastModifiedDateTime } $null = $allPolicyAssignments.Add($unassignedPolicy) } else { foreach ($assignment in $detailedAssignments) { $enrichedAssignment = [PSCustomObject]@{ PolicyCategory = $policyType.Name PolicyName = $assignment.PolicyName PolicyId = $assignment.PolicyId PolicyType = $assignment.PolicyType AssignmentType = $assignment.AssignmentType AssignmentIntent = if ($assignment.AssignmentIntent) { $assignment.AssignmentIntent } else { $null } TargetName = $assignment.TargetName TargetId = $assignment.TargetId GroupId = $assignment.GroupId FilterId = $assignment.FilterId FilterName = $assignment.FilterName FilterType = $assignment.FilterType FilterRule = $assignment.FilterRule FilterPlatform = $assignment.FilterPlatform AssignmentId = $assignment.AssignmentId CreatedDateTime = $policy.createdDateTime LastModifiedDateTime = $policy.lastModifiedDateTime } $null = $allPolicyAssignments.Add($enrichedAssignment) } } } catch { #write-warning "Error processing policy '$policyName': $($_.Exception.Message)" # Add unassigned entry on error $unassignedPolicy = [PSCustomObject]@{ PolicyCategory = $policyType.Name PolicyName = $policyName PolicyId = $policy.id PolicyType = $policyType.EntityType AssignmentType = "Not Assigned" TargetName = "Not Assigned" TargetId = $null GroupId = $null FilterId = $null FilterName = "No Filter" FilterType = "None" FilterRule = $null FilterPlatform = $null AssignmentId = $null CreatedDateTime = $policy.createdDateTime LastModifiedDateTime = $policy.lastModifiedDateTime } $null = $allPolicyAssignments.Add($unassignedPolicy) } } } } catch { #write-warning "Error processing policy type $($policyType.Name): $($_.Exception.Message)" } } # Display summary Write-Host "`n=== INTUNE POLICIES AND ASSIGNMENTS SUMMARY ===" -ForegroundColor Green Write-Host "Total policy assignments found: $($allPolicyAssignments.Count)" -ForegroundColor White # Group by policy category $categoryGroups = $allPolicyAssignments | Group-Object -Property PolicyCategory foreach ($group in $categoryGroups) { Write-Host "`n$($group.Name):" -ForegroundColor Cyan Write-Host " Total assignments: $($group.Count)" -ForegroundColor White # Sub-group by assignment type $assignmentGroups = $group.Group | Group-Object -Property AssignmentType foreach ($assignmentGroup in $assignmentGroups) { Write-Host " $($assignmentGroup.Name): $($assignmentGroup.Count)" -ForegroundColor Gray } } # Display detailed assignments Write-Host "`n=== DETAILED POLICY ASSIGNMENTS ===" -ForegroundColor Green foreach ($categoryGroup in $categoryGroups) { Write-Host "`n--- $($categoryGroup.Name) ---" -ForegroundColor Cyan foreach ($assignment in $categoryGroup.Group) { Write-Host "Policy: $($assignment.PolicyName)" -ForegroundColor White $assignmentText = " Assignment: $($assignment.AssignmentType) -> $($assignment.TargetName)" if ($assignment.AssignmentIntent) { $assignmentText += " (Intent: $($assignment.AssignmentIntent))" } Write-Host $assignmentText -ForegroundColor Gray if ($assignment.FilterName -ne "No Filter" -and $assignment.FilterName -ne "Filter Not Found" -and $assignment.FilterName) { Write-Host " Filter: $($assignment.FilterName) ($($assignment.FilterType))" -ForegroundColor Yellow if ($IncludeFilterDetails -and $assignment.FilterRule) { Write-Host " Rule: $($assignment.FilterRule)" -ForegroundColor DarkGray } } Write-Host "" } } # Export if requested if ($ExportToCsv) { $exportPath = if ($ExportPath) { $ExportPath } else { ".\IntuneAllPoliciesWithAssignments_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" } $allPolicyAssignments | Export-Csv -Path $exportPath -NoTypeInformation Write-Host "Results exported to: $exportPath" -ForegroundColor Green } return $allPolicyAssignments } function Get-DynamicGroupsWithMembershipRules { [CmdletBinding()] param () Write-Host "Retrieving Groups and their membership rules..." -ForegroundColor Cyan try { # Get all groups (both dynamic and static) $allGroupsUri = "https://graph.microsoft.com/v1.0/groups?`$select=id,displayName,groupTypes,membershipRule,membershipRuleProcessingState" $allGroupsData = Invoke-GraphRequestWithPaging -Uri $allGroupsUri -Method "GET" $groups = foreach ($group in $allGroupsData) { # Check if it's a dynamic group $isDynamic = $group.groupTypes -and ($group.groupTypes -contains "DynamicMembership") [PSCustomObject]@{ Id = $group.id DisplayName = $group.displayName MembershipRule = if ($isDynamic) { $group.membershipRule } else { $null } ProcessingState = if ($isDynamic) { $group.membershipRuleProcessingState } else { "Static" } GroupTag = $null # Will be extracted from membership rule for dynamic groups IsDynamic = $isDynamic } } # Extract group tags from membership rules for dynamic groups only foreach ($group in $groups | Where-Object { $_.IsDynamic }) { if ($group.MembershipRule) { # Look for device.devicePhysicalIds patterns that indicate group tags if ($group.MembershipRule -match '\[OrderID\]:([^"]+)') { $group.GroupTag = $matches[1] } elseif ($group.MembershipRule -match 'device\.devicePhysicalIds\s+-any\s+_\.contains\s*\(\s*"([^"]+)"\s*\)') { $group.GroupTag = $matches[1] } } } Write-Host "Found $($groups.Count) Groups" -ForegroundColor Green return $groups } catch { #write-warning "Error retrieving Groups: $($_.Exception.Message)" return @() } } function Get-ESPConfigurations { [CmdletBinding()] param() Write-Host "Retrieving Enrollment Status Page configurations..." -ForegroundColor Cyan try { # Get all ESP configurations with assignments $espConfigs = @() $enrollmentConfigs = Invoke-GraphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations" -Method "GET" foreach ($config in $enrollmentConfigs) { if ($config.deviceEnrollmentConfigurationType -eq "windows10EnrollmentCompletionPageConfiguration") { Write-Host " Processing ESP: $($config.displayName)" -ForegroundColor Gray # Get detailed ESP configuration with assignments $detailedConfig = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations/$($config.id)?`$expand=assignments" -Method GET # Get app details for selectedMobileAppIds if they exist $blockedApps = @() if ($detailedConfig.selectedMobileAppIds -and $detailedConfig.selectedMobileAppIds.Count -gt 0) { foreach ($appId in $detailedConfig.selectedMobileAppIds) { try { $app = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId" -Method GET $blockedApps += @{ Id = $app.id DisplayName = $app.displayName Publisher = $app.publisher } } catch { #write-warning "Could not retrieve app details for ID: $appId" $blockedApps += @{ Id = $appId DisplayName = "Unknown App" Publisher = "Unknown" } } } } # Process assignments $assignments = @() foreach ($assignment in $detailedConfig.assignments) { $assignmentInfo = @{ Id = $assignment.id Target = $assignment.target FilterId = $assignment.target.deviceAndAppManagementAssignmentFilterId FilterType = $assignment.target.deviceAndAppManagementAssignmentFilterType } # Determine assignment type and target details switch ($assignment.target.'@odata.type') { "#microsoft.graph.allDevicesAssignmentTarget" { $assignmentInfo.Type = "All Devices" $assignmentInfo.TargetName = "All Devices" } "#microsoft.graph.allLicensedUsersAssignmentTarget" { $assignmentInfo.Type = "All Users" $assignmentInfo.TargetName = "All Users" } "#microsoft.graph.groupAssignmentTarget" { $assignmentInfo.Type = "Group" $assignmentInfo.GroupId = $assignment.target.groupId # Get group name try { $group = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/$($assignment.target.groupId)" -Method GET $assignmentInfo.TargetName = $group.displayName } catch { $assignmentInfo.TargetName = "Unknown Group" } } default { $assignmentInfo.Type = "Unknown" $assignmentInfo.TargetName = "Unknown Target" } } $assignments += $assignmentInfo } $espConfig = @{ Id = $detailedConfig.id DisplayName = $detailedConfig.displayName Description = $detailedConfig.description InstallProgressTimeoutInMinutes = $detailedConfig.installProgressTimeoutInMinutes InstallQualityUpdates = $detailedConfig.installQualityUpdates ShowInstallationProgress = $detailedConfig.showInstallationProgress TrackInstallProgressForAutopilotOnly = $detailedConfig.trackInstallProgressForAutopilotOnly AllowDeviceResetOnInstallFailure = $detailedConfig.allowDeviceResetOnInstallFailure AllowDeviceUseOnInstallFailure = $detailedConfig.allowDeviceUseOnInstallFailure AllowLogCollectionOnInstallFailure = $detailedConfig.allowLogCollectionOnInstallFailure AllowNonBlockingAppInstallation = $detailedConfig.allowNonBlockingAppInstallation BlockedApps = $blockedApps Assignments = $assignments CreatedDateTime = $detailedConfig.createdDateTime LastModifiedDateTime = $detailedConfig.lastModifiedDateTime } $espConfigs += $espConfig } } Write-Host "Retrieved $($espConfigs.Count) ESP configurations" -ForegroundColor Green return $espConfigs } catch { Write-Error "Error retrieving ESP configurations: $($_.Exception.Message)" return @() } } function Get-EnrollmentFlowData { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [System.Collections.Generic.List[object]]$PolicyAssignments, [Parameter(Mandatory = $false)] [array]$ESPConfigurations = @() ) Write-Host "Building enrollment flow data..." -ForegroundColor Green # Get Groups (both dynamic and static) $allGroups = Get-DynamicGroupsWithMembershipRules # Get autopilot profiles and their assignments Write-Host "Retrieving Autopilot profiles..." -ForegroundColor Cyan $autopilotProfiles = Get-IntuneEntities -EntityType "windowsAutopilotDeploymentProfiles" # Use the passed policy assignments data Write-Host "Using previously retrieved policy assignments..." -ForegroundColor Cyan # Build enrollment flows - one flow per Autopilot profile $enrollmentFlows = @() foreach ($autopilotProfile in $autopilotProfiles) { Write-Host "Processing Autopilot profile: $($autopilotProfile.displayName)" -ForegroundColor Gray # Get assignments for this autopilot profile $profileAssignments = Get-DetailedPolicyAssignments -EntityType "windowsAutopilotDeploymentProfiles" -EntityId $autopilotProfile.id -PolicyName $autopilotProfile.displayName # Get all groups that this autopilot profile is assigned to $assignedGroups = @() foreach ($assignment in $profileAssignments) { if ($assignment.AssignmentType -eq "Group (Include)" -and $assignment.GroupId) { # Find the Group (both dynamic and static) $group = $allGroups | Where-Object { $_.Id -eq $assignment.GroupId } if ($group) { Write-Host " Found Group assignment: $($group.DisplayName)" -ForegroundColor Gray $assignedGroups += $group } } } # Only create a flow if there are group assignments if ($assignedGroups.Count -gt 0) { Write-Host " Creating single flow for Autopilot profile with $($assignedGroups.Count) assigned groups" -ForegroundColor Gray # Collect all policies and applications that would apply to devices covered by this Autopilot profile # by combining all policies from all assigned groups $allApplicablePolicies = @() $allApplicableApplications = @() # Find the best applicable ESP (priority: group-specific > All Devices > All Users) $applicableESP = $null foreach ($group in $assignedGroups) { Write-Host " Processing group: $($group.DisplayName)" -ForegroundColor Gray # Get all policies that would apply to devices in this Group $directGroupPolicies = $PolicyAssignments | Where-Object { $_.GroupId -eq $group.Id -and $_.AssignmentType -eq "Group (Include)" -and $_.PolicyCategory -ne "Autopilot Profiles" -and $_.PolicyCategory -ne "ESP Profiles" -and $_.PolicyCategory -ne "Applications" } # Get excluded policies for this group $excludedPolicies = $PolicyAssignments | Where-Object { $_.GroupId -eq $group.Id -and $_.AssignmentType -eq "Group (Exclude)" -and $_.PolicyCategory -ne "Autopilot Profiles" -and $_.PolicyCategory -ne "ESP Profiles" -and $_.PolicyCategory -ne "Applications" } # Add all group-specific policies $allApplicablePolicies += $directGroupPolicies $allApplicablePolicies += $excludedPolicies # Get applications for this group $directGroupApplications = $PolicyAssignments | Where-Object { $_.GroupId -eq $group.Id -and ($_.AssignmentType -eq "Group (Include)" -or $_.AssignmentType -eq "Group") -and $_.PolicyCategory -eq "Applications" } $excludedApplications = $PolicyAssignments | Where-Object { $_.GroupId -eq $group.Id -and $_.AssignmentType -eq "Group (Exclude)" -and $_.PolicyCategory -eq "Applications" } $allApplicableApplications += $directGroupApplications $allApplicableApplications += $excludedApplications # Find ESP for this group (only if we haven't found a group-specific one yet) if (-not $applicableESP -and $ESPConfigurations.Count -gt 0) { foreach ($esp in $ESPConfigurations) { $espAssignedToGroup = $esp.Assignments | Where-Object { $_.Type -eq "Group" -and $_.GroupId -eq $group.Id } if ($espAssignedToGroup) { $applicableESP = $esp Write-Host " Found group-specific ESP: $($esp.DisplayName)" -ForegroundColor DarkGray break } } } Write-Host " Group policies: $($directGroupPolicies.Count), Excluded: $($excludedPolicies.Count)" -ForegroundColor DarkGray Write-Host " Group applications: $($directGroupApplications.Count), Excluded: $($excludedApplications.Count)" -ForegroundColor DarkGray } # Add global policies (All Devices and All Users) $allDevicesPolicies = $PolicyAssignments | Where-Object { $_.AssignmentType -eq "All Devices" -and $_.PolicyCategory -ne "Autopilot Profiles" -and $_.PolicyCategory -ne "ESP Profiles" -and $_.PolicyCategory -ne "Applications" } $allUsersPolicies = $PolicyAssignments | Where-Object { $_.AssignmentType -eq "All Users" -and $_.PolicyCategory -ne "Autopilot Profiles" -and $_.PolicyCategory -ne "ESP Profiles" -and $_.PolicyCategory -ne "Applications" } $allDevicesApplications = $PolicyAssignments | Where-Object { $_.AssignmentType -eq "All Devices" -and $_.PolicyCategory -eq "Applications" } $allUsersApplications = $PolicyAssignments | Where-Object { $_.AssignmentType -eq "All Users" -and $_.PolicyCategory -eq "Applications" } # Combine all policies $allApplicablePolicies += $allDevicesPolicies $allApplicablePolicies += $allUsersPolicies $allApplicableApplications += $allDevicesApplications $allApplicableApplications += $allUsersApplications # If no group-specific ESP found, look for All Devices or All Users ESP if (-not $applicableESP -and $ESPConfigurations.Count -gt 0) { foreach ($esp in $ESPConfigurations) { $espAssignedToAllDevices = $esp.Assignments | Where-Object { $_.Type -eq "All Devices" } if ($espAssignedToAllDevices) { $applicableESP = $esp Write-Host " Found All Devices ESP: $($esp.DisplayName)" -ForegroundColor DarkGray break } } if (-not $applicableESP) { foreach ($esp in $ESPConfigurations) { $espAssignedToAllUsers = $esp.Assignments | Where-Object { $_.Type -eq "All Users" } if ($espAssignedToAllUsers) { $applicableESP = $esp Write-Host " Found All Users ESP: $($esp.DisplayName)" -ForegroundColor DarkGray break } } } } # Get all application IDs that affect any of the groups $affectedAppIds = $allApplicableApplications | Select-Object -ExpandProperty PolicyId -Unique # Get ALL assignments for these applications to show complete picture $finalApplications = $PolicyAssignments | Where-Object { $_.PolicyCategory -eq "Applications" -and $_.PolicyId -in $affectedAppIds } Write-Host " Total applicable policies: $($allApplicablePolicies.Count)" -ForegroundColor DarkGray Write-Host " Total applicable applications: $($finalApplications.Count)" -ForegroundColor DarkGray # Create enrollment flow object - one per Autopilot profile $enrollmentFlow = [PSCustomObject]@{ FlowName = $autopilotProfile.displayName # Just the Autopilot profile name AutopilotProfile = [PSCustomObject]@{ Name = $autopilotProfile.displayName Id = $autopilotProfile.id OutOfBoxExperienceSettings = $autopilotProfile.outOfBoxExperienceSettings DeviceNameTemplate = $autopilotProfile.deviceNameTemplate Language = $autopilotProfile.language Locale = $autopilotProfile.locale Assignments = $profileAssignments } AssignedGroups = $assignedGroups | ForEach-Object { [PSCustomObject]@{ Name = $_.DisplayName Id = $_.Id MembershipRule = $_.MembershipRule GroupTag = $_.GroupTag ProcessingState = $_.ProcessingState IsDynamic = $_.IsDynamic } } ESPConfiguration = if ($applicableESP) { [PSCustomObject]@{ Name = $applicableESP.DisplayName Id = $applicableESP.Id Description = $applicableESP.Description InstallProgressTimeoutInMinutes = $applicableESP.InstallProgressTimeoutInMinutes InstallQualityUpdates = $applicableESP.InstallQualityUpdates ShowInstallationProgress = $applicableESP.ShowInstallationProgress TrackInstallProgressForAutopilotOnly = $applicableESP.TrackInstallProgressForAutopilotOnly AllowDeviceResetOnInstallFailure = $applicableESP.AllowDeviceResetOnInstallFailure AllowDeviceUseOnInstallFailure = $applicableESP.AllowDeviceUseOnInstallFailure AllowLogCollectionOnInstallFailure = $applicableESP.AllowLogCollectionOnInstallFailure AllowNonBlockingAppInstallation = $applicableESP.AllowNonBlockingAppInstallation BlockedApps = $applicableESP.BlockedApps Assignments = $applicableESP.Assignments } } else { $null } AssignedPolicies = $allApplicablePolicies | Group-Object -Property PolicyCategory | ForEach-Object { [PSCustomObject]@{ Category = $_.Name Count = $_.Count Policies = $_.Group | ForEach-Object { [PSCustomObject]@{ Name = $_.PolicyName Id = $_.PolicyId AssignmentType = $_.AssignmentType TargetName = $_.TargetName FilterName = if ($_.FilterName -ne "No Filter" -and $_.FilterName -ne "Filter Not Found") { $_.FilterName } else { $null } FilterType = if ($_.FilterType -ne "None") { $_.FilterType } else { $null } } } } } AssignedApplications = $finalApplications | Group-Object -Property PolicyName | ForEach-Object { [PSCustomObject]@{ Name = $_.Name Id = $_.Group[0].PolicyId Category = $_.Group[0].PolicyCategory Assignments = $_.Group | ForEach-Object { [PSCustomObject]@{ AssignmentType = $_.AssignmentType AssignmentIntent = if ($_.AssignmentIntent) { $_.AssignmentIntent } else { "Unknown" } TargetName = $_.TargetName FilterName = if ($_.FilterName -ne "No Filter" -and $_.FilterName -ne "Filter Not Found") { $_.FilterName } else { $null } FilterType = if ($_.FilterType -ne "None") { $_.FilterType } else { $null } } } AssignmentCount = $_.Group.Count } } TotalPolicies = $allApplicablePolicies.Count PolicyBreakdown = [PSCustomObject]@{ DirectGroup = ($allApplicablePolicies | Where-Object { $_.AssignmentType -eq "Group (Include)" }).Count AllDevices = ($allApplicablePolicies | Where-Object { $_.AssignmentType -eq "All Devices" }).Count AllUsers = ($allApplicablePolicies | Where-Object { $_.AssignmentType -eq "All Users" }).Count Excluded = ($allApplicablePolicies | Where-Object { $_.AssignmentType -eq "Group (Exclude)" }).Count } } $enrollmentFlows += $enrollmentFlow } } Write-Host "Built $($enrollmentFlows.Count) enrollment flows" -ForegroundColor Green return $enrollmentFlows } function New-EnrollmentFlowHtmlReport { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [array]$EnrollmentFlows, [Parameter(Mandatory = $false)] [string]$OutputPath = ".\IntuneEnrollmentFlow_$(Get-Date -Format 'yyyyMMdd_HHmmss').html", [Parameter(Mandatory = $false)] [string]$TenantName = "Unknown Tenant", [Parameter(Mandatory = $false)] [array]$AllPolicyAssignments = @() ) Write-Host "Generating modern Bootstrap HTML report..." -ForegroundColor Green Write-Host "Enrollment flows count: $($EnrollmentFlows.Count)" -ForegroundColor Gray Write-Host "Output path: $OutputPath" -ForegroundColor Gray # Create a local copy of global assignments to avoid parameter modification issues $globalPolicyAssignments = if ($AllPolicyAssignments -and $AllPolicyAssignments.Count -gt 0) { $AllPolicyAssignments | ForEach-Object { $_ } # Create a copy } else { @() } # Validate and fix the output path if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = ".\IntuneEnrollmentFlow_$(Get-Date -Format 'yyyyMMdd_HHmmss').html" Write-Host "Empty output path provided, using default: $OutputPath" -ForegroundColor Yellow } # Check if the path ends with a directory separator (which would be invalid for a file) if ($OutputPath.EndsWith('\') -or $OutputPath.EndsWith('/')) { $OutputPath = $OutputPath.TrimEnd('\', '/') + "\IntuneEnrollmentFlow_$(Get-Date -Format 'yyyyMMdd_HHmmss').html" Write-Host "Directory path provided, appending filename: $OutputPath" -ForegroundColor Yellow } # Ensure the path has a file extension if (-not [System.IO.Path]::HasExtension($OutputPath)) { $OutputPath = $OutputPath + ".html" Write-Host "No file extension provided, adding .html: $OutputPath" -ForegroundColor Yellow } # Calculate summary statistics if ($EnrollmentFlows.Count -gt 0) { $uniqueGroups = ($EnrollmentFlows | ForEach-Object { $_.AssignedGroups } | Select-Object -Unique -Property Id).Count $uniqueAutopilotProfiles = ($EnrollmentFlows | Select-Object -ExpandProperty AutopilotProfile | Select-Object -Unique -Property Id).Count $totalPolicies = ($EnrollmentFlows | Measure-Object -Property TotalPolicies -Sum).Sum } else { $uniqueGroups = 0 $uniqueAutopilotProfiles = 0 $totalPolicies = 0 } # Initialize StringBuilder for better performance $htmlBuilder = New-Object System.Text.StringBuilder # Add initial HTML content $null = $htmlBuilder.AppendLine(@" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>$TenantName Intune Enrollment Flow Visualization</title> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css"> <link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css"> <link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.1/css/buttons.bootstrap5.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <script src="https://code.jquery.com/jquery-3.7.0.js"></script> <script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script> <script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script> <script src="https://cdn.datatables.net/buttons/2.4.1/js/dataTables.buttons.min.js"></script> <script src="https://cdn.datatables.net/buttons/2.4.1/js/buttons.bootstrap5.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.53/pdfmake.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.53/vfs_fonts.js"></script> <script src="https://cdn.datatables.net/buttons/2.4.1/js/buttons.html5.min.js"></script> <script src="https://cdn.datatables.net/buttons/2.4.1/js/buttons.print.min.js"></script> <script src="https://cdn.datatables.net/buttons/2.4.1/js/buttons.colVis.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <style> :root { /* Light mode variables (default) */ --primary-color: #0078d4; --secondary-color: #2b88d8; --permanent-color: #d83b01; --eligible-color: #107c10; --group-color: #5c2d91; --disabled-color: #d9534f; --enabled-color: #5cb85c; --service-principal-color: #0078d4; --na-color: #6c757d; --bg-color: #f8f9fa; --card-bg: #ffffff; --text-color: #333333; --table-header-bg: #f5f5f5; --table-header-color: #333333; --table-stripe-bg: rgba(0,0,0,0.02); --table-hover-bg: rgba(0,0,0,0.04); --table-border-color: #dee2e6; --filter-tag-bg: #e9ecef; --filter-tag-color: #495057; --filter-bg: white; --btn-outline-color: #6c757d; --border-color: #dee2e6; --toggle-bg: #ccc; --button-bg: #f8f9fa; --button-color: #333; --button-border: #ddd; --button-hover-bg: #e9ecef; --footer-text: white; --input-bg: #fff; --input-color: #333; --input-border: #ced4da; --input-focus-border: #86b7fe; --input-focus-shadow: rgba(13, 110, 253, 0.25); --datatable-even-row-bg: #fff; --datatable-odd-row-bg: #f9f9f9; --tab-active-bg: #0078d4; --tab-active-color: #fff; --accent-color: #107c10; --warning-color: #f59e0b; --danger-color: #d83b01; --info-color: #0078d4; --bg-primary: #ffffff; --bg-secondary: #f8f9fa; --bg-tertiary: #f1f5f9; --text-primary: #333333; --text-secondary: #6c757d; --text-muted: #94a3b8; --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); --gradient-primary: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); --gradient-secondary: linear-gradient(135deg, var(--secondary-color) 0%, var(--accent-color) 100%); --nav-bg: #f8f9fa; --nav-border: #dee2e6; --nav-link-color: #6c757d; --nav-link-active-color: #0078d4; --nav-link-active-bg: #ffffff; } [data-theme="dark"] { /* Dark mode variables */ --primary-color: #0078d4; --secondary-color: #2b88d8; --permanent-color: #d83b01; --eligible-color: #107c10; --group-color: #5c2d91; --disabled-color: #6c757d; --enabled-color: #0078d4; --service-principal-color: #0078d4; --bg-color: #121212; --card-bg: #1e1e1e; --text-color: #e0e0e0; --table-header-bg: #333333; --table-header-color: #e0e0e0; --table-stripe-bg: rgba(255,255,255,0.03); --table-hover-bg: rgba(255,255,255,0.05); --table-border-color: #444444; --filter-bg: #252525; --btn-outline-color: #adb5bd; --border-color: #444444; --toggle-bg: #555555; --button-bg: #2a2a2a; --button-color: #e0e0e0; --button-border: #444; --button-hover-bg: #3a3a3a; --footer-text: white; --input-bg: #2a2a2a; --input-color: #e0e0e0; --input-border: #444444; --input-focus-border: #0078d4; --input-focus-shadow: rgba(0, 120, 212, 0.25); --datatable-even-row-bg: #1e1e1e; --datatable-odd-row-bg: #252525; --tab-active-bg: #0078d4; --tab-active-color: #fff; --accent-color: #107c10; --warning-color: #f59e0b; --danger-color: #d83b01; --info-color: #0078d4; --bg-primary: #121212; --bg-secondary: #1e1e1e; --bg-tertiary: #2a2a2a; --text-primary: #e0e0e0; --text-secondary: #adb5bd; --text-muted: #6c757d; --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.4); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.4); --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.4), 0 8px 10px -6px rgb(0 0 0 / 0.4); --gradient-primary: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); --gradient-secondary: linear-gradient(135deg, var(--secondary-color) 0%, var(--accent-color) 100%); --nav-bg: #1e1e1e; --nav-border: #444444; --nav-link-color: #adb5bd; --nav-link-active-color: #ffffff; --nav-link-active-bg: #0078d4; } * { box-sizing: border-box; } body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background: var(--bg-color); min-height: 100vh; color: var(--text-color); line-height: 1.4; position: relative; transition: all 0.3s ease; font-size: 12px; } [data-theme="dark"] body { background: var(--bg-color); } body::before { display: none; } .app-container { min-height: 100vh; padding: 0; display: flex; flex-direction: column; } .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; background: var(--card-bg); border-radius: 0; box-shadow: none; overflow: hidden; border: none; position: relative; transition: all 0.3s ease; flex: 1; display: flex; flex-direction: column; } /* Responsive container widths */ @media (max-width: 576px) { .container { max-width: 100%; padding: 0 15px; } } @media (min-width: 577px) and (max-width: 768px) { .container { max-width: 95%; padding: 0 15px; } } @media (min-width: 769px) and (max-width: 992px) { .container { max-width: 90%; padding: 0 20px; } } @media (min-width: 993px) and (max-width: 1200px) { .container { max-width: 85%; padding: 0 20px; } } [data-theme="dark"] .container { background: var(--card-bg); } .container::before { display: none; } .dashboard-header { background: var(--gradient-primary); color: white; padding: 1.2rem 0.8rem; text-align: center; position: relative; overflow: hidden; margin: 0; border-radius: 0; z-index: 1; } .dashboard-header::before { display: none; } .dashboard-header::after { display: none; } .dashboard-title { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 0.3rem; } .dashboard-title h1 { margin: 0; font-size: 1rem; font-weight: 700; color: white; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .logo { height: 20px; width: 20px; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); } .report-date { font-size: 0.65rem; color: rgba(255, 255, 255, 0.9); font-weight: 400; position: relative; z-index: 1; } .stats-section { padding: 0.6rem; background: var(--bg-secondary); transition: background-color 0.3s ease; } [data-theme="dark"] .stats-section { background: var(--bg-secondary); } .stats-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; padding: 1rem; margin-top: 1rem; margin-bottom: 1rem; } @media (max-width: 768px) { .stats-container { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.8rem; padding: 0.8rem; } } @media (max-width: 480px) { .stats-container { grid-template-columns: 1fr 1fr; gap: 0.6rem; padding: 0.6rem; } } .stats-card { height: 100%; text-align: center; padding: 0.6rem 0.4rem; border-radius: 6px; color: white; position: relative; overflow: hidden; min-height: 60px; display: flex; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; transition: all 0.3s ease; box-shadow: var(--shadow-md); } .stats-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); } .stats-card::before { content: ''; position: absolute; top: -6px; right: -6px; width: 32px; height: 32px; border-radius: 50%; background-color: rgba(255,255,255,0.1); z-index: 0; } .stats-card i { font-size: 1rem; margin-bottom: 0.2rem; position: relative; z-index: 1; } .stats-card h3 { font-size: 0.6rem; font-weight: 600; margin-bottom: 0.1rem; position: relative; z-index: 1; } .stats-card .number { font-size: 1.1rem; font-weight: 700; position: relative; z-index: 1; } .primary-bg { background: var(--primary-color); } .success-bg { background: var(--eligible-color); } .warning-bg { background: var(--permanent-color); } .info-bg { background: var(--secondary-color); } .flows-section { padding: 0.6rem; background: var(--bg-primary); transition: background-color 0.3s ease; flex: 1; display: flex; flex-direction: column; } [data-theme="dark"] .flows-section { background: var(--bg-primary); } /* Navigation Tabs */ .nav-tabs { border-bottom: 2px solid var(--nav-border); background-color: var(--nav-bg); border-radius: 6px 6px 0 0; padding: 3px 3px 0 3px; margin-bottom: 0; display: flex; justify-content: space-between; } .nav-tabs .nav-item { flex: 1; margin-right: 0; } .nav-tabs .nav-link { border: none; color: var(--nav-link-color); padding: 6px 8px; margin-right: 0; border-radius: 6px 6px 0 0; background-color: transparent; transition: all 0.3s ease; font-weight: 500; font-size: 0.7rem; text-align: center; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Add small gaps between tabs */ .nav-tabs .nav-item:not(:last-child) { margin-right: 3px; } /* Responsive tabs for smaller screens */ @media (max-width: 768px) { .nav-tabs .nav-link { font-size: 0.6rem; padding: 4px 6px; } } @media (max-width: 480px) { .nav-tabs { flex-wrap: wrap; } .nav-tabs .nav-item { flex: 1 1 50%; margin-bottom: 3px; } .nav-tabs .nav-link { font-size: 0.55rem; padding: 3px 4px; } } .nav-tabs .nav-link:hover { background-color: rgba(99, 102, 241, 0.1); color: var(--primary-color); transform: translateY(-1px); } .nav-tabs .nav-link.active { background-color: var(--nav-link-active-bg); color: var(--nav-link-active-color); border-bottom: 2px solid var(--primary-color); font-weight: 600; box-shadow: var(--shadow-md); } .tab-content { background-color: var(--card-bg); border: 1px solid var(--nav-border); border-top: none; border-radius: 0 0 6px 6px; padding: 0; box-shadow: var(--shadow-lg); flex: 1; overflow-y: auto; } /* Flow Steps - Vertical Layout */ .flow-container { padding: 0.6rem; position: relative; overflow: visible; } .flow-title { text-align: center; margin-bottom: 0.8rem; } .flow-title h2 { font-size: 0.9rem; font-weight: 700; color: var(--text-primary); margin-bottom: 0.2rem; } .flow-title p { font-size: 0.65rem; color: var(--text-secondary); } .flow-step { background-color: var(--card-bg); border: 1px solid var(--border-color); border-radius: 6px; margin-bottom: 1.2rem; overflow: visible; box-shadow: var(--shadow-md); transition: all 0.3s ease; position: relative; } .flow-step:hover { transform: translateY(-1px); box-shadow: var(--shadow-lg); } .flow-step:not(:last-child)::after { content: ''; position: absolute; bottom: -15px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; border-top: 15px solid var(--primary-color); z-index: 1000; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); } .flow-step:not(:last-child)::before { content: ''; position: absolute; bottom: -5px; left: 50%; transform: translateX(-50%); width: 3px; height: 10px; background: var(--primary-color); z-index: 999; border-radius: 2px; } /* Add padding to flow steps that have arrows */ .flow-step:not(:last-child) { padding-bottom: 20px; } .step-header { padding: 0.5rem 0.6rem; display: flex; align-items: center; gap: 0.3rem; font-size: 0.75rem; font-weight: 600; color: white; } .step-header i { font-size: 0.8rem; } .step-dynamic-group .step-header { background: var(--primary-color); } .step-autopilot .step-header { background: var(--secondary-color); } .step-esp .step-header { background: var(--group-color); } .blocked-apps-list { list-style: none; padding: 0; margin: 0.25rem 0; background: var(--bg-color); border: 1px solid var(--border-color); border-radius: 4px; max-height: 120px; overflow-y: auto; } .blocked-apps-list li { padding: 0.375rem 0.5rem; border-bottom: 1px solid var(--border-color); font-size: 0.7rem; } .blocked-apps-list li:last-child { border-bottom: none; } .blocked-apps-list li strong { color: var(--text-color); } .step-policies .step-header { background: var(--eligible-color); } /* Configuration Policies vertical stacking support */ .step-policies .policy-assignment { display: block; line-height: 1.4; white-space: normal; word-wrap: break-word; overflow-wrap: break-word; } .step-policies .policy-filter { display: block; line-height: 1.4; white-space: normal; word-wrap: break-word; overflow-wrap: break-word; } /* Ensure configuration policies can show multiple lines */ .step-policies .policy-item { align-items: flex-start; min-height: auto; } .step-applications .step-header { background: var(--warning-color); } .step-content { padding: 0.6rem; } .step-name { font-weight: 600; font-size: 0.75rem; margin-bottom: 0.3rem; color: var(--text-primary); } .group-tag { background: var(--primary-color); color: white; padding: 3px 6px; border-radius: 8px; font-size: 0.6rem; display: inline-block; margin-bottom: 0.3rem; font-weight: 500; } .membership-rule { background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.2); border-left: 2px solid var(--primary-color); padding: 0.5rem; border-radius: 4px; font-family: 'Fira Code', 'Courier New', monospace; font-size: 0.6rem; margin-top: 0.3rem; word-break: break-all; line-height: 1.3; color: var(--text-primary); } .autopilot-settings { background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2); border-left: 2px solid #8b5cf6; padding: 0.5rem; border-radius: 4px; margin-top: 0.3rem; } .autopilot-settings dt { font-weight: 600; color: var(--text-primary); margin-bottom: 0.1rem; font-size: 0.65rem; } .autopilot-settings dd { margin-bottom: 0.3rem; color: var(--text-secondary); font-size: 0.6rem; } .esp-config-section { margin-top: 1rem; } .dynamic-group-config-section { margin-top: 1rem; } .autopilot-config-section { margin-top: 1rem; } .config-toggle { border-color: var(--border-color); color: var(--text-primary); font-size: 0.75rem; padding: 0.4rem 0.8rem; transition: all 0.2s ease; } .config-toggle:hover { background-color: var(--primary-color); border-color: var(--primary-color); color: white; } .config-toggle i { transition: transform 0.2s ease; } .config-toggle[aria-expanded="true"] i { transform: rotate(180deg); } .config-toggle[aria-expanded="true"] { background-color: var(--primary-color); border-color: var(--primary-color); color: white; } /* Step-level collapse functionality */ .step-toggle { background-color: var(--card-bg); border: 1px solid var(--primary-color); color: var(--primary-color); padding: 0.5rem 1rem; border-radius: 6px; font-weight: 500; transition: all 0.3s ease; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; } .step-toggle:hover { background-color: var(--primary-color); color: white; transform: translateY(-2px); box-shadow: var(--shadow-md); } .step-toggle i { transition: transform 0.3s ease; } .step-toggle[aria-expanded="true"] i { transform: rotate(180deg); } .step-toggle[aria-expanded="true"] { background-color: var(--primary-color); color: white; border-color: var(--primary-color); } .policy-category { background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2); border-left: 3px solid var(--accent-color); padding: 0.8rem; border-radius: 8px; margin-bottom: 0.8rem; } .policy-category h5 { color: var(--accent-color); margin-bottom: 0.6rem; font-size: 0.9rem; font-weight: 600; display: flex; justify-content: space-between; align-items: center; } .policy-count { background: var(--accent-color); color: white; padding: 4px 8px; border-radius: 12px; font-size: 0.65rem; font-weight: 500; } .policy-table-header { display: grid; grid-template-columns: 3fr 1.2fr 1.5fr 2.3fr; gap: 0.5rem; padding: 0.5rem 0.8rem; background: rgba(16, 185, 129, 0.1); border-radius: 4px; margin-bottom: 0.5rem; font-size: 0.65rem; font-weight: 600; color: var(--accent-color); text-transform: uppercase; letter-spacing: 0.3px; border: 1px solid rgba(16, 185, 129, 0.2); } /* Applications section header styling */ .step-applications .policy-table-header { background: rgba(245, 158, 11, 0.1); color: var(--warning-color); border: 1px solid rgba(245, 158, 11, 0.2); } /* 5-column layout for applications */ .step-applications .policy-table-header { grid-template-columns: 3fr 1.5fr 1fr 1.2fr 2fr; gap: 0.5rem; } .step-applications .policy-item { display: grid; grid-template-columns: 3fr 1.5fr 1fr 1.2fr 2fr; gap: 0.5rem; align-items: flex-start; padding: 0.4rem 0.8rem; margin-bottom: 0.2rem; background: var(--card-bg); border-radius: 4px; border-left: 3px solid var(--warning-color); font-size: 0.7rem; border: 1px solid var(--border-color); transition: all 0.2s ease; min-height: 2.5rem; } .step-applications .policy-item:hover { background: rgba(245, 158, 11, 0.05); transform: translateX(3px); box-shadow: var(--shadow-md); border-left-color: var(--warning-color); } .step-applications .policy-item:nth-child(odd) { background: rgba(245, 158, 11, 0.02); } .step-applications .policy-item:nth-child(even) { background: var(--card-bg); } .step-applications .policy-name { font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .step-applications .policy-assignment { display: block; padding-top: 0.1rem; line-height: 1.4; } .step-applications .policy-filter { display: block; padding-top: 0.1rem; word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; line-height: 1.4; } .step-applications .policy-filter:last-child { word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; line-height: 1.4; } .step-applications .filter-badge { display: inline-block; max-width: 100%; word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; line-height: 1.3; white-space: normal; padding: 3px 6px; font-size: 0.55rem; border-radius: 8px; } .step-applications .assignment-badge { display: inline-block; max-width: 100%; word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; line-height: 1.2; white-space: normal; } .step-applications .intent-badge { display: inline-block; max-width: 100%; word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; line-height: 1.2; white-space: normal; } /* Continuation rows for additional assignments */ .app-item-continuation { border-top: none !important; margin-top: 0 !important; padding-top: 0.2rem !important; border-left-color: transparent !important; } .app-item-continuation .policy-name { border-left: 3px solid var(--warning-color); margin-left: -0.8rem; padding-left: 0.8rem; height: 100%; min-height: 1.5rem; } .header-policy-name { text-align: left; } .header-assignment { text-align: left; } .header-filter { text-align: left; } /* Right-align specific filter columns */ .step-dynamic-group .header-filter:last-child, .step-autopilot .header-filter:last-child, .step-esp .header-filter:last-child { text-align: right; } /* Applications specific header alignment */ .step-applications .header-assignment { text-align: left; } .step-applications .header-filter:first-of-type { text-align: left; } .step-applications .header-filter:last-of-type { text-align: left; } @media (max-width: 768px) { .policy-table-header { display: none; } } .policy-item { background: rgba(255, 255, 255, 0.8); border: 1px solid var(--border-color); padding: 0.8rem; margin-bottom: 0.4rem; border-radius: 6px; font-size: 0.7rem; display: grid; grid-template-columns: 3fr 1.2fr 1.5fr 2.3fr; gap: 0.2rem; align-items: center; color: var(--text-primary); transition: all 0.2s ease; } .policy-item:hover { background: rgba(255, 255, 255, 1); transform: translateX(5px); } .policy-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .policy-assignment { text-align: left; font-weight: 600; color: var(--primary-color); } .policy-filter { text-align: left; font-size: 0.65rem; color: var(--text-secondary); font-style: italic; font-weight: bold; } /* Right-align specific filter columns data */ .step-dynamic-group .policy-filter:last-child, .step-autopilot .policy-filter:last-child, .step-esp .policy-filter:last-child { text-align: right; justify-content: flex-end; display: flex; } /* Responsive design for smaller screens */ @media (max-width: 768px) { .policy-item { grid-template-columns: 1fr; gap: 0.5rem; padding: 0.6rem; } .step-applications .policy-item { grid-template-columns: 1fr; gap: 0.3rem; } .policy-assignment, .policy-filter { text-align: left; justify-content: flex-start; } .app-item-continuation .policy-name { display: none; } } [data-theme="dark"] .policy-item { background: rgba(51, 65, 85, 0.8); border-color: var(--border-color); } [data-theme="dark"] .policy-item:hover { background: rgba(51, 65, 85, 1); } [data-theme="dark"] .step-applications .policy-item { background: rgba(51, 65, 85, 0.6); } [data-theme="dark"] .step-applications .policy-item:nth-child(odd) { background: rgba(245, 158, 11, 0.05); } [data-theme="dark"] .step-applications .policy-item:nth-child(even) { background: rgba(51, 65, 85, 0.6); } [data-theme="dark"] .step-applications .policy-item:hover { background: rgba(245, 158, 11, 0.1); } .assignment-badge { font-size: 0.6rem; padding: 4px 8px; border-radius: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; box-shadow: 0 1px 2px rgba(0,0,0,0.1); } .badge-all-devices { background: #0ea5e9; color: white; } .badge-all-users { background: #8b5a3c; color: white; } .badge-group { background: var(--accent-color); color: white; } .badge-group-exclude { background: var(--permanent-color); color: white; border: 2px solid #dc3545; position: relative; } .badge-group-exclude::before { content: '⚠️'; margin-right: 4px; } .filter-badge { color: white; font-size: 0.6rem; padding: 4px 8px; border-radius: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.3px; display: inline-block; white-space: nowrap; box-shadow: 0 1px 2px rgba(0,0,0,0.1); } .filter-include { background: var(--eligible-color); } .filter-exclude { background: var(--permanent-color); } .filter-none { background: var(--text-secondary); opacity: 0.7; } .assignment-row { margin-bottom: 0.15rem; } .assignment-row:last-child { margin-bottom: 0; } .filter-row { margin-bottom: 0.15rem; } .filter-row:last-child { margin-bottom: 0; } .assignment-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .intent-badge { display: inline-block; padding: 4px 8px; border-radius: 12px; font-size: 0.65rem; font-weight: 600; color: white; text-transform: uppercase; letter-spacing: 0.5px; margin: 0; white-space: nowrap; box-shadow: 0 1px 2px rgba(0,0,0,0.1); } /* Group type badge for better contrast in group section */ .group-type-badge { display: inline-block; padding: 4px 8px; border-radius: 12px; font-size: 0.65rem; font-weight: 600; color: #333; background: #e0e7ef; text-transform: uppercase; letter-spacing: 0.5px; margin: 0; white-space: nowrap; box-shadow: 0 1px 2px rgba(0,0,0,0.08); } [data-theme="dark"] .group-type-badge { color: #fff; background: #444b5a; } .intent-required { background: #dc3545; } .intent-available { background: #28a745; } .intent-uninstall { background: #fd7e14; } .intent-available-no-enrollment { background: #17a2b8; } .intent-unknown { background: #6c757d; } .app-item .policy-assignment { line-height: 1.4; } .app-item .assignment-badge { margin-bottom: 2px; } .no-flows-message { text-align: center; padding: 2rem 1rem; color: var(--text-secondary); } .no-flows-message .icon { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; } .no-flows-message h3 { margin-bottom: 0.6rem; color: var(--text-primary); font-size: 1.2rem; } .no-flows-message ul { text-align: left; display: inline-block; color: var(--text-secondary); font-size: 0.8rem; } /* Theme toggle styles */ .theme-toggle { position: absolute; top: 10px; right: 10px; z-index: 1050; display: flex; align-items: center; gap: 6px; background-color: var(--card-bg); padding: 6px 10px; border-radius: 30px; box-shadow: var(--shadow-lg); transition: all 0.3s ease; border: 1px solid var(--border-color); } [data-theme="dark"] .theme-toggle { background-color: var(--card-bg); border-color: var(--border-color); } .theme-toggle-switch { position: relative; display: inline-block; width: 40px; height: 20px; } .theme-toggle-switch input { opacity: 0; width: 0; height: 0; } .theme-toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #cbd5e1; transition: .4s; border-radius: 20px; } .theme-toggle-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px; background-color: white; transition: .4s; border-radius: 50%; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } input:checked + .theme-toggle-slider { background-color: var(--primary-color); } input:checked + .theme-toggle-slider:before { transform: translateX(20px); } .theme-icon { display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--text-primary); } footer { background: var(--gradient-primary); color: white; text-align: center; padding: 0.6rem 0; margin-top: auto; } footer p { margin: 0; font-weight: 500; font-size: 0.65rem; } /* Responsive design */ @media (max-width: 768px) { .app-container { padding: 0.6rem; } .dashboard-header { padding: 1.5rem 0.6rem; } .dashboard-title { flex-direction: column; gap: 0.6rem; } .dashboard-title h1 { font-size: 1.2rem; } .logo { height: 32px; width: 32px; } .stats-card { min-height: 100px; padding: 0.8rem 0.6rem; } .stats-card .number { font-size: 1.5rem; } .policy-item { grid-template-columns: 1fr; gap: 0.5rem; text-align: left; } .policy-assignment { text-align: left; font-size: 0.65rem; } .policy-filter { text-align: left; font-size: 0.6rem; } .flow-container { padding: 1rem 0.6rem; } .flow-title h2 { font-size: 1.2rem; } .theme-toggle { position: relative; top: auto; right: auto; margin-bottom: 1rem; align-self: center; } } @media (max-width: 576px) { .step-header { padding: 1rem 1.5rem; font-size: 1.2rem; } .step-content { padding: 1.5rem; } .flow-step:not(:last-child)::after { content: ''; position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 15px solid transparent; border-right: 15px solid transparent; border-top: 20px solid var(--primary-color); z-index: 1000; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); } .flow-step:not(:last-child)::before { content: ''; position: absolute; bottom: -8px; left: 50%; transform: translateX(-50%); width: 4px; height: 12px; background: var(--primary-color); z-index: 999; border-radius: 2px; } } </style> </head> <body> <!-- Dark Mode Toggle --> <div class="theme-toggle"> <div class="theme-icon"> <i class="fas fa-sun"></i> </div> <label class="theme-toggle-switch"> <input type="checkbox" id="themeToggle"> <span class="theme-toggle-slider"></span> </label> <div class="theme-icon"> <i class="fas fa-moon"></i> </div> </div> <div class="app-container"> <div class="container"> <div class="dashboard-header"> <div class="dashboard-title"> <svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"> <path fill="#ff5722" d="M6 6H22V22H6z" transform="rotate(-180 14 14)"/> <path fill="#4caf50" d="M26 6H42V22H26z" transform="rotate(-180 34 14)"/> <path fill="#ffc107" d="M6 26H22V42H6z" transform="rotate(-180 14 34)"/> <path fill="#03a9f4" d="M26 26H42V42H26z" transform="rotate(-180 34 34)"/> </svg> <h1>$TenantName Enrollment Flow Visualization</h1> </div> <div class="report-date"> <i class="fas fa-calendar-alt me-2"></i> Report generated on: $(Get-Date -Format 'MMMM dd, yyyy \a\t HH:mm') </div> </div> <div class="stats-container"> <div class="stats-card primary-bg"> <i class="fas fa-sitemap"></i> <h3>Complete Flows</h3> <div class="number">$($EnrollmentFlows.Count)</div> </div> <div class="stats-card info-bg"> <i class="fas fa-users"></i> <h3>Groups</h3> <div class="number">$uniqueGroups</div> </div> <div class="stats-card warning-bg"> <i class="fas fa-rocket"></i> <h3>Autopilot Profiles</h3> <div class="number">$uniqueAutopilotProfiles</div> </div> <div class="stats-card success-bg"> <i class="fas fa-cog"></i> <h3>Total Policies</h3> <div class="number">$totalPolicies</div> </div> </div> <!-- Enrollment Flows --> <div class="row"> <div class="col-12"> "@) if ($EnrollmentFlows.Count -eq 0) { $null = $htmlBuilder.AppendLine(@" <div class="card"> <div class="card-body"> <div class="no-flows-message"> <div class="icon"> <i class="fas fa-exclamation-triangle"></i> </div> <h3>No Enrollment Flows Found</h3> <p>No complete enrollment flows were detected in this tenant.</p> <p>This could mean:</p> <ul> <li>No Autopilot profiles are assigned to Groups</li> <li>Groups don't have valid membership rules</li> <li>Autopilot profiles are assigned to static groups only</li> </ul> </div> </div> </div> "@) } else { # Generate navigation tabs $null = $htmlBuilder.AppendLine(@" <ul class="nav nav-tabs" id="flowTabs" role="tablist"> "@) for ($i = 0; $i -lt $EnrollmentFlows.Count; $i++) { $autopilotProfileName = $EnrollmentFlows[$i].AutopilotProfile.Name $isActive = if ($i -eq 0) { "active" } else { "" } $null = $htmlBuilder.AppendLine(@" <li class="nav-item" role="presentation"> <button class="nav-link $isActive" id="flow-$i-tab" data-bs-toggle="tab" data-bs-target="#flow-$i" type="button" role="tab" aria-controls="flow-$i" aria-selected="$(if ($i -eq 0) { "true" } else { "false" })"> <i class="fas fa-rocket me-2"></i>$autopilotProfileName </button> </li> "@) } $null = $htmlBuilder.AppendLine(@" </ul> <div class="tab-content" id="flowTabContent"> "@) # Generate flow content for each tab - BUILD HTML IN CHUNKS FOR BETTER PERFORMANCE $htmlChunks = [System.Collections.Generic.List[string]]::new() # Global ESP deduplication - track ESP profiles across all flows $globalESPProfilesProcessed = @{} for ($i = 0; $i -lt $EnrollmentFlows.Count; $i++) { $flow = $EnrollmentFlows[$i] $isActive = if ($i -eq 0) { "show active" } else { "" } # Build the entire flow HTML as a single string to minimize AppendLine calls $flowHtml = @" <div class="tab-pane fade $isActive" id="flow-$i" role="tabpanel" aria-labelledby="flow-$i-tab" tabindex="0"> <div class="flow-container"> <div class="text-center mb-4"> <h2><i class="fas fa-route me-3"></i>$($flow.AutopilotProfile.Name)</h2> <p class="text-muted">Complete enrollment flow visualization</p> </div> <!-- Group Step --> <div class="flow-step step-dynamic-group"> <div class="step-header"> <i class="fas fa-users"></i> <span>Group</span> </div> <div class="step-content"> <button class="btn btn-outline-primary btn-sm step-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#dynamicGroupStep-$i" aria-expanded="false" aria-controls="dynamicGroupStep-$i"> <i class="fas fa-chevron-down"></i>Expand Group Details </button> <div class="collapse" id="dynamicGroupStep-$i"> "@ # Show all assigned groups if ($flow.AssignedGroups -and $flow.AssignedGroups.Count -gt 0) { # Check if any groups have tags $hasGroupTags = $false foreach ($group in $flow.AssignedGroups) { if ($group.GroupTag) { $hasGroupTags = $true break } } $flowHtml += @" <div class="step-name">$($flow.AssignedGroups.Count) Assigned Groups</div> <!-- Group Assignment Information --> <div class="policy-category"> <h5> Group Assignment <span class="policy-count">$($flow.AssignedGroups.Count)</span> </h5> "@ # For visual consistency, use 3-column layout for Group Assignment (removed assignment column) $flowHtml += @" <div class="policy-table-header" style="grid-template-columns: 3fr 1.5fr 2.3fr;"> <div class="header-policy-name">Group</div> <div class="header-filter">Type</div> <div class="header-filter">Group Tag</div> </div> "@ # Add each assigned group foreach ($group in $flow.AssignedGroups) { # Use 3-column layout for group items (removed assignment column) $flowHtml += @" <div class="policy-item" style="grid-template-columns: 3fr 1.5fr 2.3fr;"> <div class="policy-name">$($group.Name)</div> <div class="policy-filter"> <span class="group-type-badge">$(if ($group.IsDynamic) { "Dynamic" } else { "Static" })</span> </div> <div class="policy-filter"> "@ # Add group tag if available if ($group.GroupTag) { $flowHtml += @" Tag: $($group.GroupTag) "@ } $flowHtml += @" </div> </div> "@ } $flowHtml += @" </div> <!-- Group Configuration Details --> <div class="dynamic-group-config-section"> <button class="btn btn-outline-secondary btn-sm config-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#dynamicGroupConfig-$i" aria-expanded="false" aria-controls="dynamicGroupConfig-$i"> <i class="fas fa-chevron-down me-2"></i>Show Configuration Details </button> <div class="collapse mt-3" id="dynamicGroupConfig-$i"> <div class="autopilot-settings"> "@ # Show configuration details for each group foreach ($group in $flow.AssignedGroups) { $flowHtml += @" <div class="group-config-item"> <h6>$($group.Name)</h6> <dl class="row"> "@ # Show membership rule only for dynamic groups if ($group.IsDynamic -and $group.MembershipRule) { $flowHtml += @" <dt class="col-sm-3">Membership Rule:</dt> <dd class="col-sm-9">$($group.MembershipRule)</dd> "@ } else { $flowHtml += @" <dt class="col-sm-3">Group Type:</dt> <dd class="col-sm-9">Static Group (manually managed membership)</dd> "@ } $flowHtml += @" </dl> </div> "@ } $flowHtml += @" </div> </div> </div> "@ } else { $flowHtml += @" <div class="step-name">No Groups Assigned</div> <!-- Group Assignment Information --> <div class="policy-category"> <h5> Group Assignment <span class="policy-count">0</span> </h5> <div class="alert alert-warning"> No groups are assigned to this Autopilot profile. </div> </div> "@ } $flowHtml += @" </div> </div> </div> <!-- Autopilot Profile Step --> <div class="flow-step step-autopilot"> <div class="step-header"> <i class="fas fa-rocket"></i> <span>Autopilot Profile</span> </div> <div class="step-content"> <button class="btn btn-outline-primary btn-sm step-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#autopilotStep-$i" aria-expanded="false" aria-controls="autopilotStep-$i"> <i class="fas fa-chevron-down"></i>Expand Autopilot Profile Details </button> <div class="collapse" id="autopilotStep-$i"> <div class="step-name">$($flow.AutopilotProfile.Name)</div> <!-- Autopilot Assignment Information --> <div class="policy-category"> <h5> Autopilot Assignment <span class="policy-count">1</span> </h5> "@ # Process autopilot assignments efficiently - Show profile once with all assignments if ($flow.AutopilotProfile.Assignments -and $flow.AutopilotProfile.Assignments.Count -gt 0) { # Get unique assignment types (consolidate all groups into single badge) $uniqueAssignmentTypes = @{} $hasFilters = $false $filterInfo = @() foreach ($assignment in $flow.AutopilotProfile.Assignments) { # Consolidate all group assignments into a single "Group" type $assignmentType = switch ($assignment.AssignmentType) { "Group (Include)" { "Group" } "Group (Exclude)" { "Group (Exclude)" } default { $assignment.AssignmentType } } # Track unique assignment types if ($assignmentType -and -not $uniqueAssignmentTypes.ContainsKey($assignmentType)) { $uniqueAssignmentTypes[$assignmentType] = $true } # Collect filter information if ($assignment.FilterName -and $assignment.FilterName -ne "No Filter" -and $assignment.FilterName -ne "Filter Not Found") { $filterClass = switch ($assignment.FilterType) { "include" { "filter-include" } "exclude" { "filter-exclude" } default { $null } } $filterTypeText = switch ($assignment.FilterType) { "include" { "Include" } "exclude" { "Exclude" } default { $null } } # Only add filter info if we have valid filter type and class if ($filterTypeText -and $filterClass) { $hasFilters = $true $filterKey = "$filterTypeText|$($assignment.FilterName)" if ($filterInfo -notcontains $filterKey) { $filterInfo += $filterKey } } } } # Use 3-column layout for Autopilot (removed language column) $flowHtml += @" <div class="policy-table-header" style="grid-template-columns: 3fr 1.2fr 2.3fr;"> <div class="header-policy-name">Autopilot Profile</div> <div class="header-assignment">Assignment</div> <div class="header-filter">Filter</div> </div> "@ # Build assignment text for unique types only $assignmentTexts = @() foreach ($assignmentType in $uniqueAssignmentTypes.Keys) { $assignmentText = switch ($assignmentType) { "All Devices" { "All Devices" } "All Users" { "All Users" } "Group" { "Group" } "Group (Exclude)" { "Group (Exclude)" } default { $assignmentType } } $assignmentTexts += $assignmentText } # Build filter text $filterTexts = @() if ($hasFilters -and $filterInfo.Count -gt 0) { foreach ($filter in $filterInfo) { $filterParts = $filter -split '\|' $filterTypeText = $filterParts[0] $filterName = $filterParts[1] $filterTexts += "${filterTypeText}: $filterName" } } # Create a single policy item with all assignments using 3-column layout $allAssignmentText = $assignmentTexts -join ", " $allFilterText = if ($filterTexts.Count -gt 0) { $filterTexts -join ", " } else { "" } # Use 3-column layout for Autopilot (removed language column) $flowHtml += @" <div class="policy-item" style="grid-template-columns: 3fr 1.2fr 2.3fr;"> <div class="policy-name">$($flow.AutopilotProfile.Name)</div> <div class="policy-assignment">$allAssignmentText</div> <div class="policy-filter">$allFilterText</div> </div> "@ } else { $flowHtml += @" <div class="policy-item" style="grid-template-columns: 3fr 1.2fr 2.3fr;"> <div class="policy-name">$($flow.AutopilotProfile.Name)</div> <div class="policy-assignment">No Assignment</div> <div class="policy-filter"></div> </div> "@ } $flowHtml += @" </div> "@ # Add autopilot configuration details if available if ($flow.AutopilotProfile.DeviceNameTemplate -or $flow.AutopilotProfile.Language -or $flow.AutopilotProfile.Locale) { $flowHtml += @" <!-- Autopilot Configuration Details --> <div class="autopilot-config-section"> <button class="btn btn-outline-secondary btn-sm config-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#autopilotConfig-$i" aria-expanded="false" aria-controls="autopilotConfig-$i"> <i class="fas fa-chevron-down me-2"></i>Show Configuration Details </button> <div class="collapse mt-3" id="autopilotConfig-$i"> <div class="autopilot-settings"> "@ if ($flow.AutopilotProfile.DeviceNameTemplate) { $flowHtml += @" <dt>Device Name Template:</dt> <dd>$($flow.AutopilotProfile.DeviceNameTemplate)</dd> "@ } if ($flow.AutopilotProfile.Language) { $flowHtml += @" <dt>Language:</dt> <dd>$($flow.AutopilotProfile.Language)</dd> "@ } if ($flow.AutopilotProfile.Locale) { $flowHtml += @" <dt>Locale:</dt> <dd>$($flow.AutopilotProfile.Locale)</dd> "@ } $flowHtml += @" </div> </div> </div> "@ } $flowHtml += @" </div> </div> </div> <!-- ESP Step --> <div class="flow-step step-esp"> <div class="step-header"> <i class="fas fa-shield-alt"></i> <span>Enrollment Status Page</span> </div> <div class="step-content"> <button class="btn btn-outline-primary btn-sm step-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#espStep-$i" aria-expanded="false" aria-controls="espStep-$i"> <i class="fas fa-chevron-down"></i>Expand ESP Configuration </button> <div class="collapse" id="espStep-$i"> "@ # Add ESP configuration content if ($flow.ESPConfiguration) { # Always show ESP configuration details for each flow (removed global deduplication) $flowHtml += @" <div class="step-name">$($flow.ESPConfiguration.Name)</div> <!-- ESP Assignment Information --> <div class="policy-category"> <h5> ESP Assignment <span class="policy-count">1</span> </h5> "@ # Use 3-column layout for ESP (removed apps column) $flowHtml += @" <div class="policy-table-header" style="grid-template-columns: 3fr 1.2fr 2.3fr;"> <div class="header-policy-name">ESP Profile</div> <div class="header-assignment">Assignment</div> <div class="header-filter">Filter</div> </div> "@ # Collect all assignments from all flows that use this ESP profile $allESPAssignments = @() foreach ($checkFlow in $EnrollmentFlows) { if ($checkFlow.ESPConfiguration -and $checkFlow.ESPConfiguration.Id -eq $flow.ESPConfiguration.Id) { $allESPAssignments += $checkFlow.ESPConfiguration.Assignments } } # Collect unique assignment types and filters for this ESP profile from all flows $uniqueAssignmentTypes = @{} $hasFilters = $false $filterInfo = @() foreach ($assignment in $allESPAssignments) { # Track unique assignment types $assignmentType = $assignment.Type if ($assignmentType -and -not $uniqueAssignmentTypes.ContainsKey($assignmentType)) { $uniqueAssignmentTypes[$assignmentType] = $true } # Check for actual filters (not just FilterId existence) if ($assignment.FilterId -and $assignment.FilterId -ne "" -and $null -ne $assignment.FilterId) { $filterClass = switch ($assignment.FilterType) { "include" { "filter-include" } "exclude" { "filter-exclude" } default { $null } } $filterTypeText = switch ($assignment.FilterType) { "include" { "Include" } "exclude" { "Exclude" } default { $null } } # Only add filter info if we have valid filter type and class if ($filterTypeText -and $filterClass) { $hasFilters = $true $filterKey = "$filterTypeText|$($assignment.FilterId)" if ($filterInfo -notcontains $filterKey) { $filterInfo += $filterKey } } } } # Build assignment text for unique types only $assignmentTexts = @() foreach ($assignmentType in $uniqueAssignmentTypes.Keys) { $assignmentText = switch ($assignmentType) { "All Devices" { "All Devices" } "All Users" { "All Users" } "Group" { "Group" } default { $assignmentType } } $assignmentTexts += $assignmentText } # Build filter text only if there are actual filters $filterTexts = @() if ($hasFilters -and $filterInfo.Count -gt 0) { foreach ($filter in $filterInfo) { $filterParts = $filter -split '\|' $filterTypeText = $filterParts[0] $filterName = $filterParts[1] $filterTexts += "${filterTypeText}: $filterName" } } # Create a single entry for the ESP profile with deduplicated assignments using 3-column layout $allAssignmentText = $assignmentTexts -join ", " $allFilterText = if ($filterTexts.Count -gt 0) { $filterTexts -join ", " } else { "" } # Use 3-column layout for ESP (removed apps column) $blockedAppsText = if ($flow.ESPConfiguration.BlockedApps -and $flow.ESPConfiguration.BlockedApps.Count -gt 0) { "$($flow.ESPConfiguration.BlockedApps.Count) blocked" } else { "No blocked apps" } $flowHtml += @" <div class="policy-item" style="grid-template-columns: 3fr 1.2fr 2.3fr;"> <div class="policy-name">$($flow.ESPConfiguration.Name)</div> <div class="policy-assignment">$allAssignmentText</div> <div class="policy-filter">$allFilterText</div> </div> "@ $flowHtml += @" </div> <!-- ESP Configuration Details --> <div class="esp-config-section"> <button class="btn btn-outline-secondary btn-sm config-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#espConfig-$i" aria-expanded="false" aria-controls="espConfig-$i"> <i class="fas fa-chevron-down me-2"></i>Show Configuration Details </button> <div class="collapse mt-3" id="espConfig-$i"> <div class="autopilot-settings"> "@ if ($flow.ESPConfiguration.Description) { $flowHtml += @" <dt>Description:</dt> <dd>$($flow.ESPConfiguration.Description)</dd> "@ } if ($flow.ESPConfiguration.InstallProgressTimeoutInMinutes) { $flowHtml += @" <dt>Show an error when installation takes longer than specified number of minutes:</dt> <dd>$($flow.ESPConfiguration.InstallProgressTimeoutInMinutes) minutes</dd> "@ } $flowHtml += @" <dt>Show app and profile configuration progress:</dt> <dd>$($flow.ESPConfiguration.ShowInstallationProgress)</dd> <dt>Track Progress for Autopilot Only:</dt> <dd>$($flow.ESPConfiguration.TrackInstallProgressForAutopilotOnly)</dd> <dt>Install Quality Updates:</dt> <dd>$($flow.ESPConfiguration.InstallQualityUpdates)</dd> <dt>Allow Device Reset on Install Failure:</dt> <dd>$($flow.ESPConfiguration.AllowDeviceResetOnInstallFailure)</dd> <dt>Allow Device Use on Install Failure:</dt> <dd>$($flow.ESPConfiguration.AllowDeviceUseOnInstallFailure)</dd> <dt>Allow Log Collection on Install Failure:</dt> <dd>$($flow.ESPConfiguration.AllowLogCollectionOnInstallFailure)</dd> <dt>Allow Non-Blocking App Installation:</dt> <dd>$($flow.ESPConfiguration.AllowNonBlockingAppInstallation)</dd> "@ if ($flow.ESPConfiguration.BlockedApps -and $flow.ESPConfiguration.BlockedApps.Count -gt 0) { $flowHtml += @" <dt>Blocked Apps:</dt> <dd> <ul class="blocked-apps-list"> "@ foreach ($blockedApp in $flow.ESPConfiguration.BlockedApps) { $flowHtml += @" <li><strong>$($blockedApp.DisplayName)</strong> - $($blockedApp.Publisher)</li> "@ } $flowHtml += @" </ul> </dd> "@ } $flowHtml += @" </div> </div> </div> "@ } else { $flowHtml += @" <div class="step-name">No ESP Configuration Found</div> <p class="text-muted">No ESP configuration is assigned to this enrollment flow.</p> "@ } $flowHtml += @" </div> </div> </div> <!-- Configuration Policies Step --> <div class="flow-step step-policies"> <div class="step-header"> <i class="fas fa-cogs"></i> <span>Configuration Policies</span> </div> <div class="step-content"> <button class="btn btn-outline-primary btn-sm step-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#policiesStep-$i" aria-expanded="false" aria-controls="policiesStep-$i"> <i class="fas fa-chevron-down"></i>Expand Configuration Policies ($($flow.TotalPolicies)) </button> <div class="collapse" id="policiesStep-$i"> "@ # Add configuration policies content if ($flow.AssignedPolicies -and $flow.AssignedPolicies.Count -gt 0) { foreach ($category in $flow.AssignedPolicies) { # Check if any policies in this category have filters $hasFilters = $false foreach ($policy in $category.Policies) { if ($policy.FilterName -and $policy.FilterName -ne "" -and $policy.FilterName -ne "No Filter" -and $policy.FilterName -ne "Filter Not Found") { $hasFilters = $true break } } $flowHtml += @" <div class="policy-category"> <h5> $($category.Category) <span class="policy-count">$($category.Count)</span> </h5> "@ # For visual consistency, use 4-column layout for Configuration Policies (removed badge column) $flowHtml += @" <div class="policy-table-header" style="grid-template-columns: 3fr 1.5fr 1.2fr 2fr;"> <div class="header-policy-name">Policy Name</div> <div class="header-assignment">Assignment</div> <div class="header-filter">Intent</div> <div class="header-filter">Filter</div> </div> "@ # Group policies by name and collect all assignments from global data $groupedPolicies = @{} foreach ($policy in $category.Policies) { if (-not $groupedPolicies.ContainsKey($policy.Name)) { $groupedPolicies[$policy.Name] = @() } $groupedPolicies[$policy.Name] += $policy } foreach ($policyName in $groupedPolicies.Keys) { # ALWAYS use global data to show ALL assignments for each policy if ($globalPolicyAssignments -and $globalPolicyAssignments.Count -gt 0) { # Use global data to get all assignments for this policy across all flows # Note: Flow data uses 'Name' property, global data uses 'PolicyName' property $allPolicyAssignments = $globalPolicyAssignments | Where-Object { $_.PolicyName -eq $policyName } } # Only fall back to flow-specific data if we have NO global data at all if ((-not $globalPolicyAssignments) -or ($globalPolicyAssignments.Count -eq 0)) { # Fallback to flow-specific data if global data is not available $allPolicyAssignments = $groupedPolicies[$policyName] } # If global lookup failed (policy name mismatch), fall back to flow data if ($allPolicyAssignments.Count -eq 0 -and $groupedPolicies[$policyName]) { $allPolicyAssignments = $groupedPolicies[$policyName] } # Sort assignments: Include first, then Exclude $sortedPolicyAssignments = $allPolicyAssignments | Sort-Object @( @{ Expression = { switch ($_.AssignmentType) { "All Devices" { 1 } "All Users" { 2 } "Group (Include)" { 3 } "Group (Exclude)" { 4 } default { 5 } } }; Ascending = $true } ) # Build all assignment details for this policy $assignmentLines = @() $intentLines = @() $filterLines = @() foreach ($policy in $sortedPolicyAssignments) { # Assignment text with group name and type $assignmentText = switch ($policy.AssignmentType) { "All Devices" { "All Devices" } "All Users" { "All Users" } "Group (Include)" { $policy.TargetName } "Group (Exclude)" { $policy.TargetName } default { $policy.TargetName } } # Intent text $intentText = switch ($policy.AssignmentType) { "Group (Include)" { "Include" } "Group (Exclude)" { "Exclude" } "All Devices" { "Include" } "All Users" { "Include" } default { "Include" } } # Filter text - include it on the same line as assignment if ($policy.FilterName -and $policy.FilterName -ne "" -and $policy.FilterName -ne "No Filter" -and $policy.FilterName -ne "Filter Not Found") { $filterTypeText = switch ($policy.FilterType) { "include" { "Include" } "exclude" { "Exclude" } default { "Filter" } } $filterText = "${filterTypeText}: $($policy.FilterName)" } else { $filterText = "" } # Build combined lines to maintain alignment $assignmentLines += $assignmentText $intentLines += $intentText $filterLines += $filterText } # Join all assignment details with line breaks to maintain one-to-one alignment $assignmentHtml = $assignmentLines -join "<br>" $intentHtml = $intentLines -join "<br>" $filterHtml = $filterLines -join "<br>" # Use 4-column layout for Configuration Policies (removed badge column) $flowHtml += @" <div class="policy-item" style="grid-template-columns: 3fr 1.5fr 1.2fr 2fr;"> <div class="policy-name">$policyName</div> <div class="policy-assignment">$assignmentHtml</div> <div class="policy-filter">$intentHtml</div> <div class="policy-filter">$filterHtml</div> </div> "@ } $flowHtml += @" </div> "@ } } else { $flowHtml += @" <div class="policy-category"> <h5>No Configuration Policies</h5> <p class="text-muted">No configuration policies are assigned to this enrollment flow.</p> </div> "@ } $flowHtml += @" </div> </div> </div> <!-- Applications Step --> <div class="flow-step step-applications"> <div class="step-header"> <i class="fas fa-mobile-alt"></i> <span>Applications</span> </div> <div class="step-content"> <button class="btn btn-outline-primary btn-sm step-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#applicationsStep-$i" aria-expanded="false" aria-controls="applicationsStep-$i"> <i class="fas fa-chevron-down"></i>Expand Applications ($($flow.AssignedApplications.Count)) </button> <div class="collapse" id="applicationsStep-$i"> "@ # Add applications content if ($flow.AssignedApplications -and $flow.AssignedApplications.Count -gt 0) { $flowHtml += @" <div class="policy-category"> <h5> Applications <span class="policy-count">$($flow.AssignedApplications.Count)</span> </h5> <div class="policy-table-header" style="grid-template-columns: 3fr 1.5fr 1.2fr 2fr;"> <div class="header-policy-name">Application Name</div> <div class="header-assignment">Group</div> <div class="header-filter">Intent</div> <div class="header-filter">Filter</div> </div> "@ foreach ($application in $flow.AssignedApplications) { # Sort assignments: Required > Available > Uninstall, and Include before Exclude $sortedAssignments = $application.Assignments | Sort-Object @( @{ Expression = { switch ($_.AssignmentIntent) { "required" { 1 } "available" { 2 } "availableWithoutEnrollment" { 3 } "uninstall" { 4 } default { 5 } } }; Ascending = $true }, @{ Expression = { switch ($_.AssignmentType) { "All Devices" { 1 } "All Users" { 2 } "Group (Include)" { 3 } "Group (Exclude)" { 4 } default { 5 } } }; Ascending = $true } ) # Build all assignment details for this application $groupNames = @() $intentTexts = @() $filterTexts = @() foreach ($assignment in $sortedAssignments) { # Group name $groupName = switch ($assignment.AssignmentType) { "All Devices" { "All Devices" } "All Users" { "All Users" } "Group (Include)" { $assignment.TargetName } "Group (Exclude)" { $assignment.TargetName } default { $assignment.TargetName } } $groupNames += $groupName # Intent text (no badges) $intentText = switch ($assignment.AssignmentIntent) { "required" { "Required" } "available" { "Available" } "uninstall" { "Uninstall" } "availableWithoutEnrollment" { "Available (No Enrollment)" } default { "Unknown" } } $intentTexts += $intentText # Filter text (no badges) if ($assignment.FilterName) { $filterTypeText = switch ($assignment.FilterType) { "include" { "Include" } "exclude" { "Exclude" } default { "Filter" } } $filterTexts += "${filterTypeText}: $($assignment.FilterName)" } else { $filterTexts += "" } } # Join all text with line breaks to maintain alignment $groupHtml = $groupNames -join "<br>" $intentHtml = $intentTexts -join "<br>" $filterHtml = $filterTexts -join "<br>" $flowHtml += @" <div class="policy-item" style="grid-template-columns: 3fr 1.5fr 1.2fr 2fr;"> <div class="policy-name">$($application.Name)</div> <div class="policy-assignment">$groupHtml</div> <div class="policy-filter">$intentHtml</div> <div class="policy-filter">$filterHtml</div> </div> "@ } $flowHtml += @" </div> "@ } else { $flowHtml += @" <div class="policy-category"> <h5>No Applications</h5> <p class="text-muted">No applications are assigned to this enrollment flow.</p> </div> "@ } $flowHtml += @" </div> </div> </div> </div> </div> "@ # Add the completed flow HTML to chunks $htmlChunks.Add($flowHtml) } # Add all flow chunks at once to StringBuilder $null = $htmlBuilder.AppendLine(($htmlChunks -join "")) $null = $htmlBuilder.AppendLine( @" </div> "@) } # End of else block $null = $htmlBuilder.AppendLine( @" </div> </div> </div> "@) $null = $htmlBuilder.AppendLine( @" <footer> <p>Generated by Roy Klooster - RK Solutions</p> </footer> <script> // Initialize DataTables if any tables exist document.addEventListener('DOMContentLoaded', function() { // Theme toggling functionality const themeToggle = document.getElementById('themeToggle'); const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)'); // Check for saved user preference, or use system preference const savedTheme = localStorage.getItem('theme'); if (savedTheme === 'dark' || (!savedTheme && prefersDarkScheme.matches)) { document.documentElement.setAttribute('data-theme', 'dark'); themeToggle.checked = true; } // Add event listener for theme toggle themeToggle.addEventListener('change', function() { if (this.checked) { document.documentElement.setAttribute('data-theme', 'dark'); localStorage.setItem('theme', 'dark'); } else { document.documentElement.setAttribute('data-theme', 'light'); localStorage.setItem('theme', 'light'); } }); // Handle system theme changes prefersDarkScheme.addEventListener('change', (e) => { if (!localStorage.getItem('theme')) { if (e.matches) { document.documentElement.setAttribute('data-theme', 'dark'); themeToggle.checked = true; } else { document.documentElement.setAttribute('data-theme', 'light'); themeToggle.checked = false; } } }); // Handle ESP configuration collapse toggle document.querySelectorAll('.config-toggle').forEach(function(button) { button.addEventListener('click', function() { // Use a timeout to allow Bootstrap to update the aria-expanded attribute setTimeout(() => { const isExpanded = this.getAttribute('aria-expanded') === 'true'; const icon = this.querySelector('i'); if (isExpanded) { this.innerHTML = '<i class="fas fa-chevron-up me-2"></i>Hide Configuration Details'; } else { this.innerHTML = '<i class="fas fa-chevron-down me-2"></i>Show Configuration Details'; } }, 100); }); }); // Handle step-level collapse toggle document.querySelectorAll('.step-toggle').forEach(function(button) { button.addEventListener('click', function() { // Use a timeout to allow Bootstrap to update the aria-expanded attribute setTimeout(() => { const isExpanded = this.getAttribute('aria-expanded') === 'true'; const icon = this.querySelector('i'); const stepName = this.textContent.replace('Expand ', '').replace('Collapse ', ''); if (isExpanded) { this.innerHTML = '<i class="fas fa-chevron-up"></i>Collapse ' + stepName; } else { this.innerHTML = '<i class="fas fa-chevron-down"></i>Expand ' + stepName; } }, 100); }); }); if (jQuery && jQuery.fn.DataTable) { jQuery('table').each(function() { if (!jQuery(this).hasClass('dataTable')) { jQuery(this).DataTable({ responsive: true, pageLength: 25, order: [[0, 'asc']], dom: 'Bfrtip', buttons: [ 'copy', 'csv', 'excel', 'pdf', 'print' ] }); } }); } // Add smooth scrolling for anchor links document.querySelectorAll('a[href^="#"]').forEach(anchor => { anchor.addEventListener('click', function (e) { e.preventDefault(); const target = document.querySelector(this.getAttribute('href')); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); }); // Initialize Bootstrap tooltips const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); tooltipTriggerList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl); }); // Add animation on scroll const observerOptions = { threshold: 0.1, rootMargin: '0px 0px -50px 0px' }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.style.opacity = '1'; entry.target.style.transform = 'translateY(0)'; } }); }, observerOptions); // Observe flow steps for animation document.querySelectorAll('.flow-step').forEach(step => { step.style.opacity = '0'; step.style.transform = 'translateY(20px)'; step.style.transition = 'opacity 0.6s ease, transform 0.6s ease'; observer.observe(step); }); // Enhance tab switching document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => { tab.addEventListener('shown.bs.tab', function (e) { // Re-trigger animations when switching tabs const targetPane = document.querySelector(e.target.getAttribute('data-bs-target')); if (targetPane) { const steps = targetPane.querySelectorAll('.flow-step'); steps.forEach((step, index) => { setTimeout(() => { step.style.opacity = '1'; step.style.transform = 'translateY(0)'; }, index * 200); }); } }); }); }); // Export functions window.exportAllFlows = function() { alert('Combined view coming soon! For now, you can see individual flows in separate tabs.'); }; </script> </body> </html> "@) $htmlContent = $htmlBuilder.ToString() if ($htmlContent.Length -eq 0) { Write-Host "ERROR: HTML content is empty!" -ForegroundColor Red return $null } try { # Convert to absolute path if relative $absolutePath = if (-not [System.IO.Path]::IsPathRooted($OutputPath)) { Join-Path (Get-Location) $OutputPath } else { $OutputPath } # Ensure directory exists $directory = [System.IO.Path]::GetDirectoryName($absolutePath) if (-not (Test-Path $directory)) { Write-Host "Creating directory: $directory" -ForegroundColor Gray New-Item -ItemType Directory -Path $directory -Force | Out-Null } # Use UTF8 without BOM for better compatibility Write-Host "Writing HTML content to file: $absolutePath" -ForegroundColor Gray Write-Host "HTML content length: $($htmlContent.Length) characters" -ForegroundColor Gray [System.IO.File]::WriteAllText($absolutePath, $htmlContent, [System.Text.UTF8Encoding]::new($false)) # Wait a moment for file system to catch up Start-Sleep -Milliseconds 100 # Verify file was created if (Test-Path $absolutePath) { $fileInfo = Get-Item $absolutePath Write-Host "File created successfully: $($fileInfo.FullName)" -ForegroundColor Green Write-Host "File size: $($fileInfo.Length) bytes" -ForegroundColor Gray # Open the HTML file automatically try { Write-Host "Opening HTML report in default browser..." -ForegroundColor Cyan Invoke-Item $absolutePath } catch { Write-Host "Could not automatically open HTML file: $($_.Exception.Message)" -ForegroundColor Yellow Write-Host "You can manually open: $absolutePath" -ForegroundColor Cyan } return $absolutePath } else { Write-Host "ERROR: File was not created at $absolutePath" -ForegroundColor Red # Try alternative method Write-Host "Attempting alternative file creation method..." -ForegroundColor Yellow $htmlBuilder.ToString() | Out-File -FilePath $absolutePath -Encoding UTF8 if (Test-Path $absolutePath) { $fileInfo = Get-Item $absolutePath Write-Host "File created successfully using alternative method: $($fileInfo.FullName)" -ForegroundColor Green # Open the HTML file automatically try { Write-Host "Opening HTML report in default browser..." -ForegroundColor Cyan Invoke-Item $absolutePath } catch { Write-Host "Could not automatically open HTML file: $($_.Exception.Message)" -ForegroundColor Yellow Write-Host "You can manually open: $absolutePath" -ForegroundColor Cyan } return $absolutePath } else { Write-Host "ERROR: Alternative method also failed" -ForegroundColor Red return $null } } } catch { Write-Error "Error generating HTML report: $($_.Exception.Message)" return $null } } # Connect to the Graph API Write-Verbose "Connecting to Graph API..." try { # Determine which authentication method to use based on parameters $connectionParams = @{ RequiredScopes = $RequiredScopes Verbose = $VerbosePreference -eq 'Continue' } # Add parameters based on which ones were provided if ($PSCmdlet.ParameterSetName -eq "Interactive") { if ($TenantId) { $connectionParams.TenantId = $TenantId } if ($ClientId) { $connectionParams.ClientId = $ClientId } } elseif ($PSCmdlet.ParameterSetName -eq "ClientSecret") { $connectionParams.TenantId = $TenantId $connectionParams.ClientId = $ClientId $connectionParams.ClientSecret = $ClientSecret } elseif ($PSCmdlet.ParameterSetName -eq "Certificate") { $connectionParams.TenantId = $TenantId $connectionParams.ClientId = $ClientId $connectionParams.CertificateThumbprint = $CertificateThumbprint } elseif ($PSCmdlet.ParameterSetName -eq "Identity") { $connectionParams.Identity = $true if ($TenantId) { $connectionParams.TenantId = $TenantId } } elseif ($PSCmdlet.ParameterSetName -eq "AccessToken") { $connectionParams.AccessToken = $AccessToken $connectionParams.TenantId = $TenantId } $connected = Connect-ToMgGraph @connectionParams if ($connected) { Write-Verbose "Connected to Graph API successfully." # Get Tenant Name $tenantInfo = Invoke-MgGraphRequest -Uri "beta/organization" -Method Get -OutputType PSObject $tenantName = $tenantInfo.value[0].displayName Write-Host "Connected to tenant: $tenantName" -ForegroundColor Green # Execute the comprehensive policy retrieval Write-Host "`nExecuting comprehensive Intune policy and assignment analysis..." -ForegroundColor Cyan # Get all policies with their detailed assignments $allPolicyAssignments = Get-AllIntunePoliciesWithAssignments -IncludeFilterDetails:$IncludeFilterDetails -ExportToCsv:$ExportToCsv -ExportPath $ExportPath Write-Host "`nAnalysis complete! Found $($allPolicyAssignments.Count) total policy assignments." -ForegroundColor Green # Get ESP configurations $espConfigurations = Get-ESPConfigurations Write-Host "Found $($espConfigurations.Count) ESP configurations" -ForegroundColor Green # Generate HTML Enrollment Flow Report if requested Write-Host "`nChecking if HTML report should be generated..." -ForegroundColor Cyan if ($GenerateHtmlReport) { Write-Host "HTML report generation requested - proceeding..." -ForegroundColor Green Write-Host "`nGenerating HTML Enrollment Flow Report..." -ForegroundColor Cyan try { $enrollmentFlows = Get-EnrollmentFlowData -PolicyAssignments $allPolicyAssignments -ESPConfigurations $espConfigurations Write-Host "Found $($enrollmentFlows.Count) enrollment flows" -ForegroundColor Yellow # Always generate HTML report, even if no flows found $htmlPath = if ($HtmlReportPath) { # Convert to absolute path if relative if (-not [System.IO.Path]::IsPathRooted($HtmlReportPath)) { Join-Path (Get-Location) $HtmlReportPath } else { $HtmlReportPath } } else { # Use absolute path for default filename Join-Path (Get-Location) "EnrollmentFlowReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html" } Write-Host "Generating HTML report at: $htmlPath" -ForegroundColor Gray $generatedReport = New-EnrollmentFlowHtmlReport -EnrollmentFlows $enrollmentFlows -OutputPath $htmlPath -TenantName $tenantName -AllPolicyAssignments $allPolicyAssignments Write-Host "`n=== FILE VERIFICATION ===" -ForegroundColor Magenta if ($generatedReport -and (Test-Path $generatedReport)) { $fileInfo = Get-Item $generatedReport Write-Host "HTML Enrollment Flow Report generated: $($fileInfo.FullName)" -ForegroundColor Green Write-Host "File size: $($fileInfo.Length) bytes" -ForegroundColor Green Write-Host "You can open it with: Invoke-Item '$($fileInfo.FullName)'" -ForegroundColor Cyan if ($enrollmentFlows.Count -gt 0) { # Show enrollment flow summary Write-Host "`n=== ENROLLMENT FLOW SUMMARY ===" -ForegroundColor Magenta Write-Host "Total enrollment flows: $($enrollmentFlows.Count)" -ForegroundColor White foreach ($flow in $enrollmentFlows) { Write-Host "`nFlow: $($flow.FlowName)" -ForegroundColor Cyan Write-Host " Group: $($flow.DynamicGroup.Name)" -ForegroundColor Gray if ($flow.DynamicGroup.GroupTag) { Write-Host " Group Tag: $($flow.DynamicGroup.GroupTag)" -ForegroundColor Yellow } Write-Host " Autopilot Profile: $($flow.AutopilotProfile.Name)" -ForegroundColor Gray Write-Host " Assigned Policies: $($flow.TotalPolicies)" -ForegroundColor Gray Write-Host " └─ Direct Group: $($flow.PolicyBreakdown.DirectGroup)" -ForegroundColor DarkGray Write-Host " └─ All Devices: $($flow.PolicyBreakdown.AllDevices)" -ForegroundColor DarkGray Write-Host " └─ All Users: $($flow.PolicyBreakdown.AllUsers)" -ForegroundColor DarkGray Write-Host " └─ Excluded: $($flow.PolicyBreakdown.Excluded)" -ForegroundColor DarkGray if ($flow.AssignedPolicies) { foreach ($category in $flow.AssignedPolicies) { Write-Host " $($category.Category): $($category.Count) policies" -ForegroundColor DarkGray } } } } else { Write-Host "`nNo enrollment flows found, but HTML report was generated with all policy data" -ForegroundColor Yellow } } else { Write-Host "Failed to generate HTML report" -ForegroundColor Red } } catch { Write-Host "Error generating HTML report: $($_.Exception.Message)" -ForegroundColor Red Write-Host "Stack trace: $($_.ScriptStackTrace)" -ForegroundColor Red } } else { Write-Host "HTML report generation NOT requested (GenerateHtmlReport = $GenerateHtmlReport)" -ForegroundColor Yellow } # Additional analysis - Show policies by assignment type Write-Host "`n=== ASSIGNMENT TYPE BREAKDOWN ===" -ForegroundColor Magenta $assignmentTypeGroups = $allPolicyAssignments | Group-Object -Property AssignmentType foreach ($group in $assignmentTypeGroups | Sort-Object Name) { Write-Host "$($group.Name): $($group.Count) assignments" -ForegroundColor White } # Show filter usage (only if filters are actually used) $filterGroups = $allPolicyAssignments | Where-Object { $_.FilterName -ne "No Filter" -and $_.FilterName -ne "Filter Not Found" } | Group-Object -Property FilterName if ($filterGroups) { Write-Host "`n=== FILTER USAGE ANALYSIS ===" -ForegroundColor Magenta Write-Host "Policies using filters: $($filterGroups.Count) unique filters" -ForegroundColor White foreach ($group in $filterGroups | Sort-Object Name) { Write-Host " $($group.Name): Used in $($group.Count) assignments" -ForegroundColor Gray } } } else { throw "Failed to connect to Microsoft Graph API." } } catch { Write-Error "Error: $_" throw $_ } finally { # Disconnect from Microsoft Graph if connected $contextInfo = Get-MgContext -ErrorAction SilentlyContinue if ($contextInfo) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Write-Host "Disconnected from Microsoft Graph." -ForegroundColor Green } else { Write-Host "No active connection to disconnect." -ForegroundColor Yellow } } |