repopack-output.txt

This file is a merged representation of the entire codebase, combining all repository files into a single document.
Generated by Repopack on: 2024-11-14T13:25:30.377Z

================================================================
File Summary
================================================================

Purpose:
--------
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.

File Format:
------------
The content is organized as follows:
1. This summary section
2. Repository information
3. Repository structure
4. Multiple file entries, each consisting of:
  a. A separator line (================)
  b. The file path (File: path/to/file)
  c. Another separator line
  d. The full contents of the file
  e. A blank line

Usage Guidelines:
-----------------
- This file should be treated as read-only. Any changes should be made to the
  original repository files, not this packed version.
- When processing this file, use the file path to distinguish
  between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
  the same level of security as you would the original repository.

Notes:
------
- Some files may have been excluded based on .gitignore rules and Repopack's
  configuration.
- Binary files are not included in this packed representation. Please refer to
  the Repository Structure section for a complete list of file paths, including
  binary files.

Additional Info:
----------------

For more information about Repopack, visit: https://github.com/yamadashy/repopack

================================================================
Repository Structure
================================================================
.repopackignore
JyskIT.Automation.psd1
JyskIT.Automation.psm1
Private/Authentication/ConvertFrom-JwtToken.ps1
Private/Authentication/Curanet/Get-CuranetAccessToken.ps1
Private/Authentication/Curanet/Get-CuranetCredentials.ps1
Private/Authentication/Microsoft/Get-PartnerAccessToken.ps1
Private/Authentication/Microsoft/Get-PartnerCredentials.ps1
Private/Authentication/Microsoft/Get-RefreshedToken.ps1
Private/Authentication/Microsoft/Initialize-TokenCache.ps1
Private/Authentication/Microsoft/TokenExceptions.ps1
Private/Consent/Set-ApplicationConsent.ps1
Private/GDAP/New-GDAPRelationship.ps1
Private/GDAP/Set-GDAPPermissions.ps1
Private/PartnerCenter/Invoke-PartnerCenterRequest.ps1
Private/PartnerMenu/Get-PartnerMenuHeader.ps1
Private/PartnerMenu/Start-PartnerMenuTenantCreation.ps1
Private/Utilities/Get-AutomationUpdates.ps1
Private/Utilities/Get-PSGalleryKey.ps1
Private/Utilities/Get-RandomPassword.ps1
Public/Configuration/Add-BaselineConfiguration.ps1
Public/Configuration/Add-CompanyBranding.ps1
Public/Configuration/Add-CustomerDomain.ps1
Public/Configuration/Baseline/Add-BaselinePolicy.ps1
Public/Configuration/Baseline/Add-EOPPolicies.ps1
Public/Configuration/Baseline/EOPPolicies/AntiPhishingPolicies/JyskIT-Baseline-EOP-AntiPhishing-Unlicensed.jx
Public/Configuration/Baseline/Read-PolicyFile.ps1
Public/Configuration/ExchangeOnline/BitTitan/New-BitTitanAppRegistration.ps1
Public/Configuration/ExchangeOnline/BitTitan/Start-BitTitanPreperation.ps1
Public/Configuration/ExchangeOnline/Enable-Auditlogging.ps1
Public/Configuration/ExchangeOnline/Enable-CustomerDKIM.ps1
Public/Configuration/ExchangeOnline/Enable-CustomerDMARC.ps1
Public/Configuration/New-AdminUser.ps1
Public/Connect/Connect-CustomerExchange.ps1
Public/Connect/Connect-CustomerGraph.ps1
Public/Curanet/Customers/Get-CuranetCustomer.ps1
Public/Curanet/DNS/Get-CuranetDNSRecords.ps1
Public/Curanet/DNS/New-CuranetDNSRecord.ps1
Public/Curanet/DNS/Remove-CuranetDNSRecord.ps1
Public/Curanet/DNS/Update-CuranetDNSRecord.ps1
Public/Curanet/Invoicing/Get-CuranetInvoice.ps1
Public/Curanet/Invoke-CuranetAPI.ps1
Public/Curanet/M365/Get-CuranetM365AzureBilling.ps1
Public/Curanet/M365/Get-CuranetM365Licenses.ps1
Public/Curanet/M365/Get-CuranetM365OnboardingCredentials.ps1
Public/Curanet/Subscriptions/Get-CuranetCustomerSubscriptions.ps1
Public/Curanet/Subscriptions/Get-CuranetSubscriptionDetails.ps1
Public/Initialize-CustomerTenant.ps1
Public/PartnerCenter/Get-PartnerCustomer.ps1
Public/PartnerMenu/Start-PartnerMenu.ps1
Public/PartnerMenu/Start-PartnerMenuTenantSelection.ps1
Public/Utilities/Publish-JyskITAutomation.ps1

================================================================
Repository Files
================================================================

================
File: .repopackignore
================
/config
*.json

================
File: JyskIT.Automation.psd1
================
@{
    # Script module or binary module file associated with this manifest.
    RootModule = 'JyskIT.Automation.psm1'
     
    # Version number of this module.
    ModuleVersion = '2.0.4'
     
    # ID used to uniquely identify this module
    GUID = '26d4c649-d11a-4aa9-bed1-9ab55f777b3f'
     
    # Author of this module
    Author = 'Jysk IT'
     
    # Company or vendor of this module
    CompanyName = 'Jysk IT'
     
    # Copyright statement for this module
    Copyright = '(c) Jysk IT. All rights reserved.'
     
    # Description of the functionality provided by this module
    Description = 'Provides different cmdlets for automating Microsoft 365 and related services.'
     
    # Minimum version of the PowerShell engine required by this module
    PowerShellVersion = '7.4.0'
     
    # Minimum version of the PowerShell host required by this module
    PowerShellHostVersion = '7.4.0'
     
    # Functions to export from this module
    FunctionsToExport = @(
        # Connection functions
        'Connect-CustomerGraph',
        'Connect-CustomerExchange',
         
        # Tenant management
        'Initialize-CustomerTenant',
 
        'Get-PartnerCustomer',
         
        # Configuration functions
        'New-AdminUser',
        'Add-CompanyBranding',
        'Add-CustomerDomain',
        'Add-BaselineConfiguration',
        'Add-BaselinePolicy',
 
        'New-BitTitanAppRegistration',
        'Enable-AuditLogging',
        'Enable-CustomerDKIM',
        'Enable-CustomerDMARC',
         
        # DNS and domain functions
        'Invoke-CuranetAPI',
        'Get-CuranetCustomer',
        'Get-CuranetCustomerSubscriptions',
        'Get-CuranetCustomerSubscriptionDetails'
        'Get-CuranetDNSRecords',
        'New-CuranetDNSRecord',
        'Remove-CuranetDNSRecord',
        'Update-CuranetDNSRecord',
        'Get-CuranetInvoice',
        'Get-CuranetM365AzureBilling',
        'Get-CuranetM365Licenses',
        'GetCuranetM365OnboardingCredentials',
         
        # Menu system
        'Start-PartnerMenu',
        'Start-PartnerMenuTenantSelection',
 
        # Utility
        'Publish-JyskITAutomation'
    )
 
    AliasesToExport = @('back')
}

================
File: JyskIT.Automation.psm1
================
#Region Module Variables
$script:ModuleRoot = $PSScriptRoot
$script:Config = $null
$script:CurrentGraphToken = $null
$script:PartnerCredentials = $null
 
# Token cache configuration
$script:TokenCache = @{}
$script:TokenCacheConfig = @{
    MaxSize = 100 # Maximum number of tokens to cache
    RefreshBuffer = 5 # Minutes before expiry to trigger refresh
    # PersistencePath should be in a writable location even if the module is installed in Program Files, such as local app data
    PersistencePath = [System.IO.Path]::Combine([System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData), 'JyskIT.Automation\tokencache.xml')
}
 
. (Join-Path $PSScriptRoot 'Config\Baseline\PolicyTypeSettings.ps1')
 
# Module default configuration
$script:DefaultConfig = @{
    CuranetBaseURL = 'https://reseller.curanet.dk'
    SpamfilterAPIBaseURL = 'https://spamfilter.io/api'
    KeyVaultName = 'KV-M365-SAM'
    SubscriptionName = 'internal-002-mpn'
    PartnerTenantId = 'b6a41db1-6b1a-4833-9b69-f8e363090e45'
    GDAP = Get-Content (Join-Path $PSScriptRoot 'Config\GDAP\default.json') | ConvertFrom-Json -AsHashtable
    PolicyTypeSettings = $PolicyTypeSettings
}
 
#Region Custom Exceptions
class TokenOperationException : Exception {
    [string]$Operation
    [string]$DetailedMessage
     
    TokenOperationException([string]$operation, [string]$message, [Exception]$innerException)
        : base($message, $innerException) {
        $this.Operation = $operation
        $this.DetailedMessage = $message
    }
}
 
#Region Helper Functions
$script:LogConfig = @{
    LogPath = [System.IO.Path]::Combine([System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData), 'JyskIT.Automation\logs', "JyskIT.Automation-$(Get-Date -Format 'dd-MM-yyyy').log")
    MaxLogAgeDays = 30
    LogLevel = 'Verbose' # Default log level: Error, Warning, Info, Debug, Verbose
    WriteToFile = $true
    WriteToHost = $true
}
 
function Write-ModuleLog {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Message,
         
        [Parameter()]
        [ValidateSet('Error', 'Warning', 'Info', 'Debug', 'Verbose')]
        [string]$Level = 'Info',
         
        [Parameter()]
        [System.Management.Automation.ErrorRecord]$ErrorRecord,
         
        [Parameter()]
        [string]$Component = 'General',
         
        [Parameter()]
        [string]$FunctionName = $MyInvocation.MyCommand.Name,
         
        [Parameter()]
        [switch]$NoOutput,
 
        [Parameter()]
        [switch]$ThrowError,
 
        [Parameter()]
        [string]$ErrorOperation,
 
        [Parameter()]
        [string]$ErrorMessage
    )
     
    # Define log level weights for filtering
    $levelWeights = @{
        Error = 1
        Warning = 2
        Info = 3
        Debug = 4
        Verbose = 5
    }
     
    # Check if we should process this log level
    if ($levelWeights[$Level] -gt $levelWeights[$script:LogConfig.LogLevel]) {
        return
    }
     
    try {
        $timestamp = Get-Date -Format 'dd-MM-yyyy HH:mm:ss'
        $callStack = Get-PSCallStack | Select-Object -Skip 1 -First 1
        $caller = if ($callStack) { $callStack.Command } else { 'Unknown' }
         
        # Build the log entry
        $logEntry = [PSCustomObject]@{
            Timestamp = $timestamp
            Level = $Level.ToUpper()
            Component = $Component
            Function = $FunctionName
            Caller = $caller
            Message = $Message
            ErrorDetails = if ($ErrorRecord) {
                @{
                    Exception = $ErrorRecord.Exception.Message
                    Category = $ErrorRecord.CategoryInfo.Category
                    TargetObject = $ErrorRecord.TargetObject
                    ScriptStackTrace = $ErrorRecord.ScriptStackTrace
                    PositionMessage = $ErrorRecord.InvocationInfo.PositionMessage
                } | ConvertTo-Json -Compress
            } else { $null }
        }
         
        # Format the log message for output
        $consoleMessage = "$($logEntry.Timestamp) [$($logEntry.Level)] [$($logEntry.Component)] $($logEntry.Message)"
        if ($ErrorRecord) {
            $consoleMessage += "`nError: $($ErrorRecord.Exception.Message)"
            $consoleMessage += "`nStack: $($ErrorRecord.ScriptStackTrace)"
        }
         
        # Write to console if enabled
        if ($script:LogConfig.WriteToHost -and -not $NoOutput) {
            switch ($Level) {
                'Error' {
                    Write-Error $consoleMessage
                }
                'Warning' {
                    Write-Warning $consoleMessage
                }
                'Info' {
                    Write-Information $consoleMessage -InformationAction Continue
                }
                'Debug' {
                    Write-Debug $consoleMessage
                }
                'Verbose' {
                    Write-Verbose $consoleMessage
                }
            }
        }
         
        # Write to file if enabled
        if ($script:LogConfig.WriteToFile) {
            # Ensure log directory exists
            $logDir = Split-Path $script:LogConfig.LogPath -Parent
            if (-not (Test-Path $logDir)) {
                New-Item -Path $logDir -ItemType Directory -Force | Out-Null
            }
             
            # Convert log entry to JSON and append to file
            $logEntry | ConvertTo-Json -Compress |
                Add-Content -Path $script:LogConfig.LogPath -Encoding UTF8
        }
 
        # Throw error if requested
        if ($ThrowError) {
            if ($ErrorRecord) {
                throw [TokenOperationException]::new(
                    $ErrorOperation,
                    $ErrorMessage ?? $Message,
                    $ErrorRecord.Exception
                )
            } else {
                throw [TokenOperationException]::new(
                    $ErrorOperation,
                    $ErrorMessage ?? $Message,
                    $null
                )
            }
        }
    }
    catch {
        # Fallback error handling - write directly to console
        $fallbackMessage = "Failed to write to log: $($_.Exception.Message)"
        Write-Error $fallbackMessage
         
        if ($ThrowError) {
            throw
        }
    }
}
 
function Initialize-ModuleLogging {
    [CmdletBinding()]
    param()
     
    try {
        # Create logs directory if it doesn't exist
        $logDir = Split-Path $script:LogConfig.LogPath -Parent
        if (-not (Test-Path $logDir)) {
            New-Item -Path $logDir -ItemType Directory -Force | Out-Null
        }
         
        # Clean up old log files
        Get-ChildItem -Path $logDir -Filter "JyskIT.Automation-*.log" |
            Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$script:LogConfig.MaxLogAgeDays) } |
            ForEach-Object {
                Write-ModuleLog -Message "Removing old log file: $($_.Name)" -Level Debug -Component 'LogMaintenance'
                Remove-Item $_.FullName -Force
            }
         
        # Initialize new log file with header
        $moduleInfo = Get-Module JyskIT.Automation
        @"
# JyskIT.Automation Module Log
# Module Version: $($moduleInfo.Version)
# Log Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
# PowerShell Version: $($PSVersionTable.PSVersion)
# OS: $([System.Environment]::OSVersion.VersionString)
"@ | Out-File -FilePath $script:LogConfig.LogPath -Encoding UTF8
         
        Write-ModuleLog -Message "Module logging initialized" -Level Verbose -Component 'LogInitialization'
    }
    catch {
        Write-Error "Failed to initialize logging: $_"
    }
}
 
# Helper function to get log entries
function Get-ModuleLog {
    [CmdletBinding()]
    param(
        [Parameter()]
        [DateTime]$StartTime,
         
        [Parameter()]
        [DateTime]$EndTime,
         
        [Parameter()]
        [ValidateSet('Error', 'Warning', 'Info', 'Debug', 'Verbose')]
        [string[]]$Level,
         
        [Parameter()]
        [string]$Component,
         
        [Parameter()]
        [switch]$Raw
    )
     
    try {
        $logs = Get-Content -Path $script:LogConfig.LogPath |
            Where-Object { $_ -match '^{' } |
            ForEach-Object { $_ | ConvertFrom-Json }
         
        # Apply filters
        if ($StartTime) {
            $logs = $logs | Where-Object { [DateTime]$_.Timestamp -ge $StartTime }
        }
        if ($EndTime) {
            $logs = $logs | Where-Object { [DateTime]$_.Timestamp -le $EndTime }
        }
        if ($Level) {
            $logs = $logs | Where-Object { $_.Level -in $Level }
        }
        if ($Component) {
            $logs = $logs | Where-Object { $_.Component -eq $Component }
        }
         
        if ($Raw) {
            return $logs
        }
        else {
            return $logs | Format-Table -Property Timestamp, Level, Component, Message -AutoSize
        }
    }
    catch {
        Write-Error "Failed to retrieve logs: $_"
    }
}
 
function Initialize-ModuleDependencies {
    [CmdletBinding()]
    param()
     
    $requiredModules = @(
        @{Name = 'Microsoft.Graph'; MinimumVersion = '2.24.0'},
        @{Name = 'Az.Accounts'; MinimumVersion = '3.0.5'},
        @{Name = 'Az.KeyVault'; MinimumVersion = '6.2.0'},
        @{Name = 'ExchangeOnlineManagement'; MinimumVersion = '3.6.0'},
        @{Name = 'Microsoft.PowerShell.ConsoleGuiTools'; MinimumVersion = '0.7.7'}
    )
     
    foreach ($module in $requiredModules) {
        $installed = Get-Module -ListAvailable -Name $module.Name |
            Where-Object { $_.Version -ge [version]$module.MinimumVersion }
             
        if (-not $installed) {
            try {
                Write-ModuleLog -Message "Installing $($module.Name) module (minimum version $($module.MinimumVersion))..." -Level Info -Component 'ModuleInitialization'
                Write-ModuleLog -Message "This might take a while, please be patient" -Level Info -Component 'ModuleInitialization'
                Install-Module -Name $module.Name -MinimumVersion $module.MinimumVersion -Force -AllowClobber -Scope CurrentUser
                Write-ModuleLog -Message "$($module.Name) module installed successfully" -Level Info -Component 'ModuleInitialization'
            }
            catch {
                Write-ModuleLog -Message "Failed to install $($module.Name) module: $_" -Level Error -Component 'ModuleInitialization'
                throw
            }
        }
    }
}
 
#Region Module Initialization
 
try {
    Initialize-ModuleLogging
}
catch {
    Write-Error "Failed to initialize module logging: $_"
    throw
}
 
# Initialize required modules
try {
    Initialize-ModuleDependencies
}
catch {
    Write-Error "Failed to initialize module dependencies: $_"
    throw
}
 
# Create necessary directories
$directories = @(
    (Join-Path $PSScriptRoot 'cache'),
    (Join-Path $PSScriptRoot 'logs')
)
 
foreach ($dir in $directories) {
    if (!(Test-Path $dir)) {
        New-Item -Path $dir -ItemType Directory -Force | Out-Null
    }
}
 
# Load config if exists
$configPath = Join-Path $script:ModuleRoot 'Config\config.json'
if (Test-Path $configPath) {
    try {
        $script:Config = Get-Content $configPath | ConvertFrom-Json -AsHashtable
        Write-Verbose "Loaded configuration from $configPath"
    }
    catch {
        Write-Warning "Failed to load configuration from $configPath, using defaults"
        $script:Config = $script:DefaultConfig
    }
}
else {
    $script:Config = $script:DefaultConfig
    Write-Verbose "No configuration file found, using defaults"
}
 
