Back to Scripts

Create App-Based Entra ID Groups

This script queries Intune-managed devices to identify which applications are installed, then creates or updates Entra ID groups containing devices with specific applications. It supports multiple detection methods including detected apps and deployment status, handles all app types (Win32, Store, LOB, Web apps), and provides flexible group creation options. Perfect for dynamic device targeting based on installed software.

Devices
Author: Ugur Koc
Version: 1.0
All Tests PassedTested on 08-31-2025
View on GitHub

Required Permissions

DeviceManagementManagedDevices.Read.All

Allows the app to read the properties of devices managed by Microsoft Intune, without a signed-in user.

DeviceManagementApps.Read.All

Allows the app to read the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune, without a signed-in user.

Group.ReadWrite.All
Directory.Read.All
create-app-based-groups.ps1
<#
.TITLE
    Create App-Based Entra ID Groups

.SYNOPSIS
    Creates Entra ID groups based on applications installed on Intune-managed devices.

.DESCRIPTION
    This script queries Intune-managed devices to identify which applications are installed,
    then creates or updates Entra ID groups containing devices with specific applications.
    It supports multiple detection methods including detected apps and deployment status,
    handles all app types (Win32, Store, LOB, Web apps), and provides flexible group
    creation options. Perfect for dynamic device targeting based on installed software.

.TAGS
    Devices

.MINROLE
    Intune Administrator

.PERMISSIONS
    DeviceManagementManagedDevices.Read.All,DeviceManagementApps.Read.All,Group.ReadWrite.All,Directory.Read.All

.AUTHOR
    Ugur Koc

.VERSION
    1.0

.CHANGELOG
    1.0 - Initial release

.LASTUPDATE
    2025-06-23

.EXAMPLE
    .\create-app-based-groups.ps1 -ApplicationName "TeamViewer"
    Creates a group named "Devices-With-TeamViewer" containing all devices with TeamViewer installed

.EXAMPLE
    .\create-app-based-groups.ps1 -ApplicationName "Microsoft*" -GroupPrefix "SW-" -GroupSuffix "-Installed"
    Creates groups for all Microsoft apps with custom naming (e.g., "SW-Microsoft Teams-Installed")

.EXAMPLE
    .\create-app-based-groups.ps1 -ApplicationName "Chrome" -MinimumVersion "120.0" -UpdateExisting
    Creates/updates a group with devices having Chrome version 120.0 or higher

.EXAMPLE
    .\create-app-based-groups.ps1 -ApplicationName "*" -FilterByType "Win32" -DryRun
    Preview groups that would be created for all Win32 applications

.NOTES
    - Requires Microsoft.Graph.Authentication module
    - Supports wildcards in application names
    - Can create multiple groups in a single run
    - Uses both detected apps and deployment status for comprehensive coverage
    - Groups are created as security groups by default
    - Device limit per group is 100,000 (Entra ID limitation)
#>

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory = $true, HelpMessage = "Application name or pattern (supports wildcards)")]
    [string]$ApplicationName,
    
    [Parameter(Mandatory = $false, HelpMessage = "Prefix for group names")]
    [string]$GroupPrefix = "Devices-With-",
    
    [Parameter(Mandatory = $false, HelpMessage = "Suffix for group names")]
    [string]$GroupSuffix = "",
    
    [Parameter(Mandatory = $false, HelpMessage = "Update existing groups instead of creating new")]
    [switch]$UpdateExisting,
    
    [Parameter(Mandatory = $false, HelpMessage = "Minimum application version")]
    [string]$MinimumVersion,
    
    [Parameter(Mandatory = $false, HelpMessage = "Filter by app type (Win32, Store, LOB, Web, etc.)")]
    [ValidateSet("Win32", "Store", "LOB", "Web", "iOS", "Android", "macOS", "All")]
    [string]$FilterByType = "All",
    
    [Parameter(Mandatory = $false, HelpMessage = "Filter by device platform")]
    [ValidateSet("Windows", "iOS", "Android", "macOS", "All")]
    [string]$FilterByPlatform = "All",
    
    [Parameter(Mandatory = $false, HelpMessage = "Only include devices with successful installations")]
    [switch]$OnlySuccessfulInstalls,
    
    [Parameter(Mandatory = $false, HelpMessage = "Preview changes without creating groups")]
    [switch]$DryRun,
    
    [Parameter(Mandatory = $false, HelpMessage = "Maximum devices to process (0 = all)")]
    [int]$MaxDevices = 0,
    
    [Parameter(Mandatory = $false, HelpMessage = "Force module installation without prompting")]
    [switch]$ForceModuleInstall
)

