Private/Write-AACGroupMembershipHtml.ps1

function Write-AACGroupMembershipHtml {
    <#
    .SYNOPSIS
        Writes Get-AACEntraGroupMembership's result as an interactive HTML
        report: an overview with tiles, charts and every group as a tree
        (details, owners, direct members, nested groups opening to their
        members), then tabs with the groups, everyone in each group once,
        and every membership row - searchable, filterable and downloadable
        as CSV.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Membership,

        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter(Mandatory)]
        [string] $Title,

        [System.Collections.IDictionary] $Detail
    )

    $stats = $Membership.Stats
    $count = { param([int] $Value, [string] $One, [string] $Many) , @($Value, $(if ($Value -eq 1) { $One } else { $Many })) }
    $account = { param($Item) if ($Item.MemberType -ne 'User') { '' } elseif ($Item.AccountEnabled -eq $false) { 'Disabled' } else { 'Enabled' } }
    $rows = @($Membership.Rows | Select-Object -Property *, @{ Name = 'Account'; Expression = { & $account $_ } })
    $people = @($Membership.People | Select-Object -Property *, @{ Name = 'Account'; Expression = { & $account $_ } })
    $top = {
        param([object[]] $Items, [string] $Property, [int] $First = 12)
        @($Items | Group-Object -Property $Property | Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, Name | Select-Object -First $First | ForEach-Object {
                @{ Label = $(if ($_.Name) { $_.Name } else { '(none)' }); Value = $_.Count; Filter = $_.Name }
            })
    }

    $tiles = @(
        @{ Value = '{0:N0}' -f $stats.Groups; Label = 'groups'; Tone = 'violet'; Table = 'groups' }
        @{ Value = '{0:N0}' -f $stats.Users; Label = 'unique users'; Tone = 'info'; Table = 'people'; Filters = @{ MemberType = 'User' } }
        @{ Value = '{0:N0}' -f $stats.Guests; Label = 'guests'; Tone = $(if ($stats.Guests) { 'warn' } else { 'good' }); Table = 'people'; Filters = @{ UserType = 'Guest' } }
        @{ Value = '{0:N0}' -f $stats.Disabled; Label = 'disabled accounts'; Tone = $(if ($stats.Disabled) { 'bad' } else { 'good' }); Table = 'people'; Filters = @{ Account = 'Disabled' } }
        @{ Value = '{0:N0}' -f $stats.NestedGroups; Label = 'nested groups'; Tone = 'warn'; Table = 'members'; Filters = @{ MemberType = 'Group' } }
        @{ Value = '{0:N0}' -f $stats.Rows; Label = 'membership rows'; Tone = 'neutral'; Table = 'members' }
    )
    if ($stats.EmptyGroups) { $tiles += @{ Value = '{0:N0}' -f $stats.EmptyGroups; Label = 'empty groups'; Tone = 'bad'; Table = 'members'; Filters = @{ Membership = 'Empty' } } }
    $ownerless = @($Membership.Groups | Where-Object { -not $_.OwnerCount }).Count
    if ($ownerless) { $tiles += @{ Value = '{0:N0}' -f $ownerless; Label = 'groups without owners'; Tone = 'warn'; Table = 'groups'; Filters = @{ OwnerCount = '0' } } }

    $charts = @(
        @{ Title = 'Members by group (everyone, once)'; Items = @(& $top $people 'GroupName'); Table = 'people'; Column = 'GroupName'; Tone = 'info'; Wide = $true }
        @{ Title = 'Direct or through a nested group'; Items = @(& $top $people 'Membership'); Table = 'people'; Column = 'Membership' }
        @{ Title = 'Members by type'; Items = @(& $top $people 'MemberType'); Table = 'people'; Column = 'MemberType' }
        @{ Title = 'Users: members and guests'; Items = @(& $top @($people | Where-Object MemberType -EQ 'User') 'UserType'); Table = 'people'; Column = 'UserType'; Tone = 'warn' }
    )

    # --- The tree: each group, its owners, its members, nested groups opening to theirs ------------------
    $memberNode = {
        param($Row, [hashtable] $ByPath, [string] $Here, $Section)
        $name = if ($Row.MemberName) { $Row.MemberName } elseif ($Row.UserPrincipalName) { $Row.UserPrincipalName } else { $Row.MemberId }
        if ($Row.MemberType -eq 'Group') {
            $nestedPath = if ($Here) { "$Here > $name" } else { $name }
            if ($Section.Loops.Contains($Row.MemberId) -or -not $ByPath.Contains($nestedPath)) { return [ordered]@{ l = 'N'; n = $name; d = 'already listed above (a loop)' } }
            $inner = $ByPath[$nestedPath]
            $node = [ordered]@{ l = 'N'; n = $name; d = 'nested group'; c = @(& $count $inner.Members.Count 'member' 'members') }
            if ($inner.Error) { $node.d = "members could not be read: $($inner.Error)"; $node.x = $true }
            elseif (-not $inner.Members.Count) { $node.d = 'no members'; $node.x = $true }
            else { $node.k = @(& $children $inner $ByPath) }
            return $node
        }
        $level = switch ($Row.MemberType) { 'User' { 'U' } 'Service principal' { 'P' } 'Device' { 'D' } default { 'X' } }
        $node = [ordered]@{ l = $level; n = $name; d = $(if ($Row.UserPrincipalName) { $Row.UserPrincipalName } elseif ($Row.MemberType -ne 'User') { $Row.MemberType } else { $Row.Mail }) }
        $pills = @(
            if ($Row.UserType -eq 'Guest') { , @('GUEST', 'warn', 'A guest from another tenant') }
            if ($Row.AccountEnabled -eq $false) { , @('DISABLED', 'bad', 'The account is disabled') }
        )
        if ($pills.Count) { $node.pl = $pills }
        $node
    }
    # People first, by name; nested groups after them.
    $children = {
        param($Section, [hashtable] $ByPath)
        foreach ($r in @($Section.Members | Sort-Object -Property @{ Expression = { $_.MemberType -eq 'Group' } }, MemberName)) { & $memberNode $r $ByPath $Section.Path $Section }
    }
    $groupNodes = foreach ($group in $Membership.Groups) {
        $sections = @($Membership.Sections[$group.GroupId])
        $byPath = @{}
        foreach ($s in $sections) { if (-not $byPath.Contains($s.Path)) { $byPath[$s.Path] = $s } }
        $mine = @($people | Where-Object GroupId -EQ $group.GroupId)
        $guests = @($mine | Where-Object UserType -EQ 'Guest').Count
        $disabled = @($mine | Where-Object Account -EQ 'Disabled').Count
        $ownerNode = [ordered]@{ l = 'O'; n = 'Owners'; d = $(if (-not $group.OwnerCount) { $group.Owners }); c = @(& $count $group.OwnerCount 'owner' 'owners') }
        if ($group.OwnerCount) {
            $ownerNode.k = @(foreach ($o in ($group.Owners -split '; ')) { [ordered]@{ l = 'W'; n = $o } })
        }
        else { $ownerNode.x = $true }
        $node = [ordered]@{
            l = 'G'
            n = $group.GroupName
            d = (@($group.GroupType, $group.GroupSource, $group.MembershipType, $(if ($group.Created) { 'created ' + $group.Created.ToString('yyyy-MM-dd') })) | Where-Object { $_ }) -join ' · '
            c = @(@($mine.Count, 'in all'), @($group.DirectMembers, 'direct'), (& $count $group.NestedGroups 'nested group' 'nested groups'))
            f = @{ table = 'people'; filters = @{ GroupName = $group.GroupName } }
            k = @(, $ownerNode) + @(if ($byPath.Contains('')) { & $children $byPath[''] $byPath })
        }
        $pills = @(
            if ($guests) { , @("$guests guest$(if ($guests -ne 1) { 's' })", 'warn', 'Guests in the group, direct or nested') }
            if ($disabled) { , @("$disabled disabled", 'bad', 'Disabled accounts in the group, direct or nested') }
            if ($group.Error) { , @('not all read', 'bad', $group.Error) }
        )
        if ($pills.Count) { $node.pl = $pills }
        if (-not $mine.Count) { $node.x = $true }
        $node
    }
    $tree = @{
        Title       = 'Groups: owners, direct members and nested groups'
        # A few groups open all the way down; many open to the groups.
        OpenTo      = $(if ($rows.Count -le 300) { 'X' } else { 'G' })
        Top         = $true
        Root        = [ordered]@{ l = 'A'; n = "$($stats.Groups) group$(if ($stats.Groups -ne 1) { 's' })"; d = 'open a group for its owners and members; a nested group opens to its own'; k = @($groupNodes) }
        Levels      = @{ A = @('ALL', 'neutral'); G = @('GROUP', 'violet'); O = @('OWNERS', 'neutral'); W = @('OWNER', 'neutral'); N = @('NESTED', 'warn'); U = @('USER', 'info'); P = @('APP', 'good'); D = @('DEVICE', 'neutral'); X = @('OTHER', 'neutral') }
        Order       = @('A', 'G', 'O', 'W', 'N', 'U', 'P', 'D', 'X')
        Buttons     = @(@('Groups', 'G'), @('Everyone', 'X'))
        Placeholder = 'Find a group, person, app or owner...'
    }

    $tables = @(
        @{
            Id = 'groups'; Title = 'Groups'; Section = 'Groups'; Noun = 'groups'; File = 'groups'; GroupBy = @('GroupType', 'GroupSource', 'MembershipType')
            Note = 'One row per group: its details, owners and counts. Click a group in the tree on the overview for its members.'
            Rows = @($Membership.Groups | Select-Object -Property @{ Name = 'Created'; Expression = { if ($_.Created) { $_.Created.ToString('yyyy-MM-dd HH:mm') } } }, * -ExcludeProperty Created)
            Sort = @{ Key = 'Users'; Desc = $true }
            Columns = @(
                @{ Key = 'GroupName'; Label = 'Group' }
                @{ Key = 'GroupType'; Label = 'Type'; Facet = $true }
                @{ Key = 'GroupSource'; Label = 'Source'; Facet = $true }
                @{ Key = 'MembershipType'; Label = 'Membership type'; Type = 'badge'; Tones = @{ Assigned = 'neutral'; Dynamic = 'info'; 'Dynamic (paused)' = 'warn' }; Facet = $true }
                @{ Key = 'Created'; Label = 'Created (UTC)' }
                @{ Key = 'Owners'; Label = 'Owners'; Type = 'wide' }
                @{ Key = 'OwnerCount'; Label = 'Owner count'; Type = 'number'; Sum = $true; Format = 'N0' }
                @{ Key = 'DirectMembers'; Label = 'Direct members'; Type = 'number'; Sum = $true; Format = 'N0' }
                @{ Key = 'NestedGroups'; Label = 'Nested groups'; Type = 'number'; Sum = $true; Format = 'N0' }
                @{ Key = 'Users'; Label = 'Users (all levels)'; Type = 'number'; Sum = $true; Format = 'N0' }
                @{ Key = 'Guests'; Label = 'Guests'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'warn' }
                @{ Key = 'DisabledUsers'; Label = 'Disabled'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'bad' }
                @{ Key = 'Description'; Label = 'Description'; Type = 'wide' }
                @{ Key = 'MembershipRule'; Label = 'Membership rule'; Type = 'wide' }
                @{ Key = 'Mail'; Label = 'Mail' }
                @{ Key = 'Error'; Label = 'Could not read'; Type = 'wide' }
                @{ Key = 'GroupId'; Label = 'Group ID'; Type = 'mono'; Hidden = $true }
            )
        }
        @{
            Id = 'people'; Title = 'Members'; Section = 'Members'; Noun = 'members'; File = 'group-members'; GroupBy = @('GroupName', 'Membership', 'MemberType', 'UserType')
            Note = 'Everyone in each group once - users, apps, devices, contacts - Direct, Nested (Through names the nested groups) or both.'
            Rows = $people; Group = 'GroupName'
            Columns = @(
                @{ Key = 'GroupName'; Label = 'Group'; Facet = $true }
                @{ Key = 'MemberName'; Label = 'Member' }
                @{ Key = 'MemberType'; Label = 'Type'; Type = 'badge'; Tones = @{ User = 'info'; 'Service principal' = 'good'; Device = 'neutral'; Contact = 'neutral' }; Facet = $true }
                @{ Key = 'UserPrincipalName'; Label = 'User principal name' }
                @{ Key = 'Membership'; Label = 'Membership'; Type = 'badge'; Tones = @{ Direct = 'good'; Nested = 'info'; 'Direct and nested' = 'violet' }; Facet = $true }
                @{ Key = 'Through'; Label = 'Through nested group'; Type = 'wide' }
                @{ Key = 'UserType'; Label = 'Member or guest'; Type = 'badge'; Tones = @{ Guest = 'warn'; Member = 'good' }; Facet = $true }
                @{ Key = 'Account'; Label = 'Account'; Type = 'badge'; Tones = @{ Disabled = 'bad'; Enabled = 'good' }; Facet = $true }
                @{ Key = 'Mail'; Label = 'Mail' }
                @{ Key = 'JobTitle'; Label = 'Job title' }
                @{ Key = 'Department'; Label = 'Department'; Facet = $true }
                @{ Key = 'MemberId'; Label = 'Member ID'; Type = 'mono'; Hidden = $true }
                @{ Key = 'GroupId'; Label = 'Group ID'; Type = 'mono'; Hidden = $true }
            )
        }
        @{
            Id = 'members'; Title = 'All membership rows'; Section = 'All rows'; Note = 'One row per group and member at every level, nested groups included. Nested: through a group nested in it - Via shows the path.'
            Noun = 'rows'; File = 'group-membership'; Rows = $rows; GroupBy = @('GroupName', 'MemberType', 'UserType', 'Membership')
            Columns = @(
                @{ Key = 'GroupName'; Label = 'Group'; Facet = $true }
                @{ Key = 'MemberName'; Label = 'Member' }
                @{ Key = 'MemberType'; Label = 'Type'; Type = 'badge'; Tones = @{ User = 'info'; Group = 'warn'; 'Service principal' = 'violet'; Device = 'neutral' }; Facet = $true }
                @{ Key = 'UserPrincipalName'; Label = 'User principal name' }
                @{ Key = 'UserType'; Label = 'Member or guest'; Type = 'badge'; Tones = @{ Guest = 'warn'; Member = 'good' }; Facet = $true }
                @{ Key = 'Account'; Label = 'Account'; Type = 'badge'; Tones = @{ Disabled = 'bad'; Enabled = 'good' }; Facet = $true }
                @{ Key = 'Membership'; Label = 'Membership'; Type = 'badge'; Tones = @{ Direct = 'good'; Nested = 'info'; Empty = 'warn'; 'Not read' = 'bad' }; Facet = $true }
                @{ Key = 'Via'; Label = 'Via'; Type = 'wide' }
                @{ Key = 'Mail'; Label = 'Mail' }
                @{ Key = 'JobTitle'; Label = 'Job title' }
                @{ Key = 'Department'; Label = 'Department'; Facet = $true }
                @{ Key = 'GroupType'; Label = 'Group type'; Facet = $true }
                @{ Key = 'GroupSource'; Label = 'Group source'; Facet = $true }
                @{ Key = 'MemberId'; Label = 'Member ID'; Type = 'mono'; Hidden = $true }
                @{ Key = 'GroupId'; Label = 'Group ID'; Type = 'mono'; Hidden = $true }
            )
        }
    )
    $tabs = @(
        @{ Name = 'Groups'; Badge = $stats.Groups; Tone = 'violet'; Note = 'Each group''s type, source, membership type, created date, owners and counts.' }
        @{ Name = 'Members'; Badge = $people.Count; Tone = 'info'; Note = 'Everyone in each group once, direct and nested together - filter by group, or by Direct / Nested.' }
        @{ Name = 'All rows'; Badge = $rows.Count; Tone = 'neutral'; Note = 'Every membership row at every level, nested groups included, as -CsvLayout Member writes them.' }
    )
    $notices = @()
    if ($stats.Unreadable) { $notices += @{ Tone = 'warn'; Text = "The members of $($stats.Unreadable) group(s) couldn't be read; the Groups tab says why." } }
    $notices += @{ Tone = 'info'; Text = 'Open a group in the tree for its owners, direct members and nested groups - each nested group opens to its own members. People are counted once per group however many ways they are in it. Microsoft Graph, read with delegated Group.Read.All and User.Read.All.' }

    Write-AACHtmlReport -Path $Path -Title $Title -Subtitle 'Entra ID groups: details, owners, direct members and nested groups' -Fact $Detail -Tile $tiles -Chart $charts -Table $tables -Notice $notices -Tree $tree -Tab $tabs
}