# Import functions
$Public = @(Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -Recurse -ErrorAction SilentlyContinue)
$Private = @(Get-ChildItem -Path $PSScriptRoot\Private\*.ps1 -Recurse -ErrorAction SilentlyContinue)
 
foreach ($import in @($Private + $Public)) {
    try {
        Write-Verbose "Importing $($import.FullName)"
        . $import.FullName
    }
    catch {
        Write-Error -Message "Failed to import function $($import.FullName): $_"
    }
}
 
# Initialize token cache
try {
    Write-Verbose "Initializing token cache"
    Initialize-TokenCache
}
catch {
    Write-Warning "Failed to initialize token cache: $_"
}
 
# Clean up on module removal
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
    Write-Verbose "Cleaning up module resources..."
    try {
        Disconnect-MgGraph -ErrorAction SilentlyContinue
        Save-TokenCache
    }
    catch {
        Write-Warning "Cleanup error: $_"
    }
}
 
# Export public functions
Export-ModuleMember -Function $Public.BaseName -Alias *

================
File: Private/Authentication/ConvertFrom-JwtToken.ps1
================
function ConvertFrom-JwtToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Token
    )
     
    if (-not $Token.Contains(".") -or -not $Token.StartsWith("eyJ")) {
        Write-ModuleLog -Message "Invalid token format detected" -Level Error -Component 'TokenProcessing' `
            -ThrowError -ErrorOperation 'TokenDecoding' -ErrorMessage 'Invalid token format'
    }
 
    try {
        $tokenPayload = $Token.Split(".")[1].Replace('-', '+').Replace('_', '/')
 
        while ($tokenPayload.Length % 4) {
            $tokenPayload += "="
        }
 
        $tokenBytes = [System.Convert]::FromBase64String($tokenPayload)
        $tokenJson = [System.Text.Encoding]::ASCII.GetString($tokenBytes)
        $tokenData = $tokenJson | ConvertFrom-Json -AsHashtable
 
        $tokenData["expirationDateTime"] = ([DateTime]('1970,1,1')).AddSeconds($tokenData["exp"]).ToLocalTime()
        $tokenData["access_token"] = $Token
 
        return [PSCustomObject]$tokenData
    }
    catch {
        Write-ModuleLog -Message "Unexpected error during token conversion" -Level Error -Component 'TokenProcessing' `
            -ErrorRecord $_ -ThrowError -ErrorOperation 'TokenDecoding' -ErrorMessage 'Failed to decode JWT token'
    }
}

