Public/Report/Export-SPCReport.ps1
|
function Export-SPCReport { <# .SYNOPSIS Generates a formatted report from [SPC.OrphanedUser] pipeline input per SRS 3.3.1. .DESCRIPTION Accepts orphaned user objects and writes a CSV, HTML, or JSON file. HTML output is self-contained with inline CSS/JS, color-coded risk badges, sortable columns, and an optional summary card. .PARAMETER InputObject One or more [SPC.OrphanedUser] objects. Accepts pipeline input. .PARAMETER Format Output format: 'CSV' (default), 'HTML', or 'JSON'. Case-insensitive. .PARAMETER OutputPath Destination file path. Defaults to .\SPClean_OrphanedUsers_{TenantName}_{yyyyMMddHHmm}.{ext}. .PARAMETER GroupBy Group results by 'Site' (default), 'RiskLevel', or 'OrphanType'. .PARAMETER IncludeSummary Prepend a summary: total orphans, breakdown by RiskLevel and OrphanType, sites scanned. .PARAMETER PassThru Pass input objects through to the pipeline after writing the file. .EXAMPLE Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' | Export-SPCReport -Format HTML -IncludeSummary .EXAMPLE $orphans | Export-SPCReport -Format CSV -GroupBy RiskLevel -OutputPath C:\reports\orphans.csv .OUTPUTS SPC.ReportResult #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject[]] $InputObject, [Parameter()] [ValidateSet('CSV', 'HTML', 'JSON')] [string] $Format = 'CSV', [Parameter()] [Alias('Path')] [string] $OutputPath, [Parameter()] [ValidateSet('Site', 'RiskLevel', 'OrphanType')] [string] $GroupBy = 'Site', [Parameter()] [switch] $IncludeSummary, [Parameter()] [switch] $PassThru ) begin { Test-SPCConnection if ($Format -ieq 'HTML') { Assert-SPCProLicense -Feature 'HTMLReport' } $collected = [System.Collections.Generic.List[PSCustomObject]]::new() } process { foreach ($item in $InputObject) { $collected.Add($item) } } end { if ($collected.Count -eq 0) { Write-Warning 'Export-SPCReport: No input objects received.' return } $Format = $Format.ToUpper() $ext = switch ($Format) { 'CSV' { 'csv' } 'HTML' { 'html' } 'JSON' { 'json' } } $tenantName = if ($script:SPCContext) { $script:SPCContext.TenantName } else { 'unknown' } $generatedAt = (Get-Date).ToUniversalTime() $moduleVer = (Get-Module SPClean -ErrorAction SilentlyContinue | Select-Object -First 1).Version $moduleVer = if ($moduleVer) { $moduleVer.ToString() } else { '1.0.0' } if ([string]::IsNullOrWhiteSpace($OutputPath)) { $ts = $generatedAt.ToString('yyyyMMddHHmm') $OutputPath = ".\SPClean_OrphanedUsers_${tenantName}_${ts}.${ext}" } $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) $groupField = switch ($GroupBy) { 'Site' { 'SiteUrl' } 'RiskLevel' { 'RiskLevel' } 'OrphanType' { 'OrphanType' } } $sorted = @($collected | Sort-Object -Property $groupField) $csvEsc = { param($v) $s = if ($null -ne $v) { [string]$v } else { '' } if ($s -match '[,"\r\n]') { '"' + $s.Replace('"', '""') + '"' } else { $s } } switch ($Format) { 'CSV' { $enc = [System.Text.UTF8Encoding]::new($true) # BOM required for Excel compatibility $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('SiteUrl,SiteTitle,DisplayName,Email,UPN,OrphanType,RiskLevel,HasDirectPermissions,GroupMemberships,LastActivityDate,DetectedAt') foreach ($item in $sorted) { $grp = if ($item.GroupMemberships) { $item.GroupMemberships -join ';' } else { '' } $last = if ($item.LastActivityDate) { ([datetime]$item.LastActivityDate).ToUniversalTime().ToString('o') } else { '' } $det = if ($item.DetectedAt) { ([datetime]$item.DetectedAt).ToUniversalTime().ToString('o') } else { '' } $row = @( (& $csvEsc $item.SiteUrl), (& $csvEsc $item.SiteTitle), (& $csvEsc $item.DisplayName), (& $csvEsc $item.Email), (& $csvEsc $item.UPN), $item.OrphanType, $item.RiskLevel, ([string]$item.HasDirectPermissions).ToLower(), (& $csvEsc $grp), $last, $det ) [void]$sb.AppendLine($row -join ',') } [System.IO.File]::WriteAllText($resolvedPath, $sb.ToString(), $enc) } 'HTML' { $he = { param($v) $s = if ($null -ne $v) { [string]$v } else { '' } $s.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace('"', '"') } $summHtml = '' if ($IncludeSummary) { $byRisk = @($collected | Group-Object RiskLevel | Sort-Object Name) $byType = @($collected | Group-Object OrphanType | Sort-Object Name) $sites = ($collected | Select-Object -ExpandProperty SiteUrl -Unique).Count $rCards = ($byRisk | ForEach-Object { $col = switch ($_.Name) { 'HIGH' { '#dc3545' } 'MEDIUM' { '#ffc107' } 'LOW' { '#28a745' } default { '#6c757d' } } "<div class=`"sc`"><div class=`"sv`" style=`"color:$col`">$($_.Count)</div><div class=`"sl`">$($_.Name)</div></div>" }) -join '' $tCards = ($byType | ForEach-Object { "<div class=`"sc`"><div class=`"sv`">$($_.Count)</div><div class=`"sl`">$(& $he $_.Name)</div></div>" }) -join '' $summHtml = @" <div class="sum"> <div class="sc main"><div class="sv">$($collected.Count)</div><div class="sl">Total Orphans</div></div> <div class="sc main"><div class="sv">$sites</div><div class="sl">Sites Scanned</div></div> $rCards $tCards </div> "@ } $tbSb = [System.Text.StringBuilder]::new() $prevGrp = $null foreach ($item in $sorted) { $curGrp = $item.$groupField if ($curGrp -ne $prevGrp) { [void]$tbSb.Append("<tr class=`"gh`"><td colspan=`"11`">$(& $he $curGrp)</td></tr>`n") $prevGrp = $curGrp } $badge = "<span class=`"b b-$($item.RiskLevel)`">$(& $he $item.RiskLevel)</span>" $grp = if ($item.GroupMemberships) { & $he ($item.GroupMemberships -join '; ') } else { '' } $last = if ($item.LastActivityDate) { & $he (([datetime]$item.LastActivityDate).ToUniversalTime().ToString('yyyy-MM-dd')) } else { '' } $det = if ($item.DetectedAt) { & $he (([datetime]$item.DetectedAt).ToUniversalTime().ToString('yyyy-MM-dd')) } else { '' } [void]$tbSb.Append("<tr><td>$(& $he $item.SiteUrl)</td><td>$(& $he $item.SiteTitle)</td><td>$(& $he $item.DisplayName)</td><td>$(& $he $item.Email)</td><td>$(& $he $item.UPN)</td><td>$(& $he $item.OrphanType)</td><td>$badge</td><td>$($item.HasDirectPermissions)</td><td>$grp</td><td>$last</td><td>$det</td></tr>`n") } $tsStr = $generatedAt.ToString('yyyy-MM-dd HH:mm') $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>SPClean Report - $tenantName</title> <style> *{box-sizing:border-box;margin:0;padding:0} body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f0f2f5;padding:16px;color:#212529} h1{font-size:1.35em;margin-bottom:14px} .sum{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:14px} .sc{background:#fff;border-radius:8px;padding:10px 16px;box-shadow:0 1px 3px rgba(0,0,0,.1);min-width:90px;text-align:center} .sc.main .sv{color:#343a40} .sv{font-size:1.7em;font-weight:700}.sl{font-size:.75em;color:#6c757d;margin-top:2px} .wrap{overflow-x:auto;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.1)} table{width:100%;border-collapse:collapse;background:#fff} th{background:#343a40;color:#fff;padding:9px 12px;text-align:left;cursor:pointer;white-space:nowrap;user-select:none;font-size:.84em} th:hover{background:#495057} td{padding:7px 12px;border-bottom:1px solid #dee2e6;font-size:.84em;word-break:break-word} tr:last-child td{border-bottom:none}tr:not(.gh):hover td{background:#f8f9fa} .gh td{background:#e9ecef;font-weight:600;color:#495057;padding:5px 12px;font-size:.80em} .b{display:inline-block;padding:2px 8px;border-radius:10px;font-size:.76em;font-weight:700;letter-spacing:.3px} .b-HIGH{background:#dc3545;color:#fff}.b-MEDIUM{background:#ffc107;color:#212529}.b-LOW{background:#28a745;color:#fff} footer{margin-top:14px;text-align:center;font-size:.76em;color:#6c757d} @media(max-width:600px){th,td{padding:6px 8px;font-size:.8em}} </style> </head> <body> <h1>SPClean - Orphaned User Report</h1> $summHtml <div class="wrap"> <table id="t"> <thead><tr> <th>SiteUrl</th><th>SiteTitle</th><th>DisplayName</th><th>Email</th><th>UPN</th> <th>OrphanType</th><th>RiskLevel</th><th>DirectPerms</th><th>Groups</th> <th>LastActivity</th><th>DetectedAt</th> </tr></thead> <tbody> $($tbSb.ToString())</tbody> </table> </div> <footer>Generated by SPClean v$moduleVer • Tenant: $tenantName • $tsStr UTC</footer> <script> (function(){ var tb=document.querySelector('#t tbody'),ths=document.querySelectorAll('#t th'),dir={}; ths.forEach(function(th,i){ th.addEventListener('click',function(){ var asc=dir[i]!==true;dir={};dir[i]=asc; ths.forEach(function(h){h.className='';}); th.className=asc?'asc':'desc'; Array.from(tb.rows).filter(function(r){return r.classList.contains('gh');}).forEach(function(r){r.parentNode.removeChild(r);}); var rows=Array.from(tb.rows); rows.sort(function(a,b){ var av=a.cells[i]?a.cells[i].textContent.trim():''; var bv=b.cells[i]?b.cells[i].textContent.trim():''; return asc?av.localeCompare(bv,undefined,{numeric:true}):bv.localeCompare(av,undefined,{numeric:true}); }); rows.forEach(function(r){tb.appendChild(r);}); }); }); })(); </script> </body> </html> "@ [System.IO.File]::WriteAllText($resolvedPath, $html, [System.Text.UTF8Encoding]::new($false)) } 'JSON' { $byGroup = $sorted | Group-Object -Property $groupField $jsonData = @($byGroup | ForEach-Object { [ordered]@{ Group = $_.Name Count = $_.Count Items = @($_.Group | Select-Object SiteUrl, SiteTitle, DisplayName, Email, UPN, OrphanType, RiskLevel, HasDirectPermissions, GroupMemberships, LastActivityDate, DetectedAt) } }) $output = if ($IncludeSummary) { [ordered]@{ Summary = [ordered]@{ TotalOrphans = $collected.Count SitesScanned = ($collected | Select-Object -ExpandProperty SiteUrl -Unique).Count ByRiskLevel = @($collected | Group-Object RiskLevel | ForEach-Object { [ordered]@{ RiskLevel = $_.Name; Count = $_.Count } }) ByOrphanType = @($collected | Group-Object OrphanType | ForEach-Object { [ordered]@{ OrphanType = $_.Name; Count = $_.Count } }) GeneratedAt = $generatedAt.ToString('o') } Groups = $jsonData } } else { $jsonData } $jsonText = $output | ConvertTo-Json -Depth 10 [System.IO.File]::WriteAllText($resolvedPath, $jsonText, [System.Text.UTF8Encoding]::new($false)) } } Write-Verbose "Export-SPCReport: Wrote $($collected.Count) records to '$resolvedPath'" $result = [PSCustomObject][ordered]@{ FilePath = $resolvedPath Format = $Format TotalOrphansReported = $collected.Count GeneratedAt = $generatedAt } $result.PSObject.TypeNames.Insert(0, 'SPC.ReportResult') $result if ($PassThru) { foreach ($item in $collected) { $item } } } } |