# ============================================================================
# ENVIRONMENT DETECTION AND SETUP
# ============================================================================

function Initialize-RequiredModule {
    param(
        [string[]]$ModuleNames,
        [bool]$IsAutomationEnvironment,
        [bool]$ForceInstall = $false
    )
    
    foreach ($ModuleName in $ModuleNames) {
        Write-Verbose "Checking module: $ModuleName"
        
        $module = Get-Module -ListAvailable -Name $ModuleName | Select-Object -First 1
        
        if (-not $module) {
            if ($IsAutomationEnvironment) {
                throw "Module '$ModuleName' is not available in Azure Automation"
            }
            else {
                Write-Information "Module '$ModuleName' not found. Installing..." -InformationAction Continue
                
                if (-not $ForceInstall) {
                    $response = Read-Host "Install module '$ModuleName'? (Y/N)"
                    if ($response -notmatch '^[Yy]') {
                        throw "Module '$ModuleName' is required but installation was declined."
                    }
                }
                
                try {
                    $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
                    $scope = if ($isAdmin) { "AllUsers" } else { "CurrentUser" }
                    
                    Install-Module -Name $ModuleName -Scope $scope -Force -AllowClobber -Repository PSGallery
                    Write-Information "✓ Successfully installed '$ModuleName'" -InformationAction Continue
                }
                catch {
                    throw "Failed to install module '$ModuleName': $($_.Exception.Message)"
                }
            }
        }
        
        Import-Module -Name $ModuleName -Force -ErrorAction Stop
    }
}

# Detect execution environment
$IsAzureAutomation = $null -ne $PSPrivateMetadata.JobId.Guid

# Initialize required modules
$RequiredModules = @("Microsoft.Graph.Authentication")

try {
    Initialize-RequiredModule -ModuleNames $RequiredModules -IsAutomationEnvironment $IsAzureAutomation -ForceInstall $ForceModuleInstall
    Write-Verbose "✓ All required modules are available"
}
catch {
    Write-Error "Module initialization failed: $_"
    exit 1
}

# ============================================================================
# AUTHENTICATION
# ============================================================================

try {
    if ($IsAzureAutomation) {
        Write-Output "Connecting to Microsoft Graph using Managed Identity..."
        Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop
    }
    else {
        Write-Information "Connecting to Microsoft Graph..." -InformationAction Continue
        $Scopes = @(
            "DeviceManagementManagedDevices.Read.All",
            "DeviceManagementApps.Read.All", 
            "Group.ReadWrite.All",
            "Directory.Read.All"
        )
        Connect-MgGraph -Scopes $Scopes -NoWelcome -ErrorAction Stop
    }
    Write-Information "✓ Successfully connected to Microsoft Graph" -InformationAction Continue
}
catch {
    Write-Error "Failed to connect to Microsoft Graph: $($_.Exception.Message)"
    exit 1
}

# ============================================================================
# HELPER FUNCTIONS
# ============================================================================

function Get-MgGraphAllPage {
    param(
        [string]$Uri,
        [int]$DelayMs = 100
    )
    
    $allResults = @()
    $nextLink = $Uri
    $requestCount = 0
    
    do {
        try {
            if ($requestCount -gt 0) {
                Start-Sleep -Milliseconds $DelayMs
            }
            
            $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET
            $requestCount++
            
            if ($response.value) {
                $allResults += $response.value
            }
            else {
                $allResults += $response
            }
            
            $nextLink = $response.'@odata.nextLink'
            
            if ($requestCount % 10 -eq 0) {
                Write-Information "." -InformationAction Continue
            }
        }
        catch {
            if ($_.Exception.Message -like "*429*") {
                Write-Information "`nRate limit hit, waiting 60 seconds..." -InformationAction Continue
                Start-Sleep -Seconds 60
                continue
            }
            Write-Warning "Error fetching data: $($_.Exception.Message)"
            break
        }
    } while ($nextLink)
    
    return $allResults
}