================
File: Private/Authentication/Curanet/Get-CuranetAccessToken.ps1
================
function Get-CuranetAccessToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet("3370", "3850")]
        [string]$Account,
         
        [Parameter()]
        [switch]$Force
    )
 
    begin {
        try {
            if (!$script:CuranetCredentials -or $script:CuranetCredentials.Account -ne $Account) {
                $script:CuranetCredentials = Get-CuranetCredentials -Account $Account
            }
 
            # Define account-specific scopes
            $accountScopes = @{
                '3850' = 'customers orders subscriptions invoicing microsoft365 microsoft365backup'
                '3370' = 'dns'
            }
 
            # Define OAuth endpoint
            $tokenEndpoint = 'https://apiauth.dk.team.blue/realms/Curanet/protocol/openid-connect/token'
        }
        catch {
            Write-ModuleLog -Message "Failed to get Curanet credentials" -Level Error -Component 'CuranetAccessToken' -ErrorRecord $_
            throw [CuranetTokenOperationException]::new(
                'Initialization',
                'Failed to get Curanet credentials',
                $_
            )
        }
    }
 
    process {
        try {
            $cacheKey = "Curanet_$Account"
            $now = Get-Date
            # Set refresh threshold to 1 minute before expiration (tokens valid for 5 minutes)
            $refreshThreshold = $now.AddMinutes(4)
 
            # Check cache for valid token
            if (!$Force -and $script:TokenCache.ContainsKey($cacheKey)) {
                $cachedToken = $script:TokenCache[$cacheKey]
                 
                # Token is still valid
                if ($cachedToken.ExpirationDateTime -gt $refreshThreshold) {
                    Write-ModuleLog -Message "Using cached token for $cacheKey" -Level Verbose -Component 'CuranetAccessToken'
                    return $cachedToken
                }
            }
 
            Write-ModuleLog -Message "Getting new token for $cacheKey" -Level Verbose -Component 'CuranetAccessToken'
 
            # Prepare token request
            try {
                $body = @{
                    grant_type = "client_credentials"
                    client_id = $script:CuranetCredentials.ClientId
                    client_secret = $script:CuranetCredentials.ClientSecret
                    scope = $accountScopes[$Account]
                }
 
                $response = Invoke-RestMethod -Uri $tokenEndpoint `
                    -Method POST `
                    -Body $body `
                    -ContentType 'application/x-www-form-urlencoded'
 
                # Create token object with formatted Authorization header value
                $token = [PSCustomObject]@{
                    AccessToken = $response.access_token
                    AuthorizationHeader = "bearer $($response.access_token)"
                    TokenType = $response.token_type
                    ExpirationDateTime = (Get-Date).AddSeconds(300) # 5 minutes
                    Scope = $response.scope
                    Account = $Account
                }
            }
            catch {
                $detailedError = switch -Regex ($_.Exception.Message) {
                    'invalid_client' { 'Invalid client credentials' }
                    'invalid_scope' { 'Invalid scope requested' }
                    'invalid_request' { 'Malformed request' }
                    default { $_.Exception.Message }
                }
 
                Write-ModuleLog -Message "Failed to acquire Curanet token: $detailedError" -Level Error -Component 'CuranetAccessToken' -ErrorRecord $_
                throw [CuranetTokenOperationException]::new(
                    'TokenAcquisition',
                    "Failed to acquire Curanet token: $detailedError",
                    $_
                )
            }
             
            # Manage cache
            try {
                if ($script:TokenCache.Count -ge $script:TokenCacheConfig.MaxSize) {
                    $oldestTokens = $script:TokenCache.GetEnumerator() |
                    Sort-Object { $_.Value.ExpirationDateTime } |
                    Select-Object -First ($script:TokenCache.Count - $script:TokenCacheConfig.MaxSize + 1)
                     
                    foreach ($oldToken in $oldestTokens) {
                        $script:TokenCache.Remove($oldToken.Key)
                    }
                }
 
                $script:TokenCache[$cacheKey] = $token
                Save-TokenCache
            }
            catch {
                Write-ModuleLog -Message "Failed to manage token cache" -Level Error -Component 'CuranetAccessToken' -ErrorRecord $_
            }
             
            return $token
        }
        catch [CuranetTokenOperationException] {
            throw
        }
        catch {
            Write-ModuleLog -Message "An unexpected error occurred during token operation" -Level Error -Component 'CuranetAccessToken' -ErrorRecord $_
            throw [CuranetTokenOperationException]::new(
                'Unknown',
                'An unexpected error occurred during token operation',
                $_
            )
        }
    }
}

================
File: Private/Authentication/Curanet/Get-CuranetCredentials.ps1
================
function Get-CuranetCredentials {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet("3370", "3850")]
        [string]$Account
    )
     
    # Start by connecting to Azure Key Vault
    $AzContext = Get-AzContext
    if (!$AzContext -or $AzContext.Tenant.Id -ne $script:Config.PartnerTenantId) {
        try {
            # Try certificate auth first
            $Certificate = Get-ChildItem -Path Cert:\LocalMachine\My |
                Where-Object { $_.Thumbprint -eq $script:Config.CertificateThumbprint }
 
            if ($Certificate) {
                Write-Verbose "Using certificate authentication for Azure Key Vault access"
                Connect-AzAccount -ApplicationId $script:Config.ApplicationId `
                    -CertificateThumbprint $Certificate.Thumbprint `
                    -Tenant $script:Config.PartnerTenantId `
                    -SubscriptionName $script:Config.SubscriptionName | Out-Null
            }
            else {
                Write-Verbose "Using interactive authentication for Azure Key Vault access"
                Connect-AzAccount -Tenant $script:Config.PartnerTenantId `
                    -SubscriptionName $script:Config.SubscriptionName | Out-Null
            }
        }
        catch {
            Write-ModuleLog -Message "Failed to connect to Azure" -Level Error -Component 'AzureConnection' -ErrorRecord $_
        }
    }
 
    try {
        # Retrieve secrets from Key Vault
        $CuranetClientId = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "curanet$($Account)apiclient" -AsPlainText
        $CuranetClientSecret = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "curanet$($Account)apisecret" -AsPlainText
 
        return [PSCustomObject]@{
            Account = $Account
            ClientId = $CuranetClientId
            ClientSecret = $CuranetClientSecret
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to retrieve credentials from Key Vault" -Level Error -Component 'CredentialRetrieval' -ErrorRecord $_
    }
}

================
File: Private/Authentication/Microsoft/Get-PartnerAccessToken.ps1
================
function Get-PartnerAccessToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$TenantId,
         
        [Parameter(Mandatory)]
        [string]$Scopes,
         
        [Parameter()]
        [ValidateSet('Application', 'Delegated')]
        [string]$FlowType = 'Delegated',
 
        [Parameter()]
        [switch]$Force
    )
 
    begin {
        try {
            if (!$script:PartnerCredentials) {
                $script:PartnerCredentials = Get-PartnerCredentials
            }
        }
        catch {
            Write-ModuleLog -Message "Failed to get partner credentials" -Level Error -Component 'PartnerAccessToken' -ErrorRecord $_
            throw [TokenOperationException]::new(
                'Initialization',
                'Failed to get partner credentials',
                $_
            )
        }
    }
 
    process {
        try {
            $cacheKey = "$TenantId|$Scopes|$FlowType"
            $now = Get-Date
            $refreshThreshold = $now.AddMinutes($script:TokenCacheConfig.RefreshBuffer)
 
            # Validate inputs
            if (-not [Guid]::TryParse($TenantId, [ref][Guid]::Empty)) {
                Write-ModuleLog -Message "Invalid token format detected" -Level Error -Component 'PartnerAccessToken' `
                    -ThrowError -ErrorOperation 'ValidateTenantId' -ErrorMessage 'Invalid TenantId format'
            }
 
            # Check cache for valid token
            if (!$Force -and $script:TokenCache.ContainsKey($cacheKey)) {
                $cachedToken = $script:TokenCache[$cacheKey]
                 
                # Token is still valid
                if ($cachedToken.ExpirationDateTime -gt $refreshThreshold) {
                    Write-ModuleLog -Message "Using cached token for $cacheKey" -Level Verbose -Component 'PartnerAccessToken'
                    return $cachedToken
                }
                # Token needs refresh
                elseif ($cachedToken.ExpirationDateTime -gt $now -and $FlowType -eq 'Delegated') {
                    Write-ModuleLog -Message "Token for $cacheKey is expired, attempting refresh" -Level Verbose -Component 'PartnerAccessToken'
                    try {
                        $newToken = Get-RefreshedToken -ExistingToken $cachedToken -TenantId $TenantId -Scopes $Scopes
                        $script:TokenCache[$cacheKey] = $newToken
                        Save-TokenCache
                        return $newToken
                    }
                    catch {
                        Write-ModuleLog -Message "Token refresh failed for $cacheKey" -Level Warning -Component 'PartnerAccessToken' -ErrorRecord $_
                        # Fall through to get new token
                    }
                }
            }
 
            Write-ModuleLog -Message "Getting new token for $cacheKey" -Level Verbose -Component 'PartnerAccessToken'
 
            # Prepare token request
            $body = @{
                client_id = $script:PartnerCredentials.ApplicationId
                client_secret = $script:PartnerCredentials.ApplicationSecret
                scope = $Scopes
            }
 
            if ($FlowType -eq 'Application') {
                $body.grant_type = "client_credentials"
            }
            else {
                if ([string]::IsNullOrEmpty($script:PartnerCredentials.RefreshToken)) {
                    Write-ModuleLog -Message "No refresh token available for delegated flow" -Level Error -Component 'PartnerAccessToken' `
                        -ThrowError -ErrorOperation 'TokenAcquisition' -ErrorMessage 'No refresh token available for delegated flow'
                }
                $body.grant_type = "refresh_token"
                $body.refresh_token = $script:PartnerCredentials.RefreshToken
            }
 
            # Get new token
            try {
                $response = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
                    -Method POST `
                    -Body $body `
                    -ContentType 'application/x-www-form-urlencoded'
            }
            catch {
                $detailedError = switch -Regex ($_.Exception.Message) {
                    'AADSTS700016' { 'Application not found or not authorized for tenant' }
                    'AADSTS7000215' { 'Invalid client secret provided' }
                    'AADSTS9000410' { 'Invalid scope requested' }
                    'AADSTS50034' { 'Invalid tenant ID' }
                    default { $_.Exception.Message }
                }
 
                Write-ModuleLog -Message "Failed to acquire token: $detailedError" -Level Error -Component 'PartnerAccessToken' -ErrorRecord $_
            }
 
            try {
                $token = ConvertFrom-JwtToken -Token $response.access_token
 
                # Store refresh token if available (for delegated flow)
                if ($FlowType -eq 'Delegated' -and $response.refresh_token) {
                    $token | Add-Member -NotePropertyName 'refresh_token' -NotePropertyValue $response.refresh_token
                }
            }
            catch {
                Write-ModuleLog -Message "Failed to process acquired token" -Level Error -Component 'PartnerAccessToken' -ErrorRecord $_
            }
             
            # Manage cache
            try {
                if ($script:TokenCache.Count -ge $script:TokenCacheConfig.MaxSize) {
                    $oldestTokens = $script:TokenCache.GetEnumerator() |
                    Sort-Object { $_.Value.ExpirationDateTime } |
                    Select-Object -First ($script:TokenCache.Count - $script:TokenCacheConfig.MaxSize + 1)
                     
                    foreach ($oldToken in $oldestTokens) {
                        $script:TokenCache.Remove($oldToken.Key)
                    }
                }
 
                $script:TokenCache[$cacheKey] = $token
                Save-TokenCache
            }
            catch {
                Write-ModuleLog -Message "Failed to manage token cache" -Level Error -Component 'PartnerAccessToken' -ErrorRecord $_
            }
             
            return $token
        }
        catch [TokenOperationException] {
            throw
        }
        catch {
            Write-ModuleLog -Message "An unexpected error occurred during token operation" -Level Error -Component 'PartnerAccessToken' -ErrorRecord $_
        }
    }
}

================
File: Private/Authentication/Microsoft/Get-PartnerCredentials.ps1
================
function Get-PartnerCredentials {
    [CmdletBinding()]
    param()
     
    # Start by connecting to Azure Key Vault
    $AzContext = Get-AzContext
    if (!$AzContext -or $AzContext.Tenant.Id -ne $script:Config.PartnerTenantId) {
        try {
            # Try certificate auth first
            $Certificate = Get-ChildItem -Path Cert:\LocalMachine\My |
                Where-Object { $_.Thumbprint -eq $script:Config.CertificateThumbprint }
 
            if ($Certificate) {
                Write-Verbose "Using certificate authentication for Azure Key Vault access"
                Connect-AzAccount -ApplicationId $script:Config.ApplicationId `
                    -CertificateThumbprint $Certificate.Thumbprint `
                    -Tenant $script:Config.PartnerTenantId `
                    -SubscriptionName $script:Config.SubscriptionName | Out-Null
            }
            else {
                Write-Verbose "Using interactive authentication for Azure Key Vault access"
                Connect-AzAccount -Tenant $script:Config.PartnerTenantId `
                    -SubscriptionName $script:Config.SubscriptionName | Out-Null
            }
        }
        catch {
            Write-ModuleLog -Message "Failed to connect to Azure" -Level Error -Component 'AzureConnection' -ErrorRecord $_
        }
    }
 
    try {
        # Retrieve secrets from Key Vault
        $ApplicationId = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "ApplicationId" -AsPlainText
        $ApplicationSecret = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "ApplicationSecret" -AsPlainText
        $RefreshToken = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "RefreshToken" -AsPlainText
        $ExchangeRefreshToken = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "ExchangeRefreshToken" -AsPlainText
         
        return [PSCustomObject]@{
            ApplicationId = $ApplicationId
            ApplicationSecret = $ApplicationSecret
            RefreshToken = $RefreshToken
            ExchangeRefreshToken = $ExchangeRefreshToken
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to retrieve credentials from Key Vault" -Level Error -Component 'CredentialRetrieval' -ErrorRecord $_
    }
}

================
File: Private/Authentication/Microsoft/Get-RefreshedToken.ps1
================
function Get-RefreshedToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$ExistingToken,
         
        [Parameter(Mandatory)]
        [string]$TenantId,
         
        [Parameter(Mandatory)]
        [string]$Scopes
    )
     
    try {
        if ($null -eq $ExistingToken) {
            Write-ModuleLog -Message "Existing token is null" -Level Error -Component 'TokenRefresh' `
                -ThrowError -ErrorOperation 'TokenRefresh' -ErrorMessage 'ExistingToken is null'
        }
 
        if (!$ExistingToken.refresh_token) {
            Write-ModuleLog -Message "No refresh token available in existing token" -Level Error -Component 'TokenRefresh' `
                -ThrowError -ErrorOperation 'TokenRefresh' -ErrorMessage 'No refresh token available in existing token'
        }
 
        $body = @{
            client_id = $script:PartnerCredentials.ApplicationId
            client_secret = $script:PartnerCredentials.ApplicationSecret
            grant_type = "refresh_token"
            refresh_token = $ExistingToken.refresh_token
            scope = $Scopes
        }
 
        try {
            $response = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
                -Method POST `
                -Body $body `
                -ContentType 'application/x-www-form-urlencoded'
        }
        catch {
            $detailedError = switch -Regex ($_.Exception.Message) {
                'AADSTS70000' { 'Invalid refresh token' }
                'AADSTS700082' { 'The refresh token has expired' }
                default { $_.Exception.Message }
            }
            Write-ModuleLog -Message "Failed to refresh token: $detailedError" -Level Error -Component 'TokenRefresh' -ErrorRecord $_
        }
 
        $newToken = ConvertFrom-JwtToken -Token $response.access_token
         
        if ($response.refresh_token) {
            $newToken | Add-Member -NotePropertyName 'refresh_token' -NotePropertyValue $response.refresh_token
        }
         
        return $newToken
    }
    catch [TokenOperationException] {
        Write-ModuleLog -Message "An error occurred during token refresh" -Level Error -Component 'TokenRefresh' -ErrorRecord $_
    }
    catch {
        Write-ModuleLog -Message "An unexpected error occurred during token refresh" -Level Error -Component 'TokenRefresh' -ErrorRecord $_
        throw [TokenOperationException]::new(
            'TokenRefresh',
            'An unexpected error occurred during token refresh',
            $_
        )
    }
}

================
File: Private/Authentication/Microsoft/Initialize-TokenCache.ps1
================
function Initialize-TokenCache {
    [CmdletBinding()]
    param()
     
    try {
        # Create cache directory if it doesn't exist
        $cacheDir = Split-Path $script:TokenCacheConfig.PersistencePath -Parent
        if (-not (Test-Path $cacheDir)) {
            New-Item -Path $cacheDir -ItemType Directory -Force | Out-Null
        }
 
        # Initialize empty hashtable
        $script:TokenCache = @{}
 
        # Load cached tokens if they exist
        if (Test-Path $script:TokenCacheConfig.PersistencePath) {
            try {
                $importedCache = Import-Clixml -Path $script:TokenCacheConfig.PersistencePath
                # Only import non-expired tokens
                $importedCache.GetEnumerator() |
                Where-Object { $_.Value.ExpirationDateTime -gt (Get-Date) } |
                ForEach-Object {
                    $script:TokenCache[$_.Key] = $_.Value
                }
            }
            catch {
                Write-ModuleLog -Message "Failed to load token cache from disk" -Level Warning -Component 'TokenCache'
            }
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to initialize token cache" -Level Warning -Component 'TokenCache'
        $script:TokenCache = @{}
    }
}
function Save-TokenCache {
    [CmdletBinding()]
    param()
     
    try {
        if((Test-Path $script:TokenCacheConfig.PersistencePath) -and ($Script:TokenCache.Count -ne 0)) {
            $script:TokenCache | Export-Clixml -Path $script:TokenCacheConfig.PersistencePath -Force
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to save token cache to disk" -Level Error -Component 'Tokencache' `
            -ErrorRecord $_ -ThrowError -ErrorOperation 'SaveToDisk' -ErrorMessage 'Failed to save token cache to disk'
    }
}

================
File: Private/Authentication/Microsoft/TokenExceptions.ps1
================
class TokenOperationException : Exception {
    [string]$Operation
    [string]$DetailedMessage
     
    TokenOperationException([string]$operation, [string]$message, [Exception]$innerException) : base($message, $innerException) {
        $this.Operation = $operation
        $this.DetailedMessage = $message
    }
}

================
File: Private/Consent/Set-ApplicationConsent.ps1
================
function Set-ApplicationConsent {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$CustomerTenantId,
         
        [Parameter()]
        [switch]$Force
    )
 
    try {
        # Step 1: Create initial consent through Partner Center
        $customer = Get-PartnerCustomer -CustomerTenantId $CustomerTenantId
        if (!$customer) {
            Write-ModuleLog -Message "Customer with tenant ID $CustomerTenantId not found" -Level Error -Component 'ApplicationConsent' -ThrowError
        }
 
        $consentBody = @{
            ApplicationId = $script:PartnerCredentials.ApplicationId
            ApplicationGrants = @(
                @{
                    EnterpriseApplicationId = '00000003-0000-0000-c000-000000000000'
                    Scope = @(
                        'DelegatedPermissionGrant.ReadWrite.All',
                        'Directory.ReadWrite.All',
                        'AppRoleAssignment.ReadWrite.All'
                    ) -Join ','
                }
            )
        }
 
        $response = Invoke-PartnerCenterRequest `
            -Uri "https://api.partnercenter.microsoft.com/v1/customers/$CustomerTenantId/applicationconsents" `
            -Method POST `
            -Body $consentBody `
 
        # Handle initial consent response
        switch ($response.StatusCode) {
            { $_ -in @(200, 201) } {
                Write-ModuleLog -Message "Successfully created consent for $($customer.displayName)" -Level Info -Component 'ApplicationConsent'
                Write-ModuleLog -Message "Waiting for consent to propagate..." -Level Info -Component 'ApplicationConsent'
                Start-Sleep -Seconds 5
            }
            409 {
                Write-ModuleLog -Message "Consent already exists for $($customer.displayName)" -Level Warning -Component 'ApplicationConsent'
                if ($Force) {
                    Write-ModuleLog -Message "Force specified - recreating consent..." -Level Warning -Component 'ApplicationConsent'
                    $consentUri = "https://api.partnercenter.microsoft.com/v1/customers/$CustomerTenantId/applicationconsents/$($script:PartnerCredentials.ApplicationId)"
                    Invoke-PartnerCenterRequest -Uri $consentUri -Method DELETE -ErrorAction SilentlyContinue | Out-Null
                    return Set-ApplicationConsent -CustomerTenantId $CustomerTenantId
                }
            }
            400 {
                if ($response.message -like "*doesnt exist in customer tenant*") {
                    Write-ModuleLog -Message "Successfully created partial consent for $($customer.displayName)" -Level Info -Component 'ApplicationConsent'
                    Write-ModuleLog -Message "Some APIs are not available in the tenant - this is normal" -Level Warning -Component 'ApplicationConsent'
                    Start-Sleep -Seconds 5
                }
                else {
                    Write-ModuleLog -Message "Bad request: $($response.message)" -Level Error -Component 'ApplicationConsent' -ThrowError
                }
            }
            default {
                Write-ModuleLog -Message "Unexpected response: $($response.StatusCode) - $($response.message)" -Level Error -Component 'ApplicationConsent' -ThrowError
            }
        }
 
        # Step 2: Connect to customer tenant to configure app permissions
        Write-ModuleLog -Message "Connecting to customer tenant to configure permissions" -Level Info -Component 'ApplicationConsent'
        Connect-CustomerGraph -CustomerTenantId $CustomerTenantId -FlowType "Delegated" -Force
 
        # Step 3: Get our service principal
        $ServicePrincipalList = Get-MgServicePrincipal -All
        $ServicePrincipal = $ServicePrincipalList | Where-Object { $_.AppId -eq $script:PartnerCredentials.ApplicationId }
         
        $retryCount = 0
        $maxRetries = 20
        while (!$ServicePrincipal -and $retryCount -lt $maxRetries) {
            Write-ModuleLog -Message "Service principal not found, waiting for propagation... Attempt $($retryCount + 1)/$maxRetries" -Level Info -Component 'ApplicationConsent'
            Start-Sleep -Seconds 10
            $ServicePrincipalList = Get-MgServicePrincipal -All
            $ServicePrincipal = $ServicePrincipalList | Where-Object { $_.AppId -eq $script:PartnerCredentials.ApplicationId }
            $retryCount++
        }
 
        if (!$ServicePrincipal) {
            Write-ModuleLog -Message "Service principal not found after waiting" -Level Error -Component 'ApplicationConsent' -ThrowError
        }
 
        # Step 4: Load required permissions from manifest and translator
        $ManifestPath = Join-Path $script:ModuleRoot "Config\Consent\SAMManifest.json"
        $TranslatorPath = Join-Path $script:ModuleRoot "Config\Consent\PermissionsTranslator.json"
 
        if (!(Test-Path $ManifestPath)) {
            Write-ModuleLog -Message "SAM Manifest not found at $ManifestPath" -Level Error -Component 'ApplicationConsent' -ThrowError
        }
 
        $RequiredResourceAccess = Get-Content $ManifestPath |
        ConvertFrom-Json |
        Select-Object -ExpandProperty requiredResourceAccess
 
        $PermissionTranslator = if (Test-Path $TranslatorPath) {
            Get-Content $TranslatorPath | ConvertFrom-Json
        }
 
        # Step 5: Configure app roles (application permissions)
        foreach ($App in $RequiredResourceAccess) {
            $ResourceServicePrincipal = $ServicePrincipalList | Where-Object { $_.AppId -eq $App.resourceAppId }
 
            if (!$ResourceServicePrincipal) {
                Write-ModuleLog -Message "Creating service principal for $($App.resourceAppId)" -Level Info -Component 'ApplicationConsent'
                $ResourceServicePrincipal = New-MgServicePrincipal -AppId $App.resourceAppId -ErrorAction SilentlyContinue
                if (!$ResourceServicePrincipal) {
                    Write-ModuleLog -Message "Failed to create service principal for $($App.resourceAppId) - API might not be available in tenant" -Level Warning -Component 'ApplicationConsent'
                    continue
                }
            }
 
            # Assign app roles
            # Get current role assignments
            $currentRoles = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ServicePrincipal.Id
 
            foreach ($Role in ($App.ResourceAccess | Where-Object { $_.type -eq 'Role' })) {
                try {
                    # Check if role is already assigned
                    $existingAssignment = $currentRoles | Where-Object {
                        $_.AppRoleId -eq $Role.Id -and
                        $_.ResourceId -eq $ResourceServicePrincipal.Id
                    }
             
                    if ($existingAssignment) {
                        Write-ModuleLog -Message "App role $($Role.Id) is already assigned for $($ResourceServicePrincipal.DisplayName)" `
                            -Level Verbose `
                            -Component 'ApplicationConsent'
                        continue
                    }
             
                    $params = @{
                        ServicePrincipalId = $ServicePrincipal.Id
                        PrincipalId = $ServicePrincipal.Id
                        ResourceId = $ResourceServicePrincipal.Id
                        AppRoleId = $Role.Id
                    }
                     
                    Write-ModuleLog -Message "Assigning app role $($Role.Id) for $($ResourceServicePrincipal.DisplayName)" `
                        -Level Info `
                        -Component 'ApplicationConsent'
                    New-MgServicePrincipalAppRoleAssignment @params -ErrorAction Stop | Out-Null
                }
                catch {
                    Write-ModuleLog -Message "Failed to assign role $($Role.Id): $_" `
                        -Level Warning `
                        -Component 'ApplicationConsent'
                }
            }
 
            # Step 6: Configure delegated permissions
            $DelegatedScopes = $App.ResourceAccess | Where-Object { $_.type -eq 'Scope' }
            if ($DelegatedScopes) {
                # Get current delegated permissions
                $currentGrants = Get-MgOauth2PermissionGrant -Filter "clientId eq '$($ServicePrincipal.Id)'"
                $existingGrant = $currentGrants | Where-Object { $_.resourceId -eq $ResourceServicePrincipal.Id }
 
                # Calculate new scope string
                $NewScope = if ($PermissionTranslator) {
                    @(($PermissionTranslator |
                            Where-Object { $_.id -in $DelegatedScopes.id }).value |
                        Sort-Object -Unique) -join ' '
                }
                else {
                    @($DelegatedScopes |
                        ForEach-Object { $_.id } |
                        Sort-Object -Unique) -join ' '
                }
 
                try {
                    if ($existingGrant) {
                        # Compare existing and new scopes
                        $existingScopes = $existingGrant.Scope -split ' ' | Sort-Object
                        $newScopes = $NewScope -split ' ' | Sort-Object
             
                        $scopeComparison = Compare-Object -ReferenceObject $existingScopes -DifferenceObject $newScopes
             
                        if ($null -eq $scopeComparison) {
                            Write-ModuleLog -Message "All delegated permissions already exist for $($ResourceServicePrincipal.DisplayName)" `
                                -Level Info `
                                -Component 'ApplicationConsent'
                        }
                        else {
                            # Update existing grant with new scopes
                            Write-ModuleLog -Message "Updating delegated permissions for $($ResourceServicePrincipal.DisplayName)" `
                                -Level Info `
                                -Component 'ApplicationConsent'
             
                            # Log changes if any
                            $added = ($scopeComparison | Where-Object { $_.SideIndicator -eq '=>' }).InputObject
                            $removed = ($scopeComparison | Where-Object { $_.SideIndicator -eq '<=' }).InputObject
             
                            if ($added) {
                                Write-ModuleLog -Message "Adding scopes: $($added -join ', ')" `
                                    -Level Info `
                                    -Component 'ApplicationConsent'
                            }
                            if ($removed) {
                                Write-ModuleLog -Message "Removing scopes: $($removed -join ', ')" `
                                    -Level Info `
                                    -Component 'ApplicationConsent'
                            }
 
                            Update-MgOauth2PermissionGrant -OAuth2PermissionGrantId $existingGrant.Id -Scope $NewScope
                        }
                    }
                    else {
                        # Create new permission grant
                        $params = @{
                            ClientId = $ServicePrincipal.Id
                            ConsentType = 'AllPrincipals'
                            ResourceId = $ResourceServicePrincipal.Id
                            Scope = $NewScope
                        }
 
                        if ($ResourceServicePrincipal.DisplayName -eq "Microsoft Partner Center" -or $ResourceServicePrincipal.DisplayName -eq "M365 License Manager") {
                            # Skip consent for Partner Center
                            Write-ModuleLog -Message "Skipping consent for $($ResourceServicePrincipal.DisplayName), since it's a customer tenant" -Level Verbose -Component 'ApplicationConsent'
                            continue
                        }
 
                        Write-ModuleLog -Message "Granting new delegated permissions for $($ResourceServicePrincipal.DisplayName): $NewScope" `
                            -Level Info `
                            -Component 'ApplicationConsent'
                        New-MgOAuth2PermissionGrant @params -ErrorAction Stop | Out-Null
                    }
                }
                catch {
                    Write-ModuleLog -Message "Failed to manage delegated permissions for $($ResourceServicePrincipal.DisplayName): $_" `
                        -Level Warning `
                        -Component 'ApplicationConsent'
                }
            }
        }
 
        # Step 7
        # Disconnect and reconnect to get the updated token
        # Keep disconnecting graph until there are no open connections
        $GraphContext = Get-MgContext
        if ($GraphContext) {
            $dc = Disconnect-Graph -ErrorAction SilentlyContinue | Out-Null
            $GraphContext = Get-MgContext
            Write-ModuleLog -Message "Disconnected from Graph to clear connections" -Level Info -Component 'ApplicationConsent'
        }
        Write-ModuleLog -Message "Waiting for 10 seconds before reconnecting to Graph" -Level Info -Component 'ApplicationConsent'
        Start-Sleep -Seconds 10
        Connect-CustomerGraph -CustomerTenantId $CustomerTenantId -FlowType "Delegated" -Force
 
        $GraphContext = Get-MgContext
        while (!$GraphContext.Scopes.Contains("RoleManagement.ReadWrite.Directory")) {
            Write-ModuleLog -Message "RoleManagement.ReadWrite.Directory permission is missing in the token, re-connecting until we have it." -Level Info -Component 'ApplicationConsent'
            $dc = Disconnect-Graph -ErrorAction SilentlyContinue | Out-Null
            Start-Sleep -Seconds 10
            Connect-CustomerGraph -CustomerTenantId $CustomerTenantId -FlowType "Delegated" -Force
            $GraphContext = Get-MgContext
        }
 
 
 
        # Step 8
        # Assign "Compliance Administrator" and "Exchange Administrator" roles to the service principal if they exist
        $ComplianceAdministratorRole = Get-MgDirectoryRole | Where-Object { $_.DisplayName -eq "Compliance Administrator" }
        $ComplianceAdministrators = Get-MgDirectoryRoleMemberAsServicePrincipal -DirectoryRoleId $ComplianceAdministratorRole.Id -ErrorAction Stop
        if ($ComplianceAdministrators.Id -notcontains $ServicePrincipal.Id) {
            New-MgDirectoryRoleMemberByRef -DirectoryRoleId $ComplianceAdministratorRole.Id -BodyParameter @{"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($ServicePrincipal.Id)" } -ErrorAction Stop
            Write-ModuleLog -Message "Assigning Compliance Administrator role to service principal" -Level Info -Component 'ApplicationConsent'
        }
 
 
        $ExchangeAdministratorRole = Get-MgDirectoryRole | Where-Object { $_.DisplayName -eq "Exchange Administrator" }
        $ExchangeAdministrators = Get-MgDirectoryRoleMemberAsServicePrincipal -DirectoryRoleId $ExchangeAdministratorRole.Id -ErrorAction Stop
        if ($ExchangeAdministrators.Id -notcontains $ServicePrincipal.Id) {
            New-MgDirectoryRoleMemberByRef -DirectoryRoleId $ExchangeAdministratorRole.Id -BodyParameter @{"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($ServicePrincipal.Id)" } -ErrorAction Stop
            Write-ModuleLog -Message "Assigning Exchange Administrator role to service principal" -Level Info -Component 'ApplicationConsent'
        }
 
        Write-ModuleLog -Message "Successfully configured application permissions for $($customer.displayName)" -Level Info -Component 'ApplicationConsent'
 
        # Keep disconnecting graph until there are no open connections
        $GraphContext = Get-MgContext
        if ($GraphContext) {
            $dc = Disconnect-Graph -ErrorAction SilentlyContinue | Out-Null
            $GraphContext = Get-MgContext
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to set up application consent: $($_.Exception.Message)" -Level Error -Component 'ApplicationConsent' -ErrorRecord $_ -ThrowError
    }
}

================
File: Private/GDAP/New-GDAPRelationship.ps1
================
function New-GDAPRelationship {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$CustomerTenantId
    )
 
    try {
        $customer = Get-PartnerCustomer -CustomerTenantId $CustomerTenantId
        if (!$customer) {
            Write-ModuleLog "Customer with tenant ID $CustomerTenantId not found" -Level Error -Component 'GDAP' -ThrowError
        }
 
        # Create relationship request parameters
        $relationshipParams = @{
            displayName = "Jysk IT - $(New-Guid)"
            duration = "P730D"
            autoExtendDuration = "P180D"
            customer = @{
                tenantId = $CustomerTenantId
                displayName = $customer.companyProfile.companyName
            }
            accessDetails = $script:Config.GDAP.AccessDetails
        }
 
        # Connect to partner tenant
        Connect-CustomerGraph -CustomerTenantId $script:Config.PartnerTenantId -FlowType 'Delegated' -Force
 
        # Create the relationship
        Write-Host $relationshipParams
        $relationship = New-MgTenantRelationshipDelegatedAdminRelationship -BodyParameter $relationshipParams
        Write-ModuleLog -Message "Created new GDAP relationship: $($relationship.DisplayName)" -Level Info -Component 'GDAP'
 
        # Lock for approval
        New-MgTenantRelationshipDelegatedAdminRelationshipRequest `
            -DelegatedAdminRelationshipId $relationship.Id `
            -Action "LockForApproval" | Out-Null
         
        Write-ModuleLog -Message "Successfully locked relationship $($relationship.DisplayName) for approval" -Level Info -Component 'GDAP'
 
        return $relationship
    }
    catch {
        Write-ModuleLog -Message "Failed to create GDAP relationship: $($_.Exception.Message)" -Level Error -Component 'GDAP' -ErrorRecord $_ -ThrowError
    }
}
 
function Wait-GDAPApproval {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$RelationshipId,
 
        [Parameter()]
        [int]$TimeoutMinutes = 30
    )
 
    try {
        $timeout = (Get-Date).AddMinutes($TimeoutMinutes)
         
        while ((Get-Date) -lt $timeout) {
            $relationship = Get-MgTenantRelationshipDelegatedAdminRelationship -DelegatedAdminRelationshipId $RelationshipId
             
            if ($relationship.Status -eq "active") {
                Write-ModuleLog -Message "GDAP relationship is now active" -Level Info -Component 'GDAP'
                return $true
            }
             
            Write-ModuleLog -Message "Waiting for GDAP approval..." -Level Info -Component 'GDAP'
            Start-Sleep -Seconds 10
        }
 
        Write-ModuleLog -Message "Timeout waiting for GDAP approval" -Level Error -Component 'GDAP' -ThrowError
    }
    catch {
        Write-ModuleLog -Message "Failed while waiting for GDAP approval: $($_.Exception.Message)" -Level Error -Component 'GDAP' -ErrorRecord $_ -ThrowError
    }
}

