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-Berechtigungsmatrix
    .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-Berechtigungsmatrix',

        [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-Datei nicht gefunden: $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 '</', '<\/'

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

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