function Get-AppTypeFromODataType {
    param([string]$ODataType)
    
    switch ($ODataType) {
        "#microsoft.graph.win32LobApp" { return "Win32" }
        "#microsoft.graph.microsoftStoreForBusinessApp" { return "Store" }
        "#microsoft.graph.webApp" { return "Web" }
        "#microsoft.graph.officeSuiteApp" { return "Office" }
        "#microsoft.graph.winGetApp" { return "WinGet" }
        "#microsoft.graph.iosLobApp" { return "iOS" }
        "#microsoft.graph.iosStoreApp" { return "iOS" }
        "#microsoft.graph.androidManagedStoreApp" { return "Android" }
        "#microsoft.graph.androidLobApp" { return "Android" }
        "#microsoft.graph.macOSLobApp" { return "macOS" }
        "#microsoft.graph.macOSOfficeSuiteApp" { return "macOS" }
        default { return "Other" }
    }
}

function Compare-Version {
    param(
        [string]$Version1,
        [string]$Version2
    )
    
    try {
        $v1 = [Version]$Version1
        $v2 = [Version]$Version2
        return $v1 -ge $v2
    }
    catch {
        # Fallback to string comparison if version parsing fails
        return $Version1 -ge $Version2
    }
}

function Get-SanitizedGroupName {
    param([string]$AppName)
    
    # Remove invalid characters for group names
    $sanitized = $AppName -replace '[^\w\s-]', ''
    $sanitized = $sanitized -replace '\s+', '-'
    $sanitized = $sanitized -replace '-+', '-'
    $sanitized = $sanitized.Trim('-')
    
    # Ensure the name is not too long (max 256 chars for Entra ID)
    $maxLength = 256 - $GroupPrefix.Length - $GroupSuffix.Length
    if ($sanitized.Length -gt $maxLength) {
        $sanitized = $sanitized.Substring(0, $maxLength)
    }
    
    return "${GroupPrefix}${sanitized}${GroupSuffix}"
}

# ============================================================================
# MAIN SCRIPT LOGIC
# ============================================================================