================
File: Private/GDAP/Set-GDAPPermissions.ps1
================
function Set-GDAPPermissions {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$RelationshipId
    )
 
    try {
        # Apply each access assignment set
        $accessSets = @($script:Config.GDAP.AccessAssignments)
         
        $index = 1
        foreach ($accessSet in $accessSets) {
            Write-ModuleLog -Message "Creating access assignment for group $($accessSet.accessContainer.accessContainerId) [$($index)/$($accessSets.Count)]" -Level Info -Component 'GDAPPermissions'
             
            $assignment = New-MgTenantRelationshipDelegatedAdminRelationshipAccessAssignment `
                -DelegatedAdminRelationshipId $RelationshipId `
                -BodyParameter $accessSet
 
            # Wait for assignment to become active
            $retryCount = 0
            $maxRetries = 50
 
            while ($retryCount -lt $maxRetries) {
                $status = Get-MgTenantRelationshipDelegatedAdminRelationshipAccessAssignment `
                    -DelegatedAdminRelationshipId $RelationshipId `
                    -DelegatedAdminAccessAssignmentId $assignment.Id
 
                if ($status.Status -eq "active") {
                    Write-ModuleLog -Message "Access assignment activated successfully" -Level Info -Component 'GDAPPermissions'
                    break
                }
 
                Write-ModuleLog -Message "Waiting for access assignment activation..." -Level Info -Component 'GDAPPermissions'
                Start-Sleep -Seconds 10
                $retryCount++
            }
 
            if ($retryCount -eq $maxRetries) {
                Write-ModuleLog -Message "Timeout waiting for access assignment activation" -Level Error -Component 'GDAPPermissions' -ThrowError
            }
            $index++
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to set GDAP permissions: $($_.Exception.Message)" -Level Error -Component 'GDAPPermissions' -ErrorRecord $_ -ThrowError
    }
}

================
File: Private/PartnerCenter/Invoke-PartnerCenterRequest.ps1
================
function Invoke-PartnerCenterRequest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Uri,
         
        [Parameter()]
        [string]$Method = 'GET',
         
        [Parameter()]
        [object]$Body,
         
        [Parameter()]
        [string]$ContentType = 'application/json'
    )
 
    try {
        # Get Partner Center token
        $token = Get-PartnerAccessToken -TenantId $script:Config.PartnerTenantId `
            -Scopes "https://api.partnercenter.microsoft.com/user_impersonation"
 
        $headers = @{
            'Authorization' = "Bearer $($token.access_token)"
            'Accept' = 'application/json'
        }
 
        $params = @{
            Uri = $Uri
            Method = $Method
            Headers = $headers
        }
 
        if ($Body) {
            $params.Body = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 100 }
            $params.ContentType = $ContentType
        }
 
        $response = Invoke-RestMethod @params -SkipHttpErrorCheck -StatusCodeVariable StatusCode
        # Add status code
        $response | Add-Member -NotePropertyName 'StatusCode' -NotePropertyValue $StatusCode
        return $response
    }
    catch {
        Write-ModuleLog -Message "Failed to execute Partner Center request" -Level Error -Component 'PartnerCenter' `
            -ErrorRecord $_ -ThrowError -ErrorOperation 'PartnerCenterRequest' -ErrorMessage 'Failed to execute Partner Center request'
    }
}

================
File: Private/PartnerMenu/Get-PartnerMenuHeader.ps1
================
function Get-PartnerMenuHeader() {
    Param(
        [Parameter()]
        [string]$SectionString
    )
    Clear-Host
    Write-host "#######################################"
    Write-Host "# Welcome to the Jysk IT Partner Menu #" -ForegroundColor Green
    Write-host "#######################################"
    if($SectionString) {
        Write-Host $SectionString -ForegroundColor Cyan
        Write-Host ""
    }
}

================
File: Private/PartnerMenu/Start-PartnerMenuTenantCreation.ps1
================
function Start-PartnerMenuTenantInitilization() {
 
    $TenantDomain = Read-Host "Please enter the tenant default domain (e.g. customerdomain.onmicrosoft.com)"
 
    Connect-CustomerGraph -CustomerTenantId $Script:config.PartnerTenantId -FlowType Delegated
    $Tenants = Get-MgContract -All
    $Tenants = $Tenants | Select-Object -Property DisplayName, DefaultDomainName, CustomerId, Id
    $Tenant = $Tenants | Where-Object { $_.DefaultDomainName -eq $TenantDomain } | Select-Object -First 1
    if(!$Tenant) {
        Write-Error "Failed to find tenant with default domain: $($TenantDomain)"
        Read-Host "Press any key to continue.."
        Start-PartnerMenu
    }
    Clear-Host
 
    Get-PartnerMenuHeader -SectionString "($($Tenant.displayName)) - Tenant Initilization"
 
    Initialize-CustomerTenant -CustomerTenantId $Tenant.CustomerId
 
    Read-Host "Press any key to continue.."
}

================
File: Private/Utilities/Get-AutomationUpdates.ps1
================
function Get-AutomationUpdates {
    Set-Variable -Name AutomationCurrentVersion -Value $($MyInvocation.MyCommand.ScriptBlock.Module.Version) -Scope Global
 
    if( -not [bool](Get-Variable AutomationUpdateNotice -Erroraction SilentlyContinue) ) {
        Set-Variable -Name AutomationUpdateNotice -Value $true -Scope Global
    }
 
    if( -not $Global:AutomationNewestVersion) {
        $Response = ((Invoke-WebRequest -Uri "https://www.powershellgallery.com/packages/JyskIT.Automation" -MaximumRedirection 0 -ErrorAction SilentlyContinue -SkipHttpErrorCheck).Headers.Location -split '/')[-1]
        Set-variable -Name AutomationNewestVersion -Value $($Response) -Scope Global
    }
 
    if($Global:AutomationNewestVersion -gt $Global:AutomationCurrentVersion) {
        if( $Global:AutomationUpdateNotice ) {
            Write-Host "New version available: $($Global:AutomationNewestVersion)" -BackgroundColor Green -ForegroundColor Black
            Write-Host "Please update the module..." -BackgroundColor Green -ForegroundColor Black
            Set-Variable -Name AutomationUpdateNotice -Value $false -Scope Global
            Pause
            Clear-Host
        }
    }
}

================
File: Private/Utilities/Get-PSGalleryKey.ps1
================
function Get-PSGalleryKey() {
        # Start by connecting to our Azure Key Vault.
        $AzContext = Get-AzContext
        if (!$AzContext -or $AzContext.Tenant.Id -ne $script:Config.PartnerTenantId) {
            try {
                Write-Host "Please log in to Azure with your @jlhosting.dk account. A browser window has been opened." -ForegroundColor Yellow
                Connect-AzAccount -Tenant $script:Config.PartnerTenantId -SubscriptionName $script:Config.SubscriptionName | Out-Null
            }
            catch {
                Write-Error "Failed to connect to Azure. Please make sure you have the Az module installed."
            }
        }
        try {
            # Retreive all required values from Azure Key Vault
            $PSGalleryKey = Get-AzKeyVaultSecret -VaultName $script:Config.KeyVaultName -Name "psgallerykey" -AsPlainText
        }
        catch {
            Write-Error "Failed to connect to Azure Key Vault and retreive secrets."
        }
     
        return $PSGalleryKey
}

================
File: Private/Utilities/Get-RandomPassword.ps1
================
Function Get-RandomPassword
{
    #define parameters
    param([int]$PasswordLength = 10)
  
    #ASCII Character set for Password
    $CharacterSet = @{
            Uppercase = (97..122) | Get-Random -Count 10 | ForEach-Object {[char]$_}
            Lowercase = (65..90) | Get-Random -Count 10 | ForEach-Object {[char]$_}
            Numeric = (48..57) | Get-Random -Count 10 | ForEach-Object {[char]$_}
            SpecialChar = (33..47)+(58..64)+(91..96)+(123..126) | Get-Random -Count 10 | ForEach-Object {[char]$_}
    }
  
    #Frame Random Password from given character set
    $StringSet = $CharacterSet.Uppercase + $CharacterSet.Lowercase + $CharacterSet.Numeric + $CharacterSet.SpecialChar
  
    -join(Get-Random -Count $PasswordLength -InputObject $StringSet)
}

================
File: Public/Configuration/Add-BaselineConfiguration.ps1
================
function Add-BaselineConfiguration {
    param(
        [Parameter(Mandatory)]
        [string]$TenantId
    )
 
    try {
        Connect-CustomerGraph -CustomerTenantId $TenantId
         
        $SelectedItems = $script:DefaultConfig.PolicyTypeSettings | Select-Object Type, Name, Description| Out-ConsoleGridView -Title "Select the desired baseline configuration items you want to add" -OutputMode Multiple
 
 
        foreach($SelectedItem in $SelectedItems) {
            Write-ModuleLog -Message "Adding baseline configuration item '$($SelectedItem.Name)'.." -Level Info -Component 'BaselineConfiguration'
            Add-BaselinePolicy -TenantId $TenantId -PolicyType $SelectedItem.Type
            Write-ModuleLog -Message "Baseline configuration item '$($SelectedItem.Name)' added." -Level Info -Component 'BaselineConfiguration'
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to create baseline configuration" -ErrorRecord $_ -Level Error -Component 'BaselineConfiguration'
    }
}

================
File: Public/Configuration/Add-CompanyBranding.ps1
================
function Add-CompanyBranding {
    param(
        [Parameter(Mandatory)]
        [string]$TenantId,
 
        [Parameter()]
        [string]$SignInPageText = '**Har du udfordringer med login?** Kontakt [Jysk IT](https://jyskit.dk) på 76 60 22 00'
    )
 
    try {
        Connect-CustomerGraph -CustomerTenantId $TenantId
        $Branding = Get-MgOrganizationBranding -OrganizationId $TenantId -ErrorAction SilentlyContinue
 
        if( !$Branding ) {
            $Branding = New-MgOrganizationBrandingLocalization -OrganizationId $TenantId -SignInPageText $SignInPageText
            Write-ModuleLog -Message "Created company branding." -Level Info -Component 'CompanyBranding'
        }
 
        elseif ( $Branding.SignInPageText -ne $SignInPageText ) {
            $Branding = Update-MgOrganizationBrandingLocalization -OrganizationId $TenantId -SignInPageText $SignInPageText -OrganizationalBrandingLocalizationId $Branding.Id
            Write-ModuleLog -Message "Updated company branding." -Level Info -Component 'CompanyBranding'
        }
 
        else {
            Write-ModuleLog -Message "Company branding already set..." -Level Info -Component 'CompanyBranding'
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to create company branding: $_" -Level Error -Component 'CompanyBranding'
    }
}

================
File: Public/Configuration/Add-CustomerDomain.ps1
================
function Add-CustomerDomain() {
    param(
        [Parameter(Mandatory)]
        [string]$TenantId,
 
        [Parameter(Mandatory)]
        [string]$DomainName
        )
     
    try {
        $IsDefault = $true
 
        Connect-CustomerGraph -CustomerTenantId $TenantId
 
        $Domain = Get-MgDomain -DomainId $DomainName -ErrorAction SilentlyContinue
 
        $NumberOfDomains = (Get-MgDomain).Count
 
        if($NumberOfDomains -ne 1) { $IsDefault = $false }
 
        if($null -eq $Domain) {
            Write-ModuleLog -Message "Adding domain $($DomainName).." -Level Info -Component 'AddCustomerDomain'
 
            $Params = @{
                Id = $DomainName
            }
 
            New-MgDomain -BodyParameter $Params | Out-Null
 
            $VerificationCode = (Get-MgDomainVerificationDnsRecord -DomainId $DomainName | Where-Object { $_.RecordType -eq "TXT" }).AdditionalProperties.text
 
            $NameServer = Resolve-DnsName -Name $DomainName -Type NS -ErrorAction SilentlyContinue | Select-Object -First 1
            if ($NameServer.NameHost) { $NameServer = $NameServer.NameHost }
            else { $NameServer = 'Not Found'}
 
            $DNSRecords = Get-CuranetDNSRecords -DomainName $DomainName
 
            if($NameServer -like '*.curanet.dk' -and $DNSRecords.status -notlike "4*") {
                $TTL = $DNSRecords | Where-Object { $_.type -eq "TXT" } | Select-Object -First 1 | Select-Object -ExpandProperty ttl
 
                if ( !$TTL ) { $TTL = 3600 }
 
                $NewRecord = New-CuranetDNSRecord -DomainName $DomainName -Type "TXT" -TTL $TTL -Value $VerificationCode
 
                if(!$NewRecord.status) {
                    Write-ModuleLog "Successfully created DNS record on Curanet" -Level Info -Component 'AddCustomerDomain'
                }
 
                else {
                    Write-ModuleLog "Failed to create DNS record on Curanet - $($NewRecord.status)" -Level Error -Component 'AddCustomerDomain'
                    Write-ModuleLog "Please add the following DNS record to your DNS provider:" -Level Warning -Component 'AddCustomerDomain'
                    Write-ModuleLog "Type: TXT" -Level Warning -Component 'AddCustomerDomain'
                    Write-ModuleLog "Name: @ or leave blank" -Level Warning -Component 'AddCustomerDomain'
                    Write-ModuleLog "Value: $($VerificationCode)" -Level Warning -Component 'AddCustomerDomain'
                    Write-ModuleLog "TTL: $($TTL)" -Level Warning -Component 'AddCustomerDomain'
                    Read-Host "Press any key to continue.."
                }
            }
            else {
                Write-ModuleLog "Please add the following DNS record to your DNS provider:" -Level Warning -Component 'AddCustomerDomain'
                Write-ModuleLog "Type: TXT" -Level Warning -Component 'AddCustomerDomain'
                Write-ModuleLog "Name: @ or leave blank" -Level Warning -Component 'AddCustomerDomain'
                Write-ModuleLog "Value: $($VerificationCode)" -Level Warning -Component 'AddCustomerDomain'
                Write-ModuleLog "TTL: 3600" -Level Warning -Component 'AddCustomerDomain'
                Read-Host "Press any key to continue.."
            }
 
            Write-ModuleLog "Verifying domain.." -Level Info -Component 'AddCustomerDomain'
 
            Start-Sleep -Seconds 10
 
            $ConfirmDomain = Confirm-MgDomain -DomainId $DomainName -ErrorAction SilentlyContinue
 
            while (!$ConfirmDomain) {
                Write-ModuleLog -Message "Domain not verified yet, waiting 10 seconds.." -Level Warning -Component 'AddCustomerDomain'
                Start-Sleep -Seconds 10
                $ConfirmDomain = Confirm-MgDomain -DomainId $DomainName -ErrorAction SilentlyContinue
            }
 
            if( $IsDefault ) {
                Write-ModuleLog -Message "Setting domain as default.." -Level Info -Component 'AddCustomerDomain'
                Start-Sleep -Seconds 5
                Update-MgDomain -DomainId $DomainName -IsDefault | Out-Null
            }
 
            Write-ModuleLog -Message "Domain $($DomainName) has been successfully added!" -Level Info -Component 'AddCustomerDomain'
        }
        else {
            Write-ModuleLog -Message "Domain $($DomainName) already exists, skipping.." -Level Warning -Component 'AddCustomerDomain'
        }
    } catch {
        Write-ModuleLog -Message "Failed to add domain" -Level Error -Component 'AddCustomerDomain' -ErrorRecord $_ -ThrowError
    }
}

================
File: Public/Configuration/Baseline/Add-BaselinePolicy.ps1
================
function Add-BaselinePolicy {
    param(
        [Parameter(Mandatory)]
        [string]$TenantId,
        [Parameter(Mandatory)]
        [ValidateSet("ConditionalAccessPolicies", "NamedLocations", "AuthenticationStrengths", "iOSAppProtectionPolicies", "AndroidAppProtectionPolicies", "CompliancePolicies", "ConfigurationPolicies", "AuthenticationMethodPolicy", "AuthorizationPolicy", "DeviceRegistrationPolicy", "MobileDeviceManagementPolicy", "MobileDeviceManagementPolicy", "DeviceEnrollmentConfigurations", "WindowsUpdateForBusinessConfigurations", "MobileThreatDefenseConnector", "WindowsAutopilotDeploymentProfiles","MobileApps", "Groups", "ExchangeOnlineProtectionPolicies")]
        [string]$PolicyType
    )
    Connect-CustomerGraph -CustomerTenantId $TenantId
 
    $ExistingPolicies = . ($script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).GetCommand)
    $PolicyFiles = Get-ChildItem -Path $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).PolicyFilesPath -Filter *.json
    $PolicyTypeName = $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).Name
 
    if($PolicyTypeName -eq "Exchange Online Protection Policies") {
        Add-EOPPolicies -TenantId $TenantId
        return
    }
 
    $PolicyFilesFormatted = $PolicyFiles | ForEach-Object {
        $Policy = Get-Content -Path $_.FullName | ConvertFrom-Json -Depth 100
        if ($Policy.Description) {
            [PSCustomObject]@{
                Name = [System.IO.Path]::GetFileNameWithoutExtension($_.Name)
                Description = $Policy.Description
            }
        }
        else {
            [PSCustomObject]@{
                Name = [System.IO.Path]::GetFileNameWithoutExtension($_.Name)
            }
        }
    }
 
    $Selectable = ($script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).Selectable)
 
    if ($Selectable) {
        $Selected = $PolicyFilesFormatted | Out-ConsoleGridView -OutputMode Multiple -Title "Select the $($PolicyTypeName) you want to add"
    } else {
        $Selected = $PolicyFilesFormatted
    }
 
    $PolicyFiles = $PolicyFiles | Where-Object { $Selected.Name -contains [System.IO.Path]::GetFileNameWithoutExtension($_.Name) }
 
    foreach ($PolicyFile in $PolicyFiles) {
        $Policy = Read-PolicyFile -Path $PolicyFile.FullName -TenantId $TenantId
        $NameProperty = $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).NameProperty
        if($Policy.$NameProperty -eq $null) {
            $Policy.$NameProperty = [System.IO.Path]::GetFileNameWithoutExtension($PolicyFile.Name)
        }
        $CheckExists = $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).CheckExists
 
        $CheckExistsOverride = @("#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration")
        if ($Policy."@odata.type" -in $CheckExistsOverride) {
            $CheckExists = $false
        }
        if ($ExistingPolicies.$NameProperty -contains $Policy.$NameProperty -and $CheckExists) {
            Write-Host "$($PolicyTypeName) '$($Policy.$NameProperty)' already exists, not creating.." -ForegroundColor Yellow
        }
        else {
            $AddCommand = $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).AddCommand
            $AddedPolicy = . $AddCommand -Body $Policy
            Write-ModuleLog -Message "Created '$($Policy.$NameProperty)'" -Level Info -Component 'BaselineConfiguration'
 
            # If the policy type has an assign command, assign the policy
            $AssignCommand = $script:DefaultConfig.PolicyTypeSettings.Where({ $_.Type -eq $PolicyType }).AssignCommand
            if ($AssignCommand) {
                . $AssignCommand -OriginalPolicy $Policy -NewPolicy $AddedPolicy
                Write-ModuleLog -Message "Assigned '$($Policy.$NameProperty)'" -Level Info -Component 'BaselineConfiguration'
            }
        }
    }
}

