Public/Invoke-M365Offboarding.ps1

function Invoke-M365Offboarding {
    <#
    .SYNOPSIS
        Runs the complete leaver checklist for a user in one step.
    .DESCRIPTION
        Covers Entra, Exchange Online and OneDrive:
          1. Disable the account
          2. Revoke all sessions/tokens
          3. (optional) Set an auto-reply
          4. (optional) Convert the mailbox to a shared mailbox
          5. (optional) Delegate the mailbox to the manager (FullAccess)
          6. (optional) Grant the manager access to the user's OneDrive
          7. Remove from all groups (logged beforehand -> recoverable)
          8. (optional) Remove licenses

        Everything goes through ShouldProcess: with -WhatIf NOTHING happens (dry run). Only
        without -WhatIf are changes applied. Entra steps need Graph (Connect-TenantToolbox),
        mailbox steps need Exchange Online (Connect-ExchangeOnline), OneDrive needs
        SharePoint Online (Connect-SPOService). If a connection is missing, that step is
        cleanly skipped and reported - the rest continues.
    .PARAMETER User
        UPN or object id of the user to offboard.
    .PARAMETER Manager
        UPN of the manager for delegation/OneDrive. If omitted, the cmdlet tries to resolve
        the manager stored in Entra automatically.
    .PARAMETER ConvertToShared
        Convert the mailbox to a shared mailbox (usually no license needed afterwards).
    .PARAMETER AutoReplyMessage
        If set, enables auto-reply (internal and external) with this text.
    .PARAMETER GrantOneDriveToManager
        Grants the manager access to the user's OneDrive.
    .PARAMETER RemoveLicenses
        Remove all directly assigned licenses (runs last, after convert-to-shared).
    .EXAMPLE
        Invoke-M365Offboarding -User marta@contoso.ch -WhatIf
        Full dry run of the Entra core without changing anything.
    .EXAMPLE
        Invoke-M365Offboarding -User marta@contoso.ch -ConvertToShared -GrantOneDriveToManager `
            -AutoReplyMessage 'I am no longer with the company. Please contact info@contoso.ch.' `
            -RemoveLicenses
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('UserPrincipalName', 'Id')]
        [string]$User,

        [string]$Manager,

        [switch]$ConvertToShared,

        [string]$AutoReplyMessage,

        [switch]$GrantOneDriveToManager,

        [switch]$RemoveLicenses
    )

    begin { Assert-TTGraph }

    process {
        # --- Resolve user ------------------------------------------------------
        try {
            $mgUser = Get-MgUser -UserId $User -Property 'id,displayName,userPrincipalName,accountEnabled,assignedLicenses' -ErrorAction Stop
        }
        catch {
            Write-TTLog -Level ERROR -Message "User '$User' not found: $_"
            return
        }

        $upn    = $mgUser.UserPrincipalName
        $result = [ordered]@{
            User = $upn; Id = $mgUser.Id; Deactivated = $false; SessionsRevoked = $false
            AutoReply = $false; ConvertedToShared = $false; MailboxDelegated = $false
            OneDriveGranted = $false; GroupsRemoved = 0; LicenseRemoved = $false
            WhatIf = [bool]$WhatIfPreference
        }
        Write-TTLog -Level INFO -Message "Starting offboarding for '$upn' ($($mgUser.Id))."

        # --- Manager (if needed for delegation/OneDrive) ----------------------
        $needsManager = $ConvertToShared -or $GrantOneDriveToManager
        if (-not $Manager -and $needsManager) {
            try {
                $mgr = Get-MgUserManager -UserId $mgUser.Id -ErrorAction Stop
                $Manager = $mgr.AdditionalProperties['userPrincipalName']
                Write-TTLog -Level INFO -Message "[$upn] Manager from Entra: '$Manager'."
            }
            catch {
                Write-TTLog -Level WARN -Message "[$upn] No manager found in Entra - delegation/OneDrive skipped."
            }
        }

        # --- 1) Disable account -----------------------------------------------
        if ($PSCmdlet.ShouldProcess($upn, 'Disable account (accountEnabled = false)')) {
            Update-MgUser -UserId $mgUser.Id -AccountEnabled:$false -ErrorAction Stop
            Write-TTLog -Level ACTION -Message "[$upn] Account disabled."
            $result.Deactivated = $true
        }

        # --- 2) Revoke sessions / tokens --------------------------------------
        if ($PSCmdlet.ShouldProcess($upn, 'Revoke all sessions and refresh tokens')) {
            Revoke-MgUserSignInSession -UserId $mgUser.Id -ErrorAction Stop | Out-Null
            Write-TTLog -Level ACTION -Message "[$upn] Sessions/tokens revoked."
            $result.SessionsRevoked = $true
        }

        # --- 3) Auto-reply (Exchange) -----------------------------------------
        if ($AutoReplyMessage) {
            if ($PSCmdlet.ShouldProcess($upn, 'Enable auto-reply')) {
                try {
                    Assert-TTExchange
                    Set-MailboxAutoReplyConfiguration -Identity $upn -AutoReplyState Enabled `
                        -InternalMessage $AutoReplyMessage -ExternalMessage $AutoReplyMessage -ErrorAction Stop
                    Write-TTLog -Level ACTION -Message "[$upn] Auto-reply enabled."
                    $result.AutoReply = $true
                }
                catch { Write-TTLog -Level WARN -Message "[$upn] Auto-reply skipped: $_" }
            }
        }

        # --- 4) Mailbox -> shared (Exchange) ----------------------------------
        if ($ConvertToShared) {
            if ($PSCmdlet.ShouldProcess($upn, 'Convert mailbox to shared mailbox')) {
                try {
                    Assert-TTExchange
                    Set-Mailbox -Identity $upn -Type Shared -ErrorAction Stop
                    Write-TTLog -Level ACTION -Message "[$upn] Mailbox converted to shared mailbox."
                    $result.ConvertedToShared = $true
                }
                catch { Write-TTLog -Level WARN -Message "[$upn] Convert to shared skipped: $_" }
            }
        }

        # --- 5) Delegate mailbox to manager (Exchange) ------------------------
        if ($Manager -and ($ConvertToShared -or $GrantOneDriveToManager)) {
            if ($PSCmdlet.ShouldProcess($upn, "Delegate mailbox to '$Manager' (FullAccess)")) {
                try {
                    Assert-TTExchange
                    Add-MailboxPermission -Identity $upn -User $Manager -AccessRights FullAccess `
                        -InheritanceType All -AutoMapping:$true -ErrorAction Stop | Out-Null
                    Write-TTLog -Level ACTION -Message "[$upn] Mailbox delegated to '$Manager'."
                    $result.MailboxDelegated = $true
                }
                catch { Write-TTLog -Level WARN -Message "[$upn] Delegation skipped: $_" }
            }
        }

        # --- 6) Grant OneDrive access to manager (SharePoint) -----------------
        if ($GrantOneDriveToManager -and $Manager) {
            if ($PSCmdlet.ShouldProcess($upn, "Grant OneDrive access to '$Manager'")) {
                try {
                    $drive = Get-MgUserDefaultDrive -UserId $mgUser.Id -ErrorAction Stop
                    $siteUrl = ($drive.WebUrl -replace '/Documents/?$', '')
                    if (Get-Command -Name Set-SPOUser -ErrorAction SilentlyContinue) {
                        Set-SPOUser -Site $siteUrl -LoginName $Manager -IsSiteCollectionAdmin $true -ErrorAction Stop | Out-Null
                        Write-TTLog -Level ACTION -Message "[$upn] OneDrive access granted to '$Manager' ($siteUrl)."
                        $result.OneDriveGranted = $true
                    }
                    else {
                        Write-TTLog -Level WARN -Message "[$upn] SPO not connected - grant OneDrive manually: $siteUrl (admin: $Manager)."
                    }
                }
                catch { Write-TTLog -Level WARN -Message "[$upn] OneDrive grant skipped: $_" }
            }
        }

        # --- 7) Remove from all groups (with log) -----------------------------
        $groups = Get-MgUserMemberOf -UserId $mgUser.Id -All -ErrorAction SilentlyContinue |
            Where-Object { $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group' }

        foreach ($g in $groups) {
            $gName = $g.AdditionalProperties['displayName']
            if ($PSCmdlet.ShouldProcess($upn, "Remove from group '$gName'")) {
                try {
                    Remove-MgGroupMemberByRef -GroupId $g.Id -DirectoryObjectId $mgUser.Id -ErrorAction Stop
                    Write-TTLog -Level ACTION -Message "[$upn] Removed from group '$gName' ($($g.Id))."
                    $result.GroupsRemoved++
                }
                catch {
                    Write-TTLog -Level WARN -Message "[$upn] Group '$gName' skipped (possibly dynamic/synced): $_"
                }
            }
        }

        # --- 8) Remove licenses (optional, last) ------------------------------
        if ($RemoveLicenses -and $mgUser.AssignedLicenses.Count -gt 0) {
            $skus = @($mgUser.AssignedLicenses.SkuId)
            if ($PSCmdlet.ShouldProcess($upn, "Remove licenses ($($skus.Count))")) {
                Set-MgUserLicense -UserId $mgUser.Id -AddLicenses @() -RemoveLicenses $skus -ErrorAction Stop | Out-Null
                Write-TTLog -Level ACTION -Message "[$upn] $($skus.Count) license(s) removed."
                $result.LicenseRemoved = $true
            }
        }

        Write-TTLog -Level INFO -Message "[$upn] Offboarding complete."
        [pscustomobject]$result
    }
}