try {
    Write-Information "Starting app-based group creation process..." -InformationAction Continue
    
    # Get all managed devices
    Write-Information "Retrieving managed devices..." -InformationAction Continue
    $devicesUri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices"
    if ($MaxDevices -gt 0) {
        $devicesUri += "?`$top=$MaxDevices"
    }
    
    $devices = Get-MgGraphAllPages -Uri $devicesUri
    
    # Filter by platform if specified
    if ($FilterByPlatform -ne "All") {
        $devices = $devices | Where-Object { 
            $_.operatingSystem -like "$FilterByPlatform*" 
        }
    }
    
    Write-Information "`n✓ Found $($devices.Count) managed devices" -InformationAction Continue
    
    # Dictionary to store app->devices mapping
    $appDeviceMap = @{}
    $processedDevices = 0
    
    # Process devices to get detected apps
    Write-Information "Processing device applications..." -InformationAction Continue
    
    foreach ($device in $devices) {
        $processedDevices++
        Write-Progress -Activity "Processing Devices" -Status "$processedDevices of $($devices.Count)" -PercentComplete (($processedDevices / $devices.Count) * 100)
        
        try {
            # Get detected apps for the device
            $deviceAppsUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($device.id)?`$expand=detectedApps"
            $deviceWithApps = Invoke-MgGraphRequest -Uri $deviceAppsUri -Method GET
            
            if ($deviceWithApps.detectedApps) {
                foreach ($app in $deviceWithApps.detectedApps) {
                    # Check if app matches the filter
                    if ($app.displayName -like $ApplicationName) {
                        # Check version if specified
                        if ($MinimumVersion -and $app.version) {
                            if (-not (Compare-Version -Version1 $app.version -Version2 $MinimumVersion)) {
                                continue
                            }
                        }
                        
                        # Add device to app mapping
                        $appKey = $app.displayName
                        if (-not $appDeviceMap.ContainsKey($appKey)) {
                            $appDeviceMap[$appKey] = @{
                                Devices    = @()
                                Versions   = @{}
                                Publishers = @{}
                            }
                        }
                        
                        $appDeviceMap[$appKey].Devices += @{
                            DeviceId   = $device.id
                            DeviceName = $device.deviceName
                            Platform   = $device.operatingSystem
                            User       = $device.userPrincipalName
                            Version    = $app.version
                            Publisher  = $app.publisher
                        }
                        
                        # Track versions and publishers
                        if ($app.version) {
                            $appDeviceMap[$appKey].Versions[$app.version] = ($appDeviceMap[$appKey].Versions[$app.version] ?? 0) + 1
                        }
                        if ($app.publisher) {
                            $appDeviceMap[$appKey].Publishers[$app.publisher] = ($appDeviceMap[$appKey].Publishers[$app.publisher] ?? 0) + 1
                        }
                    }
                }
            }
            
            Start-Sleep -Milliseconds 50
        }
        catch {
            if ($_.Exception.Message -like "*429*") {
                Write-Information "`nRate limit hit, waiting 60 seconds..." -InformationAction Continue
                Start-Sleep -Seconds 60
                $processedDevices--
                continue
            }
            Write-Warning "Error processing device $($device.deviceName): $($_.Exception.Message)"
        }
    }
    
    Write-Progress -Activity "Processing Devices" -Completed
    
    # Get deployed apps if we need additional coverage
    if ($FilterByType -ne "All" -or $OnlySuccessfulInstalls) {
        Write-Information "Retrieving deployed application data..." -InformationAction Continue
        $appsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps"
        $deployedApps = Get-MgGraphAllPages -Uri $appsUri
        
        foreach ($app in $deployedApps) {
            if ($app.displayName -like $ApplicationName) {
                $appType = Get-AppTypeFromODataType -ODataType $app.'@odata.type'
                
                # Filter by type if specified
                if ($FilterByType -ne "All" -and $appType -ne $FilterByType) {
                    continue
                }
                
                # Get device installation status
                $statusUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($app.id)/deviceStatuses"
                $deviceStatuses = Get-MgGraphAllPages -Uri $statusUri
                
                foreach ($status in $deviceStatuses) {
                    # Filter by installation status if specified
                    if ($OnlySuccessfulInstalls -and $status.installState -ne "installed") {
                        continue
                    }
                    
                    # Find matching device
                    $matchingDevice = $devices | Where-Object { $_.id -eq $status.deviceId }
                    if ($matchingDevice) {
                        $appKey = $app.displayName
                        if (-not $appDeviceMap.ContainsKey($appKey)) {
                            $appDeviceMap[$appKey] = @{
                                Devices    = @()
                                Versions   = @{}
                                Publishers = @{}
                                AppType    = $appType
                            }
                        }
                        
                        # Check if device already added
                        $existingDevice = $appDeviceMap[$appKey].Devices | Where-Object { $_.DeviceId -eq $matchingDevice.id }
                        if (-not $existingDevice) {
                            $appDeviceMap[$appKey].Devices += @{
                                DeviceId     = $matchingDevice.id
                                DeviceName   = $matchingDevice.deviceName
                                Platform     = $matchingDevice.operatingSystem
                                User         = $matchingDevice.userPrincipalName
                                InstallState = $status.installState
                                AppType      = $appType
                            }
                        }
                    }
                }
            }
        }
    }
    
    # Create or update groups
    Write-Information "`nProcessing groups for $($appDeviceMap.Count) applications..." -InformationAction Continue
    $groupsCreated = 0
    $groupsUpdated = 0
    $totalDevicesProcessed = 0
    
    foreach ($appName in $appDeviceMap.Keys) {
        $appInfo = $appDeviceMap[$appName]
        $uniqueDevices = $appInfo.Devices | Select-Object -Property DeviceId -Unique
        $deviceCount = $uniqueDevices.Count
        
        if ($deviceCount -eq 0) {
            continue
        }
        
        $groupName = Get-SanitizedGroupName -AppName $appName
        Write-Information "`nProcessing: $appName ($deviceCount devices in Intune)" -InformationAction Continue
        
        if ($DryRun) {
            Write-Information "  [DRY RUN] Would create/update group: $groupName" -InformationAction Continue
            Write-Information "  Total devices with app: $deviceCount" -InformationAction Continue
            
            # Show device names
            Write-Information "  Devices to be added:" -InformationAction Continue
            foreach ($device in $appInfo.Devices) {
                Write-Information "    • $($device.DeviceName) ($($device.Platform))" -InformationAction Continue
            }
            
            if ($appInfo.Versions.Count -gt 0) {
                Write-Information "  Versions found: $($appInfo.Versions.Keys -join ', ')" -InformationAction Continue
            }
            $totalDevicesProcessed += $deviceCount
            continue
        }
        
        # Check if group exists
        $existingGroup = $null
        try {
            $groupFilter = "displayName eq '$groupName'"
            $existingGroups = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=$groupFilter" -Method GET
            $existingGroup = $existingGroups.value | Select-Object -First 1
        }
        catch {
            Write-Verbose "No existing group found with name: $groupName"
        }
        
        if ($existingGroup -and -not $UpdateExisting) {
            Write-Warning "  Group '$groupName' already exists. Use -UpdateExisting to update it."
            continue
        }
        
        # Prepare member list - need to get Entra ID device object IDs
        $memberIds = @()
        $entraDevices = @()
        
        if ($uniqueDevices.Count -gt 0) {
            Write-Verbose "Looking up Entra ID device objects for $($uniqueDevices.Count) devices..."
            
            foreach ($device in $uniqueDevices) {
                try {
                    # Get the Intune device details first
                    $intuneDevice = $devices | Where-Object { $_.id -eq $device.DeviceId } | Select-Object -First 1
                    
                    if ($intuneDevice -and $intuneDevice.azureADDeviceId) {
                        # Look up the device in Entra ID by Azure AD Device ID
                        $filter = "deviceId eq '$($intuneDevice.azureADDeviceId)'"
                        $entraDeviceUri = "https://graph.microsoft.com/v1.0/devices?`$filter=$filter"
                        $entraDeviceResponse = Invoke-MgGraphRequest -Uri $entraDeviceUri -Method GET
                        
                        if ($entraDeviceResponse.value -and $entraDeviceResponse.value.Count -gt 0) {
                            $entraDevice = $entraDeviceResponse.value[0]
                            $memberIds += "https://graph.microsoft.com/v1.0/directoryObjects/$($entraDevice.id)"
                            $entraDevices += @{
                                IntuneDeviceId = $device.DeviceId
                                EntraDeviceId  = $entraDevice.id
                                DeviceName     = $intuneDevice.deviceName
                            }
                            Write-Verbose "Found Entra ID device: $($intuneDevice.deviceName) -> $($entraDevice.id)"
                        }
                        else {
                            Write-Warning "Device not found in Entra ID: $($intuneDevice.deviceName) (Azure AD Device ID: $($intuneDevice.azureADDeviceId))"
                        }
                    }
                    else {
                        Write-Warning "No Azure AD Device ID for: $($intuneDevice.deviceName)"
                    }
                }
                catch {
                    Write-Warning "Error looking up Entra ID device for $($intuneDevice.deviceName): $($_.Exception.Message)"
                }
            }
            
            Write-Verbose "Found $($memberIds.Count) devices in Entra ID out of $($uniqueDevices.Count) Intune devices"
        }
        
        if ($existingGroup -and $UpdateExisting) {
            # Update existing group
            if ($PSCmdlet.ShouldProcess($groupName, "Update group members")) {
                try {
                    # Get current members
                    $currentMembersUri = "https://graph.microsoft.com/v1.0/groups/$($existingGroup.id)/members"
                    $currentMembers = Get-MgGraphAllPages -Uri $currentMembersUri
                    $currentMemberIds = $currentMembers | ForEach-Object { $_.id }
                    
                    # Calculate additions and removals - use Entra device IDs
                    $entraDeviceIds = $entraDevices | ForEach-Object { $_.EntraDeviceId }
                    $deviceIdsToAdd = $entraDeviceIds | Where-Object { $_ -notin $currentMemberIds }
                    $deviceIdsToRemove = $currentMemberIds | Where-Object { $_ -notin $entraDeviceIds }
                    
                    # Add new members
                    if ($deviceIdsToAdd.Count -gt 0) {
                        # Add members in batches
                        $batchSize = 20
                        for ($i = 0; $i -lt $deviceIdsToAdd.Count; $i += $batchSize) {
                            $batch = $deviceIdsToAdd[$i..([Math]::Min($i + $batchSize - 1, $deviceIdsToAdd.Count - 1))]
                            $addBody = @{
                                "members@odata.bind" = $batch | ForEach-Object {
                                    "https://graph.microsoft.com/v1.0/directoryObjects/$_"
                                }
                            } | ConvertTo-Json -Depth 10
                            
                            Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/$($existingGroup.id)" -Method PATCH -Body $addBody -ContentType "application/json"
                            Write-Verbose "Added batch of $($batch.Count) members"
                        }
                    }
                    
                    # Remove old members
                    foreach ($memberId in $deviceIdsToRemove) {
                        $removeUri = "https://graph.microsoft.com/v1.0/groups/$($existingGroup.id)/members/$memberId/`$ref"
                        Invoke-MgGraphRequest -Uri $removeUri -Method DELETE
                    }
                    
                    Write-Information "  ✓ Updated group: $groupName (Added: $($deviceIdsToAdd.Count), Removed: $($deviceIdsToRemove.Count))" -InformationAction Continue
                    
                    # Display added devices
                    if ($deviceIdsToAdd.Count -gt 0) {
                        Write-Information "  Added devices:" -InformationAction Continue
                        foreach ($deviceId in $deviceIdsToAdd) {
                            $deviceInfo = $entraDevices | Where-Object { $_.EntraDeviceId -eq $deviceId } | Select-Object -First 1
                            if ($deviceInfo) {
                                Write-Information "    • $($deviceInfo.DeviceName)" -InformationAction Continue
                            }
                        }
                    }
                    
                    $groupsUpdated++
                }
                catch {
                    Write-Error "  ✗ Failed to update group: $($_.Exception.Message)"
                }
            }
        }
        else {
            # Create new group
            if ($PSCmdlet.ShouldProcess($groupName, "Create new group")) {
                try {
                    # Create group without members first
                    $groupBody = @{
                        displayName     = $groupName
                        mailEnabled     = $false
                        mailNickname    = $groupName -replace '[^a-zA-Z0-9]', ''
                        securityEnabled = $true
                        description     = "Devices with $appName installed (Created by Intune Automation)"
                    } | ConvertTo-Json -Depth 10
                    
                    $newGroup = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups" -Method POST -Body $groupBody -ContentType "application/json"
                    Write-Information "  ✓ Created group: $groupName" -InformationAction Continue
                    Write-Information "  Group ID: $($newGroup.id)" -InformationAction Continue
                    
                    # Add members to the group if any
                    if ($memberIds.Count -gt 0) {
                        try {
                            # Add members in batches of 20 (Graph API limitation)
                            $batchSize = 20
                            for ($i = 0; $i -lt $memberIds.Count; $i += $batchSize) {
                                $batch = $memberIds[$i..([Math]::Min($i + $batchSize - 1, $memberIds.Count - 1))]
                                $addMembersBody = @{
                                    "members@odata.bind" = $batch
                                } | ConvertTo-Json -Depth 10
                                
                                Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/$($newGroup.id)" -Method PATCH -Body $addMembersBody -ContentType "application/json"
                                Write-Verbose "Added batch of $($batch.Count) members"
                            }
                            Write-Information "  ✓ Added $($memberIds.Count) devices to group" -InformationAction Continue
                            
                            # Display added devices
                            Write-Information "  Added devices:" -InformationAction Continue
                            foreach ($device in $entraDevices) {
                                Write-Information "    • $($device.DeviceName)" -InformationAction Continue
                            }
                        }
                        catch {
                            Write-Warning "  Group created but failed to add members: $($_.Exception.Message)"
                        }
                    }
                    
                    $groupsCreated++
                }
                catch {
                    Write-Error "  ✗ Failed to create group: $($_.Exception.Message)"
                    Write-Verbose "Group body: $groupBody"
                }
            }
        }
        
        $totalDevicesProcessed += $deviceCount
    }
    
    # Display summary
    Write-Information "`n📊 APP-BASED GROUP CREATION SUMMARY" -InformationAction Continue
    Write-Information "===================================" -InformationAction Continue
    Write-Information "Applications matched: $($appDeviceMap.Count)" -InformationAction Continue
    Write-Information "Total devices processed: $totalDevicesProcessed" -InformationAction Continue
    Write-Information "Groups created: $groupsCreated" -InformationAction Continue
    Write-Information "Groups updated: $groupsUpdated" -InformationAction Continue
    
    if ($DryRun) {
        Write-Information "`n[DRY RUN] No changes were made" -InformationAction Continue
    }
    
    # Display top apps by device count
    if ($appDeviceMap.Count -gt 0) {
        Write-Information "`nTop Applications by Device Count:" -InformationAction Continue
        $appDeviceMap.GetEnumerator() | 
        Sort-Object { $_.Value.Devices.Count } -Descending | 
        Select-Object -First 10 |
        ForEach-Object {
            $deviceCount = ($_.Value.Devices | Select-Object -Property DeviceId -Unique).Count
            Write-Information "  • $($_.Key): $deviceCount devices" -InformationAction Continue
        }
    }
    
    Write-Information "`n🎉 App-based group creation completed successfully!" -InformationAction Continue
}
catch {
    Write-Error "Script execution failed: $($_.Exception.Message)"
    exit 1
}
finally {
    try {
        Disconnect-MgGraph | Out-Null
        Write-Information "✓ Disconnected from Microsoft Graph" -InformationAction Continue
    }
    catch {
        Write-Verbose "Graph disconnection completed"
    }
}