================
File: Public/Configuration/Baseline/Add-EOPPolicies.ps1
================
function Add-EOPPolicies {
    param(
        [Parameter(Mandatory)]
        [string]$TenantId
    )
 
    try {
        Connect-CustomerExchange -CustomerTenantId $TenantId
 
        # Quarantine Policies and notifications
        $QuarantinePolicies = Get-QuarantinePolicy
        $QuarantinePolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\QuarantinePolicies" -Filter *.json
        foreach($QuarantinePolicyFile in $QuarantinePolicyFiles) {
            $QuarantinePolicy = Get-Content -Path $QuarantinePolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable
            if($QuarantinePolicies.Identity -contains $QuarantinePolicy.Name) {
                Write-ModuleLog -Message "Quarantine policy '$($QuarantinePolicy.Name)' already exists, not creating.." -Level Info -Component 'EOPPolicies'
            }
            else {
                # If "DefaultGlobalTag" (used for global settings)
                if($QuarantinePolicyFile.BaseName -eq "GlobalQuarantinePolicy") {
                    $DefaultDomainName = Get-AcceptedDomain | Where-Object { $_.Default -eq $true } | Select-Object -ExpandProperty DomainName
                    $QuarantinePolicy.EndUserSpamNotificationCustomFromAddress = "quarantine@$($DefaultDomainName)"
                    # Force create global policy if hasn't been updated manually
                    $GlobalQuarantinePolicy = New-QuarantinePolicy -QuarantinePolicyType GlobalQuarantineTag -Name "DefaultGlobalTag" -ErrorAction SilentlyContinue
 
                    $GlobalQuarantinePolicy = Get-QuarantinePolicy -QuarantinePolicyType GlobalQuarantinePolicy
                    $GlobalQuarantinePolicy | Set-QuarantinePolicy @QuarantinePolicy
                    Write-ModuleLog -Message "Updated global quarantine policy." -Level Info -Component 'EOPPolicies'
                } else {
                    # Parse the quarantine permissions
                    $RegexMatches = [regex]::Matches($QuarantinePolicy.EndUserQuarantinePermissions, '\b(\w+)\s*:\s*(\w+)\b')
                    $QuarantinePermissions = @{}
                    foreach ($match in $RegexMatches) {
                        $key = $match.Groups[1].Value.TrimStart('n')
                        $value = if ($match.Groups[2].Value -eq "True") { $true } else { $false }
                        $QuarantinePermissions[$key] = $value
                    }
                    $QuarantinePolicy.EndUserQuarantinePermissions = New-QuarantinePermissions @QuarantinePermissions
                    $QuarantinePolicy = New-QuarantinePolicy @QuarantinePolicy
                    Write-ModuleLog -Message "Created quarantine policy '$($QuarantinePolicy.Name)'" -Level Info -Component 'EOPPolicies'
                }
            }
        }
 
        # Anti-phishing policies
        $AntiPhishingPolicies = Get-AntiPhishPolicy
        $AntiPhishingPolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\AntiPhishingPolicies" -Filter *.json
        foreach($AntiPhishingPolicyFile in $AntiPhishingPolicyFiles) {
            $AntiPhishingPolicy = Get-Content -Path $AntiPhishingPolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable
            if($AntiPhishingPolicies.Identity -contains $AntiPhishingPolicy.Name) {
                Write-Host "Anti-phishing policy '$($AntiPhishingPolicy.Name)' already exists, not creating.." -ForegroundColor Yellow
            }
            else {
                $ValidParameters = (Get-Command New-AntiPhishPolicy).Parameters.Keys
                # Filter out parameters that are not valid for New-AntiPhishPolicy using a foreach loop returning an orderedhashtable with only valid parameters
                $ValidAntiPhishingPolicy = [ordered]@{}
                foreach ($key in $AntiPhishingPolicy.Keys) {
                    if ($ValidParameters -contains $key) {
                        $ValidAntiPhishingPolicy[$key] = $AntiPhishingPolicy[$key]
                    }
                }
                $AntiPhishingPolicy = New-AntiPhishPolicy @ValidAntiPhishingPolicy
                Write-ModuleLog -Message "Created anti-phishing policy '$($AntiPhishingPolicy.Name)'" -Level Info -Component 'EOPPolicies'
            }
        }
 
        # Anti-spam (inbound) policies
        $AntiSpamInboundPolicies = Get-HostedContentFilterPolicy
        $AntiSpamInboundPolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\AntiSpamInboundPolicies" -Filter *.json
 
        foreach($AntiSpamInboundPolicyFile in $AntiSpamInboundPolicyFiles) {
            $AntiSpamInboundPolicy = Get-Content -Path $AntiSpamInboundPolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable
            if($AntiSpamInboundPolicies.Identity -contains $AntiSpamInboundPolicy.Name) {
                Write-Host "Anti-spam (inbound) policy '$($AntiSpamInboundPolicy.Name)' already exists, not creating.." -ForegroundColor Yellow
            }
            else {
                $AntiSpamInboundPolicy = New-HostedContentFilterPolicy @AntiSpamInboundPolicy
                $AntiSpamInboundPolicy | Set-HostedContentFilterPolicy -MakeDefault:$true
                Write-Host "Created anti-spam (inbound) policy '$($AntiSpamInboundPolicy.Name)'." -ForegroundColor Green
            }
        }
 
        # Anti-spam (outbound) policies
        $AntiSpamOutboundPolicies = Get-HostedOutboundSpamFilterPolicy
        $AntiSpamOutboundPolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\AntiSpamOutboundPolicies" -Filter *.json
 
        foreach($AntiSpamOutboundPolicyFile in $AntiSpamOutboundPolicyFiles) {
            $AntiSpamOutboundPolicy = Get-Content -Path $AntiSpamOutboundPolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable
            if($AntiSpamOutboundPolicies.Identity -contains $AntiSpamOutboundPolicy.Name) {
                Write-ModuleLog -Message "Anti-spam (outbound) policy '$($AntiSpamOutboundPolicy.Name)' already exists, not creating.." -Level Info -Component 'EOPPolicies'
            }
            else {
                # New can't be created, does not work correctly. Have to edit the default one.
                $DefaultAntiSpamOutboundPolicy = Get-HostedOutboundSpamFilterPolicy -Identity "Default"
                $DefaultAntiSpamOutboundPolicy | Set-HostedOutboundSpamFilterPolicy @AntiSpamOutboundPolicy
                Write-ModuleLog -Message "Modified default anti-spam (outbound) policy '$($DefaultAntiSpamOutboundPolicy.Name)'" -Level Info -Component 'EOPPolicies'
            }
        }
 
        # Safe links policies
        if(Get-Command "Get-SafeLinksPolicy" -ErrorAction SilentlyContinue)
        {
            $SafeLinksPolicies = Get-SafeLinksPolicy
            $SafeLinksPolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\SafeLinksPolicies" -Filter *.json
            foreach($SafeLinksPolicyFile in $SafeLinksPolicyFiles) {
                $SafeLinksPolicy = Get-Content -Path $SafeLinksPolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable
                if($SafeLinksPolicies.Identity -contains $SafeLinksPolicy.Name) {
                    Write-ModuleLog -Message "Safe links policy '$($SafeLinksPolicy.Name)' already exists, not creating.." -Level Info -Component 'EOPPolicies'
                }
                else {
                    $SafeLinksPolicy = New-SafeLinksPolicy @SafeLinksPolicy
                    Write-Host "Created safe links policy '$($SafeLinksPolicy.Name)'." -ForegroundColor Green
                    $AcceptedDomains = Get-AcceptedDomain
                    $SafeLinksRule = New-SafeLinksRule -Name $SafeLinksPolicy.Name -SafeLinksPolicy $SafeLinksPolicy.Name -RecipientDomainIs $AcceptedDomains.DomainName
                    Write-ModuleLog -Message "Added all accepted domains to Safe Links Rule." -Level Info -Component 'EOPPolicies'
                }
            }
        }
        # Safe attachments policies
        if(Get-Command "Get-SafeAttachmentPolicy" -ErrorAction SilentlyContinue)
        {
            $SafeAttachmentsPolicies = Get-SafeAttachmentPolicy
            $SafeAttachmentsPolicyFiles = Get-ChildItem -Path "$script:ModuleRoot\Public\Configuration\Baseline\EOPPolicies\SafeAttachmentsPolicies" -Filter *.json
            foreach($SafeAttachmentsPolicyFile in $SafeAttachmentsPolicyFiles) {
                $SafeAttachmentsPolicy = Get-Content -Path $SafeAttachmentsPolicyFile.FullName | ConvertFrom-Json -Depth 100 -AsHashtable
                if($SafeAttachmentsPolicies.Identity -contains $SafeAttachmentsPolicy.Name) {
                    Write-ModuleLog -Message "Safe attachments policy '$($SafeAttachmentsPolicy.Name)' already exists, not creating.." -Level Info -Component 'EOPPolicies'
                }
                else {
                    $SafeAttachmentsPolicy = New-SafeAttachmentPolicy @SafeAttachmentsPolicy
                    Write-ModuleLog "Created safe attachments policy '$($SafeAttachmentsPolicy.Name)'" -Level Info -Component 'EOPPolicies'
                    $AcceptedDomains = Get-AcceptedDomain
                    $SafeAttachmentsRule = New-SafeAttachmentRule -Name $SafeAttachmentsPolicy.Name -SafeAttachmentPolicy $SafeAttachmentsPolicy.Name -RecipientDomainIs $AcceptedDomains.DomainName
                }
            }
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to create Exchange Online Protection (EOP) policies" -Level Error -ErrorRecord $_ -Component 'EOPPolicies'
    }
}

================
File: Public/Configuration/Baseline/EOPPolicies/AntiPhishingPolicies/JyskIT-Baseline-EOP-AntiPhishing-Unlicensed.jx
================
{
    "Enabled": true,
    "EnableFirstContactSafetyTips": true,
    "AuthenticationFailAction": "MoveToJmf",
    "SpoofQuarantineTag": "JyskIT-Baseline-EOP-QuarantineNotifications",
    "EnableSpoofIntelligence": true,
    "EnableViaTag": true,
    "EnableUnauthenticatedSender": true,
    "HonorDmarcPolicy": true,
    "DmarcRejectAction": "Reject",
    "DmarcQuarantineAction": "Quarantine",
    "MakeDefault": true,
    "AdminDisplayName": "The default Anti-Phishing policy for Jysk IT baseline.",
    "Name": "JyskIT-Baseline-EOP-AntiPhishing"
}

================
File: Public/Configuration/Baseline/Read-PolicyFile.ps1
================
function Read-PolicyFile {
    param(
        [Parameter(Mandatory)]
        [string]$Path,
        [Parameter(Mandatory)]
        [string]$TenantId
    )
 
    Connect-CustomerGraph -CustomerTenantId $TenantId
 
    $RawContent = Get-Content -Path $Path -Raw
 
    $ReplacementTypes = @(
        "ReplaceGroup",
        "ReplaceUser",
        "ReplaceTenantId",
        "ReplaceNamedLocation",
        "ReplaceAuthenticationStrength",
        "ReplaceGroupSID"
    )
 
    foreach($ReplacementType in $ReplacementTypes) {
        # Match patterns like #ReplaceGroup#_Jysk-IT-Baseline-Group
        $ReplacementPattern = "#$($ReplacementType)#_([\w\s-]+)"
        $ReplacementsFound = [regex]::Matches($RawContent, $ReplacementPattern) | ForEach-Object { $_.Groups[1].Value }
 
        switch ($ReplacementType) {
            "ReplaceGroup" {
                foreach($ReplacementFound in $ReplacementsFound) {
                    #Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'"
                    if($ReplacementFound -eq "AllUsers") {
                        $Group = Get-MgGroup -Filter "displayName eq 'Alle brugere' or displayName eq 'All Users'"
                    }
                    else {
                        $Group = Get-MgGroup -Filter "displayName eq '$ReplacementFound'"
                    }
                    if($Group) {
                        $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $Group.Id
                        #Write-Host "Replaced '$ReplacementFound' with '$($Group.Id)' in file '$Path'"
                    }
                    else {
                        Write-Warning "Group '$ReplacementFound' not found in tenant."
                    }
                }
            }
            "ReplaceUser" {
                foreach($ReplacementFound in $ReplacementsFound) {
                    #Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'"
                    $User = Get-MgUser -Filter "displayName eq '$ReplacementFound' or mailNickname eq '$ReplacementFound'"
                    if($User) {
                        $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $User.Id
                        #Write-Host "Replaced '$ReplacementFound' with '$($User.Id)' in file '$Path'"
                    }
                    else {
                        Write-Warning "User '$ReplacementFound' not found in tenant."
                    }
                }
            }
            "ReplaceTenantId" {
                foreach($ReplacementFound in $ReplacementsFound) {
                    Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'"
                    $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $TenantId
                    Write-Host "Replaced '$ReplacementFound' with '$TenantId' in file '$Path'"
                }
            }
            "ReplaceNamedLocation" {
                foreach($ReplacementFound in $ReplacementsFound) {
                    #Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'"
                    $NamedLocation = Get-MgIdentityConditionalAccessNamedLocation -Filter "displayName eq '$ReplacementFound'"
                    if($NamedLocation) {
                        $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $NamedLocation.Id
                        #Write-Host "Replaced '$ReplacementFound' with '$($NamedLocation.Id)' in file '$Path'"
                    }
                    else {
                        Write-Warning "Named location '$ReplacementFound' not found in tenant."
                    }
                }
            }
            "ReplaceAuthenticationStrength" {
                foreach($ReplacementFound in $ReplacementsFound) {
                    #Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'"
                    $AuthenticationStrength = Get-MgPolicyAuthenticationStrengthPolicy -Filter "displayName eq '$ReplacementFound'"
                    if($AuthenticationStrength) {
                        $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $AuthenticationStrength.Id
                        #Write-Host "Replaced '$ReplacementFound' with '$($AuthenticationStrength.Id)' in file '$Path'"
                    }
                    else {
                        Write-Warning "Authentication strength '$ReplacementFound' not found in tenant."
                    }
                }
            }
            "ReplaceGroupSID" {
                foreach($ReplacementFound in $ReplacementsFound) {
                    #Write-Host "Found replacement type '$ReplacementType' with value '$ReplacementFound' in file '$Path'"
                    $Group = Get-MgGroup -Filter "displayName eq '$ReplacementFound'"
                    if($Group) {
                        $SID = Convert-EntraIDObjectIDToSid -ObjectId $Group.id
                        $RawContent = $RawContent -replace "#$ReplacementType#_$ReplacementFound", $SID
                        #Write-Host "Replaced '$ReplacementFound' with '$($SID)' in file '$Path'"
                    }
                    else {
                        Write-Warning "Group '$ReplacementFound' not found in tenant."
                    }
                }
            }
            Default {}
        }
    }
 
    return $RawContent | ConvertFrom-Json -AsHashtable -Depth 100
}

================
File: Public/Configuration/ExchangeOnline/BitTitan/New-BitTitanAppRegistration.ps1
================
function New-BitTitanAppRegistration() {
    param(
        [Parameter(Mandatory)]
        [string]$TenantId
    )
    Connect-CustomerGraph -CustomerTenantId $TenantId
    Connect-CustomerExchange -CustomerTenantId $TenantId
         
    try {
        $Resource = Get-MgServicePrincipal -Filter "appId eq '00000002-0000-0ff1-ce00-000000000000'" -ErrorAction Stop
        if(!$Resource) {
            Write-ModuleLog -Message "Failed to find Exchange Online service principal. The customer does not have Exchange Online - and therefore app registration is impossible. Assign a license to the customer, and wait 10 minutes before trying again." -Level Error -Component 'BitTitanAppRegistration'
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to find Exchange Online service principal. The customer does not have Exchange Online - and therefore app registration is impossible. Assign a license to the customer, and wait 10 minutes before trying again." -Level Error -Component 'BitTitanAppRegistration' -ThrowError
    }
 
    try {
        $AppRegistrationParams = @{
            displayName = "BitTitan MigrationWiz"
            description = "App registration for BitTitan MigrationWiz usage."
            isFallbackPublicClient = "True"
            signInAudience = "AzureADMultipleOrgs"
            publicClient = @{
                redirectUris = @(
                    "urn:ietf:wg:oauth:2.0:oob"
                )
            }
            requiredResourceAccess = @(
                @{
                    resourceAppId = "00000002-0000-0ff1-ce00-000000000000"
                    resourceAccess = @(
                        @{
                            id = "3b5f3d61-589b-4a3c-a359-5dd4b5ee5bd5"
                            type = "Scope"
                        }
                        @{
                            id = "dc50a0fb-09a3-484d-be87-e023b12c6440"
                            type = "Role"
                        }
                        @{
                            id = "dc890d15-9560-4a4c-9b7f-a736ec74ec40"
                            type = "Role"
                        }
                    )
                }
            )
        }
        $Application = New-MgApplication -BodyParameter $AppRegistrationParams -ErrorAction Stop
        Write-ModuleLog -Message "Created BitTitan app registration: $($Application.DisplayName)" -Level Info -Component 'BitTitanAppRegistration'
    }
    catch {
        Write-ModuleLog -Message "Failed to create BitTitan app registration" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError
    }
 
    try {
        $ServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '$($Application.AppId)'" -ErrorAction Stop
        if (!$ServicePrincipal) {
            $ServicePrincipal = New-MgServicePrincipal -AppId $Application.AppId
            Write-ModuleLog -Message "Created BitTitan app registration service principal" -Level Info -Component 'BitTitanAppRegistration'
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to find/create app registration service principal" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError
    }
 
    try {
        Write-ModuleLog -Message "Waiting 30 seconds to ensure Entra ID Service Principal is synced to Exchange Online" -Level Info -Component 'BitTitanAppRegistration'
        Start-Sleep 30
        $EXOSP = New-ServicePrincipal -AppId $ServicePrincipal.AppId -ObjectId $ServicePrincipal.Id -DisplayName "BitTitan MigrationWiz" -ErrorAction Stop
        Write-ModuleLog -Message "Created Exchange Online service principal" -Level Info -Component 'BitTitanAppRegistration'
    }
    catch {
        Write-ModuleLog -Message "Failed to create Exchange Online service principal" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError
    }
 
    try {
        $OrgConfig = Get-OrganizationConfig -ErrorAction Stop
        if($OrgConfig.isDehydrated) {
            Enable-OrganizationCustomization
            Write-ModuleLog -Message "Succesfully organization customization" -Level Info -Component 'BitTitanAppRegistration'
        } else {
            Write-ModuleLog -Message "Organization customization is already enabled." -Level Info -Component 'BitTitanAppRegistration'
        }
    } catch {
        Write-ModuleLog -Message "Failed to enable organization customization" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError
    }
 
    try {
        $ManagementRoleAssignment = New-ManagementRoleAssignment -App $EXOSP.Id -Role "Application EWS.AccessAsApp" -ErrorAction Stop
        Write-ModuleLog -Message "Succesfully assigned Application EWS.AccessAsApp role" -Level Info -Component 'BitTitanAppRegistration'
    }
    catch {
        Write-ModuleLog -Message "Failed to assign Application EWS.AccessAsApp role" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError
    }
 
    # Create a client secret
    try {
        $PasswordCredential = @{
            displayName = "MigrationWiz"
            endDateTime = (Get-Date).AddMonths(6)
        }
        $ClientSecret = (Add-MgApplicationPassword -ApplicationId $Application.Id -PasswordCredential $PasswordCredential).SecretText
        Write-ModuleLog -Message "Successfully created client secret." -Level Info -Component 'BitTitanAppRegistration'
    }
    catch {
        Write-ModuleLog -Message "Failed to create client secret" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError
    }
 
    try {
        $AppRoleAssignment1 = New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ServicePrincipal.Id -PrincipalId $ServicePrincipal.Id -PrincipalDisplayName "BitTitan MigrationWiz" -ResourceDisplayName "Office 365 Exchange Online" -ResourceId $Resource.Id -AppRoleId "dc50a0fb-09a3-484d-be87-e023b12c6440" -ErrorAction Stop
        $AppRoleAssignment2 = New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ServicePrincipal.Id -PrincipalId $ServicePrincipal.Id -PrincipalDisplayName "BitTitan MigrationWiz" -ResourceDisplayName "Office 365 Exchange Online" -ResourceId $Resource.Id -AppRoleId "dc890d15-9560-4a4c-9b7f-a736ec74ec40" -ErrorAction Stop
    } catch {
        Write-ModuleLog -Message "Failed to create app role assignments" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError
    }
 
    try {
        $Grant = New-MgOauth2PermissionGrant -ClientId $ServicePrincipal.Id -ConsentType "AllPrincipals" -Scope "EWS.AccessAsUser.All Exchange.ManageAsApp full_access_as_app" -ResourceId $Resource.Id -ErrorAction Stop
        Write-ModuleLog -Message "Successfully granted admin consent." -Level Info -Component 'BitTitanAppRegistration'
    }
    catch {
        Write-ModuleLog -Message "Failed to grant admin consent for EWS.AccessAsUser.All" -Level Error -Component 'BitTitanAppRegistration' -ErrorRecord $_ -ThrowError
    }
 
    Write-ModuleLog -Message "Client ID:" -Level Info -Component 'BitTitanAppRegistration'
    Write-ModuleLog -Message "$($Application.AppId)" -Level Info -Component 'BitTitanAppRegistration'
    Write-ModuleLog -Message "Tenant ID:" -Level Info -Component 'BitTitanAppRegistration'
    Write-ModuleLog -Message "$($TenantId)" -Level Info -Component 'BitTitanAppRegistration'
    Write-ModuleLog -Message "Client Secret:" -Level Info -Component 'BitTitanAppRegistration'
    Write-ModuleLog -Message "$($ClientSecret)" -Level Info -Component 'BitTitanAppRegistration'
    Write-ModuleLog -Message "For troubleshooting, view this:" -Level Info -Component 'BitTitanAppRegistration'
    Write-ModuleLog -Message "https://help.bittitan.com/hc/en-us/articles/25111263275035-Replacement-to-the-Retirement-of-Role-Based-Access-Control-for-Applications-in-Exchange-Online" -Level Info -Component 'BitTitanAppRegistration'
}

================
File: Public/Configuration/ExchangeOnline/BitTitan/Start-BitTitanPreperation.ps1
================
function Start-BitTitanPreperation {
    param (
        [Parameter(Mandatory)]
        [string]$TenantId
    )
 
    Write-ModuleLog "Running BitTitan Preperation for $($TenantId)" -Level Info -Component 'BitTitanPreperation'
    try {
        Connect-CustomerExchange -CustomerTenantId $TenantId
        Connect-CustomerGraph -CustomerTenantId $TenantId
 
        $AdminAccount = Get-MgUser -Filter "startswith(UserPrincipalName,'jyskit-adm@')" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty UserPrincipalName
 
        if (!$AdminAccount) {
            $AdminAccount = Read-Host "Please enter the admin account to use:"
        }
 
        Write-ModuleLog "Adding mailbox permissions.." -Level Info -Component 'BitTitanPreperation'
        Get-Mailbox -ResultSize Unlimited | Add-MailboxPermission -AccessRights FullAccess -AutoMapping $false -User $AdminAccount -WarningAction SilentlyContinue | Out-Null
 
        Write-ModuleLog "Setting max send/recieve size.." -Level Info -Component 'BitTitanPreperation'
        Get-MailboxPlan | Set-MailboxPlan -MaxSendSize 150MB -MaxReceiveSize 150MB -WarningAction SilentlyContinue | Out-Null
        Get-Mailbox | Set-Mailbox -MaxReceiveSize 150MB -MaxSendSize 150MB -WarningAction SilentlyContinue | Out-Null
 
        Write-ModuleLog "Enabling Organization customization.." -Level Info -Component 'BitTitanPreperation'
        Enable-OrganizationCustomization -ErrorAction SilentlyContinue
 
        Write-ModuleLog "Disabling Focused Inbox.." -Level Info -Component 'BitTitanPreperation'
        Set-OrganizationConfig -FocusedInboxOn $false
        Get-Mailbox -ResultSize Unlimited | Set-FocusedInbox -FocusedInboxOn $false | Out-Null
 
        Write-ModuleLog "Disabling TNEF.." -Level Info -Component 'BitTitanPreperation'
        Get-RemoteDomain | Set-RemoteDomain -TNEFEnabled:$false -WarningAction SilentlyContinue| Out-Null
 
        Write-ModuleLog "Successfully ran BitTitan Preperation for $($TenantId)" -Level Info -Component 'BitTitanPreperation'
    }
    catch {
        Write-ModuleLog "Failed to run BitTitan Preperation for $($TenantId): $_" -Level Error -Component 'BitTitanPreperation' -ErrorRecord $_ -ThrowError
    }
}

================
File: Public/Configuration/ExchangeOnline/Enable-Auditlogging.ps1
================
function Enable-AuditLogging {
    param (
        [Parameter(Mandatory)]
        [string]$TenantId
    )
 
    Write-ModuleLog -Message "Starting configuration..." -Level Info -Component 'AuditLogging'
 
    try {
        Connect-CustomerExchange -CustomerTenantId $TenantId
    }
    catch {
        Write-ModuleLog -Message "Could not connect to Exchange" -Level Error -Component 'AuditLogging' -ErrorRecord $_ -ThrowError
    }
 
    $Customization = Get-OrganizationConfig | Select-Object -ExpandProperty IsDehydrated
 
    if ( $Customization -ne $false) {
        Write-ModuleLog -Message "Organization Customization is disabled, enabling." -Level Info -Component 'AuditLogging'
        Enable-OrganizationCustomization
    }
 
    else {
        Write-ModuleLog -Message "Organization Customization is already enabled." -Level Info -Component 'AuditLogging'
    }
 
    $AuditloggingEnabled = Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled -ExpandProperty UnifiedAuditLogIngestionEnabled
    if ( $AuditloggingEnabled -eq $True) {
        Write-ModuleLog -Message "Audit logging is already enabled." -Level Info -Component 'AuditLogging'
    }
 
    else {
        try {
            Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true
 
            $AuditloggingEnabled = Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled -ExpandProperty UnifiedAuditLogIngestionEnabled
             
            if( $AuditloggingEnabled -ne $true ) {
                Write-ModuleLog -Message "Could not enable auditlogging." -Level Error -Component 'AuditLogging' -ThrowError
            }
 
            else {
                Write-moduleLog -Message "Auditlogging enabled." -Level Info -Component 'AuditLogging'
            }
        }
        catch {
            Write-ModuleLog "Could not enable auditlogging : $_" -Level Error -Component 'AuditLogging' -ErrorRecord $_ -ThrowError
        }
    }
}

================
File: Public/Configuration/ExchangeOnline/Enable-CustomerDKIM.ps1
================
function Get-MainDomain {
    param (
        [string]$domain
    )
 
    # Split the domain into its components
    $domainParts = $domain -split '\.'
 
    # If the domain has more than two parts, return the last two parts
    if ($domainParts.Length -gt 2) {
        return ($domainParts[-2..-1] -join '.')
    } else {
        return $domain
    }
}
 
# Function to get the subdomain part of the domain
function Get-Subdomain {
    param (
        [string]$domain
    )
 
    # Split the domain into its components
    $domainParts = $domain -split '\.'
 
    # If the domain has more than two parts, return all but the last two parts
    if ($domainParts.Length -gt 2) {
        return ($domainParts[0..($domainParts.Length - 3)] -join '.')
    } else {
        return ""
    }
}
 
function Is-Subdomain {
    param (
        [string]$domain
    )
 
    # Split the domain into its components
    $domainParts = $domain -split '\.'
 
    # If the domain has more than two parts, return all but the last two parts
    if ($domainParts.Length -gt 2) {
        return $true
    } else {
        return $false
    }
}
 
function Enable-CustomerDKIM() {
    param(
        [Parameter(Mandatory)]
        [string]$TenantId
    )
 
    Write-ModuleLog -Message "Starting DKIM configuration..." -Level Info -Component 'EnableCustomerDKIM'
 
    try {
        Write-ModuleLog -Message "Connecting to Customer environment.." -Level Info -Component 'EnableCustomerDKIM'
        Connect-CustomerExchange -CustomerTenantId $TenantId
        Connect-CustomerGraph -CustomerTenantId $TenantId
 
        $DomainsWithoutDKIM = @()
 
        Get-MgDomain | Where-Object { $_.Id -NotLike '*.onmicrosoft.com' } | ForEach-Object {
            Write-ModuleLog -Message "Checking domain $($_.Id).." -Level Info -Component 'EnableCustomerDKIM'
 
            $DKIMStatus = Get-DkimSigningConfig -Identity $_.Id -ErrorAction SilentlyContinue
 
            if( -not $DKIMStatus -or $DKIMStatus.Enabled -eq $false) {
                $DomainsWithoutDKIM += [pscustomobject]@{
                    Domain = $_.Id
                }
            }
        }
     
        if ( $DomainsWithoutDKIM.Count -eq 0 ) {
            Write-ModuleLog -Message "No domains without DKIM found." -Level Info -Component 'EnableCustomerDKIM'
            return
        }
     
        $SelectedDomains = $DomainsWithoutDKIM | Out-ConsoleGridView -Title "Select domains to enable DKIM for.." -OutputMode Multiple
     
        $SelectedDomains | ForEach-Object {
            $Domain = $_
            $DomainName = $Domain.Domain
         
            Write-ModuleLog -Message "Getting DKIM configuration for $DomainName" -Level Info -Component 'EnableCustomerDKIM'
         
            $DKIMConfig = Get-DkimSigningConfig -Identity $DomainName -ErrorAction SilentlyContinue
         
            if ( -not $DKIMConfig ) {
                Write-ModuleLog -Message "[$($DomainName)] DKIM signing config not found. Trying to enable." -Level Info -Component 'EnableCustomerDKIM'
                New-DkimSigningConfig -DomainName $DomainName -Enabled $false -ErrorAction SilentlyContinue | Out-Null
                $DKIMConfig = Get-DkimSigningConfig -Identity $DomainName -ErrorAction SilentlyContinue
            }
         
            if ( $DKIMConfig ) {
                if ( $DKIMConfig.Enabled ) {
                    Write-ModuleLog -Message "[$($DomainName)] DKIM signing config is enabled. Skipping..." -Level Info -Component 'EnableCustomerDKIM'
                }
                else {
                    $cname1 = "selector1._domainkey.$($DomainName)"
                    $cname2 = "selector2._domainkey.$($DomainName)"
                 
                    $CreateManual = $false
 
                    # Used if the domain is a subdomain
                    $MainDomain = Get-MainDomain -Domain $DomainName
                     
                    $cname1Dns = Resolve-DnsName -Name $cname1 -Type CNAME -ErrorAction SilentlyContinue
                    $cname2Dns = Resolve-DnsName -Name $cname2 -Type CNAME -ErrorAction SilentlyContinue
                    $NameServer = Resolve-DnsName -Name $MainDomain -Type NS -ErrorAction SilentlyContinue
                 
                    Write-ModuleLog -Message "[$($DomainName)] Checking DNS records..." -Level Info -Component 'EnableCustomerDKIM'
                 
                    if ( !$cname1Dns.NameHost -and !$cname2Dns.NameHost ) {
                        Write-ModuleLog -Message "[$($DomainName)] CNAME records not found." -Level Warning -Component 'EnableCustomerDKIM'
                        Write-ModuleLog -Message "[$($DomainName)] Checking nameservers..." -Level Info -Component 'EnableCustomerDKIM'
                     
                        if( $NameServer.NameHost -notlike '*.curanet.dk' ) {
                            Write-ModuleLog -Message "[$($DomainName)] Nameservers are not curanet.dk." -Level Warning -Component 'EnableCustomerDKIM'
                            $CreateManual = $true
                        }
                        else {
                            $DNSRecords = Get-CuranetDNSRecords -DomainName $MainDomain
                         
                            if ( $DNSRecords.status -ne 404 ) {
                                Write-ModuleLog -Message "[$($DomainName)] Domain found on curanet." -Level Info -Component 'EnableCustomerDKIM'
                                Write-ModuleLog -Message "[$($DomainName)] Trying to create first cname record..." -Level Info -Component 'EnableCustomerDKIM'
 
                                if(Is-Subdomain -domain $DomainName) {
                                    $Subdomain = Get-Subdomain -domain $DomainName
                                    $Selector1 = "selector1._domainkey.$($Subdomain)"
                                    $Selector2 = "selector2._domainkey.$($Subdomain)"
                                } else {
                                    $Selector1 = "selector1._domainkey"
                                    $Selector2 = "selector2._domainkey"
                                }
                             
                                $Cname1Result = New-CuranetDNSRecord -DomainName $MainDomain -Hostname $Selector1 -Type 'CNAME' -Value $($DKIMConfig.Selector1CNAME.Trim())
                             
                                if ( -not $Cname1Result.status ) {
                                    Write-ModuleLog -Message "Selector1 - SUCCESS!" -Level Info -Component 'EnableCustomerDKIM'
                                }
                             
                                else {
                                    Write-ModuleLog -Message "Selector1 - FAILED!" -Level Error -Component 'EnableCustomerDKIM'
                                    $CreateManual = $true
                                }
                             
                                Write-ModuleLog -Message "[$($DomainName)] Trying to create second cname record..." -Level Info -Component 'EnableCustomerDKIM'
                             
                                $Cname2Result = New-CuranetDNSRecord -DomainName $MainDomain -Hostname $Selector2 -Type 'CNAME' -Value $($DKIMConfig.Selector2CNAME.Trim())
                             
                                if( -not $Cname2Result.status ) {
                                    Write-ModuleLog -Message "Selector2 - SUCCESS!" -Level Info -Component 'EnableCustomerDKIM'
                                }
                             
                                else {
                                    Write-ModuleLog -Message "Selector2 - FAILED!" -Level Error -Component 'EnableCustomerDKIM'
                                    $CreateManual = $true
                                }
                            }
                         
                            else {
                                Write-ModuleLog -Message "[$($DomainName)] Domain not found on curanet." -Level Warning -Component 'EnableCustomerDKIM'
                                $CreateManual = $true
                            }
                        }
                        if ( $CreateManual ) {
                            Write-ModuleLog -Message "[$($DomainName)] Please create the following CNAME records manually:" -Level Warning -Component 'EnableCustomerDKIM'
                            Write-ModuleLog -Message "[$($DomainName)] selector1._domainkey.$($DomainName) -> $($DKIMConfig.Selector1CNAME.Trim())" -Level Warning -Component 'EnableCustomerDKIM'
                            Write-ModuleLog -Message "[$($DomainName)] selector2._domainkey.$($DomainName) -> $($DKIMConfig.Selector2CNAME.Trim())" -Level Warning -Component 'EnableCustomerDKIM'
                            Write-ModuleLog -Message "[$($DomainName)] Press any key to continue..." -Level Warning -Component 'EnableCustomerDKIM'
                            Read-Host
                        }
                    }
                    else {
                        Write-ModuleLog -Message "[$($DomainName)] CNAME records found." -Level Info -Component 'EnableCustomerDKIM'
                    }
                 
                    Write-ModuleLog -Message "[$($DomainName)] Enabling DKIM" -Level Info -Component 'EnableCustomerDKIM'
 
                    Set-DkimSigningConfig -Identity $DomainName -Enabled $true -ErrorAction SilentlyContinue | Out-Null
 
                    While(1) {
                        Write-ModuleLog -Message "[$($DomainName)] Waiting for DKIM to be enabled..." -Level Info -Component 'EnableCustomerDKIM'
                        $result = Get-DkimSigningConfig -Identity $DomainName -ErrorAction SilentlyContinue
                     
                        if($result.Enabled -eq $true) {
                            Write-ModuleLog -Message "[$($DomainName)] DKIM enabled!" -Level Info -Component 'EnableCustomerDKIM'
                            break
                        }
                     
                        Set-DkimSigningConfig -Identity $DomainName -Enabled $true -ErrorAction SilentlyContinue | Out-Null
                        Start-Sleep -Seconds 5
                   }
                }
            }
            else {
                Write-ModuleLog -Message "[$($DomainName)] Could not enable DKIM Signing config. -Maybe exchange has not propagated yet on the tenant!" -Level Error -Component 'EnableCustomerDKIM'
            }
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to enable DKIM: $_" -Level Error -Component 'EnableCustomerDKIM'
    }
}

================
File: Public/Configuration/ExchangeOnline/Enable-CustomerDMARC.ps1
================
function Enable-CustomerDMARC {
 
    param(
        [Parameter(Mandatory)]
        [string]$TenantId,
 
        [Parameter()]
        [array]$Domains = @()
    )
 
    Write-ModuleLog -Message "Starting DMARC configuration.." -Level Info -Component 'EnableCustomerDMARC'
 
    try {
        Connect-CustomerGraph -CustomerTenantId $TenantId
         
        if ( $Domains.Count -eq 0 ) {
 
            $TenantDomains = Get-MgDomain | Where-Object { $_.Id -NotLike '*.onmicrosoft.com' }
 
            $Domains = @()
     
            $TenantDomains | ForEach-Object {
                Write-ModuleLog -Message "Checking domain $($_.Id).." -Level Info -Component 'EnableCustomerDMARC'
     
                $DMARCRecord = Resolve-DnsName -Name "_dmarc.$($_.Id)" -Type TXT -ErrorAction SilentlyContinue
                if( $null -eq $DMARCRecord.Strings) {
                    $Domains += $_.Id
                }
            }
 
            if ( $Domains.Count -eq 0 ) {
                Write-ModuleLog -Message "No domains without DMARC found." -Level Info -Component 'EnableCustomerDMARC'
                return
            }
 
            $Domains = $Domains | Out-ConsoleGridView -Title "Select domains to enable DMARC for" -OutputMode Multiple
        }
        if(!$Domains) {
            Write-ModuleLog -Message "No domains selected. Exiting.." -Level Warning -Component 'EnableCustomerDMARC'
            return
        }
 
        $Domains | ForEach-Object {
            $CreateManual = $false
            $DomainName = $_
            $NameServer = Resolve-DnsName -Name $DomainName -Type NS -ErrorAction SilentlyContinue | Select-Object -First 1
 
            if ( $NameServer -and $NameServer.NameHost -like '*.curanet.dk' ) {
                $DNSRecords = Get-CuranetDNSRecords -Domain $DomainName
 
                if ( $DNSRecords.status -ne 404 ) {
                    $Result = New-CuranetDNSRecord -DomainName $DomainName -Hostname '_dmarc' -Type 'TXT' -Value "v=DMARC1; p=reject;"
 
                    if ( !$Result.status ) {
                        Write-ModuleLog -Message "DMARC record created successfully for $DomainName" -Level Info -Component 'EnableCustomerDMARC'
                    }
                    else {
                        $CreateManual = $true
                    }
                }
 
                else {
                    $CreateManual = $true
                }
            }
 
            else {
                $CreateManual = $true
            }
 
            if ( $CreateManual ) {
                Write-ModuleLog -Message "[$($DomainName)] DMARC record could not be created automatically. Please create the following record manually:" -Level Warning -Component 'EnableCustomerDMARC'
                Write-ModuleLog -Message "[$($DomainName)] Hostname: _dmarc" -Level Warning -Component 'EnableCustomerDMARC'
                Write-ModuleLog -Message "[$($DomainName)] Type: TXT" -Level Warning -Component 'EnableCustomerDMARC'
                Write-ModuleLog -Message "[$($DomainName)] Value: v=DMARC1; p=reject;" -Level Warning -Component 'EnableCustomerDMARC'
                Read-Host "Press enter to continue.."
            }
        }
 
        Write-ModuleLog -Message "DMARC configuration completed." -Level Info -Component 'EnableCustomerDMARC'
    }
    catch {
        Write-ModuleLog -Message "Failed to enable DMARC" -Level Error -Component 'EnableCustomerDMARC' -ErrorRecord $_ -ThrowError
    }
}

================
File: Public/Configuration/New-AdminUser.ps1
================
function New-AdminUser() {
    param(
        [Parameter(Mandatory)]
        [string]$TenantId
        )
    Connect-CustomerGraph -CustomerTenantId $TenantId -FlowType "Delegated" -Force
 
    $CustomerOrganization = Get-MgOrganization
 
    $CustomerInitialDomain = $CustomerOrganization.VerifiedDomains | Where-Object { $_.IsInitial -eq $true }
 
    # Create Jysk IT Administrator
    try {
        $Users = Get-MgUser -All
        if ($Users.UserPrincipalName -notcontains "jyskit-adm@$($CustomerInitialDomain.Name)") {
            Write-Host "Jysk IT Administrator does not exist, creating.."
            $PasswordProfile = @{
                Password = (Get-RandomPassword -PasswordLength 16)
                ForceChangePasswordNextSignIn = $false
                ForceChangePasswordNextSignInWithMfa = $false
            }
            $AdminUser = New-MgUser -DisplayName "Jysk IT Administrator" -PasswordProfile $PasswordProfile -AccountEnabled -MailNickname "jyskit-adm" -UserPrincipalName "jyskit-adm@$($CustomerInitialDomain.Name)" -ErrorAction Stop
            Write-ModuleLog -Message "Created Jysk IT Administrator: $($AdminUser.UserPrincipalName) with password $($PasswordProfile.Password)" -Level Info -Component 'AdminUser'
        }
        else {
            Write-ModuleLog -Message "Jysk IT Administrator already exists" -Level Info -Component 'AdminUser'
            $AdminUser = $Users | Where-Object { $_.UserPrincipalName -eq "jyskit-adm@$($CustomerInitialDomain.Name)" }
        }
     
    }
    catch {
        Write-ModuleLog -Message "Failed to create Jysk IT Administrator: $_" -Level Error -Component 'AdminUser'
    }
     
    # Assign to Global Administrator group
    try {
        $Role = Get-MgDirectoryRole | Where-Object { $_.DisplayName -eq "Global Administrator" }
        $GlobalAdmins = Get-MgDirectoryRoleMemberAsUser -DirectoryRoleId $Role.Id -ErrorAction Stop
        if ($GlobalAdmins.Id -notcontains $AdminUser.Id) {
            New-MgDirectoryRoleMemberByRef -DirectoryRoleId $Role.Id -BodyParameter @{"@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($AdminUser.Id)" } -ErrorAction Stop
            Write-ModuleLog -Message "Assigned Jysk IT Administrator to Global Administrator group" -Level Info -Component 'AdminUser'
        }
        else {
            Write-ModuleLog -Message "Jysk IT Administrator is already a member of Global Administrator group" -Level Info -Component 'AdminUser'
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to assign Jysk IT Administrator to Global Administrator group: $_" -Level Error -Component 'AdminUser'
    }
}

================
File: Public/Connect/Connect-CustomerExchange.ps1
================
function Connect-CustomerExchange {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$CustomerTenantId,
 
        [Parameter()]
        [ValidateSet('Application', 'Delegated')]
        [string]$FlowType = 'Application',
 
        [Parameter()]
        [string[]]$Scopes = @('https://outlook.office365.com/.default'),
 
        [Parameter()]
        [switch]$Force
    )
 
    try {
        # Get token with optional cache bypass
        Write-ModuleLog -Message "Getting Exchange Online token for tenant $CustomerTenantId using $($FlowType) flow" -Level Verbose -Component 'ExchangeConnection'
        if ($Force) {
            Write-ModuleLog -Message "Force parameter specified - bypassing token cache" -Level Verbose -Component 'ExchangeConnection'
        }
 
        $token = Get-PartnerAccessToken `
            -TenantId $CustomerTenantId `
            -Scopes ($Scopes -join ' ') `
            -FlowType $FlowType `
            -Force:$Force
 
        # Connect to Graph
        Write-ModuleLog -Message "Connecting to Exchange Online" -Level Verbose -Component 'ExchangeConnection'
        Connect-ExchangeOnline -AccessToken $token.access_token -ShowBanner:$false -DelegatedOrganization $CustomerTenantId
    }
    catch {
        if ($_.Exception.Message -like '*The role assigned to application*') {
            Write-ModuleLog -Message "Failed to connect to Exchange Online for tenant $CustomerTenantId. The application does not have the required roles. Re-creating consent!" -Level Warning -Component 'ExchangeConnection'
            Set-ApplicationConsent -CustomerTenantId $CustomerTenantId -Force
            Write-ModuleLog -Message "Waiting for 30 seconds before retrying connection after consent has been granted.." -Level Info -Component 'ExchangeConnection'
            Start-Sleep -Seconds 30
            Connect-CustomerExchange -CustomerTenantId $CustomerTenantId -Force
            Write-ModuleLog -Message "Successfully connected to Exchange Online for tenant $CustomerTenantId" -Level Info -Component 'ExchangeConnection'
        }
        else {
            Write-ModuleLog -Message "Failed to connect to Exchange Online for tenant $CustomerTenantId" -Level Error -Component 'ExchangeConnection' -ErrorRecord $_
        }
    }
}

================
File: Public/Connect/Connect-CustomerGraph.ps1
================
function Connect-CustomerGraph {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$CustomerTenantId,
         
        [Parameter()]
        [ValidateSet('Application', 'Delegated')]
        [string]$FlowType = 'Application',
 
        [Parameter()]
        [string[]]$Scopes = @('https://graph.microsoft.com/.default'),
 
        [Parameter()]
        [switch]$Force
    )
 
    try {
        # Get token with optional cache bypass
        Write-ModuleLog -Message "Getting Graph token for tenant $CustomerTenantId using $FlowType flow" -Level Verbose -Component 'GraphConnection'
        if ($Force) {
            Write-ModuleLog -Message "Force parameter specified - bypassing token cache" -Level Verbose -Component 'GraphConnection'
        }
 
        $token = Get-PartnerAccessToken `
            -TenantId $CustomerTenantId `
            -Scopes ($Scopes -join ' ') `
            -FlowType $FlowType `
            -Force:$Force
 
        # Connect to Graph
        Write-ModuleLog -Message "Connecting to Graph API" -Level Verbose -Component 'GraphConnection'
        $secureToken = ConvertTo-SecureString -String $token.access_token -AsPlainText -Force
        Connect-MgGraph -AccessToken $secureToken -NoWelcome
    }
    catch {
        Write-ModuleLog -Message "Failed to connect to Microsoft Graph for tenant $CustomerTenantId" -Level Error -Component 'GraphConnection' -ErrorRecord $_
    }
}

================
File: Public/Curanet/Customers/Get-CuranetCustomer.ps1
================
function Get-CuranetCustomer() {
    Param(
        [Parameter()]
        [string]$CustomerName,
        [Parameter()]
        [string]$CustomerId,
        [Parameter(Mandatory)]
        [ValidateSet("3370", "3850")]
        [string]$Account
    )
 
    try {
        $customers = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/customers/v1/Customers?itemsPerPage=9999" -Method GET
    } catch {
        throw "Failed to retreive customers from Curanet $($Account) API: $_"
    }
 
    if($CustomerName) {
        $Customers = $Customers | Where-Object { $_.companyName -eq $CustomerName }
    } elseif ($CustomerId) {
        $Customers = $Customers | Where-Object { $_.ID -eq $CustomerId }
    }
    return $Customers
}

================
File: Public/Curanet/DNS/Get-CuranetDNSRecords.ps1
================
function Get-CuranetDNSRecords {
    <#
        .SYNOPSIS
            Command to fetch all DNS records for a given domain.
         
        .PARAMETER DomainName
            String containing domainname
 
        .OUTPUTS
            Object containing DNS records OR error
         
        .EXAMPLE
            $domain = "example.com"
            Get-CuranetDNSRecords -Domain $domain
 
            This example fetches the DNS records for "example.com".
 
        .EXAMPLE
            Get-CuranetDNSRecords -Domain "test.com"
 
            This example fetches the DNS records for "test.com".
    #>
 
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $DomainName
    )
 
    return Invoke-CuranetAPI -Uri "https://api.curanet.dk/dns/v2/Domains/$($DomainName)/Records" -Method "GET" -Account "3370"
}

================
File: Public/Curanet/DNS/New-CuranetDNSRecord.ps1
================
function New-CuranetDNSRecord {
    <#
        .SYNOPSIS
            Command to create a new DNS record
 
        .PARAMETER DomainName
            String containing domainname
 
        .PARAMETER Hostname (Optional, default empty)
            String containing hostname of the DNS record - Should not include the domainname
 
        .PARAMETER Type
            String containing the type of the DNS record.
         
        .PARAMETER TTL (Optional, default 3600)
            Integer containing the Time To Live value.
         
        .PARAMETER Priority (Optional, default 0)
            Integer containing the Priority value.
         
        .PARAMETER Value
            String containing the value of the record.
         
        .OUTPUTS
            Object containing the DNS record OR error.
         
        .EXAMPLE
            $domain = "example.com"
            $hostname = "www"
            $type = "A"
            $value = "11.22.33.44"
            New-CuranetDNSRecord -DomainName $domain -Hostname $hostname -Type $type -Value $value
 
            This example creates a new A record for "www.example.com".
 
        .EXAMPLE
            New-CuranetDNSRecord -DomainName "test.com" -Type "MX" -Value "mail.test.com"
 
            This example creates a new MX record for "test.com".
    #>
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $DomainName,
 
        [Parameter()]
        [string]
        $Hostname,
 
        [Parameter(Mandatory=$true)]
        [string]
        $Type,
 
        [Parameter()]
        [int]
        $TTL = 3600,
 
        [Parameter()]
        [int]
        $Priority = 0,
 
        [Parameter(Mandatory=$true)]
        [string]
        $Value
    )
 
    if( $Hostname -ne '') {
        $Name = "$($Hostname).$($DomainName)"
    }
 
    else {
        $Name = $DomainName
    }
 
    $Body = @{
        name = $Name
        type = $Type.ToUpper()
        ttl = $TTL
        priority = $Priority
        data = $Value
    }
 
    return Invoke-CuranetAPI "3370" -Uri "https://api.curanet.dk/dns/v2/Domains/$($DomainName)/Records" -Method "POST" -Body $Body
}

================
File: Public/Curanet/DNS/Remove-CuranetDNSRecord.ps1
================
function Remove-CuranetDNSRecord {
    <#
        .SYNOPSIS
            Command to remove a DNS record with a given ID
 
        .PARAMETER DomainName
            String containing domainname
 
        .PARAMETER ID
            Integer containing the ID of the record needed to be removed.
 
        .OUTPUTS
           $true on success, $false on error
    #>
 
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]
        $DomainName,
 
        [Parameter(Mandatory)]
        [int]
        $ID
    )
 
    $Existing = Get-CuranetDNSRecords -DomainName $DomainName | Where-Object id -eq $ID
 
    if ( $Existing ) {
 
        $result = Invoke-CuranetAPI 3370 -Uri "https://api.curanet.dk/dns/v2/Domains/$($DomainName)/Records/$($ID)" -Method "DELETE"
 
        if($result) {
            return $true
        }
 
        else {
            return $false
        }
    }
 
    return $false
}

