Invoke-M365LicenseReport.ps1
<#PSScriptInfo
.VERSION 0.1 .GUID c65d2f63-9958-427d-bf2c-0188c4739681 .AUTHOR Daniel Bradley .COMPANYNAME Ourcloudnetwork.co.uk .COPYRIGHT .TAGS ourcloudnetwork Microsoft 365 Microsoft Graph .LICENSEURI .PROJECTURI .ICONURI .EXTERNALMODULEDEPENDENCIES Microsoft.Graph.Authentication Microsoft.Graph.Beta.Users Microsoft.Graph.Identity.DirectoryManagement .RELEASENOTES v0.1 - Initial release #> <# .DESCRIPTION This script generates a license report for Microsoft 365 #> #Params param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$outpath ) # Check Microsoft Graph connection $state = Get-MgContext # Define required permissions properly as an array of strings $requiredPerms = @( "User.Read.All", "AuditLog.Read.All", "Organization.Read.All", "RoleManagement.Read.Directory" ) # Check if we're connected and have all required permissions $hasAllPerms = $false if ($state) { $missingPerms = @() foreach ($perm in $requiredPerms) { if ($state.Scopes -notcontains $perm) { $missingPerms += $perm } } if ($missingPerms.Count -eq 0) { $hasAllPerms = $true Write-Host "Connected to Microsoft Graph with all required permissions" -ForegroundColor Green } else { Write-Host "Missing required permissions: $($missingPerms -join ', ')" -ForegroundColor Yellow Write-Host "Reconnecting with all required permissions..." -ForegroundColor Yellow } } else { Write-Host "Not connected to Microsoft Graph. Connecting now..." -ForegroundColor Yellow } # Connect if we need to if (-not $hasAllPerms) { try { Connect-MgGraph -Scopes $requiredPerms -ErrorAction Stop -NoWelcome Write-Host "Successfully connected to Microsoft Graph" -ForegroundColor Green } catch { Write-Error "Failed to connect to Microsoft Graph: $_" exit } } # Download the translation table $translationTable = Invoke-RestMethod -Method Get -Uri "https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv" | ConvertFrom-Csv #Get all users including sign-in and assigned license information $AllUsers = Get-MgBetaUser -All -Property Id, accountenabled, DisplayName, UserPrincipalName, SignInActivity, AssignedLicenses ##Get tenant license usage information $Report = [System.Collections.Generic.List[Object]]::new() #Get all enabled licenses $licenses = get-MgSubscribedSku | Where {$_.CapabilityStatus -eq 'Enabled'} #Get all directory subscription information $directorySubscription = Get-MgDirectorySubscription #Loop throuugh all licenses Foreach ($license in $licenses){ #Translaste the license name $licensename = $skuNamePretty = ($translationTable | Where-Object {$_.GUID -eq $license.skuId} | Sort-Object Product_Display_Name -Unique).Product_Display_Name If (($licensename -eq "") -or ($licensename -eq $null)){ $licensename = $license.skuPartNumber } #Create a custom object with the license information $obj = [PSCustomObject][ordered]@{ "License SKU" = $licensename "Type" = If (($directorySubscription | Where-Object {$_.skuId -eq $license.SkuId}).IsTrial -eq $true) {"Trial"} else {"Paid"} "Total Licenses" = $license.PrepaidUnits.Enabled "Used Licenses" = $license.ConsumedUnits "Unused licenses" = $license.PrepaidUnits.Enabled - $license.ConsumedUnits "Renewal/Expiratrion Date" = ($directorySubscription | Where-Object {$_.skuId -eq $license.SkuId}).NextLifecycleDateTime } $report.Add($obj) } #Obtain licenses users who have not successfully signed in, in the last 90 days. #Unable to combine the needed filters $90daysago = (Get-Date).AddDays(-90).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") $inactiveUsers = $AllUsers | Where {$_.SignInActivity.LastSuccessfulSignInDateTime -lt $90daysago} $licensedInactiveUsers = $inactiveUsers | Where-Object {$_.assignedLicenses -ne $null} $licensedInactiveUsersReport = [System.Collections.Generic.List[Object]]::new() #Loop through all inactive users Foreach ($user in $licensedInactiveUsers){ $skuNamePretty = @() foreach ($individuallicense in $user.AssignedLicenses.SkuId){ $skuNamePretty += ($translationTable | Where-Object {$_.GUID -eq $individuallicense} | Sort-Object Product_Display_Name -Unique)."Product_Display_Name" } $obj = [pscustomobject][ordered]@{ Name = $user.UserPrincipalName Licenses = ($skuNamePretty -join [environment]::NewLine) ##Updated -join "---" AccountEnabled = $user.accountenabled lastSignInAttempt = $user.SignInActivity.LastSignInDateTime lastSuccessfulSignIn = $user.SignInActivity.LastSuccessfulSignInDateTime } $licensedInactiveUsersReport.Add($obj) } ##Obtain over-licensed privileged users #Check tenant Entra level $items = @("AAD_PREMIUM_P2", "AAD_PREMIUM", "AAD_BASIC") $Skus = Get-MgSubscribedSku foreach ($item in $items) { $Search = $skus | ? {$_.ServicePlans.servicePlanName -contains "$item"} if ($Search) { $licenseplan = $item break } ElseIf ((!$Search) -and ($item -eq "AAD_BASIC")){ $licenseplan = $item break } } #Get all users assigned roles If ($licenseplan -eq "AAD_PREMIUM_P2") { $EligiblePIMRoles = Invoke-MgGraphRequest -Uri "beta/roleManagement/directory/roleEligibilitySchedules?`$expand=*" -OutputType PSObject | Select -Expand Value $AssignedPIMRoles = Invoke-MgGraphRequest -Uri "beta/roleManagement/directory/roleAssignmentSchedules?`$expand=*" -OutputType PSObject | Select -Expand Value $DirectoryRoles = $EligiblePIMRoles + $AssignedPIMRoles $PrivilegedRoles = $DirectoryRoles | Where-Object { ($_.RoleDefinition.DisplayName -like "*Administrator*") -or ($_.RoleDefinition.DisplayName -like "*Writer*") -or ($_.RoleDefinition.DisplayName -eq "Global Reader") } $PrivilegedRoleUsers = $PrivilegedRoles | Where {$_.Principal.'@odata.type' -eq "#microsoft.graph.user"} $RoleMembers = $PrivilegedRoleUsers.Principal.userPrincipalName | Select-Object -Unique $PrivilegedUsers = $RoleMembers | ForEach-Object { Invoke-MgGraphRequest -uri "/beta/users/$($_)?`$select=displayName,UserPrincipalName,AssignedLicenses" -OutputType PSobject } }else{ $DirectoryRoles = Invoke-MgGraphRequest -Uri "/beta/directoryRoles?" -OutputType PSObject | Select -Expand Value $PrivilegedRoles = $DirectoryRoles | Where-Object { ($_.DisplayName -like "*Administrator*") -or ($_.DisplayName -like "*Writer*") -or ($_.DisplayName -eq "Global Reader") } $RoleMembers = $PrivilegedRoles | ForEach-Object { Invoke-MgGraphRequest -uri "/beta/directoryRoles/$($_.id)/members" -OutputType PSObject | Select -Expand Value} | Select-Object Id -Unique $PrivilegedUsers = $RoleMembers | ForEach-Object { Invoke-MgGraphRequest -uri "/beta/users/$($_.id)?`$select=displayName,UserPrincipalName,AssignedLicenses" -OutputType PSobject } } #Generate report $overLicensedPrivUsers = [System.Collections.Generic.List[Object]]::new() $translationTable = Invoke-RestMethod -Method Get -Uri "https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv" | ConvertFrom-Csv Foreach ($user in $PrivilegedUsers){ $licenses = @() If (($user.assignedLicenses.skuid -notin "41781fb2-bc02-4b7c-bd55-b576c07bb09d,eec0eb4f-6444-4f95-aba0-50c24d67f998") -and ($user.assignedLicenses.skuid.count -gt 0)){ foreach($guid in $user.assignedLicenses.skuid) { $temp = ($translationTable | Where-Object {$_.GUID -eq $guid} | Sort-Object Product_Display_Name -Unique).Product_Display_Name $licenses += $temp } $Obj2 = [pscustomobject][ordered]@{ DisplayName = $user.DisplayName UserPrincipalName = $User.UserPrincipalName License = $licenses -join [environment]::NewLine } $overLicensedPrivUsers.Add($Obj2) } } ###This is in progress ##Get users with duplicate licenses #First obtain a list of all users and the friendly name of the license they have $AllLicensedUsersReport = [System.Collections.Generic.List[Object]]::new() $AllLicensedUsers = $AllUsers | Where {$_.AssignedLicenses.count -ne 0} Foreach ($user in $AllLicensedUsers){ $licenses = @() foreach($guid in $user.assignedLicenses.skuid) { $temp = ($translationTable | Where-Object {$_.GUID -eq $guid} | Sort-Object Product_Display_Name -Unique).Product_Display_Name $licenses += $temp } $Obj2 = [pscustomobject][ordered]@{ DisplayName = $user.DisplayName UserPrincipalName = $User.UserPrincipalName License = $licenses -join [environment]::NewLine } $AllLicensedUsersReport.Add($Obj2) } # Calculate totals for summary $totalLicenses = ($report | Measure-Object "Total Licenses" -Sum).Sum $totalUsed = ($report | Measure-Object "Used Licenses" -Sum).Sum $totalUnused = ($report | Measure-Object "Unused licenses" -Sum).Sum $unusedPercentage = [math]::Round(($totalUnused / $totalLicenses) * 100, 2) # Get the count of inactive licensed users $inactiveUsersCount = $licensedInactiveUsersReport.Count # Get the count of over-licensed privileged users $overLicensedPrivUsersCount = $overLicensedPrivUsers.Count # Get the total number of licensed users $totalLicensedUsersCount = $AllLicensedUsers.Count # Generate HTML report $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Microsoft 365 License Usage Report</title> <style> body { font-family: 'Segoe UI', Arial, sans-serif; margin: 0; padding: 0; background-color: #f5f5f5; } .header-container { background: linear-gradient(135deg, #0078D4 0%, #106EBE 100%); color: white; padding: 25px 40px; margin-bottom: 30px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } .header-content { max-width: 1200px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; } h1 { font-size: 28px; font-weight: 600; margin: 0; letter-spacing: -0.5px; } .header-subtitle { font-size: 14px; font-weight: 400; margin-top: 0px; margin-bottom: 10px; opacity: 0.9; } .author-info { margin-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.3); padding-top: 10px; display: flex; align-items: center; font-size: 13px; } .author-label { opacity: 0.8; margin-right: 6px; } .author-links { display: flex; align-items: center; } .author-link { color: white; text-decoration: none; display: inline-flex; align-items: center; border: 1px solid rgba(255, 255, 255, 0.5); padding: 4px 10px; border-radius: 4px; margin-right: 10px; transition: all 0.2s ease; background-color: rgba(255, 255, 255, 0.1); } .author-link:hover { background-color: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.7); } .author-link svg { margin-right: 5px; } .report-info { text-align: right; font-size: 14px; } .report-date { font-weight: 500; margin-top: 5px; } .content-container { max-width: 1200px; margin: 0 auto; padding: 0 40px 40px; } .summary-cards { display: flex; flex-wrap: wrap; justify-content: space-between; margin-bottom: 40px; gap: 20px; } .summary-card { background-color: white; border-radius: 8px; padding: 20px; width: calc(33.333% - 14px); box-sizing: border-box; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); text-align: center; } @media (max-width: 768px) { .summary-card { width: 100%; margin-bottom: 15px; } } .card-title { font-size: 14px; color: #666; text-transform: uppercase; margin: 0 0 10px 0; } .card-value { font-size: 36px; font-weight: bold; margin: 0; color: #333; } .card-percentage { font-size: 14px; color: #666; margin-top: 5px; } table { width: 100%; border-collapse: separate; border-spacing: 0; margin-top: 20px; border-radius: 10px; overflow: hidden; box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); background-color: white; } th, td { padding: 15px; text-align: left; } th { background-color: #B5D8EB; color: #333; font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #ddd; } tr:nth-child(even) { background-color: #f9f9f9; } tr:hover { background-color: #f1f1f1; } td { border-bottom: 1px solid #ddd; } tr:last-child td { border-bottom: none; } .footer { text-align: center; margin-top: 30px; font-size: 12px; color: #666; } .high-unused { background-color: #ffe0e0 !important; } tr.high-unused:nth-child(even) { background-color: #ffdddd !important; } .export-btn { display: inline-block; padding: 10px 20px; background-color: #0078D4; color: white; text-decoration: none; border-radius: 4px; margin-top: 20px; font-weight: 500; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } .export-btn:hover { background-color: #106EBE; } .switch { position: relative; display: inline-block; width: 60px; height: 34px; margin-right: 10px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; } .slider:before { position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; } input:checked + .slider { background-color: #0078D4; } input:checked + .slider:before { transform: translateX(26px); } .filter-container { display: flex; align-items: center; margin-bottom: 20px; flex-wrap: wrap; } .filter-label { margin-left: 10px; font-weight: 500; } .filter-group { display: flex; align-items: center; margin-right: 30px; margin-bottom: 10px; } </style> </head> <body> <div class="header-container"> <div class="header-content"> <div> <h1>Microsoft 365 License Usage Report</h1> <div class="header-subtitle">Overview of license allocation and usage across your tenant</div> <div class="author-info"> <span class="author-label">Created by:</span> <div class="author-links"> <a href="https://www.linkedin.com/in/danielbradley2/" class="author-link" target="_blank"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="white"> <path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/> </svg> Daniel Bradley </a> <a href="https://ourcloudnetwork.com" class="author-link" target="_blank"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="white"> <path d="M21 13v10h-21v-19h12v2h-10v15h17v-8h2zm3-12h-10.988l4.035 4-6.977 7.07 2.828 2.828 6.977-7.07 4.125 4.172v-11z"/> </svg> ourcloudnetwork.com </a> </div> </div> </div> <div class="report-info"> <div class="report-date">Generated: $(Get-Date -Format "MMMM d, yyyy")</div> <div class="tenant">Org: $((Get-MgOrganization).DisplayName)</div> </div> </div> </div> <div class="content-container"> <div class="summary-cards"> <div class="summary-card"> <h3 class="card-title">Total Licenses</h3> <p class="card-value" id="totalLicensesValue">$totalLicenses</p> <p class="card-percentage" id="totalSubscriptions">Across $($report.Count) subscriptions</p> </div> <div class="summary-card"> <h3 class="card-title">Used Licenses</h3> <p class="card-value" id="usedLicensesValue">$totalUsed</p> <p class="card-percentage" id="usedPercentage">$([math]::Round(($totalUsed / $totalLicenses) * 100, 2))% of total</p> </div> <div class="summary-card"> <h3 class="card-title">Unused Licenses</h3> <p class="card-value" id="unusedLicensesValue">$totalUnused</p> <p class="card-percentage" id="unusedPercentage">$unusedPercentage% of total</p> </div> <div class="summary-card"> <h3 class="card-title">Licensed Users</h3> <p class="card-value">$totalLicensedUsersCount</p> <p class="card-percentage">Users with assigned licenses</p> </div> <div class="summary-card"> <h3 class="card-title">Inactive Users</h3> <p class="card-value">$inactiveUsersCount</p> <p class="card-percentage">90+ days without sign-in & licensed</p> </div> <div class="summary-card"> <h3 class="card-title">Over-licensed Admins</h3> <p class="card-value">$overLicensedPrivUsersCount</p> <p class="card-percentage">Privileged users with unnecessary licenses</p> </div> </div> <div class="filter-container"> <div class="filter-group"> <label class="switch"> <input type="checkbox" id="toggleUnused" onchange="applyFilters()"> <span class="slider"></span> </label> <span class="filter-label">Show only subscriptions with unused licenses</span> </div> <div class="filter-group"> <label class="switch"> <input type="checkbox" id="toggleHideFree" onchange="applyFilters()"> <span class="slider"></span> </label> <span class="filter-label">Hide free/promotional licenses</span> </div> <div class="filter-group"> <label class="switch"> <input type="checkbox" id="toggleHideTrial" onchange="applyFilters()"> <span class="slider"></span> </label> <span class="filter-label">Hide trial licenses</span> </div> </div> <h2>License Details</h2> <table> <thead> <tr> "@ # Add table headers foreach ($header in $report[0].PSObject.Properties.Name) { $html += " <th>$header</th>`n" } $html += @" </tr> </thead> <tbody> "@ # Add table rows with highlighting for rows with unused licenses foreach ($row in $report) { # Only highlight rows where "Unused licenses" is greater than 0 $rowClass = if ($row."Unused licenses" -gt 0) { ' class="high-unused"' } else { '' } $html += " <tr$rowClass>`n" foreach ($header in $row.PSObject.Properties.Name) { $value = $row.$header # Format date if it's a DateTime object if ($header -eq "Renewal/Expiratrion Date" -and $value -is [DateTime]) { $value = $value.ToString("yyyy-MM-dd") } $html += " <td>$value</td>`n" } $html += " </tr>`n" } $html += @" </tbody> </table> <h2 style="margin-top: 40px;">Inactive Licensed Users</h2> <p>The following users have licenses assigned but haven't successfully signed in for 90+ days.</p> <table> <thead> <tr> <th>Name</th> <th>Licenses</th> <th>Account Enabled</th> <th>Last Sign-In Attempt</th> <th>Last Successful Sign-In</th> </tr> </thead> <tbody> "@ # Add rows for inactive licensed users foreach ($user in $licensedInactiveUsersReport) { $html += @" <tr> <td>$($user.Name)</td> <td style="white-space: pre-line;">$($user.Licenses)</td> <td>$($user.AccountEnabled)</td> <td>$($user.lastSignInAttempt)</td> <td>$($user.lastSuccessfulSignIn)</td> </tr> "@ } $html += @" </tbody> </table> <h2 style="margin-top: 40px;">Over-Licensed Privileged Users</h2> <p>The following users have administrative roles but may have unnecessary licenses assigned.</p> <table> <thead> <tr> <th>Display Name</th> <th>User Principal Name</th> <th>Licenses</th> </tr> </thead> <tbody> "@ # Add rows for over-licensed privileged users foreach ($user in $overLicensedPrivUsers) { $html += @" <tr> <td>$($user.DisplayName)</td> <td>$($user.UserPrincipalName)</td> <td style="white-space: pre-line;">$($user.License)</td> </tr> "@ } $html += @" </tbody> </table> <div class="footer"> <p>License usage report generated via Microsoft Graph API. Subscriptions with unused licenses are highlighted in red.</p> </div> </div> <script> // Wait for document to be fully loaded document.addEventListener('DOMContentLoaded', function() { // Attach event handlers document.getElementById('toggleUnused').onclick = function() { filterData(); }; document.getElementById('toggleHideFree').onclick = function() { filterData(); }; document.getElementById('toggleHideTrial').onclick = function() { filterData(); }; }); // The main filter function function filterData() { console.log('Filtering data...'); // Get filter states let showOnlyUnused = document.getElementById('toggleUnused').checked; let hideFree = document.getElementById('toggleHideFree').checked; let hideTrial = document.getElementById('toggleHideTrial').checked; // Free licenses to filter let freeLicenses = [ 'windows store for business', 'microsoft power automate free', 'power virtual agents viral trial', 'rights management adhoc' ]; // Get the table and all data rows let table = document.getElementsByTagName('table')[0]; if (!table) { console.error('Table not found!'); return; } let tbody = table.getElementsByTagName('tbody')[0]; if (!tbody) { console.error('Table body not found!'); return; } let rows = tbody.getElementsByTagName('tr'); if (rows.length === 0) { console.error('No rows found in table!'); return; } // Tracking totals let totalSum = 0; let usedSum = 0; let unusedSum = 0; let visibleCount = 0; // Process each row for (let i = 0; i < rows.length; i++) { let row = rows[i]; let cells = row.getElementsByTagName('td'); // Skip if we don't have enough cells if (cells.length < 5) continue; // Extract data from cells let licenseName = cells[0].textContent.trim().toLowerCase(); let licenseType = cells[1].textContent.trim().toLowerCase(); let totalLicenses = parseInt(cells[2].textContent) || 0; let usedLicenses = parseInt(cells[3].textContent) || 0; let unusedLicenses = parseInt(cells[4].textContent) || 0; // Apply filters let showRow = true; // Filter: Only show rows with unused licenses if (showOnlyUnused && unusedLicenses <= 0) { showRow = false; } // Filter: Hide free licenses if (hideFree) { for (let j = 0; j < freeLicenses.length; j++) { if (licenseName.includes(freeLicenses[j])) { showRow = false; break; } } } // Filter: Hide trial licenses if (hideTrial && licenseType === 'trial') { showRow = false; } // Apply visibility row.style.display = showRow ? '' : 'none'; // Add to totals if row is visible if (showRow) { totalSum += totalLicenses; usedSum += usedLicenses; unusedSum += unusedLicenses; visibleCount++; } } // Update summary cards let totalLicensesValue = document.getElementById('totalLicensesValue'); let totalSubscriptions = document.getElementById('totalSubscriptions'); let usedLicensesValue = document.getElementById('usedLicensesValue'); let usedPercentageElem = document.getElementById('usedPercentage'); let unusedLicensesValue = document.getElementById('unusedLicensesValue'); let unusedPercentageElem = document.getElementById('unusedPercentage'); if (totalLicensesValue) totalLicensesValue.textContent = totalSum; if (totalSubscriptions) totalSubscriptions.textContent = 'Across ' + visibleCount + ' subscriptions'; if (usedLicensesValue) usedLicensesValue.textContent = usedSum; let usedPercent = totalSum > 0 ? Math.round((usedSum / totalSum) * 100) : 0; if (usedPercentageElem) usedPercentageElem.textContent = usedPercent + '% of total'; if (unusedLicensesValue) unusedLicensesValue.textContent = unusedSum; let unusedPercent = totalSum > 0 ? Math.round((unusedSum / totalSum) * 100) : 0; if (unusedPercentageElem) unusedPercentageElem.textContent = unusedPercent + '% of total'; console.log('Filtering complete. Visible rows:', visibleCount); } // Define legacy functions to maintain backward compatibility function applyFilters() { filterData(); } function filterUnusedLicenses() { filterData(); } function exportToCSV() { // Headers for the CSV const headers = [ $(foreach($header in $report[0].PSObject.Properties.Name) { "'$header'," }) ]; // Data rows const rows = [ headers.join(','), $(foreach($row in $report) { $rowData = foreach($header in $row.PSObject.Properties.Name) { if ($header -eq "Renewal/Expiratrion Date" -and $row.$header -is [DateTime]) { "'$($row.$header.ToString("yyyy-MM-dd"))'," } else { "'$($row.$header)'," } } "`n `"$($rowData -join ',')`"," }) ]; // Create the CSV content const csvContent = "data:text/csv;charset=utf-8," + rows.join('\\r\\n'); // Create a download link const encodedUri = encodeURI(csvContent); const link = document.createElement("a"); link.setAttribute("href", encodedUri); link.setAttribute("download", "license_usage_report_$(Get-Date -Format "yyyy-MM-dd").csv"); document.body.appendChild(link); // Trigger download link.click(); document.body.removeChild(link); } </script> </body> </html> "@ # Output the HTML to a file $outputPathFile = "$outpath\M365_License_Usage_Report.html" $html | Out-File -FilePath $outputPathFile -Encoding utf8 Write-Host "HTML report generated at $outputPathFile" -ForegroundColor Green |