Private/ConvertTo-GroupedHtmlReport.ps1

# Renders a titled, collapsible HTML report grouped by Management Group and Subscription scope
function ConvertTo-GroupedHtmlReport {
    <#
    .SYNOPSIS
        Converts Management Group / Subscription scoped row data into a titled, collapsible HTML report.
    #>

    [CmdletBinding(DefaultParameterSetName = 'ScopeData')]
    param(
        [Parameter(Mandatory)]
        [string]$Title,

        # Documents in displayName/description/resources format.
        [Parameter(Mandatory, ParameterSetName = 'Documents')]
        [object[]]$Documents,

        [Parameter(ParameterSetName = 'ScopeData')]
        [System.Collections.Specialized.OrderedDictionary]$ManagementGroupData,

        [Parameter(ParameterSetName = 'ScopeData')]
        [System.Collections.Specialized.OrderedDictionary]$SubscriptionData,

        # Optional ScopeId -> DisplayName lookups shown alongside each scope's ID in the report.
        [System.Collections.IDictionary]$ManagementGroupNames = @{},
        [System.Collections.IDictionary]$SubscriptionNames = @{},

        # Name shown in the report subtitle to identify which tool/function generated it.
        [string]$GeneratedBy = 'PowerShell.IGA.AccessGuard'
    )

    $generated_on = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'

    $style = @'
<style>
    body { font-family: Segoe UI, Arial, sans-serif; margin: 20px; color: #222; }
    h1 { margin-bottom: 0; }
    .subtitle { color: #666; margin-top: 4px; }
    h2 { border-bottom: 2px solid #444; padding-bottom: 4px; margin-top: 30px; }
    details { margin-bottom: 8px; border: 1px solid #ccc; border-radius: 4px; padding: 6px 10px; }
    summary { font-weight: bold; cursor: pointer; }
    table { border-collapse: collapse; width: 100%; margin-top: 8px; }
    th, td { border: 1px solid #ddd; padding: 6px 8px; font-size: 13px; text-align: left; }
    th { background-color: #f2f2f2; }
    tr:nth-child(even) { background-color: #fafafa; }
</style>
'@


    function ConvertTo-ScopeTable {
        param([array]$Rows)

        if (-not $Rows -or $Rows.Count -eq 0) { return '<p><em>No entries.</em></p>' }

        $columns = $Rows[0].PSObject.Properties.Name
        $sb = New-Object System.Text.StringBuilder
        [void]$sb.Append('<table><thead><tr>')
        foreach ($col in $columns) { [void]$sb.Append("<th>$([System.Net.WebUtility]::HtmlEncode($col))</th>") }
        [void]$sb.Append('</tr></thead><tbody>')
        foreach ($row in $Rows) {
            [void]$sb.Append('<tr>')
            foreach ($col in $columns) {
                [void]$sb.Append("<td>$([System.Net.WebUtility]::HtmlEncode([string]$row.$col))</td>")
            }
            [void]$sb.Append('</tr>')
        }
        [void]$sb.Append('</tbody></table>')
        return $sb.ToString()
    }

    function ConvertTo-GroupSectionHtml {
        param([string]$SectionTitle, [System.Collections.Specialized.OrderedDictionary]$Data, [System.Collections.IDictionary]$Names)

        $sb = New-Object System.Text.StringBuilder
        [void]$sb.Append("<h2>$([System.Net.WebUtility]::HtmlEncode($SectionTitle))</h2>")

        if (-not $Data -or $Data.Keys.Count -eq 0) {
            [void]$sb.Append('<p><em>No entries.</em></p>')
            return $sb.ToString()
        }

        foreach ($id in $Data.Keys) {
            $rows = @($Data[$id])
            $label = $id
            if ($Names -and $Names.Contains($id) -and $Names[$id]) { $label = "$id ($($Names[$id]))" }
            [void]$sb.Append("<details><summary>$([System.Net.WebUtility]::HtmlEncode($label)) ($($rows.Count))</summary>")
            [void]$sb.Append((ConvertTo-ScopeTable -Rows $rows))
            [void]$sb.Append('</details>')
        }

        return $sb.ToString()
    }

    function ConvertTo-DocumentSectionHtml {
        param([object]$Document, [System.Collections.IDictionary]$Names)

        $sb = New-Object System.Text.StringBuilder
        [void]$sb.Append("<h2>$([System.Net.WebUtility]::HtmlEncode([string]$Document.displayName))</h2>")

        if ($Document.description) {
            [void]$sb.Append("<p class='subtitle'>$([System.Net.WebUtility]::HtmlEncode([string]$Document.description))</p>")
        }

        $resources = @($Document.resources)

        if ($resources.Count -eq 0) {
            [void]$sb.Append('<p><em>No entries.</em></p>')
            return $sb.ToString()
        }

        foreach ($group in $resources | Group-Object { $_.properties.ScopeId }) {
            $label = $group.Name
            if ($Names -and $Names.Contains($group.Name) -and $Names[$group.Name]) { $label = "$($group.Name) ($($Names[$group.Name]))" }

            $rows = foreach ($resource in $group.Group) {
                [PSCustomObject]@{
                    displayName        = $resource.displayName
                    resourceType       = $resource.resourceType
                    DisplayNameProp    = $resource.properties.DisplayName
                    SignInName         = $resource.properties.SignInName
                    ObjectId           = $resource.properties.ObjectId
                    RoleDefinitionName = $resource.properties.RoleDefinitionName
                    Ensure             = $resource.properties.Ensure
                    Scope              = $resource.properties.Scope
                    ScopeId            = $resource.properties.ScopeId
                    Inherited          = $resource.properties.Inherited
                    InheritedFrom      = $resource.properties.InheritedFrom
                }
            }

            [void]$sb.Append("<details><summary>$([System.Net.WebUtility]::HtmlEncode($label)) ($($group.Count))</summary>")
            [void]$sb.Append((ConvertTo-ScopeTable -Rows @($rows)))
            [void]$sb.Append('</details>')
        }

        return $sb.ToString()
    }

    $body = "<h1>$([System.Net.WebUtility]::HtmlEncode($Title))</h1>"
    $body += "<p class='subtitle'>Generated by $([System.Net.WebUtility]::HtmlEncode($GeneratedBy)) on $generated_on</p>"

    if ($PSCmdlet.ParameterSetName -eq 'Documents') {
        foreach ($document in $Documents) {
            $names = if ([string]$document.displayName -like 'ManagementGroup*') { $ManagementGroupNames } else { $SubscriptionNames }
            $body += ConvertTo-DocumentSectionHtml -Document $document -Names $names
        }
    }
    else {
        $body += ConvertTo-GroupSectionHtml -SectionTitle 'Management Groups' -Data $ManagementGroupData -Names $ManagementGroupNames
        $body += ConvertTo-GroupSectionHtml -SectionTitle 'Subscriptions' -Data $SubscriptionData -Names $SubscriptionNames
    }

    return "<html><head><meta charset='utf-8'><title>$([System.Net.WebUtility]::HtmlEncode($Title))</title>$style</head><body>$body</body></html>"
}