================
File: Public/Curanet/DNS/Update-CuranetDNSRecord.ps1
================
function Update-CuranetDNSRecord {
    <#
        .SYNOPSIS
            Command to update an existing record.
         
        .PARAMETER DomainName
            String containing domainname
 
        .PARAMETER ID
            Integer containing the ID of the record needed to be changed.
         
        .PARAMETER Value
            String containing the new value
         
        .OUTPUTS
            $true on success, $false on error
    #>
 
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $DomainName,
 
        [Parameter(Mandatory=$true)]
        [int]
        $ID,
 
        [Parameter(Mandatory=$true)]
        [string]
        $Value
    )
 
    $Existing = Get-CuranetDNSRecords -DomainName $DomainName | Where-Object id -eq $ID
 
    if ( $Existing ) {
 
        $Body = @{
            name = $Existing.Name
            type = $Existing.Type
            ttl = $Existing.TTL
            priority = $Existing.Priority
            data = $Value
        }
     
        $result = Invoke-CuranetAPI "3370" -Uri "https://api.curanet.dk/dns/v2/Domains/$($DomainName)/Records/$($ID)" -Method "PUT" -Body $Body
 
        if($result) {
            return $true
        }
 
        else {
            return $false
        }
    }
 
    return $false
}

================
File: Public/Curanet/Invoicing/Get-CuranetInvoice.ps1
================
function Get-CuranetInvoice() {
    Param(
        [Parameter(Mandatory)]
        [ValidateSet("3370", "3850")]
        [string]$Account,
        [string]$InvoiceID
    )
 
    try {
        if($InvoiceID) {
            $Invoices = Invoke-CuranetAPi -Account $Account -Uri "https://api.curanet.dk/invoicing/v1/Invoices/$($InvoiceID)" -Method GET
        } else {
            $Invoices = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/invoicing/v1/Invoices" -Method GET
        }
 
    } catch {
        throw "Failed to retreive invoices from Curanet $($Account) API: $_"
    }
 
    return $Invoices
}

