Public/New-SPPermissionReport.ps1

function New-SPPermissionReport {
    <#
    .SYNOPSIS
        Renders a scan JSON (from Invoke-SPPermissionScan) into a self-contained, interactive HTML report.
    .DESCRIPTION
        The report needs no server and can be sent by mail: matrix per site (hierarchy as a
        collapsible tree, principals as columns, V/B/M/L abbreviations in the cells),
        inherited permissions dimmed, unique permissions highlighted, group members shown on
        click (including nested groups), search/filter (including "where does person X have
        access?"), sharing links flagged and filterable, overview page for tenant scans.
    .PARAMETER InputPath
        Path to the scan JSON file.
    .PARAMETER ScanData
        Scan object (from Invoke-SPPermissionScan -PassThru) or scan JSON FileInfo - pipeline capable.
    .PARAMETER OutputPath
        Path of the HTML output file. Default: input path with .html extension.
    .PARAMETER Title
        Report title. Default: SharePoint Permission Matrix
    .PARAMETER Open
        Opens the report in the default browser afterwards.
    .EXAMPLE
        New-SPPermissionReport -InputPath .\scan.json -Open
    .EXAMPLE
        Invoke-SPPermissionScan -SiteUrl $url -Depth 2 -Interactive -ClientId $appId | New-SPPermissionReport -Open
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Path')]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Path', Position = 0)]
        [string]$InputPath,

        [Parameter(Mandatory, ParameterSetName = 'Data', ValueFromPipeline)]
        [psobject]$ScanData,

        [string]$OutputPath,

        [string]$Title = 'SharePoint Permission Matrix',

        [string]$LogoBase64,

        [ValidatePattern('^$|^#[0-9a-fA-F]{6}$')]
        [string]$AccentColor,

        [switch]$Open
    )

    process {
        $sourcePath = $null
        if ($PSCmdlet.ParameterSetName -eq 'Path') {
            $sourcePath = $InputPath
        }
        elseif ($ScanData -is [System.IO.FileInfo]) {
            $sourcePath = $ScanData.FullName
        }

        if ($sourcePath) {
            if (-not (Test-Path -Path $sourcePath)) {
                throw "Scan file not found: $sourcePath"
            }
            $json = Get-Content -Path $sourcePath -Raw -Encoding utf8
            $null = $json | ConvertFrom-Json  # validate early
        }
        else {
            $json = $ScanData | ConvertTo-Json -Depth 64
        }

        if (-not $OutputPath) {
            if ($sourcePath) {
                $OutputPath = [System.IO.Path]::ChangeExtension((Resolve-Path -Path $sourcePath).Path, '.html')
            }
            else {
                $OutputPath = Join-Path (Get-Location) ('SPPermissionReport_{0:yyyyMMdd_HHmmss}.html' -f (Get-Date))
            }
        }

        # "</" would end the inline <script> block early - escape it (valid JSON escape).
        $safeJson = $json -replace '</', '<\/'

        $logoHtml = @'
<svg class="logo" viewBox="0 0 46 46" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <rect x="1.5" y="1.5" width="43" height="43" rx="11" fill="rgba(255,255,255,.06)" stroke="rgba(255,255,255,.35)" stroke-width="1.5"/>
      <rect x="10" y="10" width="7.5" height="7.5" rx="2" fill="#5eead4"/>
      <rect x="19.5" y="10" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.28)"/>
      <rect x="29" y="10" width="7.5" height="7.5" rx="2" fill="#38bdf8"/>
      <rect x="10" y="19.5" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.16)"/>
      <rect x="19.5" y="19.5" width="7.5" height="7.5" rx="2" fill="#818cf8"/>
      <rect x="29" y="19.5" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.28)"/>
      <rect x="10" y="29" width="7.5" height="7.5" rx="2" fill="#38bdf8"/>
      <rect x="19.5" y="29" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.16)"/>
      <rect x="29" y="29" width="7.5" height="7.5" rx="2" fill="#5eead4"/>
    </svg>
'@

        if ($LogoBase64) {
            $src = if ($LogoBase64.StartsWith('data:')) { $LogoBase64 } else { "data:image/png;base64,$LogoBase64" }
            $logoHtml = '<img class="logo" src="' + $src + '" alt="logo" style="object-fit:contain;background:#fff;border-radius:10px;padding:3px">'
        }
        $brandCss = if ($AccentColor) {
            ":root { --accent: $AccentColor; } header { background: $AccentColor; }"
        }
        else { '' }

        $html = (Get-SPMHtmlTemplate).
        Replace('__REPORT_TITLE__', [System.Net.WebUtility]::HtmlEncode($Title)).
        Replace('__GENERATED__', (Get-Date -Format 'yyyy-MM-dd HH:mm')).
        Replace('__LOGO_HTML__', $logoHtml).
        Replace('__BRAND_CSS__', $brandCss).
        Replace('"__SCAN_DATA__"', $safeJson)

        if ($PSCmdlet.ShouldProcess($OutputPath, 'Create HTML report')) {
            Set-Content -Path $OutputPath -Value $html -Encoding utf8
            Write-Verbose "Report saved: $OutputPath"
            if ($Open) { Invoke-Item -Path $OutputPath }
            return (Get-Item -Path $OutputPath)
        }
    }
}