Private/Export-EMOSHtmlReport.ps1

function Export-EMOSHtmlReport {
    <#
    .SYNOPSIS
        Generates a self-contained HTML remediation dashboard with sortable/filterable table.
    #>

    param(
        [PSCustomObject[]]$Findings,
        [string]$OutputPath,
        [int]$DeadlineDays
    )

    Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue

    $urgencyColor = if ($DeadlineDays -lt 30) { '#c0392b' } elseif ($DeadlineDays -lt 90) { '#e67e22' } else { '#27ae60' }
    $generatedAt  = Get-Date -Format 'yyyy-MM-dd HH:mm'
    $caTargeted   = @($Findings | Where-Object { $_.BlastRadius -like '*ConditionalAccess*' }).Count
    $blastCount   = @($Findings | Where-Object { $_.BlastRadius -ne '' }).Count

    $rows = foreach ($f in $Findings) {
        $complexityClass = switch ($f.RuleComplexity) {
            'High'   { 'complexity-high' }
            'Medium' { 'complexity-med' }
            default  { 'complexity-low' }
        }
        $typeIcon = switch ($f.ObjectType) {
            'DynamicGroup'       { '👥' }
            'DynamicAdminUnit'   { '🏢' }
            'EMAutoAssignPolicy' { '📦' }
            default              { '❓' }
        }
        $rule = [System.Web.HttpUtility]::HtmlEncode($f.MembershipRule ?? '')

        $apId = if ($f.PSObject.Properties['AccessPackageId']) { $f.AccessPackageId } else { '' }

        # Direct Entra portal links per object type
        $portalLink = switch ($f.ObjectType) {
            'DynamicGroup' {
                "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/GroupDetailsMenuBlade/~/DynamicMembership/groupId/$($f.ObjectId)"
            }
            'DynamicAdminUnit' {
                "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/AdministrativeUnitEditMenuBlade/~/DynamicMembership/objectId/$($f.ObjectId)"
            }
            'EMAutoAssignPolicy' {
                "https://entra.microsoft.com/#view/Microsoft_AAD_ELM/AccessPackageMenuBlade/~/Policies/accessPackageId/$apId"
            }
            default { '#' }
        }

        # Action varies by type
        $groupCount = ([regex]::Matches($f.MembershipRule ?? '', "'[^']+'") | Measure-Object).Count
        $actionTag = switch ($f.ObjectType) {
            'DynamicGroup' {
                if ($f.RuleComplexity -eq 'Low') {
                    "<span class='action-tag'>Replace rule</span> or <span class='action-tag'>Convert to Assigned</span>"
                } else {
                    "<span class='action-tag action-hard'>Replace $($f.RuleComplexity) rule</span> — $groupCount group$(if($groupCount -ne 1){'s'}) to replicate"
                }
            }
            'DynamicAdminUnit' {
                if ($f.RuleComplexity -eq 'Low') {
                    "<span class='action-tag'>Replace rule</span> or <span class='action-tag'>Convert to Assigned</span>"
                } else {
                    "<span class='action-tag action-hard'>Replace $($f.RuleComplexity) rule</span> — $groupCount group$(if($groupCount -ne 1){'s'}) to replicate"
                }
            }
            'EMAutoAssignPolicy' {
                "<span class='action-tag action-hard'>Update auto-assignment filter</span>"
            }
            default { $f.SuggestedAction }
        }
        $portalLabel = switch ($f.ObjectType) {
            'EMAutoAssignPolicy' { '→ Edit policy' }
            'DynamicGroup' { '→ Edit rule' }
            'DynamicAdminUnit' { '→ Edit rule' }
            default { '→ Edit in portal' }
        }
        $action = "$actionTag <a href='$portalLink' target='_blank' class='portal-link'>$portalLabel</a>"

        $blastCell = if ($f.BlastRadius) {
            $badges = $f.BlastRadius.Split(',') | ForEach-Object { "<span class='badge blast'>$($_.Trim())</span>" }
            $badges -join ' '
        } else { '<span style="color:#bdc3c7">—</span>' }

        # Store raw ObjectType in a hidden span so DataTables can search/filter on it
        "<tr>
          <td><span class='dt-type' style='display:none'>$($f.ObjectType)</span>$typeIcon<br><small>$(($f.ObjectType -replace 'Dynamic','').ToUpper())</small></td>
          <td><a href='$portalLink' target='_blank' class='obj-link'>$($f.DisplayName)</a><br><small class='obj-id'>$($f.ObjectId)</small></td>
          <td><code>$rule</code></td>
          <td data-order='$(if($f.RuleComplexity -eq 'High'){3}elseif($f.RuleComplexity -eq 'Medium'){2}else{1})'><span class='$complexityClass'>$($f.RuleComplexity)</span></td>
          <td>$action</td>
          <td>$blastCell</td>
        </tr>"

    }

    $html = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>EMOS Report - $generatedAt</title>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.8/css/jquery.dataTables.min.css">
<style>
  * { box-sizing: border-box; }
  body { font-family: Segoe UI, sans-serif; margin: 0; background: #f4f6f9; color: #2c3e50; }
  header { background: #2c3e50; color: white; padding: 20px 40px; }
  header h1 { margin: 0; font-size: 1.6em; }
  header p { margin: 4px 0 0; opacity: 0.8; }
  .deadline { display: inline-block; background: $urgencyColor; color: white; padding: 6px 14px; border-radius: 20px; font-weight: bold; margin-top: 10px; }
  .container { padding: 24px 40px; }
  .summary { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
  .card { background: white; border-radius: 8px; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); min-width: 130px; text-align: center; }
  .card h2 { font-size: 2em; margin: 0; color: #c0392b; }
  .card p { margin: 4px 0 0; font-size: .82em; color: #7f8c8d; }
  .card .tip { font-size: .72em; color: #aab; margin-top: 2px; }
  .toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
  .toolbar label { font-size: .85em; color: #7f8c8d; }
  .toolbar select, .toolbar input { border: 1px solid #ddd; border-radius: 6px; padding: 6px 10px; font-size: .9em; }
  .toolbar input[type=search] { width: 280px; }
  #findings-clear { font-size: .82em; color: #3498db; cursor: pointer; text-decoration: underline; }
  table.dataTable { width: 100% !important; border-collapse: collapse; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
  table.dataTable thead th { background: #34495e !important; color: white; padding: 11px 14px; font-size: .82em; text-transform: uppercase; letter-spacing: .05em; border: none !important; }
  table.dataTable thead th.sorting:after,
  table.dataTable thead th.sorting_asc:after,
  table.dataTable thead th.sorting_desc:after { opacity: .7; }
  table.dataTable tbody td { padding: 10px 14px; border-bottom: 1px solid #ecf0f1 !important; font-size: .88em; vertical-align: top; }
  table.dataTable tbody tr:last-child td { border-bottom: none !important; }
  table.dataTable tbody tr:hover td { background: #f8f9fa; }
  .dataTables_info, .dataTables_paginate { margin-top: 10px; font-size: .85em; }
  code { background: #f0f0f0; padding: 2px 5px; border-radius: 4px; font-size: .78em; word-break: break-all; display: block; max-width: 340px; }
  .obj-id { color: #aab; font-size: .78em; }
  .obj-link { color: #2980b9; text-decoration: none; font-weight: 600; }
  .obj-link:hover { text-decoration: underline; }
  .complexity-high { color: #c0392b; font-weight: bold; }
  .complexity-med { color: #e67e22; font-weight: bold; }
  .complexity-low { color: #27ae60; }
  .action-tag { display: inline-block; background: #eaf4fb; color: #2980b9; border-radius: 4px; padding: 2px 7px; font-size: .82em; margin: 1px; }
  .action-tag.action-hard { background: #fdedec; color: #c0392b; }
  .portal-link { color: #2980b9; font-size: .82em; text-decoration: none; margin-left: 4px; }
  .portal-link:hover { text-decoration: underline; }
  .badge { padding: 2px 8px; border-radius: 10px; font-size: .78em; font-weight: bold; white-space: nowrap; }
  .badge.blast { background: #fadbd8; color: #c0392b; }
  footer { padding: 16px 40px; font-size: .8em; color: #95a5a6; border-top: 1px solid #ecf0f1; margin-top: 20px; }
  [title] { cursor: help; border-bottom: 1px dotted #bbb; }
</style>
</head>
<body>
<header>
  <h1>🔍 EMOS — Entra MemberOf Scanner Report</h1>
  <p>Generated: $generatedAt</p>
  <span class="deadline">⏰ $DeadlineDays days until retirement (Nov 3, 2026)</span>
</header>
<div class="container">
  <div class="summary">
    <div class="card"><h2>$($Findings.Count)</h2><p>Total findings</p></div>
    <div class="card"><h2>$(@($Findings | Where-Object ObjectType -eq 'DynamicGroup').Count)</h2><p>Dynamic Groups</p></div>
    <div class="card"><h2>$(@($Findings | Where-Object ObjectType -eq 'DynamicAdminUnit').Count)</h2><p>Admin Units</p></div>
    <div class="card"><h2>$(@($Findings | Where-Object ObjectType -eq 'EMAutoAssignPolicy').Count)</h2><p>EM Policies</p></div>
    <div class="card"><h2>$caTargeted</h2><p title="Groups targeted by Conditional Access policies. Stale membership may cause access enforcement gaps.">CA-targeted ⓘ</p></div>
    <div class="card"><h2>$blastCount</h2><p title="Objects whose stale membership could impact CA policy enforcement, EM access package assignments, or AU-scoped admin roles. Licensing impact is detected separately.">Blast radius ⓘ</p><div class="tip">wider impact risk</div></div>
  </div>
 
  <div class="toolbar">
    <label>Type:</label>
    <select id="filter-type">
      <option value="">All</option>
      <option value="DynamicGroup">👥 Dynamic Groups</option>
      <option value="DynamicAdminUnit">🏢 Admin Units</option>
      <option value="EMAutoAssignPolicy">📦 EM Policies</option>
    </select>
    <label>Complexity:</label>
    <select id="filter-complexity">
      <option value="">All</option>
      <option value="High">High</option>
      <option value="Medium">Medium</option>
      <option value="Low">Low</option>
    </select>
    <label>Blast Radius:</label>
    <select id="filter-blast">
      <option value="">All</option>
      <option value="with">With blast radius</option>
      <option value="ConditionalAccess">CA-targeted</option>
      <option value="Licensing">Licensing</option>
      <option value="Entitlement Management">EM Policy</option>
      <option value="none">None</option>
    </select>
    <label>Search:</label>
    <input type="search" id="filter-search" placeholder="Filter by name, ID, or rule…">
    <span id="findings-clear" onclick="clearFilters()">Clear filters</span>
  </div>
 
  <table id="findings" class="display" style="width:100%">
    <thead><tr>
      <th>Type</th><th>Name / ID</th><th>Rule</th><th>Complexity</th><th>Suggested Action</th>
      <th title="Blast radius: the broader impact if membership becomes stale. CA = Conditional Access, EM = Entitlement Management.">Blast Radius ⓘ</th>
    </tr></thead>
    <tbody>$($rows -join "`n")</tbody>
  </table>
</div>
<footer>
  EMOS v1.0.0 · <a href="https://github.com/kayasax/EMOS">github.com/kayasax/EMOS</a> ·
  <a href="https://learn.microsoft.com/entra/identity/users/groups-dynamic-rule-member-of">Microsoft retirement docs</a>
  &nbsp;·&nbsp; Click any name to open directly in Entra portal
</footer>
 
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/1.13.8/js/jquery.dataTables.min.js"></script>
<script>
var table;
`$(document).ready(function() {
  table = `$('#findings').DataTable({
    pageLength: 25,
    order: [[3, 'desc']],
    columnDefs: [
      { orderable: false, targets: [1, 2, 4, 5] },
      { type: 'num', targets: 3 }
    ],
    language: {
      search: '',
      searchPlaceholder: '',
      lengthMenu: 'Show _MENU_',
      info: '_START_–_END_ of _TOTAL_ findings',
      infoFiltered: ' (filtered from _MAX_)',
      paginate: { previous: '‹', next: '›' }
    },
    dom: 'tip' // table, info, pagination — search handled by custom toolbar
  });
 
  `$('#filter-type').on('change', function() {
    // Search the hidden .dt-type span text in column 0
    table.column(0).search(this.value, false, false).draw();
  });
  `$('#filter-complexity').on('change', function() {
    // Search the visible span text in column 3
    table.column(3).search(this.value, false, false).draw();
  });
  `$('#filter-blast').on('change', function() {
    var val = this.value;
    if (val === 'with') {
      table.column(5).search('^(?!—$).+', true, false).draw();
    } else if (val === 'none') {
      table.column(5).search('^—$', true, false).draw();
    } else {
      table.column(5).search(val, false, false).draw();
    }
  });
  `$('#filter-search').on('input', function() {
    table.search(this.value).draw();
  });
});
 
function clearFilters() {
  `$('#filter-type').val('');
  `$('#filter-complexity').val('');
  `$('#filter-blast').val('');
  `$('#filter-search').val('');
  table.column(5).search('').draw();
  table.search('').columns().search('').draw();
}
</script>
</body>
</html>
"@


    $html | Set-Content -Path $OutputPath -Encoding UTF8
}