================
File: Public/Curanet/Invoke-CuranetAPI.ps1
================
function Invoke-CuranetAPI {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet("3370", "3850")]
        [string]$Account,
 
        [Parameter(Mandatory)]
        [string]$Uri,
 
        [Parameter()]
        [Microsoft.PowerShell.Commands.WebRequestMethod]
        $Method = 'Get',
 
        [Parameter()]
        $Body,
 
        [Parameter()]
        [switch]$Force
    )
 
    try {
        # Get access token
        $token = Get-CuranetAccessToken -Account $Account -Force:$Force
 
        $params = @{
            Uri = $Uri
            Method = $Method
            Headers = @{
                Authorization = $token.AuthorizationHeader
            }
            ContentType = 'application/json'
        }
 
        if ($Body) {
            $params.Body = ($Body | ConvertTo-Json -Depth 10)
        }
 
        $response = Invoke-RestMethod @params
 
        return $response
    }
    catch {
        if ($_.Exception.Response.StatusCode -eq 401) {
            # Token expired - retry once with force
            if (!$Force) {
                return Invoke-CuranetApi @PSBoundParameters -Force
            }
        }
        throw
    }
}

================
File: Public/Curanet/M365/Get-CuranetM365AzureBilling.ps1
================
function Get-CuranetM365AzureBilling() {
    Param(
        [Parameter(Mandatory)]
        [string]$SubscriptionId,
        [Parameter(Mandatory)]
        [ValidateSet("3370", "3850")]
        [string]$Account,
        [Parameter(Mandatory)]
        [DateTime]$StartDate,
        [Parameter(Mandatory)]
        [DateTime]$EndDate
    )
 
    $Body = @{
        startDate = $StartDate
        endDate = $EndDate
        costType = "Usage"
        timeFrame = "Custom"
    }
 
    try {
        $AzureBilling = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/microsoft365/v1/Subscriptions/$($SubscriptionId)/Azurebilling/Query" -Method POST -Body $Body
    } catch {
        throw "Failed to retreive Azure Billing from Curanet $($Account) API: $_"
    }
 
    return $AzureBilling
}

