Public/Export-M365ConditionalAccessReport.ps1
|
function Export-M365ConditionalAccessReport { <# .SYNOPSIS Generates a modern, self-contained HTML report of all Conditional Access policies. .DESCRIPTION Reads all CA policies via Microsoft Graph, resolves GUIDs to readable names (users, groups, roles, apps, named locations) and renders a nice HTML report: a KPI overview, one card per policy with assignments and controls, plus automatically computed impact/hint pills (e.g. "Block access", "Enforces MFA", "Applies to ALL users", "N exclusions", "Report-only"). The report is a single HTML file (inline CSS, light/dark, no external dependencies). Read-only - changes nothing. .PARAMETER Path Target path of the HTML file. Default: .\ConditionalAccess-Report.html .PARAMETER PassThru Also emit the structured policy objects on the pipeline. .PARAMETER NoOpen Do NOT open the report in the default browser after generating it. .EXAMPLE Export-M365ConditionalAccessReport .EXAMPLE Export-M365ConditionalAccessReport -Path C:\Reports\ca.html -PassThru #> [CmdletBinding()] [OutputType([pscustomobject])] param( [string]$Path = (Join-Path (Get-Location) 'ConditionalAccess-Report.html'), # Branding in the report header/footer. For CloudNest365 simply: -BrandName 'CloudNest365' [string]$BrandName = 'TenantToolbox', [string]$BrandTagline = 'M365 Tenant Administration', # Additionally export the raw data as CSV or XLSX (XLSX needs the ImportExcel module). [switch]$Csv, [switch]$Excel, [string]$DataPath, [switch]$NoHtml, [switch]$PassThru, [switch]$NoOpen ) Assert-TTGraph Write-TTLog -Level INFO -Message "Reading Conditional Access policies ..." # --- Raw data + lookups -------------------------------------------------- $policies = Get-MgIdentityConditionalAccessPolicy -All -ErrorAction Stop $locMap = @{} try { Get-MgIdentityConditionalAccessNamedLocation -All -ErrorAction Stop | ForEach-Object { $locMap[$_.Id] = $_.DisplayName } } catch { } $roleMap = @{} try { Get-MgDirectoryRoleTemplate -All -ErrorAction Stop | ForEach-Object { $roleMap[$_.Id] = $_.DisplayName } } catch { } $userCache = @{}; $groupCache = @{}; $appCache = @{} $enc = { param($s) [System.Net.WebUtility]::HtmlEncode([string]$s) } function Get-Cached { param($Cache, $Id, [scriptblock]$Fetch) if ([string]::IsNullOrWhiteSpace($Id)) { return $Id } if ($Cache.ContainsKey($Id)) { return $Cache[$Id] } $name = $Id try { $r = & $Fetch $Id; if ($r) { $name = $r } } catch { } $Cache[$Id] = $name; return $name } function Resolve-Users { param($Ids) foreach ($id in @($Ids)) { if ([string]::IsNullOrWhiteSpace($id)) { continue } switch ($id) { 'All' { 'All users'; break } 'None' { 'None'; break } 'GuestsOrExternalUsers' { 'Guests / external users'; break } default { Get-Cached $userCache $id { param($x) (Get-MgUser -UserId $x -Property displayName -ErrorAction Stop).DisplayName } } } } } function Resolve-Groups { param($Ids) foreach ($id in @($Ids)) { if ([string]::IsNullOrWhiteSpace($id)) { continue } Get-Cached $groupCache $id { param($x) (Get-MgGroup -GroupId $x -Property displayName -ErrorAction Stop).DisplayName } } } function Resolve-Roles { param($Ids) foreach ($id in @($Ids)) { if ([string]::IsNullOrWhiteSpace($id)) { continue } if ($roleMap.ContainsKey($id)) { $roleMap[$id] } else { $id } } } function Resolve-Apps { param($Ids) foreach ($id in @($Ids)) { if ([string]::IsNullOrWhiteSpace($id)) { continue } switch ($id) { 'All' { 'All cloud apps'; break } 'None' { 'None'; break } 'Office365' { 'Office 365'; break } 'MicrosoftAdminPortals' { 'Microsoft admin portals'; break } default { Get-Cached $appCache $id { param($x) (Get-MgServicePrincipal -Filter "appId eq '$x'" -Property displayName -ErrorAction Stop | Select-Object -First 1).DisplayName } } } } } function Resolve-Locations { param($Ids) foreach ($id in @($Ids)) { if ([string]::IsNullOrWhiteSpace($id)) { continue } switch ($id) { 'All' { 'All locations'; break } 'AllTrusted' { 'All trusted locations'; break } default { if ($locMap.ContainsKey($id)) { $locMap[$id] } else { $id } } } } } $stateLabel = @{ 'enabled' = 'Enabled'; 'disabled' = 'Off'; 'enabledForReportingButNotEnforced' = 'Report-only' } $ctrlLabel = @{ 'mfa' = 'MFA'; 'block' = 'Block access'; 'compliantDevice' = 'Compliant device' 'domainJoinedDevice' = 'Domain-joined'; 'approvedApplication' = 'Approved app' 'compliantApplication' = 'App protection policy'; 'passwordChange' = 'Password change' } # --- Process policies into objects -------------------------------------- $report = foreach ($p in $policies) { $c = $p.Conditions $incUsers = @(Resolve-Users $c.Users.IncludeUsers) + @(Resolve-Groups $c.Users.IncludeGroups) + @(Resolve-Roles $c.Users.IncludeRoles) $excUsers = @(Resolve-Users $c.Users.ExcludeUsers) + @(Resolve-Groups $c.Users.ExcludeGroups) + @(Resolve-Roles $c.Users.ExcludeRoles) $incApps = @(Resolve-Apps $c.Applications.IncludeApplications) $excApps = @(Resolve-Apps $c.Applications.ExcludeApplications) $platforms = @($c.Platforms.IncludePlatforms) $locations = @(Resolve-Locations $c.Locations.IncludeLocations) $grants = @(@($p.GrantControls.BuiltInControls) | Where-Object { $_ } | ForEach-Object { if ($ctrlLabel.ContainsKey($_)) { $ctrlLabel[$_] } else { $_ } }) # Impact / hints $insights = @() if ($p.GrantControls.BuiltInControls -contains 'block') { $insights += @{ k = 'block'; t = 'Blocks access' } } if ($p.GrantControls.BuiltInControls -contains 'mfa') { $insights += @{ k = 'good'; t = 'Enforces MFA' } } if ($c.Users.IncludeUsers -contains 'All') { $insights += @{ k = 'warn'; t = 'Applies to ALL users' } } $exCount = @($c.Users.ExcludeUsers).Count + @($c.Users.ExcludeGroups).Count + @($c.Users.ExcludeRoles).Count if ($exCount -gt 0) { $insights += @{ k = 'warn'; t = "$exCount exclusion(s)" } } if ($p.State -eq 'enabledForReportingButNotEnforced') { $insights += @{ k = 'report'; t = 'Not enforced (report-only)' } } if ($p.State -eq 'disabled') { $insights += @{ k = 'off'; t = 'Disabled' } } if (@($c.SignInRiskLevels).Count -or @($c.UserRiskLevels).Count) { $insights += @{ k = 'info'; t = 'Risk-based' } } $legacy = @($c.ClientAppTypes | Where-Object { $_ -in 'exchangeActiveSync', 'other' }) if ($legacy.Count -and ($p.GrantControls.BuiltInControls -contains 'block')) { $insights += @{ k = 'good'; t = 'Blocks legacy auth' } } [pscustomobject]@{ Name = $p.DisplayName State = $stateLabel[$p.State] StateRaw = $p.State IncludeUsers = $incUsers ExcludeUsers = $excUsers IncludeApps = $incApps ExcludeApps = $excApps Platforms = $platforms Locations = $locations ClientApps = @($c.ClientAppTypes) SignInRisk = @($c.SignInRiskLevels) UserRisk = @($c.UserRiskLevels) GrantOperator = $p.GrantControls.Operator Grants = $grants Session = @( if ($p.SessionControls.SignInFrequency.IsEnabled) { 'Sign-in frequency' } if ($p.SessionControls.PersistentBrowser.IsEnabled) { 'Persistent browser' } if ($p.SessionControls.ApplicationEnforcedRestrictions.IsEnabled) { 'App-enforced restrictions' } if ($p.SessionControls.CloudAppSecurity.IsEnabled) { 'Defender for Cloud Apps' } ) Insights = $insights Modified = $p.ModifiedDateTime } } # --- Build HTML ---------------------------------------------------------- $total = @($report).Count $onCount = @($report | Where-Object StateRaw -eq 'enabled').Count $repCount = @($report | Where-Object StateRaw -eq 'enabledForReportingButNotEnforced').Count $offCount = @($report | Where-Object StateRaw -eq 'disabled').Count $mfaCount = @($report | Where-Object { $_.Grants -contains 'MFA' }).Count $blkCount = @($report | Where-Object { $_.Grants -contains 'Block access' }).Count $genAt = Get-Date -Format 'yyyy-MM-dd HH:mm' $tenant = (Get-MgContext).TenantId function New-Pill { param($Text, $Kind = 'info') "<span class='pill pill-$Kind'>$(& $enc $Text)</span>" } function New-Chips { param($Items, $Kind = 'chip') $arr = @($Items) | Where-Object { $_ } if (-not $arr) { return "<span class='muted'>–</span>" } ($arr | ForEach-Object { "<span class='$Kind'>$(& $enc $_)</span>" }) -join ' ' } $cards = foreach ($r in $report) { $stateKind = switch ($r.StateRaw) { 'enabled' { 'on' } 'enabledForReportingButNotEnforced' { 'report' } default { 'off' } } $insightPills = ($r.Insights | ForEach-Object { New-Pill $_.t $_.k }) -join ' ' $grantText = if ($r.Grants) { (@($r.Grants) -join " <span class='op'>$($r.GrantOperator)</span> ") } else { "<span class='muted'>No grant controls</span>" } $mfaFlag = if ($r.Grants -contains 'MFA') { '1' } else { '0' } $blockFlag = if ($r.Grants -contains 'Block access') { '1' } else { '0' } $searchRaw = (@($r.Name) + @($r.IncludeUsers) + @($r.ExcludeUsers) + @($r.IncludeApps) + @($r.ExcludeApps) + @($r.Locations) + @($r.Grants) + @($r.Platforms)) -join ' ' $searchAttr = & $enc ($searchRaw.ToLower()) $nameAttr = & $enc ([string]$r.Name).ToLower() $modAttr = & $enc ([string]$r.Modified) @" <article class="card" data-state="$stateKind" data-mfa="$mfaFlag" data-block="$blockFlag" data-name="$nameAttr" data-modified="$modAttr" data-search="$searchAttr"> <header class="card-head" role="button" tabindex="0"> <div class="title-wrap"> <span class="chevron" aria-hidden="true">▾</span> <h3>$(& $enc $r.Name)</h3> <span class="state state-$stateKind">$(& $enc $r.State)</span> </div> <div class="insights">$insightPills</div> </header> <div class="card-body"> <div class="grid"> <section> <div class="block-title">Who</div> <div class="row"><span class="lbl">Included</span><div class="vals">$(New-Chips $r.IncludeUsers 'chip chip-inc')</div></div> <div class="row"><span class="lbl">Excluded</span><div class="vals">$(New-Chips $r.ExcludeUsers 'chip chip-exc')</div></div> <div class="block-title">What (apps)</div> <div class="row"><span class="lbl">Included</span><div class="vals">$(New-Chips $r.IncludeApps 'chip chip-inc')</div></div> <div class="row"><span class="lbl">Excluded</span><div class="vals">$(New-Chips $r.ExcludeApps 'chip chip-exc')</div></div> </section> <section> <div class="block-title">Conditions</div> <div class="row"><span class="lbl">Platforms</span><div class="vals">$(New-Chips $r.Platforms)</div></div> <div class="row"><span class="lbl">Locations</span><div class="vals">$(New-Chips $r.Locations)</div></div> <div class="row"><span class="lbl">Client apps</span><div class="vals">$(New-Chips $r.ClientApps)</div></div> <div class="row"><span class="lbl">Sign-in risk</span><div class="vals">$(New-Chips $r.SignInRisk)</div></div> <div class="row"><span class="lbl">User risk</span><div class="vals">$(New-Chips $r.UserRisk)</div></div> <div class="block-title">Controls</div> <div class="row"><span class="lbl">Grant</span><div class="vals grant">$grantText</div></div> <div class="row"><span class="lbl">Session</span><div class="vals">$(New-Chips $r.Session)</div></div> </section> </div> <footer class="card-foot">Last modified: $(& $enc $r.Modified)</footer> </div> </article> "@ } $brandName = & $enc $BrandName $brandTagline = & $enc $BrandTagline $brandInitial = & $enc ($BrandName.Substring(0, 1).ToUpper()) $html = @" <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>$brandName · Conditional Access Report</title> <style> :root{ --bg:#f5f6fa; --card:#ffffff; --text:#171923; --muted:#7b8394; --border:#e7e9f0; --border-strong:#d6d9e4; --on:#16a34a; --report:#e08600; --off:#9aa0ad; --block:#e5484d; --good:#0ea5e9; --warn:#e08600; --info:#6366f1; --brand1:#4f46e5; --brand2:#7c3aed; --brand3:#db2777; --shadow:0 1px 2px rgba(16,24,40,.04),0 6px 20px rgba(16,24,40,.06); --shadow-lg:0 8px 30px rgba(16,24,40,.10); --radius:16px; } :root[data-theme="dark"], html:not([data-theme="light"]) {} @media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#0c0e14; --card:#161922; --text:#e8eaf0; --muted:#8a90a1; --border:#242835; --border-strong:#2e3342; --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.35); --shadow-lg:0 12px 40px rgba(0,0,0,.5); } } :root[data-theme="dark"]{ --bg:#0c0e14; --card:#161922; --text:#e8eaf0; --muted:#8a90a1; --border:#242835; --border-strong:#2e3342; --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.35); --shadow-lg:0 12px 40px rgba(0,0,0,.5); } *{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--bg);color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.5;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility} .wrap{max-width:1200px;margin:0 auto;padding:0 24px} .hero{position:relative;padding:30px 0 30px;color:#fff;overflow:hidden; background:linear-gradient(120deg,var(--brand1) 0%,var(--brand2) 52%,var(--brand3) 100%)} .hero::after{content:"";position:absolute;inset:0;opacity:.18; background:radial-gradient(600px 200px at 85% -20%,#fff,transparent)} .hero .wrap{position:relative;z-index:1} .brand{display:flex;align-items:center;gap:11px;margin-bottom:18px} .brand .logo{width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,.16); display:grid;place-items:center;font-weight:800;font-size:17px;backdrop-filter:blur(4px); border:1px solid rgba(255,255,255,.25)} .brand .bn{font-weight:700;font-size:15px;letter-spacing:.01em} .brand .bt{font-size:12px;opacity:.8;margin-left:2px} .hero h1{margin:0;font-size:27px;font-weight:750;letter-spacing:-.022em} .hero .sub{margin:6px 0 0;opacity:.86;font-size:13px} .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:13px;margin:-24px auto 20px;position:relative;z-index:3} .kpi{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:15px 17px;box-shadow:var(--shadow); cursor:pointer;transition:transform .12s ease,box-shadow .12s ease,border-color .12s} .kpi:hover{transform:translateY(-2px);box-shadow:var(--shadow-lg)} .kpi.active{border-color:var(--brand2);box-shadow:0 0 0 2px color-mix(in srgb,var(--brand2) 35%,transparent)} .kpi .n{font-size:29px;font-weight:780;letter-spacing:-.03em;line-height:1} .kpi .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:5px;font-weight:600} .kpi.on .n{color:var(--on)} .kpi.report .n{color:var(--report)} .kpi.off .n{color:var(--off)} .kpi.mfa .n{color:var(--good)} .kpi.block .n{color:var(--block)} .toolbar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;gap:10px;align-items:center; padding:12px 0;margin-bottom:14px;background:color-mix(in srgb,var(--bg) 88%,transparent); backdrop-filter:blur(10px);border-bottom:1px solid var(--border)} .search{flex:1 1 240px;position:relative} .search input{width:100%;padding:9px 12px 9px 34px;border-radius:10px;border:1px solid var(--border-strong); background:var(--card);color:var(--text);font-size:14px;outline:none;transition:border-color .12s,box-shadow .12s} .search input:focus{border-color:var(--brand2);box-shadow:0 0 0 3px color-mix(in srgb,var(--brand2) 22%,transparent)} .search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);opacity:.5} .filters{display:flex;flex-wrap:wrap;gap:6px} .fbtn{font-size:12.5px;font-weight:600;padding:7px 12px;border-radius:9px;border:1px solid var(--border-strong); background:var(--card);color:var(--text);cursor:pointer;transition:all .12s} .fbtn:hover{border-color:var(--brand2)} .fbtn.active{background:var(--brand2);border-color:var(--brand2);color:#fff} select,.tbtn{font-size:13px;padding:8px 11px;border-radius:9px;border:1px solid var(--border-strong); background:var(--card);color:var(--text);cursor:pointer;outline:none} .tbtn{font-weight:600} .tbtn:hover{border-color:var(--brand2)} .count{font-size:12.5px;color:var(--muted);margin-left:auto;white-space:nowrap;font-weight:600} .cards{display:grid;gap:16px;padding-bottom:20px} .card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow); overflow:hidden;transition:box-shadow .12s,border-color .12s} .card:hover{box-shadow:var(--shadow-lg)} .card.hidden{display:none} .card-head{padding:16px 20px;cursor:pointer;user-select:none;transition:background .12s} .card-head:hover{background:color-mix(in srgb,var(--text) 3%,transparent)} .card-head:focus-visible{outline:2px solid var(--brand2);outline-offset:-2px} .title-wrap{display:flex;align-items:center;gap:11px;flex-wrap:wrap} .chevron{color:var(--muted);font-size:13px;transition:transform .18s ease;flex:0 0 auto} .card.collapsed .chevron{transform:rotate(-90deg)} .title-wrap h3{margin:0;font-size:16.5px;font-weight:650;letter-spacing:-.01em;flex:1 1 auto} .state{font-size:10.5px;font-weight:700;padding:3px 10px;border-radius:999px;text-transform:uppercase;letter-spacing:.05em;flex:0 0 auto} .state-on{background:color-mix(in srgb,var(--on) 15%,transparent);color:var(--on)} .state-report{background:color-mix(in srgb,var(--report) 16%,transparent);color:var(--report)} .state-off{background:color-mix(in srgb,var(--off) 20%,transparent);color:var(--off)} .insights{margin-top:10px;display:flex;flex-wrap:wrap;gap:6px;padding-left:24px} .pill{font-size:11px;font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid transparent} .pill-block{background:color-mix(in srgb,var(--block) 13%,transparent);color:var(--block)} .pill-good{background:color-mix(in srgb,var(--good) 15%,transparent);color:var(--good)} .pill-warn{background:color-mix(in srgb,var(--warn) 15%,transparent);color:var(--warn)} .pill-info{background:color-mix(in srgb,var(--info) 15%,transparent);color:var(--info)} .pill-report{background:color-mix(in srgb,var(--report) 15%,transparent);color:var(--report)} .pill-off{background:color-mix(in srgb,var(--off) 18%,transparent);color:var(--off)} .card-body{border-top:1px solid var(--border)} .card.collapsed .card-body{display:none} .grid{display:grid;grid-template-columns:1fr 1fr;gap:0} .grid section{padding:16px 20px} .grid section:first-child{border-right:1px solid var(--border)} .block-title{font-size:10.5px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:14px 0 8px} .block-title:first-child{margin-top:0} .row{display:flex;gap:12px;padding:4px 0;align-items:flex-start} .lbl{flex:0 0 116px;font-size:12.5px;color:var(--muted)} .vals{flex:1;display:flex;flex-wrap:wrap;gap:5px;font-size:12.5px} .chip{font-size:12px;padding:2px 8px;border-radius:7px;background:color-mix(in srgb,var(--text) 6%,transparent);border:1px solid var(--border)} .chip-inc{background:color-mix(in srgb,var(--on) 11%,transparent);border-color:color-mix(in srgb,var(--on) 28%,transparent)} .chip-exc{background:color-mix(in srgb,var(--block) 11%,transparent);border-color:color-mix(in srgb,var(--block) 28%,transparent)} .grant{font-weight:600} .op{font-size:10px;color:var(--muted);font-weight:700;padding:0 2px} .muted{color:var(--muted)} .card-foot{padding:10px 20px;font-size:12px;color:var(--muted);border-top:1px solid var(--border)} .empty{display:none;text-align:center;padding:60px 20px;color:var(--muted)} .empty.show{display:block} .footer{border-top:1px solid var(--border);margin-top:24px;padding:22px 0 40px;color:var(--muted);font-size:12.5px; display:flex;align-items:center;gap:10px;flex-wrap:wrap} .footer .logo{width:24px;height:24px;border-radius:7px;color:#fff;display:grid;place-items:center;font-weight:800;font-size:12px; background:linear-gradient(135deg,var(--brand1),var(--brand3))} .footer b{color:var(--text)} @media (max-width:720px){ .grid{grid-template-columns:1fr} .grid section:first-child{border-right:none;border-bottom:1px solid var(--border)} .count{margin-left:0} } </style> </head> <body> <div class="hero"> <div class="wrap"> <div class="brand"> <div class="logo">$brandInitial</div> <div><span class="bn">$brandName</span> <span class="bt">$brandTagline</span></div> </div> <h1>Conditional Access Report</h1> <p class="sub">Tenant $tenant · generated $genAt · $total policies</p> </div> </div> <div class="wrap"> <div class="kpis"> <div class="kpi active" data-filter="all"><div class="n">$total</div><div class="l">Total policies</div></div> <div class="kpi on" data-filter="on"><div class="n">$onCount</div><div class="l">Enabled</div></div> <div class="kpi report" data-filter="report"><div class="n">$repCount</div><div class="l">Report-only</div></div> <div class="kpi off" data-filter="off"><div class="n">$offCount</div><div class="l">Disabled</div></div> <div class="kpi mfa" data-filter="mfa"><div class="n">$mfaCount</div><div class="l">Enforce MFA</div></div> <div class="kpi block" data-filter="block"><div class="n">$blkCount</div><div class="l">Block</div></div> </div> <div class="toolbar"> <div class="search"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="m21 21-4.3-4.3"></path></svg> <input id="q" type="search" placeholder="Search policies (name, user, app, location ...)" autocomplete="off"> </div> <div class="filters" id="filters"> <button class="fbtn active" data-filter="all">All</button> <button class="fbtn" data-filter="on">Enabled</button> <button class="fbtn" data-filter="report">Report-only</button> <button class="fbtn" data-filter="off">Disabled</button> <button class="fbtn" data-filter="mfa">MFA</button> <button class="fbtn" data-filter="block">Block</button> </div> <select id="sort" title="Sorting"> <option value="name">Name (A–Z)</option> <option value="name-desc">Name (Z–A)</option> <option value="modified-desc">Last modified</option> <option value="state">Status</option> </select> <button class="tbtn" id="toggleAll" title="Expand/collapse all">Collapse all</button> <button class="tbtn" id="theme" title="Switch theme">◑ Theme</button> <span class="count" id="count"></span> </div> <div class="cards" id="cards"> $($cards -join "`n") </div> <div class="empty" id="empty">No policy matches the filters.</div> <div class="footer"> <div class="logo">$brandInitial</div> <div>Generated with <b>$brandName</b> · $brandTagline · $genAt</div> </div> </div> <script> (function(){ var cards = Array.prototype.slice.call(document.querySelectorAll('.card')); var q = document.getElementById('q'); var count = document.getElementById('count'); var empty = document.getElementById('empty'); var container = document.getElementById('cards'); var activeFilter = 'all'; function matchesFilter(c){ switch(activeFilter){ case 'all': return true; case 'on': case 'report': case 'off': return c.dataset.state === activeFilter; case 'mfa': return c.dataset.mfa === '1'; case 'block': return c.dataset.block === '1'; } return true; } function apply(){ var term = (q.value || '').trim().toLowerCase(); var visible = 0; cards.forEach(function(c){ var ok = matchesFilter(c) && (term === '' || c.dataset.search.indexOf(term) !== -1 || c.dataset.name.indexOf(term) !== -1); c.classList.toggle('hidden', !ok); if(ok) visible++; }); count.textContent = visible + ' of ' + cards.length + ' shown'; empty.classList.toggle('show', visible === 0); } function setFilter(f){ activeFilter = f; document.querySelectorAll('#filters .fbtn').forEach(function(b){ b.classList.toggle('active', b.dataset.filter === f); }); document.querySelectorAll('.kpi').forEach(function(k){ k.classList.toggle('active', k.dataset.filter === f); }); apply(); } // Search + filter q.addEventListener('input', apply); document.querySelectorAll('#filters .fbtn').forEach(function(b){ b.addEventListener('click', function(){ setFilter(b.dataset.filter); }); }); document.querySelectorAll('.kpi').forEach(function(k){ k.addEventListener('click', function(){ setFilter(k.dataset.filter); }); }); // Sorting var stateOrder = { on:0, report:1, off:2 }; document.getElementById('sort').addEventListener('change', function(e){ var v = e.target.value; var sorted = cards.slice().sort(function(a,b){ if(v === 'name') return a.dataset.name.localeCompare(b.dataset.name); if(v === 'name-desc') return b.dataset.name.localeCompare(a.dataset.name); if(v === 'modified-desc') return (b.dataset.modified||'').localeCompare(a.dataset.modified||''); if(v === 'state') return (stateOrder[a.dataset.state]-stateOrder[b.dataset.state]) || a.dataset.name.localeCompare(b.dataset.name); return 0; }); sorted.forEach(function(c){ container.appendChild(c); }); }); // Collapse/expand cards function toggleCard(c){ c.classList.toggle('collapsed'); } cards.forEach(function(c){ var head = c.querySelector('.card-head'); head.addEventListener('click', function(){ toggleCard(c); }); head.addEventListener('keydown', function(e){ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); toggleCard(c); } }); }); var allCollapsed = false; document.getElementById('toggleAll').addEventListener('click', function(e){ allCollapsed = !allCollapsed; cards.forEach(function(c){ c.classList.toggle('collapsed', allCollapsed); }); e.target.textContent = allCollapsed ? 'Expand all' : 'Collapse all'; }); // Theme toggle (auto -> light -> dark) var themes = ['auto','light','dark']; var ti = 0; try { var saved = localStorage.getItem('ca_theme'); if(saved){ ti = themes.indexOf(saved); if(ti<0) ti=0; } } catch(_){} function applyTheme(){ var t = themes[ti]; if(t === 'auto') document.documentElement.removeAttribute('data-theme'); else document.documentElement.setAttribute('data-theme', t); document.getElementById('theme').innerHTML = '◑ ' + (t==='auto'?'Auto':(t==='light'?'Light':'Dark')); try { localStorage.setItem('ca_theme', t); } catch(_){} } document.getElementById('theme').addEventListener('click', function(){ ti = (ti+1)%themes.length; applyTheme(); }); applyTheme(); apply(); })(); </script> </body> </html> "@ Write-TTLog -Level INFO -Message "Report created: $Path ($total policies)." $flat = $report | Select-Object Name, State, @{N = 'IncludeUsers'; E = { $_.IncludeUsers -join '; ' } }, @{N = 'ExcludeUsers'; E = { $_.ExcludeUsers -join '; ' } }, @{N = 'IncludeApps'; E = { $_.IncludeApps -join '; ' } }, @{N = 'ExcludeApps'; E = { $_.ExcludeApps -join '; ' } }, @{N = 'Platforms'; E = { $_.Platforms -join '; ' } }, @{N = 'Locations'; E = { $_.Locations -join '; ' } }, @{N = 'ClientApps'; E = { $_.ClientApps -join '; ' } }, @{N = 'SignInRisk'; E = { $_.SignInRisk -join '; ' } }, @{N = 'UserRisk'; E = { $_.UserRisk -join '; ' } }, GrantOperator, @{N = 'Grants'; E = { $_.Grants -join '; ' } }, @{N = 'Session'; E = { $_.Session -join '; ' } }, Modified Complete-TTReport -Html $html -Path $Path -Data $flat -Csv:$Csv -Excel:$Excel -DataPath $DataPath -NoHtml:$NoHtml -NoOpen:$NoOpen -Kind 'Conditional-Access-Report' if ($PassThru) { $report } } |