================
File: Public/Curanet/M365/Get-CuranetM365Licenses.ps1
================
function Get-CuranetM365Licenses() {
    Param(
        [Parameter(Mandatory)]
        [string]$SubscriptionId,
        [Parameter(Mandatory)]
        [ValidateSet("3370", "3850")]
        [string]$Account
    )
 
    try {
        $Licenses = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/microsoft365/v1/Subscriptions/$($SubscriptionId)/Licenses" -Method GET
    } catch {
        throw "Failed to retreive subscription details from Curanet $($Account) API: $_"
    }
 
    return $Licenses
}

================
File: Public/Curanet/M365/Get-CuranetM365OnboardingCredentials.ps1
================
function Get-CuranetM365OnboardingCredentials() {
    Param(
        [Parameter(Mandatory)]
        [string]$SubscriptionId,
        [Parameter(Mandatory)]
        [ValidateSet("3370", "3850")]
        [string]$Account
    )
 
    try {
        $OnboardingCredentials = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/microsoft365/v1/Subscriptions/$($SubscriptionId)/OnboardingCredentials" -Method GET
    } catch {
        throw "Failed to retreive Onboarding Credentials from Curanet $($Account) API: $_"
    }
 
    return $OnboardingCredentials
}

================
File: Public/Curanet/Subscriptions/Get-CuranetCustomerSubscriptions.ps1
================
function Get-CuranetCustomerSubscriptions() {
    Param(
        [Parameter()]
        [string]$CustomerId,
        [Parameter(Mandatory)]
        [ValidateSet("3370", "3850")]
        [string]$Account
    )
 
    if($CustomerId) {
        try {
            $Subscriptions = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/customers/v1/Customers/$($CustomerId)/Subscriptions" -Method GET
        } catch {
            throw "Failed to retreive subscriptions from Curanet $($Account) API: $_"
        }
    } else {
        try {
            $Subscriptions = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/customers/v1/Customers/Subscriptions" -Method GET
        } catch {
            throw "Failed to retreive subscriptions from Curanet $($Account) API: $_"
        }
    }
 
 
    return $Subscriptions
}

================
File: Public/Curanet/Subscriptions/Get-CuranetSubscriptionDetails.ps1
================
function Get-CuranetSubscriptionDetails() {
    Param(
        [Parameter(Mandatory)]
        [string]$SubscriptionId,
        [Parameter(Mandatory)]
        [ValidateSet("3370", "3850")]
        [string]$Account
    )
 
    try {
        $Subscription = Invoke-CuranetAPI -Account $Account -Uri "https://api.curanet.dk/subscriptions/v1/Subscriptions/$($SubscriptionId)" -Method GET
    } catch {
        throw "Failed to retreive subscription details from Curanet $($Account) API: $_"
    }
 
    return $Subscription
}

================
File: Public/Initialize-CustomerTenant.ps1
================
function Initialize-CustomerTenant {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$CustomerTenantId
    )
 
    try {
        # Step 1: Connect to partner tenant
        Write-ModuleLog -Message "Connecting to partner tenant..." -Level Info -Component 'CustomerInitialization'
        Connect-CustomerGraph -CustomerTenantId $script:Config.PartnerTenantId
 
        # Step 2: Get customer info and show instructions
        $customer = Get-PartnerCustomer -CustomerTenantId $CustomerTenantId
        if (!$customer) {
            Write-ModuleLog "Customer with tenant ID $CustomerTenantId not found" -Level Error -Component 'CustomerInitialization' -ThrowError
            return
        }
        Write-ModuleLog -Message "Setting up application consent for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization'
        Write-Host "`nInstructions:" -ForegroundColor Yellow
        Write-Host "1. Log in using the Curanet-provided admin credentials in a Microsoft Edge inPrivate window (admin@$($customer.DefaultDomainName))" -ForegroundColor Cyan
        Write-Host "2. You will be asked to change the password - change it to something random, it does not matter" -ForegroundColor Cyan
        Write-Host "3. Consent to their GDAP invitation from the Curanet control panel" -ForegroundColor Cyan
        Write-Host "4. When logged in and ready, continue here" -ForegroundColor Cyan
        Read-Host "`nPress Enter to continue"
 
 
        # Step 3: Create GDAP relationship
        Write-ModuleLog -Message "Creating GDAP relationship for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization'
        $relationship = New-GDAPRelationship -CustomerTenantId $CustomerTenantId
 
        # Step 4: Show invitation link
        $InvitationLink = "https://admin.microsoft.com/AdminPortal/Home#/partners/invitation/granularAdminRelationships/$($relationship.Id)"
        Write-ModuleLog -Message "GDAP relationship created." -Level Info -Component 'CustomerInitialization'
        Write-ModuleLog -Message "Invitation link: $($InvitationLink)" -Level Info -Component 'CustomerInitialization'
        Write-ModuleLog -Message "Please accept the invitation link to grant access to the customer" -Level Info -Component 'CustomerInitialization'
 
        # Step 5: Wait for approval
        if (Wait-GDAPApproval -RelationshipId $relationship.Id) {
            # Step 6: Set GDAP permissions
            Write-ModuleLog -Message "Setting up GDAP permissions for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization'
            Set-GDAPPermissions -RelationshipId $relationship.Id
 
            # Step 7: Set up application consent
            Write-ModuleLog -Message "Setting up application consent for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization'
            Set-ApplicationConsent -CustomerTenantId $CustomerTenantId -Force
 
            # Step 8: Connect to customer tenant and set up admin user
            Write-ModuleLog -Message "Setting up administrator account for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization'
            New-AdminUser -TenantId $CustomerTenantId
 
            # Step 9: Set up company branding
            Write-ModuleLog -Message "Setting up company branding for $($customer.companyProfile.companyName)" -Level Info -Component 'CustomerInitialization'
            Add-CompanyBranding -TenantId $CustomerTenantId
 
            Write-ModuleLog -Message "Tenant initialization completed successfully!" -Level Info -Component 'CustomerInitialization'
        }
    }
    catch {
        Write-ModuleLog -Message "Failed to initialize tenant: $_" -Level Error -Component 'CustomerInitialization'
    }
    finally {
        Write-ModuleLog -Message "Disconnecting from partner tenant..." -Level Info -Component 'CustomerInitialization'
        Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
    }
}

================
File: Public/PartnerCenter/Get-PartnerCustomer.ps1
================
function Get-PartnerCustomer {
    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$CustomerTenantId
    )
 
    try {
        Connect-CustomerGraph -CustomerTenantId $Script:config.PartnerTenantId -FlowType Delegated
        $customers = Get-MgContract -All
        if($CustomerTenantId) {
            return $customers | Where-Object { $_.CustomerId -eq $CustomerTenantId }
        } else {
            return $customers
        }
    }
    catch {
        throw [TokenOperationException]::new(
            'PartnerCenter',
            "Failed to get customer information: $($_.Exception.Message)",
            $_
        )
    }
}

================
File: Public/PartnerMenu/Start-PartnerMenu.ps1
================
function Start-PartnerMenu() {
    $Version = $MyInvocation.MyCommand.ScriptBlock.Module.Version
    Get-AutomationUpdates
 
    $MenuItems = [ordered]@{
        1 = 'Select an existing tenant.'
        2 = 'Initialize a new tenant.'
        4 = 'Exit.'
    }
 
    $SelectedOption = ($MenuItems | Out-ConsoleGridView -Title "Welcome to the Jysk IT Partner Menu v. $($Version)" -OutputMode Single).Name
 
    switch ($SelectedOption) {
        "1" {
            Start-PartnerMenuTenantSelection
        }
        "2" {
            Start-PartnerMenuTenantInitilization
        }
        "4" { exit }
        Default { exit }
    }
 
}

================
File: Public/PartnerMenu/Start-PartnerMenuTenantSelection.ps1
================
function Start-PartnerMenuTenantSelection() {
    [Alias("back")]
    param(
        [Parameter()]
        $Tenant
    )
 
    if (!$Tenant -and !$Global:Tenant) {
        Get-PartnerMenuHeader -SectionString "Preparing tenant selection..."
        # Connect to Partner graph
        Connect-CustomerGraph -CustomerTenantId $Script:config.PartnerTenantId -FlowType Delegated
        $Tenants = Get-MgContract -All
 
        $Tenants = $Tenants | Select-Object -Property DisplayName, DefaultDomainName, CustomerId, Id
        $Global:Tenant = $Tenants | Out-ConsoleGridView -Title "Select a tenant" -OutputMode Single
    }
 
    if(!$Global:Tenant) {
        Start-PartnerMenu
    }
     
    Clear-Host
 
    $MenuItems = [ordered]@{
        1 = 'Connect to Exchange Online environment.'
        2 = 'Connect to Microsoft Graph environment.'
        3 = 'Set mailbox language/region.'
        4 = 'Set calendar permissions.'
        5 = 'Disable Security Defaults.'
        6 = 'Create BitTitan app registration.'
        7 = 'Add a domain to the tenant.'
        8 = 'Add DKIM configuration to the tenant.'
        9 = 'Add DMARC configuration to the tenant.'
        10 = 'BitTitan Preperation.'
        11 = 'Add company branding'
        12 = 'Re-create SAM consent for tenant.'
        13 = 'Add baseline configuration'
        14 = 'Enable Audit Logging'
        q = 'Back'
    }
 
    $SelectedOption = ($MenuItems | Out-ConsoleGridView -Title "($($Global:Tenant.displayName)) - Tenant options" -OutputMode Single).Name
 
    switch ($SelectedOption) {
        "1" {
            try {
                Connect-CustomerExchange -CustomerTenantId $Global:Tenant.CustomerId
                Write-Host "Connected to Exchange Online environment." -ForegroundColor Green
                Write-Host "You can always type 'back' to return to the previous menu." -ForegroundColor Yellow
            }
            catch {
                Write-Error "Failed to connect to Exchange Online: $_"
                Read-Host "Press any key to continue.."
                Start-PartnerMenuTenantSelection -Tenant $Tenant
            }
        }
        "2" {
            try {
                Connect-CustomerGraph -CustomerTenantId $Global:Tenant.CustomerId
                Write-Host "Connected to Microsoft Graph environment." -ForegroundColor Green
                Write-Host "You can always type 'back' to return to the previous menu." -ForegroundColor Yellow
            }
            catch {
                Write-Error "Failed to connect to Microsoft Graph: $_"
                Read-Host "Press any key to continue.."
                Start-PartnerMenuTenantSelection -Tenant $Tenant
            }
        }
        "3" {
            try {
                Connect-CustomerExchange -CustomerTenantId $Global:Tenant.CustomerId
                $Mailboxes = Get-EXOMailbox -ResultSize Unlimited | Where-Object { $_.Name -notlike 'Discovery*' } | Out-ConsoleGridView -Title "Select the mailboxes to set language/region for" -OutputMode Multiple
                if (($LanguageRegion = Read-Host "Which language/region do you want to set? [da-dk]") -eq '') { $LanguageRegion = "da-dk" }
                foreach ($Mailbox in $Mailboxes) {
                    Write-Host "Setting language/region to $($LanguageRegion) for $($Mailbox.UserPrincipalName)..." -ForegroundColor Cyan
                    Set-MailboxRegionalConfiguration -Identity $Mailbox -Language $LanguageRegion -LocalizeDefaultFolderName
                }
                Write-Host "Completed setting language/region to $($LanguageRegion) for $($Mailboxes.Count) mailboxes." -ForegroundColor Green
            }
            catch {
                Write-Error "Failed to set language/region: $_"
            }
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Tenant
        }
        "4" {
            try {
                Connect-CustomerExchange -CustomerTenantId $Global:Tenant.CustomerId
                $Mailboxes = Get-EXOMailbox -ResultSize Unlimited | Where-Object { $_.Name -notlike 'Discovery*' } | Out-ConsoleGridView -Title "Select the mailboxes to set calendar permissions for" -OutputMode Multiple
                if (($CalendarPermission = Read-Host "Which calendar permission do you want to set? [Reviewer]") -eq '') { $LanguageRegion = "Reviewer" }
                foreach ($Mailbox in $Mailboxes) {
                    $CalendarFolder = Get-EXOMailboxFolderStatistics -Identity $Mailbox -Folderscope Calendar | Select-Object -First 1
                    Write-Host "Setting calendar permission to $($CalendarPermission) for $($Mailbox.UserPrincipalName)..." -ForegroundColor Cyan
                    Set-MailboxFolderPermission -Identity "$($Mailbox.UserPrincipalName):\$($CalendarFolder.Name)" -User Default -AccessRights $CalendarPermission
                }
                Write-Host "Completed setting calendar permission to $($CalendarPermission) for $($Mailboxes.Count) mailboxes." -ForegroundColor Green
 
            }
            catch {
                Write-Error "Failed to set calendar permissions: $_"
            }
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
 
        }
        "5" {
            try {
                Disable-SecurityDefaults -TenantId $Global:Tenant.CustomerId
            }
            catch {
                Write-Error "Failed to disable Security Defaults: $_"
            }
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
        }
        "6" {
            try {
                New-BitTitanAppRegistration -TenantId $Global:Tenant.CustomerId
            } catch {
                Write-Error "Failed to create BitTitan app registration: $_"
            }
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
        }
        "7" {
            try {
                Read-Host "Enter domain name to add to the tenant" -OutVariable DomainName
                Add-CustomerDomain -TenantId $Global:Tenant.CustomerId -DomainName $DomainName
            } catch {
                Write-Error "Failed to add domain to tenant: $_"
            }
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
        }
 
        "8" {
            try {
                Enable-CustomerDKIM -TenantId $Global:Tenant.CustomerId
            } catch {
                Write-Error "Failed to add DKIM configuration: $_"
            }
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
        }
 
        "9" {
            try {
                Enable-CustomerDMARC -TenantId $Global:Tenant.CustomerId
            } catch {
                Write-Error "Failed to add DMARC configuration: $_"
            }
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
        }
 
        "10" {
            try {
                Start-BitTitanPreperation -TenantId $Global:Tenant.CustomerId
            } catch {
                Write-Error "Failed to run BitTitan Preperation: $_"
            }
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
        }
 
        "11" {
            $SignInPageText = Read-Host "Enter the text to display on the sign-in page (Leave empty for defalut)"
            if ($SignInPageText -eq '') {
                Add-CompanyBranding -TenantId $Global:Tenant.CustomerId
            }
            else {
                Add-CompanyBranding -TenantId $Global:Tenant.CustomerId -SignInPageText $SignInPageText
            }
 
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
             
        }
 
        "12" {
            Set-ApplicationConsent -CustomerTenantId $Global:Tenant.CustomerId -Force
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
             
        }
 
        "13" {
            try {
                Add-BaselineConfiguration -TenantId $Global:Tenant.CustomerId
            } catch {
                Write-Error "Failed to add baseline configuration: $_"
            }
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
        }
 
        "14" {
            try {
                Enable-AuditLogging -TenantId $Global:Tenant.CustomerId
            } catch {
                Write-Error "Failed to enable Audit Logging: $_"
            }
            Read-Host "Press any key to continue.."
            Start-PartnerMenuTenantSelection -Tenant $Global:Tenant
        }
 
        "q" {
            $Global:Tenant = $null
            Start-PartnerMenu
        }
        Default {
            $Global:Tenant = $null
            Start-PartnerMenu
        }
    }
 
}

================
File: Public/Utilities/Publish-JyskITAutomation.ps1
================
function Publish-JyskITAutomation() {
    try {
        $PSGalleryKey = Get-PSGalleryKey
    } catch {
        Write-Error "Failed to retreive PSGallery key from Azure Key Vault: $_"
    }
     
 
    # Remove logs and cache folder
    Write-Host "Removing logs and cache folder" -ForegroundColor Yellow
    if(Test-Path (Join-Path $script:ModuleRoot "cache")) {
        Join-Path $script:ModuleRoot "cache" | Remove-Item -Recurse -Force
    }
    if(Test-Path (Join-Path $script:ModuleRoot "logs")) {
        Join-Path $script:ModuleRoot "logs" | Remove-Item -Recurse -Force
    }
 
    Write-Host "Please make sure you have updated the version number in the module manifest. (JyskIT.Automation.psd1)" -ForegroundColor Yellow
    $Response = Read-Host "Are you sure you want to publish the module to PSGallery? [Y/N]"
    if ($Response.ToLower() -ne 'y') { return }
    Publish-Module -Path ".\" -NuGetApiKey $PSGalleryKey
}