Graph.EasyPIM.psm1

# Common variables
## The scopes that we need. Identified these from the Graph API docs + while testing.
$requiredScopesArrayRoles = @("RoleEligibilitySchedule.Read.Directory","RoleEligibilitySchedule.ReadWrite.Directory",
                                "RoleManagement.Read.Directory","RoleManagement.Read.All","RoleManagement.ReadWrite.Directory",
                                "RoleAssignmentSchedule.ReadWrite.Directory","RoleAssignmentSchedule.Remove.Directory"
)

$requiredScopesArrayGroups = @("PrivilegedEligibilitySchedule.Read.AzureADGroup","PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup",
                                "PrivilegedAccess.Read.AzureADGroup","PrivilegedAccess.ReadWrite.AzureADGroup",
                                "RoleManagementPolicy.Read.AzureADGroup"
)

$requiredScopesArray = $requiredScopesArrayRoles + $requiredScopesArrayGroups

## The colors I will be using with Write-Host. Initially I was going to hardcode Yellow to match Write-Verbose, but then I thought what if the user has a different color scheme?
## Thanks https://www.pdq.com/blog/change-powershell-colors/ for showing me where to get these colors
$colorParams = @{}
if ($host.PrivateData.VerboseForegroundColor -ne "-1") {
    $colorParams.ForegroundColor = $host.PrivateData.VerboseForegroundColor
}

if ($host.PrivateData.VerboseBackgroundColor -ne "-1") {
    $colorParams.BackgroundColor = $host.PrivateData.VerboseBackgroundColor
}

## Various variables used below to cache info
$lastUpdatedGroups = @{}
$lastUpdatedRoles = @{}
$myEligibleRoles = @{}
$myEligibleGroups = @{}
$policyAssignmentHashRoles = @{}
$policyObjsHashRoles = @{}
$policyAssignmentHashGroupsOwner = @{}
$policyAssignmentHashGroupsMember = @{}

function Enable-PIMRole {
    param(
        [Parameter(Mandatory=$false)]
        [Alias("SkipReason")]
        [switch]$SkipJustification,

        [Parameter(Mandatory=$false)]
        [Alias("Reason")]
        [string]$Justification,

        [Parameter(Mandatory=$false)]
        [string]$TicketingSystem,

        [switch]$RefreshEligibleRoles,

        [switch]$UseDeviceCode,

        [Parameter(Mandatory=$false)]
        [string]$TenantId,

        [Parameter(Mandatory=$false)]
        [string]$ClientId
    )

    <#
    .DESCRIPTION
    Enable Entra ID PIM roles via an easy to use TUI (Text User Interface). Only supports enabling; not disabling. Use Disable-PIMRole to disable.

    If a role needs a reason/ justification you can either enter one, or press enter to go with a default, or type something and end with * to use it for all the activations.

    .PARAMETER SkipJustification
    Optional. If specified, it sets the reason/ justifaction for activation to be a default.

    .PARAMETER Justification
    Optional. If specified, it sets the reason/ justifaction for activation to whatever is input.

    .PARAMETER TicketingSystem
    Optional. If specified, it sets the tickting system (for role activations that need a ticket number) to be whatever is input.

    .PARAMETER RefreshEligibleRoles
    Optional. By default, eligible roles are only checked if it's been more than 30 mins since the last invocation. If you want to check before that, use this switch.

    .PARAMETER UseDeviceCode
    Optional. Use Device Code authentication.

    .PARAMETER TenantId
    Optional. Use this TenantId.

    .PARAMETER ClientId
    Optional. Use this Client Id.
    #>


    begin {
        Write-Host ""
        $colorParams = $script:colorParams

        [System.Version]$installedVersion = (Get-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version
        [System.Version]$availableVersion = (Find-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version

        if ($installedVersion -and $availableVersion -and ($installedVersion -lt $availableVersion)) {
            Write-Host @colorParams "🎉 A newer version of this module is available in PowerShell Gallery"
        }

        $graphParams = @{
            "Scopes" = $script:requiredScopesArray
            "NoWelcome" = $true
            "ErrorAction" = "Stop"
        }

        if ($PSBoundParameters.ContainsKey("UseDeviceCode")) { $graphParams.UseDeviceCode = $true }
        if ($PSBoundParameters.ContainsKey("TenantId")) { $graphParams.TenantId = $TenantId }
        if ($PSBoundParameters.ContainsKey("ClientId")) { $graphParams.ClientId = $ClientId }

        # Disconnect the existing sessions if one of these were provided
        if ($PSBoundParameters.ContainsKey("UseDeviceCode") -or $PSBoundParameters.ContainsKey("TenantId") -or $PSBoundParameters.ContainsKey("ClientId")) { 
            try {
                Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
            } catch {}
        }

        try {
            Connect-MgGraph @graphParams

        } catch {
            throw "$($_.Exception.Message)"
        }

        $context = Get-MgContext

        $scopes = $context.scopes

        if ($scopes -notcontains "Directory.ReadWrite.All") {
            foreach ($requiredScope in $script:requiredScopesArray) {
                if ($requiredScope -notin $scopes) {
                    Write-Warning "Required scope '$requiredScope' missing"
                }
            }
        }

        $userId = (Get-MgUser -UserId $context.Account).Id

        if ($RefreshEligibleRoles) {
            $needsUpdating = $true

        } else {
            # Only pull in the eligible roles if needed; else use the cached info
            $currentTime = (Get-Date).ToUniversalTime()
            $lastUpdatedRoles = $script:lastUpdatedRoles[$userId]

            if ($null -ne $lastUpdatedRoles) {
                $lastUpdatedTimespan = New-TimeSpan -Start $lastUpdatedRoles -End $currentTime
            
                if ($lastUpdatedTimespan.TotalMinutes -gt 30) {
                    $needsUpdating = $true
                
                } else {
                    $needsUpdating = $false
                    if ($lastUpdatedTimespan.TotalMinutes -eq 1) {
                        $minutes = "a minute"

                    } else {
                        $minutes = "$([int]$lastUpdatedTimespan.TotalMinutes) minutes"
                    }
                }
        
            } else {
                $needsUpdating = $true
            }
        }

        try {
            if ($needsUpdating) {
                Write-Host @colorParams "🥷 Fetching all eligible & active Entra ID roles. This could take a few minutes."

                Write-Progress -Activity "Fetching all eligible Entra ID roles" -Id 0
                [array]$myEligibleRoles = Get-MgRoleManagementDirectoryRoleEligibilitySchedule -ExpandProperty RoleDefinition -All -Filter "principalId eq '$userId'" -ErrorAction Stop
                [array]$script:myEligibleRoles[$userId] = $myEligibleRoles

            } else {
                Write-Host @colorParams "⏳ Not fetching eligible Entra ID roles & their settings as it has only been $minutes since we last checked."
                Write-Host @colorParams "🫵 You can re-run with the -RefreshEligibleRoles switch to force a refresh."
                [array]$myEligibleRoles = $script:myEligibleRoles[$userId]
            }

            Write-Progress -Activity "Fetching all active Entra ID roles" -Id 0
            [array]$myActiveRoles = Get-MgRoleManagementDirectoryRoleAssignmentSchedule -ExpandProperty RoleDefinition -All -Filter "principalId eq '$userId'" -ErrorAction Stop
            
        } catch {
            throw "Error fetching roles: $($_.Exception.Message)"
        }

        Write-Progress -Id 0 -Completed

        # Create a cache of assignments. This is faster as I can lookup a bunch of them beforehand.
        # All roles have the same policy (settings) assigned to them. And a user could have the same role assigned in more than one way - e.g. various admin units.
        $policyAssignmentHashRoles = @{}
        # I must set scopeId to '/' coz if I search for a specific scopeId it errors: Attempted to perform an unauthorized operation.
        $searchSnippetMain = "scopeType eq 'DirectoryRole' and scopeId eq '/' and ("
        $searchSnippetsArray = @()

        # Filter has a max length (not sure what) so I will do it in batches of 5.
        # A temp variable I keep incrementing
        $counter = 0
        # Total number of entries for this scope
        $totalCount = $myEligibleRoles.Count

        # Loop through the entries
        if ($needsUpdating) {
            Write-Host @colorParams "🚀 Fetching all role assignment settings. This could take a few minutes."

            foreach ($roleObj in $myEligibleRoles) {
                $counter++
                $roleDefinitionId = $roleObj.RoleDefinitionId

                # An array where I keep adding the snippets
                $searchSnippetsArray += "roleDefinitionId eq '$roleDefinitionId'"

                Write-Progress -Activity "Fetching..." -Id 0 -Status "${counter}/${totalCount}" -PercentComplete $($counter*100/$totalCount)
    
                # In batches of 5, or if the counter has reached the end...
                if ($counter % 5 -eq 0 -or $counter -ge $totalCount) {
                    # ... construct the search snippet
                    $searchSnippet = $searchSnippetMain + $($searchSnippetsArray -join ' or ') + ")"
    
                    # Do the search
                    try {
                        $policyAssignment = Get-MgPolicyRoleManagementPolicyAssignment -All -Filter $searchSnippet -ExpandProperty "policy(`$expand=rules)" -ErrorAction Stop
                    
                    } catch {
                        throw "Error fetching settings assignments: $($_.Exception.Message)"
                    }
                    
                    # And add it to the hash
                    foreach ($result in $policyAssignment) {
                        $policyAssignmentHashRoles[$($result.RoleDefinitionId)] = $result
                    }
    
                    # Initialize the array again
                    $searchSnippetsArray = @()
                }
            }
            
            Write-Progress -Id 0 -Completed

            # Fetching all the policies
            Write-Host @colorParams "🧙 Fetching all role settings."

            try {
                $policyObjsHashRoles = @{}

                Get-MgPolicyRoleManagementPolicy -All -Filter "scopeId eq '/' and scopeType eq 'DirectoryRole'" -ExpandProperty Rules -ErrorAction Stop | ForEach-Object {
                    $policyObjsHashRoles[$($_.Id)] = $_
                }

            } catch {
                throw "Error fetching all the settings: $($_.Exception.Message)"
            }

            $script:policyAssignmentHashRoles[$userId] = $policyAssignmentHashRoles
            $script:policyObjsHashRoles[$userId] = $policyObjsHashRoles

            $script:lastUpdatedRoles[$userId] = $currentTime  # Set the lastUpdated timestamp since we have successfully updated the cache

        } else {
            $policyAssignmentHashRoles = $script:policyAssignmentHashRoles[$userId]
            $policyObjsHashRoles = $script:policyObjsHashRoles[$userId]

        }
    }

    process {
        Write-Host ""
        $policyEnablementRulesCache = @{}
        $roleDefinitionsCache = @{}

        # Random 12 lower case characters
        # $defaultJustification = -join ((97..122) | Get-Random -Count 12 | ForEach-Object {[char]$_})
        $defaultJustification = "Activated using Graph.EasyPIM"

        if ($env:USERDOMAIN) { 
            $userDomain = "$($env:USERDOMAIN)\"
        }

        if ($env:USER) {
            $defaultJustification = $defaultJustification + " by ${userDomain}$($env:USER)"
        } elseif ($env:LOGNAME) {
            $defaultJustification = $defaultJustification + " by ${userDomain}$($env:LOGNAME)"
        }  elseif ($env:USERNAME) {
            $defaultJustification = $defaultJustification + " by ${userDomain}$($env:USERNAME)"
        }

        if ($env:ComputerName) { 
            $defaultJustification = $defaultJustification + " on $($env:ComputerName)"
        }

        # I use these for showing progress
        [int]$counter = 0
        [int]$totalCount = $myEligibleRoles.Count

        $roleStates = foreach ($roleObj in $myEligibleRoles) {
            $counter++
            $percentageComplete = ($counter/$totalCount)*100

            $roleDefinitionId = $roleObj.RoleDefinitionId
            $roleName = $roleObj.RoleDefinition.DisplayName
            $roleDirectoryScopeId = $roleObj.DirectoryScopeId

            $roleDefinitionsCache[$roleDefinitionId] = $roleName

            $timespanArray = @()
            $roleExpired = $false
            $roleAssignmentType = "Inactive"

            Write-Progress -Activity "Processing role '$roleName'" -Id 0 -PercentComplete $percentageComplete -Status "$counter/$totalCount"

            $activeRoleObj = $null
            $activeRoleObj = $myActiveRoles | Where-Object { $_.RoleDefinitionId -eq "$roleDefinitionId" -and $_.DirectoryScopeId -eq "$roleDirectoryScopeId" }

            if ($activeRoleObj) {
                Write-Progress -Activity "Role is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete
                Start-Sleep -Milliseconds 200   # a stupid hack coz Write-Progress doesn't display outside loops apparently! https://github.com/PowerShell/PowerShell/issues/5741
                Write-Progress -Activity "Role is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete
                
                # Double checking coz during my testing I ran into instances where this was sometimes incomplete
                if ($activeRoleObj.ScheduleInfo.Expiration.EndDateTime) {
                    # $roleAssignmentType = $activeRoleObj.AssignmentType
                    $roleAssignmentType = "Active"

                    $timeSpan = New-TimeSpan -Start (Get-Date).ToUniversalTime() -End $activeRoleObj.ScheduleInfo.Expiration.EndDateTime
                    if ($timeSpan.Days -gt 0) {
                        if ($timeSpan.Days -eq 1) {
                            $timespanArray += "$($timeSpan.Days) day"
    
                        } else {
                            $timespanArray += "$($timeSpan.Days) days"
                        }
                    }
    
                    if ($timeSpan.Hours -gt 0) {
                        if ($timeSpan.Hours -eq 1) {
                            $timespanArray += "$($timeSpan.Hours) hour"
    
                        } else {
                            $timespanArray += "$($timeSpan.Hours) hours"
                        }
                    }
    
                    if ($timeSpan.Minutes -gt 0) {
                        if ($timeSpan.Minutes -eq 1) {
                            $timespanArray += "$($timeSpan.Minutes) minute"
    
                        } else {
                            $timespanArray += "$($timeSpan.Minutes) minutes"
                        }
                    }
    
                    # Just in case there's a delay between getting the states and when I calculate this...
                    if ($timeSpan.Ticks -lt 0) { 
                        $roleExpired = $true 
                    }

                } else {
                    $roleExpired = $true 
                }

                Write-Progress -Id 1 -Completed

            } else {
                $roleExpired = $true
            }

            # Using the roledefinitionid, find the policy assignment on this role
            # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyassignment?view=graph-rest-1.0
            
            <#
            $roleDirectoryScopeId = $roleObj.DirectoryScopeId
            
            Write-Progress -Activity "Fetching policy assignment of role '$roleName'" -Id 2 -PercentComplete $percentageComplete -Status "$counter/$totalCount"
            try {
                $policyAssignment = Get-MgPolicyRoleManagementPolicyAssignment -All -Filter "scopeId eq '$roleDirectoryScopeId' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '$roleDefinitionId'" -ErrorAction Stop

            } catch {
                Write-Warning "Error fetching policy assignments for '$roleName': $($_.Exception.Message)"
                continue
            }
            #>

            # Skipping the above code as I now cache it before hand. This is faster than doing individual lookups.
            $policyAssignment = $policyAssignmentHashRoles[$roleDefinitionId]

            # From there find the policy :)
            # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicy?view=graph-rest-1.0
            $policyId = $policyAssignment.PolicyId

            # Look it up in the cached table; but in the off chance that it isn't there, look it up directly
            if ($policyObjsHashRoles.Keys -contains $policyId) {
                $policyObj = $policyObjsHashRoles[$policyId]

            } else {
                Write-Progress -Activity "Fetching settings '$(($policyId -split '_')[2])'" -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete
                Start-Sleep -Milliseconds 200   # a stupid hack coz Write-Progress doesn't display outside loops apparently! https://github.com/PowerShell/PowerShell/issues/5741
                Write-Progress -Activity "Fetching settings '$(($policyId -split '_')[2])'" -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete
    
                try {
                    $policyObj = Get-MgPolicyRoleManagementPolicy -UnifiedRoleManagementPolicyId $policyId -ExpandProperty Rules -ErrorAction Stop
                    
                    $policyObjsHashRoles[$policyId] = $policyObj # caching it for within this current execution
                    $script:policyObjsHashRoles[$userId][$policyId] = $policyObj  # caching it for future invocations of the module

                } catch {
                    Write-Warning "Error fetching settings id '$policyId': $($_.Exception.Message)"
                    continue
                }
            }

            # The policy is what defines the max duration of the role and other factors. We are interested in here are the rules
            # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyrule?view=graph-rest-1.0
            
            # The 'Expiration_EndUser_Assignment' rule in the policy is what defines the maximum duration
            # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyexpirationrule?view=graph-rest-1.0
            $expirationRule = ($policyObj.Rules | Where-Object { $_.Id -eq "Expiration_EndUser_Assignment" }).AdditionalProperties

            if ($expirationRule.maximumDuration -match "^PT") {
                # Thanks https://stackoverflow.com/a/57296616
                $timeSpan = [System.Xml.XmlConvert]::ToTimeSpan($expirationRule.maximumDuration)
                
                $maxDurationArray = @()

                if ($timeSpan.Days -gt 0) {
                    if ($timeSpan.Days -eq 1) {
                        $maxDurationArray += "$($timeSpan.Days) day"

                    } else {
                        $maxDurationArray += "$($timeSpan.Days) days"
                    }
                }

                if ($timeSpan.Hours -gt 0) {
                    if ($timeSpan.Hours -eq 1) {
                        $maxDurationArray += "$($timeSpan.Hours) hour"

                    } else {
                        $maxDurationArray += "$($timeSpan.Hours) hours"
                    }
                }

                if ($timeSpan.Minutes -gt 0) {
                    if ($timeSpan.Minutes -eq 1) {
                        $maxDurationArray += "$($timeSpan.Minutes) minute"

                    } else {
                        $maxDurationArray += "$($timeSpan.Minutes) minutes"
                    }
                }

                $maxDuration = $maxDurationArray -join ' '

            } else {
                $maxDuration = $expirationRule.maximumDuration
            }

            # Repeat, but for the enablement rules
            if ($policyEnablementRulesCache.Keys -contains $policyId) {
                $enablementRule = $policyEnablementRulesCache.$policyId

            } else {
                # The 'Expiration_EndUser_Assignment' rule in the policy is what defines the maximum duration
                # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyexpirationrule?view=graph-rest-1.0
                $enablementRule = ($policyObj.Rules | Where-Object { $_.Id -eq "Enablement_EndUser_Assignment" }).AdditionalProperties.enabledRules
                $policyEnablementRulesCache.$policyId = $enablementRule
            }

            # Thanks to https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/assign-roles-different-scopes
            if ($roleDirectoryScopeId -eq '/') {
                $roleScope = "Tenant"

            } elseif ($roleDirectoryScopeId -match "\/administrativeUnits\/") {
                $adminUnitId = $roleDirectoryScopeId -replace '\/administrativeUnits\/',''
                try {
                    $adminUnitName = (Get-MgDirectoryAdministrativeUnit -AdministrativeUnitId $adminUnitId -ErrorAction Stop).DisplayName

                } catch {
                    $adminUnitName = $adminUnitId
                }

                $roleScope = "$adminUnitName (Admin Unit)"

            } else {
                $appScope = $roleDirectoryScopeId -replace '\/',''
                $roleScope = "$appScope (App)"
            }

            Write-Progress -Completed -Id 1

            [pscustomobject][ordered]@{
                "RoleName" = $roleName
                "Status" = $roleAssignmentType
                "ExpiresIn" = if (!($roleExpired)) {
                    # Take only the topmost entry (day or hour in case of more than one)
                    if ($timespanArray.Count -gt 1) {
                        "~" + $timespanArray[0]
                    } else {
                        $timespanArray[0]
                    }
                } # Tweak the output to to save some space

                "MaxDuration" = $maxDuration
                "EnablementRules" = $enablementRule -join '|' -replace 'Justification','Reason' -replace 'Ticketing','Ticket' -replace 'MultiFactorAuthentication','MFA'
                "Scope" = $roleScope
                "More" = [pscustomobject]@{
                    "More" = [pscustomobject]@{
                        "RoleDefinitionId" = $roleObj.RoleDefinitionId
                        "DirectoryScopeId" = $roleDirectoryScopeId
                        "MaxDuration" = $expirationRule.maximumDuration
                        "EnablementRule" = $enablementRule
                        "ActiveMinutes" = if (!($roleExpired)) { (New-TimeSpan -End (Get-Date).ToUniversalTime() -Start $activeRoleObj.ScheduleInfo.StartDateTime).TotalMinutes }    
                    }
                } # Two levels to hide this and save some space
            }
        }

        Write-Progress -Completed -Id 0

        $userSelections = $roleStates | Out-ConsoleGridView -Title "List of active & eligible Entra ID PIM roles (count: $totalCount)"

        # Let's ask for the required info upfront
        $justificationsHash = @{}
        $ticketSystemHash = @{}
        $ticketNumberHash = @{}

        # I use this for tidying up some of the output later; find the longest entry in the selections
        $longestRoleLength = ($userSelections.RoleName | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length
        $longestScopeLength = ($userSelections.Scope | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length

        $rolesWereDisabled = $false
        foreach ($selection in $userSelections) {
            if ($selection.Status -ne "Inactive") {
                if ($selection.More.More.ActiveMinutes -le 5) {
                    Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope))
                    Write-Host "Cannot disable the role as it must be active for at least 5 minutes."
                    continue
                }

                Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope))
                Write-Host "Disabling role (so we can enable it again)"

                $params = @{
                    Action = "selfDeactivate"
                    PrincipalId = $userId
                    RoleDefinitionId = $selection.More.More.RoleDefinitionId
                    DirectoryScopeId = $selection.More.More.DirectoryScopeId
                }

                try {
                    $requestObj = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop
                
                    $rolesWereDisabled = $true
            
                } catch {
                    Write-Error "Error deactivating '$($selection.RoleName)': $($_.Exception.Message)"
                }
            }
        }

        if ($rolesWereDisabled) {
            $counter = 0
            $maxWaitSecs = 20
            while ($counter -lt $maxWaitSecs) {
                Write-Progress "Waiting $maxWaitSecs seconds before continuing" -PercentComplete $($counter*100/$maxWaitSecs) -Status " "
                Start-Sleep -Seconds 1
                $counter++
            }

            Write-Progress -Completed
            Write-Host ""
        }

        foreach ($selection in $userSelections) {
            # Skip activating active roles that have been active for less than 5 mins
            # Coz we wouldn't have been able to disable them above to reactivate
            if ($selection.Status -ne "Inactive" -and $selection.More.More.ActiveMinutes -le 5) { continue }

            if ($selection.More.More.EnablementRule -contains "Justification") {
                Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope))

                if ($SkipJustification) {
                    $justificationsHash[$($selection.RoleName)] = "$defaultJustification"
                    Write-Host "Reason will be set to: $defaultJustification"

                } elseif ($Justification.Length -ne 0) {
                    $justificationsHash[$($selection.RoleName)] = $Justification
                    Write-Host "Reason will be set to: $Justification"

                } else {
                    $justificationInput = Read-Host "Please provide a reason"
                
                    # If the justitication ends with an asterisk or is empty, use it for everything else that follows...
                    if ($justificationInput -match '\*$' -or $justificationInput.Length -eq 0) {
                        # First, remove the asterisk
                        $justificationInput = $justificationInput -replace '\*$',''

                        # Then check whether anything remains. This is to cater to situations where someone enters * or *** etc.
                        # If after removing the asterisk there's nothing, then set it to $defaultJustification for all. This is basically equivalent to -SkipJustification
                        if ($justificationInput.Length -eq 0) {
                            $justificationInput = "$defaultJustification"
                            $justificationsHash[$($selection.RoleName)] = $justificationInput
                        }
                        
                        # Set the justification for everything that follows to be this
                        $Justification = $justificationInput
                        $justificationsHash[$($selection.RoleName)] = $justificationInput

                    } else {
                        $justificationsHash[$($selection.RoleName)] = $justificationInput
                    }

                    Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope))
                    Write-Host "Reason will be set to: $justificationInput"
                }
            }

            if ($selection.More.More.EnablementRule -contains "Ticketing") {
                Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope))

                $ticketNumberHash[$($selection.RoleName)] = Read-Host "Please provide a ticket number"

                if ($TicketingSystem.Length -ne 0) {
                    Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope))
                    $ticketingSystemInput = Read-Host "Please provide the ticketing system name"

                    # If the justitication ends with an asterisk, use it for everything else that follows...
                    if ($ticketingSystemInput -match '\*$') {
                        $ticketingSystemInput = $ticketingSystemInput -replace '\*$',''
                        $TicketingSystem = $ticketingSystemInput
                    }

                    $ticketSystemHash[$($selection.RoleName)] = $ticketingSystemInput

                } else {
                    $ticketSystemHash[$($selection.RoleName)] = $TicketingSystem
                }
            }
        }

        if ($userSelections.Count -ne 0) {
            Write-Host ""
        }

        # An array to capture each of the items we action below
        $requestObjsArray = @()

        foreach ($selection in $userSelections) {
            # Skip activating active roles that have been active for less than 5 mins
            # Coz we wouldn't have been able to disable them above to reactivate
            if ($selection.Status -ne "Inactive" -and $selection.More.More.ActiveMinutes -le 5) { continue }

            Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope))
            Write-Host "Enabling for $($selection.MaxDuration)"

            $params = @{
                Action = "selfActivate"
                PrincipalId = $userId
                RoleDefinitionId = $selection.More.More.RoleDefinitionId
                DirectoryScopeId = $selection.More.More.DirectoryScopeId

                ScheduleInfo = @{
                    StartDateTime = Get-Date
                    Expiration = @{
                        Type = "AfterDuration"
                        Duration = $selection.More.More.MaxDuration
                    }
                }
            }

            if ($selection.More.More.EnablementRule -contains "Justification") {
                $params.Justification = $justificationsHash[$($selection.RoleName)]
            }

            if ($selection.More.More.EnablementRule -contains "Ticketing") {
                $params.TicketInfo = @{
                    TicketNumber = $ticketNumberHash[$($selection.RoleName)]
                    TicketSystem = $ticketSystemHash[$($selection.RoleName)]
                }
            }

            try {
                $requestObj = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop

                # Show the output to screen

                <#
                $requestObj | Select-Object -Property @{
                    "Name" = "Role";
                    "Expression" = { $roleDefinitionsCache[$($_.RoleDefinitionId)] }
                },Status
                #>

            
                # And add it to an array so we can loop over in the end
                $requestObjsArray += $requestObj
        
            } catch {
                Write-Error "Error activating '$($selection.RoleName)': $($_.Exception.Message)"
            }
        }

        if ($requestObjsArray.Count -ne 0) {
            Write-Host ""

            $counter = 0
            $maxWaitSecs = 20
            while ($counter -lt $maxWaitSecs) {
                Write-Progress "Waiting $maxWaitSecs seconds before showing the final status" -PercentComplete $($counter*100/$maxWaitSecs) -Status " "
                Start-Sleep -Seconds 1
                $counter++
            }

            Write-Progress -Completed
        }

        $counter = 0
        $totalCount = $requestObjsArray.Count

        $finalOutput = foreach ($requestObj in $requestObjsArray) {
            $counter++
            Write-Progress "Fetching status of role '$($roleDefinitionsCache[$($requestObj.RoleDefinitionId)])'" -PercentComplete $($counter*100/$totalCount) -Status "$counter/$totalCount" 
            
            Get-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -UnifiedRoleAssignmentScheduleRequestId $requestObj.Id | Select-Object -Property @{
                "Name" = "Role";
                "Expression" = { $roleDefinitionsCache[$($_.RoleDefinitionId)] }
            },Status 
        }

        $finalOutput | Format-Table
    }
}

# This is a copy paste of Enable-PIMRole with some bits removed...
# It's very simple compared to Enable-PIMRole
function Disable-PIMRole {
    param(
        [switch]$UseDeviceCode,

        [Parameter(Mandatory=$false)]
        [string]$TenantId,

        [Parameter(Mandatory=$false)]
        [string]$ClientId
    )

    <#
    .PARAMETER UseDeviceCode
    Optional. Use Device Code authentication.

    .PARAMETER TenantId
    Optional. Use this TenantId.

    .PARAMETER ClientId
    Optional. Use this Client Id.
    #>


    begin {
        Write-Host ""
        $colorParams = $script:colorParams
        
        [System.Version]$installedVersion = (Get-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version
        [System.Version]$availableVersion = (Find-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version

        if ($installedVersion -and $availableVersion -and ($installedVersion -lt $availableVersion)) {
            Write-Host @colorParams "🎉 A newer version of this module is available in PowerShell Gallery"
        }

        $graphParams = @{
            "Scopes" = $script:requiredScopesArray
            "NoWelcome" = $true
            "ErrorAction" = "Stop"
        }

        if ($PSBoundParameters.ContainsKey("UseDeviceCode")) { $graphParams.UseDeviceCode = $true }
        if ($PSBoundParameters.ContainsKey("TenantId")) { $graphParams.TenantId = $TenantId }
        if ($PSBoundParameters.ContainsKey("ClientId")) { $graphParams.ClientId = $ClientId }

        # Disconnect the existing sessions if one of these were provided
        if ($PSBoundParameters.ContainsKey("UseDeviceCode") -or $PSBoundParameters.ContainsKey("TenantId") -or $PSBoundParameters.ContainsKey("ClientId")) { 
            try {
                Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
            } catch {}
        }

        try {
            Connect-MgGraph @graphParams

        } catch {
            throw "$($_.Exception.Message)"
        }

        $context = Get-MgContext

        $scopes = $context.scopes

        if ($scopes -notcontains "Directory.ReadWrite.All") {
            foreach ($requiredScope in $script:requiredScopesArray) {
                if ($requiredScope -notin $scopes) {
                    Write-Warning "Required scope '$requiredScope' missing"
                }
            }
        }

        $userId = (Get-MgUser -UserId $context.Account).Id

        try {
            Write-Host @colorParams "🥷 Fetching all active Entra ID roles. This is usually pretty quick!"

            Write-Progress -Activity "Fetching all active Entra ID roles" -Id 0
            [array]$myActiveRoles = Get-MgRoleManagementDirectoryRoleAssignmentSchedule -ExpandProperty RoleDefinition -All -Filter "principalId eq '$userId'" -ErrorAction Stop
            
        } catch {
            throw "Error fetching roles: $($_.Exception.Message)"
        }

        Write-Progress -Id 0 -Completed
    }

    process {
        Write-Host ""

        $roleDefinitionsCache = @{}

        # I use these for showing progress
        [int]$counter = 0
        [int]$totalCount = $myActiveRoles.Count

        $roleStates = foreach ($roleObj in $myActiveRoles) {
            $counter++
            $percentageComplete = ($counter/$totalCount)*100

            $roleDefinitionId = $roleObj.RoleDefinitionId
            $roleName = $roleObj.RoleDefinition.DisplayName
            $roleDirectoryScopeId = $roleObj.DirectoryScopeId

            $roleDefinitionsCache[$roleDefinitionId] = $roleName

            $timespanArray = @()
            $roleExpired = $false
            $roleAssignmentType = "Inactive"

            Write-Progress -Activity "Processing role '$roleName'" -Id 0 -PercentComplete $percentageComplete -Status "$counter/$totalCount"

            Write-Progress -Activity "Calculating role durations" -ParentId 0 -Id 1 -Status "Waiting..."
            Start-Sleep -Milliseconds 200   # a stupid hack coz Write-Progress doesn't display outside loops apparently! https://github.com/PowerShell/PowerShell/issues/5741
            Write-Progress -Activity "Calculating role durations" -ParentId 0 -Id 1 -Status "Waiting..."

            $activeRoleObj = $myActiveRoles | Where-Object { $_.RoleDefinitionId -eq "$roleDefinitionId" }
                
            # Double checking coz during my testing I ran into instances where this was sometimes incomplete
            if ($activeRoleObj.ScheduleInfo.Expiration.EndDateTime) {
                # $roleAssignmentType = $activeRoleObj.AssignmentType
                $roleAssignmentType = "Active"

                $timeSpan = New-TimeSpan -Start (Get-Date).ToUniversalTime() -End $activeRoleObj.ScheduleInfo.Expiration.EndDateTime
                if ($timeSpan.Days -gt 0) {
                    if ($timeSpan.Days -eq 1) {
                        $timespanArray += "$($timeSpan.Days) day"

                    } else {
                        $timespanArray += "$($timeSpan.Days) days"
                    }
                }

                if ($timeSpan.Hours -gt 0) {
                    if ($timeSpan.Hours -eq 1) {
                        $timespanArray += "$($timeSpan.Hours) hour"

                    } else {
                        $timespanArray += "$($timeSpan.Hours) hours"
                    }
                }

                if ($timeSpan.Minutes -gt 0) {
                    if ($timeSpan.Minutes -eq 1) {
                        $timespanArray += "$($timeSpan.Minutes) minute"

                    } else {
                        $timespanArray += "$($timeSpan.Minutes) minutes"
                    }
                }

                # Just in case there's a delay between getting the states and when I calculate this...
                if ($timeSpan.Ticks -lt 0) { 
                    $roleExpired = $true 
                }

            } else {
                $roleExpired = $true 
            }

            # Thanks to https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/assign-roles-different-scopes
            if ($roleDirectoryScopeId -eq '/') {
                $roleScope = "Tenant"

            } elseif ($roleDirectoryScopeId -match "\/administrativeUnits\/") {
                $adminUnitId = $roleDirectoryScopeId -replace '\/administrativeUnits\/',''
                try {
                    $adminUnitName = (Get-MgDirectoryAdministrativeUnit -AdministrativeUnitId $adminUnitId -ErrorAction Stop).DisplayName

                } catch {
                    $adminUnitName = $adminUnitId
                }

                $roleScope = "$adminUnitName (Admin Unit)"

            } else {
                $appScope = $roleDirectoryScopeId -replace '\/',''
                $roleScope = "$appScope (App)"
            }

            Write-Progress -Id 1 -Completed

            [pscustomobject][ordered]@{
                "RoleName" = $roleName
                "Status" = $roleAssignmentType
                "ExpiresIn" = if (!($roleExpired)) {
                    # Take only the topmost entry (day or hour in case of more than one)
                    if ($timespanArray.Count -gt 1) {
                        "~" + $timespanArray[0]
                    } else {
                        $timespanArray[0]
                    }
                } # Tweak the output to to save some space

                "Scope" = $roleScope
                "More" = [pscustomobject]@{
                    "More" = [pscustomobject]@{
                        "RoleDefinitionId" = $roleObj.RoleDefinitionId
                        "DirectoryScopeId" = $roleObj.DirectoryScopeId
                        "ActiveMinutes" = (New-TimeSpan -End (Get-Date).ToUniversalTime() -Start $activeRoleObj.ScheduleInfo.StartDateTime).TotalMinutes
                    }
                    }  # Two levels to hide this and save some space
            }
        }

        Write-Progress -Id 0 -Completed

        if ($roleStates.Count -eq 0) {
            Write-Host @colorParams ("🚀 No active Entra ID roles found.")
            Write-Host ""
        }

        $userSelections = $roleStates | Out-ConsoleGridView -Title "List of active Entra ID PIM roles"

        # I use this for tidying up some of the output later; find the longest entry in the selections
        $longestRoleLength = ($userSelections.RoleName | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length
        $longestScopeLength = ($userSelections.Scope | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length

        # An array to capture each of the items we action below
        $requestObjsArray = @()

        foreach ($selection in $userSelections) {
            if ($selection.More.More.ActiveMinutes -le 5) {
                Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope))
                Write-Host "Cannot disable the role as it must be active for at least 5 minutes."
                continue
            }

            Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} [{1,-$longestScopeLength}] " -f $($selection.RoleName), $($selection.Scope))
            Write-Host "Disabling role"

            $params = @{
                Action = "selfDeactivate"
                PrincipalId = $userId
                RoleDefinitionId = $selection.More.More.RoleDefinitionId
                DirectoryScopeId = $selection.More.More.DirectoryScopeId
            }

            try {
                $requestObj = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop
            
                # And add it to an array so we can loop over in the end
                $requestObjsArray += $requestObj
        
            } catch {
                Write-Error "Error deactivating '$($selection.RoleName)': $($_.Exception.Message)"
            }
        }

        if ($requestObjsArray.Count -ne 0) {
            Write-Host ""

            $counter = 0
            $maxWaitSecs = 20
            while ($counter -lt $maxWaitSecs) {
                Write-Progress "Waiting $maxWaitSecs seconds before showing the final status" -PercentComplete $($counter*100/$maxWaitSecs) -Status " "
                Start-Sleep -Seconds 1
                $counter++
            }

            Write-Progress -Completed
        }

        $counter = 0
        $totalCount = $requestObjsArray.Count

        $finalOutput = foreach ($requestObj in $requestObjsArray) {
            $counter++
            Write-Progress "Fetching status of role '$($roleDefinitionsCache[$($requestObj.RoleDefinitionId)])'" -PercentComplete $($counter*100/$totalCount) -Status "$counter/$totalCount" 
            
            Get-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -UnifiedRoleAssignmentScheduleRequestId $requestObj.Id | Select-Object -Property @{
                "Name" = "Role";
                "Expression" = { $roleDefinitionsCache[$($_.RoleDefinitionId)] }
            },Status 
        }

        $finalOutput | Format-Table
    }
}

function Enable-PIMGroup {
    param(
        [Parameter(Mandatory=$false)]
        [Alias("SkipReason")]
        [switch]$SkipJustification,

        [Parameter(Mandatory=$false)]
        [Alias("Reason")]
        [string]$Justification,

        [Parameter(Mandatory=$false)]
        [string]$TicketingSystem,

        [switch]$RefreshEligibleGroups,

        [switch]$UseDeviceCode,

        [Parameter(Mandatory=$false)]
        [string]$TenantId,

        [Parameter(Mandatory=$false)]
        [string]$ClientId
    )

    <#
    .DESCRIPTION
    Enable Entra ID PIM groups via an easy to use TUI (Text User Interface). Only supports enabling; not disabling. Use Disable-PIMGroup to disable.

    If a group needs a reason/ justification you can either enter one, or press enter to go with a default, or type something and end with * to use it for all the activations.

    .PARAMETER SkipJustification
    Optional. If specified, it sets the reason/ justifaction for activation to be a default.

    .PARAMETER Justification
    Optional. If specified, it sets the reason/ justifaction for activation to whatever is input.

    .PARAMETER TicketingSystem
    Optional. If specified, it sets the tickting system (for group activations that need a ticket number) to be whatever is input.

    .PARAMETER RefreshEligibleGroups
    Optional. By default, eligible groups are only checked if it's been more than 30 mins since the last invocation. If you want to check before that, use this switch.
    #>


    begin {
        Write-Host ""
        $colorParams = $script:colorParams

        [System.Version]$installedVersion = (Get-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version
        [System.Version]$availableVersion = (Find-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version

        if ($installedVersion -and $availableVersion -and ($installedVersion -lt $availableVersion)) {
            Write-Host @colorParams "🎉 A newer version of this module is available in PowerShell Gallery"
        }

        $graphParams = @{
            "Scopes" = $script:requiredScopesArray
            "NoWelcome" = $true
            "ErrorAction" = "Stop"
        }

        if ($PSBoundParameters.ContainsKey("UseDeviceCode")) { $graphParams.UseDeviceCode = $true }
        if ($PSBoundParameters.ContainsKey("TenantId")) { $graphParams.TenantId = $TenantId }
        if ($PSBoundParameters.ContainsKey("ClientId")) { $graphParams.ClientId = $ClientId }

        # Disconnect the existing sessions if one of these were provided
        if ($PSBoundParameters.ContainsKey("UseDeviceCode") -or $PSBoundParameters.ContainsKey("TenantId") -or $PSBoundParameters.ContainsKey("ClientId")) { 
            try {
                Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
            } catch {}
        }

        try {
            Connect-MgGraph @graphParams

        } catch {
            throw "$($_.Exception.Message)"
        }

        $context = Get-MgContext

        $scopes = $context.scopes

        if ($scopes -notcontains "Directory.ReadWrite.All") {
            foreach ($requiredScope in $script:requiredScopesArray) {
                if ($requiredScope -notin $scopes) {
                    Write-Warning "Required scope '$requiredScope' missing"
                }
            }
        }

        $userId = (Get-MgUser -UserId $context.Account).Id

        if ($RefreshEligibleGroups) {
            $needsUpdating = $true

        } else {
            # Only pull in the eligible Groups if needed; else use the cached info
            $currentTime = (Get-Date).ToUniversalTime()
            $lastUpdatedGroups = $script:lastUpdatedGroups[$userId]

            if ($null -ne $lastUpdatedGroups) {
                $lastUpdatedTimespan = New-TimeSpan -Start $lastUpdatedGroups -End $currentTime
            
                if ($lastUpdatedTimespan.TotalHours -gt 8) {
                    $needsUpdating = $true
                
                } else {
                    $needsUpdating = $false
                    if ($lastUpdatedTimespan.TotalHours -eq 1) {
                        $minutes = "an hour"

                    } elseif ($lastUpdatedTimespan.TotalHours -eq 0) {
                        if ($lastUpdatedTimespan.TotalMinutes -eq 1) {
                            $minutes = "a minute"
    
                        } else {
                            $minutes = "$([int]$lastUpdatedTimespan.TotalMinutes) minutes"
                        }
                    } 
                    else {
                        $minutes = "$([int]$lastUpdatedTimespan.TotalHours) hours"
                    }
                }
        
            } else {
                $needsUpdating = $true
            }
        }

        try {
            if ($needsUpdating) {
                Write-Host @colorParams "🥷 Fetching all eligible & active Entra ID groups. This might take a few minutes."

                Write-Progress -Activity "Fetching all eligible Entra ID groups" -Id 0
                [array]$myEligibleGroups = Get-MgIdentityGovernancePrivilegedAccessGroupEligibilitySchedule -All -Filter "principalId eq '$userId'" -ExpandProperty Group -ErrorAction Stop
                [array]$script:myEligibleGroups[$userId] = $myEligibleGroups

            } else {
                Write-Host @colorParams "⏳ Not fetching eligible Entra ID groups & their settings as it has only been $minutes since we last checked."
                Write-Host @colorParams "🫵 You can re-run with the -RefreshEligibleGroups switch to force a refresh."
                [array]$myEligibleGroups = $script:myEligibleGroups[$userId]
            }

            Write-Progress -Activity "Fetching all active Entra ID groups" -Id 0
            [array]$myActiveGroups = Get-MgIdentityGovernancePrivilegedAccessGroupAssignmentSchedule -All -Filter "principalId eq '$userId'" -ExpandProperty Group -ErrorAction Stop
            
        } catch {
            throw "Error fetching groups: $($_.Exception.Message)"
        }

        Write-Progress -Id 0 -Completed

        # Create a cache of assignments. This is faster as I can lookup a bunch of them beforehand.
        $policyAssignmentHashGroupsOwner = @{}
        $policyAssignmentHashGroupsMember = @{}
        # The scopeId is the groupId, so I add this on later
        $searchSnippetMain = "scopeType eq 'Group' and "
        
        # Filter has a max length (not sure what) so I will do it in batches of 5.
        # A temp variable I keep incrementing
        $counter = 0
        # Total number of entries for this scope
        $totalCount = $myEligibleGroups.Count

        # Loop through the entries
        # Below doesn't work... loop through each group & do accessId member and owner
        if ($needsUpdating) {
            Write-Host @colorParams "🧙 Fetching all group settings. This will take a few minutes."

            foreach ($groupRoleObj in $myEligibleGroups) {
                $counter++
                $groupId = $groupRoleObj.GroupId

                $searchSnippet = $searchSnippetMain + "scopeId eq '$groupId'"
    
                Write-Progress -Activity "$($groupRoleObj.Group.DisplayName)" -Id 0 -Status "${counter}/${totalCount}" -PercentComplete $($counter*100/$totalCount)
                
                # Do the search
                try {
                    $policyAssignment = Get-MgPolicyRoleManagementPolicyAssignment -All -Filter $searchSnippet -ExpandProperty "policy(`$expand=rules)" -ErrorAction Stop
                
                } catch {
                    throw "Error fetching settings assignments: $($_.Exception.Message)"
                }
                
                # And add it to the hash. There are two results - member and owner
                foreach ($result in $policyAssignment) {
                    if ($result.RoleDefinitionId -eq "member") {
                        $policyAssignmentHashGroupsMember[$groupId] = $result

                    } elseif ($result.RoleDefinitionId -eq "owner") {
                        $policyAssignmentHashGroupsOwner[$groupId] = $result

                    }
                }
            }

            $script:policyAssignmentHashGroupsOwner[$userId] = $policyAssignmentHashGroupsOwner
            $script:policyAssignmentHashGroupsMember[$userId] = $policyAssignmentHashGroupsMember

            $script:lastUpdatedGroups[$userId] = $currentTime  # Set the lastUpdated timestamp since we have successfully updated the cache

        } else {
            $policyAssignmentHashGroupsOwner = $script:policyAssignmentHashGroupsOwner[$userId]
            $policyAssignmentHashGroupsMember = $script:policyAssignmentHashGroupsMember[$userId]

        }

        Write-Progress -Id 0 -Completed
    }

    process {
        Write-Host ""

        # Random 12 lower case characters
        # $defaultJustification = -join ((97..122) | Get-Random -Count 12 | ForEach-Object {[char]$_})
        $defaultJustification = "Activated using Graph.EasyPIM"

        if ($env:USERDOMAIN) { 
            $userDomain = "$($env:USERDOMAIN)\"
        }

        if ($env:USER) {
            $defaultJustification = $defaultJustification + " by ${userDomain}$($env:USER)"
        } elseif ($env:LOGNAME) {
            $defaultJustification = $defaultJustification + " by ${userDomain}$($env:LOGNAME)"
        }  elseif ($env:USERNAME) {
            $defaultJustification = $defaultJustification + " by ${userDomain}$($env:USERNAME)"
        }

        if ($env:ComputerName) { 
            $defaultJustification = $defaultJustification + " on $($env:ComputerName)"
        }

        # I use these for showing progress
        [int]$counter = 0
        [int]$totalCount = $myEligibleGroups.Count
        $groupNamesCache = @{}

        $groupStates = foreach ($groupRoleObj in $myEligibleGroups) {
            $counter++
            $percentageComplete = ($counter/$totalCount)*100

            $groupId = $groupRoleObj.GroupId
            $groupName = $groupRoleObj.Group.DisplayName
            $groupNamesCache[$groupId] = $groupName

            $accessId = $groupRoleObj.AccessId

            $timespanArray = @()
            $groupRoleExpired = $false
            $groupRoleAssignmentType = "Inactive"

            Write-Progress -Activity "Processing group '$groupName'" -Id 0 -PercentComplete $percentageComplete -Status "$counter/$totalCount"

            $activeGroupRoleObj = $null
            $activeGroupRoleObj = $myActiveGroups | Where-Object { $_.GroupId -eq "$groupId" -and $_.AccessId -eq "$accessId" }

            if ($activeGroupRoleObj) {
                Write-Progress -Activity "Group is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete
                Start-Sleep -Milliseconds 200   # a stupid hack coz Write-Progress doesn't display outside loops apparently! https://github.com/PowerShell/PowerShell/issues/5741
                Write-Progress -Activity "o is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete
                
                # Double checking coz during my testing I ran into instances where this was sometimes incomplete
                if ($activeGroupRoleObj.ScheduleInfo.Expiration.EndDateTime) {
                    $groupRoleAssignmentType = "Active"

                    $timeSpan = New-TimeSpan -Start (Get-Date).ToUniversalTime() -End $activeGroupRoleObj.ScheduleInfo.Expiration.EndDateTime
                    if ($timeSpan.Days -gt 0) {
                        if ($timeSpan.Days -eq 1) {
                            $timespanArray += "$($timeSpan.Days) day"
    
                        } else {
                            $timespanArray += "$($timeSpan.Days) days"
                        }
                    }
    
                    if ($timeSpan.Hours -gt 0) {
                        if ($timeSpan.Hours -eq 1) {
                            $timespanArray += "$($timeSpan.Hours) hour"
    
                        } else {
                            $timespanArray += "$($timeSpan.Hours) hours"
                        }
                    }
    
                    if ($timeSpan.Minutes -gt 0) {
                        if ($timeSpan.Minutes -eq 1) {
                            $timespanArray += "$($timeSpan.Minutes) minute"
    
                        } else {
                            $timespanArray += "$($timeSpan.Minutes) minutes"
                        }
                    }
    
                    # Just in case there's a delay between getting the states and when I calculate this...
                    if ($timeSpan.Ticks -lt 0) { 
                        $groupRoleExpired = $true 
                    }

                } else {
                    $groupRoleExpired = $true 
                }

                Write-Progress -Id 1 -Completed

            } else {
                $groupRoleExpired = $true
            }

            # Using the roledefinitionid, find the policy assignment on this role
            # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyassignment?view=graph-rest-1.0
            
            $policyAssignment = if ($accessId -eq "member") { $policyAssignmentHashGroupsMember[$groupId] } else { $policyAssignmentHashGroupsOwner[$groupId] }
            $policyObj = $policyAssignment.Policy

            # The policy is what defines the max duration of the role and other factors. We are interested in here are the rules
            # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyrule?view=graph-rest-1.0
            
            # The 'Expiration_EndUser_Assignment' rule in the policy is what defines the maximum duration
            # https://learn.microsoft.com/en-us/graph/api/resources/unifiedrolemanagementpolicyexpirationrule?view=graph-rest-1.0
            $expirationRule = ($policyObj.Rules | Where-Object { $_.Id -eq "Expiration_EndUser_Assignment" }).AdditionalProperties

            if ($expirationRule.maximumDuration -match "^PT") {
                # Thanks https://stackoverflow.com/a/57296616
                $timeSpan = [System.Xml.XmlConvert]::ToTimeSpan($expirationRule.maximumDuration)
                
                $maxDurationArray = @()

                if ($timeSpan.Days -gt 0) {
                    if ($timeSpan.Days -eq 1) {
                        $maxDurationArray += "$($timeSpan.Days) day"

                    } else {
                        $maxDurationArray += "$($timeSpan.Days) days"
                    }
                }

                if ($timeSpan.Hours -gt 0) {
                    if ($timeSpan.Hours -eq 1) {
                        $maxDurationArray += "$($timeSpan.Hours) hour"

                    } else {
                        $maxDurationArray += "$($timeSpan.Hours) hours"
                    }
                }

                if ($timeSpan.Minutes -gt 0) {
                    if ($timeSpan.Minutes -eq 1) {
                        $maxDurationArray += "$($timeSpan.Minutes) minute"

                    } else {
                        $maxDurationArray += "$($timeSpan.Minutes) minutes"
                    }
                }

                $maxDuration = $maxDurationArray -join ' '

            } else {
                $maxDuration = $expirationRule.maximumDuration
            }

            # Repeat, but for the enablement rules
            $enablementRule = ($policyObj.Rules | Where-Object { $_.Id -eq "Enablement_EndUser_Assignment" }).AdditionalProperties.enabledRules

            Write-Progress -Completed -Id 1

            [pscustomobject][ordered]@{
                "GroupName" = $groupName
                "Status" = $groupRoleAssignmentType
                "Type" = if ($accessId -eq "member") { "Member" } else { "Owner" }
                "ExpiresIn" = if (!($groupRoleExpired)) {
                    # Take only the topmost entry (day or hour in case of more than one)
                    if ($timespanArray.Count -gt 1) {
                        "~" + $timespanArray[0]
                    } else {
                        $timespanArray[0]
                    }
                } # Tweak the output to to save some space

                "MaxDuration" = $maxDuration
                "EnablementRules" = $enablementRule -join '|' -replace 'Justification','Reason' -replace 'Ticketing','Ticket' -replace 'MultiFactorAuthentication','MFA'
                "More" = [pscustomobject]@{
                    "More" = [pscustomobject]@{
                        "AccessId" = $accessId
                        "GroupId" = $groupRoleObj.GroupId
                        "MaxDuration" = $expirationRule.maximumDuration
                        "EnablementRule" = $enablementRule
                        "ActiveMinutes" = if (!($groupRoleExpired)) { (New-TimeSpan -End (Get-Date).ToUniversalTime() -Start $activeGroupRoleObj.ScheduleInfo.StartDateTime).TotalMinutes }
                    }
                }  # Two levels to hide this and save some space
            }
        }

        Write-Progress -Completed -Id 0

        $userSelections = $groupStates | Out-ConsoleGridView -Title "List of active & eligible Entra ID PIM groups (count: $totalCount)"

        # Let's ask for the required info upfront
        $justificationsHash = @{}
        $ticketSystemHash = @{}
        $ticketNumberHash = @{}

        # I use this for tidying up some of the output later; find the longest entry in the selections
        $longestRoleLength = ($userSelections.GroupName | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length

        $groupsWereDisabled = $false
        foreach ($selection in $userSelections) {
            if ($selection.Status -ne "Inactive") {
                if ($selection.More.More.ActiveMinutes -le 5) {
                    Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} " -f $($selection.GroupName))
                    Write-Host "Cannot disable the group as it must be active for at least 5 minutes."
                    continue
                }

                Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} " -f $($selection.GroupName))
                Write-Host "Disabling group (so we can enable it again)"

                $params = @{
                    accessId = $selection.More.More.AccessId
                    action = "selfDeactivate"
                    principalId = $userId
                    groupId = $selection.More.More.GroupId
                }

                try {
                    $requestObj = New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop
                
                    $groupsWereDisabled = $true
            
                } catch {
                    Write-Error "Error deactivating '$($selection.GroupName)': $($_.Exception.Message)"
                }
            }
        }

        if ($groupsWereDisabled) {
            $counter = 0
            $maxWaitSecs = 20
            while ($counter -lt $maxWaitSecs) {
                Write-Progress "Waiting $maxWaitSecs seconds before continuing" -PercentComplete $($counter*100/$maxWaitSecs) -Status " "
                Start-Sleep -Seconds 1
                $counter++
            }

            Write-Progress -Completed
            Write-Host ""
        }

        foreach ($selection in $userSelections) {
            # Skip activating active roles that have been active for less than 5 mins
            # Coz we wouldn't have been able to disable them above to reactivate
            if ($selection.Status -ne "Inactive" -and $selection.More.More.ActiveMinutes -le 5) { continue }

            if ($selection.More.More.EnablementRule -contains "Justification") {
                Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} " -f $($selection.GroupName))

                if ($SkipJustification) {
                    $justificationsHash[$($selection.GroupName)] = "$defaultJustification"
                    Write-Host "Reason will be set to: $defaultJustification"

                } elseif ($Justification.Length -ne 0) {
                    $justificationsHash[$($selection.GroupName)] = $Justification
                    Write-Host "Reason will be set to: $Justification"

                } else {
                    $justificationInput = Read-Host "Please provide a reason"
                
                    # If the justitication ends with an asterisk or is empty, use it for everything else that follows...
                    if ($justificationInput -match '\*$' -or $justificationInput.Length -eq 0) {
                        # First, remove the asterisk
                        $justificationInput = $justificationInput -replace '\*$',''

                        # Then check whether anything remains. This is to cater to situations where someone enters * or *** etc.
                        # If after removing the asterisk there's nothing, then set it to $defaultJustification for all. This is basically equivalent to -SkipJustification
                        if ($justificationInput.Length -eq 0) {
                            $justificationInput = "$defaultJustification"
                            $justificationsHash[$($selection.GroupName)] = $justificationInput
                        }
                        
                        # Set the justification for everything that follows to be this
                        $Justification = $justificationInput
                        $justificationsHash[$($selection.GroupName)] = $justificationInput

                    } else {
                        $justificationsHash[$($selection.GroupName)] = $justificationInput
                    }

                    Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} " -f $($selection.GroupName))
                    Write-Host "Reason will be set to: $justificationInput"
                }
            }

            if ($selection.More.More.EnablementRule -contains "Ticketing") {
                Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} " -f $($selection.GroupName))

                $ticketNumberHash[$($selection.GroupName)] = Read-Host "Please provide a ticket number"

                if ($TicketingSystem.Length -ne 0) {
                    Write-Host -NoNewline @colorParams ("📋 {0,-$longestRoleLength} " -f $($selection.GroupName))
                    $ticketingSystemInput = Read-Host "Please provide the ticketing system name"

                    # If the justitication ends with an asterisk, use it for everything else that follows...
                    if ($ticketingSystemInput -match '\*$') {
                        $ticketingSystemInput = $ticketingSystemInput -replace '\*$',''
                        $TicketingSystem = $ticketingSystemInput
                    }

                    $ticketSystemHash[$($selection.GroupName)] = $ticketingSystemInput

                } else {
                    $ticketSystemHash[$($selection.GroupName)] = $TicketingSystem
                }
            }
        }

        if ($userSelections.Count -ne 0) {
            Write-Host ""
        }

        # An array to capture each of the items we action below
        $requestObjsArray = @()

        foreach ($selection in $userSelections) {
            # Skip activating active roles that have been active for less than 5 mins
            # Coz we wouldn't have been able to disable them above to reactivate
            if ($selection.Status -ne "Inactive" -and $selection.More.More.ActiveMinutes -le 5) { continue }

            Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} " -f $($selection.GroupName))
            Write-Host "Enabling for $($selection.MaxDuration)"

            $params = @{
                accessId = $selection.More.More.AccessId
                action = "selfActivate"
                principalId = $userId
                groupId = $selection.More.More.GroupId

                scheduleInfo = @{
                    startDateTime = Get-Date
                    expiration = @{
                        type = "AfterDuration"
                        duration = $selection.More.More.MaxDuration
                    }
                }
            }

            if ($selection.More.More.enablementRule -contains "Justification") {
                $params.justification = $justificationsHash[$($selection.GroupName)]
            }

            if ($selection.More.More.enablementRule -contains "Ticketing") {
                $params.ticketInfo = @{
                    ticketNumber = $ticketNumberHash[$($selection.GroupName)]
                    ticketSystem = $ticketSystemHash[$($selection.GroupName)]
                }
            }

            try {
                $requestObj = New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop
            
                # And add it to an array so we can loop over in the end
                $requestObjsArray += $requestObj
        
            } catch {
                Write-Error "Error activating '$($selection.GroupName)': $($_.Exception.Message)"
            }
        }

        if ($requestObjsArray.Count -ne 0) {
            Write-Host ""

            $counter = 0
            $maxWaitSecs = 20
            while ($counter -lt $maxWaitSecs) {
                Write-Progress "Waiting $maxWaitSecs seconds before showing the final status" -PercentComplete $($counter*100/$maxWaitSecs) -Status " "
                Start-Sleep -Seconds 1
                $counter++
            }

            Write-Progress -Completed
        }

        $counter = 0
        $totalCount = $requestObjsArray.Count

        $finalOutput = foreach ($requestObj in $requestObjsArray) {
            $counter++
            Write-Progress "Fetching status of group '$($groupNamesCache[$($requestObj.GroupId)])'" -PercentComplete $($counter*100/$totalCount) -Status "$counter/$totalCount" 
            
            Get-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -PrivilegedAccessGroupAssignmentScheduleRequestId $requestObj.Id | Select-Object -Property @{
                "Name" = "Group";
                "Expression" = { $groupNamesCache[$($_.GroupId)] }
            }, @{
                "Name" = "Type";
                "Expression" = { if ($_.AccessId -eq "member") { "Member" } else { "Owner" } }
            }, Status 
        }

        $finalOutput | Format-Table
    }
}

# This is a copy paste of Enable-PIMRole with some bits removed...
# It's very simple compared to Enable-PIMRole
function Disable-PIMGroup {
    param(
        [switch]$UseDeviceCode,

        [Parameter(Mandatory=$false)]
        [string]$TenantId,

        [Parameter(Mandatory=$false)]
        [string]$ClientId
    )

    <#
    .PARAMETER UseDeviceCode
    Optional. Use Device Code authentication.

    .PARAMETER TenantId
    Optional. Use this TenantId.

    .PARAMETER ClientId
    Optional. Use this Client Id.
    #>


    begin {
        Write-Host ""
        $colorParams = $script:colorParams

        [System.Version]$installedVersion = (Get-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version
        [System.Version]$availableVersion = (Find-Module Graph.EasyPIM -ErrorAction SilentlyContinue).Version

        if ($installedVersion -and $availableVersion -and ($installedVersion -lt $availableVersion)) {
            Write-Host @colorParams "🎉 A newer version of this module is available in PowerShell Gallery"
        }

        $graphParams = @{
            "Scopes" = $script:requiredScopesArray
            "NoWelcome" = $true
            "ErrorAction" = "Stop"
        }

        if ($PSBoundParameters.ContainsKey("UseDeviceCode")) { $graphParams.UseDeviceCode = $true }
        if ($PSBoundParameters.ContainsKey("TenantId")) { $graphParams.TenantId = $TenantId }
        if ($PSBoundParameters.ContainsKey("ClientId")) { $graphParams.ClientId = $ClientId }

        # Disconnect the existing sessions if one of these were provided
        if ($PSBoundParameters.ContainsKey("UseDeviceCode") -or $PSBoundParameters.ContainsKey("TenantId") -or $PSBoundParameters.ContainsKey("ClientId")) { 
            try {
                Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
            } catch {}
        }

        try {
            Connect-MgGraph @graphParams

        } catch {
            throw "$($_.Exception.Message)"
        }

        $context = Get-MgContext

        $scopes = $context.scopes

        if ($scopes -notcontains "Directory.ReadWrite.All") {
            foreach ($requiredScope in $script:requiredScopesArray) {
                if ($requiredScope -notin $scopes) {
                    Write-Warning "Required scope '$requiredScope' missing"
                }
            }
        }

        $userId = (Get-MgUser -UserId $context.Account).Id

        try {
            Write-Host @colorParams "🥷 Fetching all active Entra ID groups. This is usually pretty quick!"

            Write-Progress -Activity "Fetching all active Entra ID groups" -Id 0
            [array]$myActiveGroups = Get-MgIdentityGovernancePrivilegedAccessGroupAssignmentSchedule -All -Filter "principalId eq '$userId'" -ExpandProperty Group -ErrorAction Stop
            
        } catch {
            throw "Error fetching groups: $($_.Exception.Message)"
        }

        Write-Progress -Id 0 -Completed
    }

    process {
        Write-Host ""

        # I use these for showing progress
        [int]$counter = 0
        [int]$totalCount = $myActiveGroups.Count
        $groupNamesCache = @{}

        $groupStates = foreach ($groupRoleObj in $myActiveGroups) {
            $counter++
            $percentageComplete = ($counter/$totalCount)*100

            $groupId = $groupRoleObj.GroupId
            $groupName = $groupRoleObj.Group.DisplayName
            $groupNamesCache[$groupId] = $groupName

            $accessId = $groupRoleObj.AccessId

            $timespanArray = @()
            $groupRoleExpired = $false
            $groupRoleAssignmentType = "Inactive"

            Write-Progress -Activity "Processing group '$groupName'" -Id 0 -PercentComplete $percentageComplete -Status "$counter/$totalCount"

            $activeGroupRoleObj = $null
            $activeGroupRoleObj = $myActiveGroups | Where-Object { $_.GroupId -eq "$groupId" -and $_.AccessId -eq "$accessId" }

            if ($activeGroupRoleObj) {
                Write-Progress -Activity "Group is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete
                Start-Sleep -Milliseconds 200   # a stupid hack coz Write-Progress doesn't display outside loops apparently! https://github.com/PowerShell/PowerShell/issues/5741
                Write-Progress -Activity "o is active; calculating time remaining..." -ParentId 0 -Id 1 -Status "Waiting..." -PercentComplete $percentageComplete
                
                # Double checking coz during my testing I ran into instances where this was sometimes incomplete
                if ($activeGroupRoleObj.ScheduleInfo.Expiration.EndDateTime) {
                    $groupRoleAssignmentType = "Active"

                    $timeSpan = New-TimeSpan -Start (Get-Date).ToUniversalTime() -End $activeGroupRoleObj.ScheduleInfo.Expiration.EndDateTime
                    if ($timeSpan.Days -gt 0) {
                        if ($timeSpan.Days -eq 1) {
                            $timespanArray += "$($timeSpan.Days) day"
    
                        } else {
                            $timespanArray += "$($timeSpan.Days) days"
                        }
                    }
    
                    if ($timeSpan.Hours -gt 0) {
                        if ($timeSpan.Hours -eq 1) {
                            $timespanArray += "$($timeSpan.Hours) hour"
    
                        } else {
                            $timespanArray += "$($timeSpan.Hours) hours"
                        }
                    }
    
                    if ($timeSpan.Minutes -gt 0) {
                        if ($timeSpan.Minutes -eq 1) {
                            $timespanArray += "$($timeSpan.Minutes) minute"
    
                        } else {
                            $timespanArray += "$($timeSpan.Minutes) minutes"
                        }
                    }
    
                    # Just in case there's a delay between getting the states and when I calculate this...
                    if ($timeSpan.Ticks -lt 0) { 
                        $groupRoleExpired = $true 
                    }

                } else {
                    $groupRoleExpired = $true 
                }

                Write-Progress -Id 1 -Completed

            } else {
                $groupRoleExpired = $true
            }

            [pscustomobject][ordered]@{
                "GroupName" = $groupName
                "Status" = $groupRoleAssignmentType
                "Type" = if ($accessId -eq "member") { "Member" } else { "Owner" }
                "ExpiresIn" = if (!($groupRoleExpired)) {
                    # Take only the topmost entry (day or hour in case of more than one)
                    if ($timespanArray.Count -gt 1) {
                        "~" + $timespanArray[0]
                    } else {
                        $timespanArray[0]
                    }
                } # Tweak the output to to save some space

                "More" = [pscustomobject]@{
                    "More" = [pscustomobject]@{
                        "AccessId" = $accessId
                        "GroupId" = $groupRoleObj.GroupId
                        "ActiveMinutes" = if (!($groupRoleExpired)) { (New-TimeSpan -End (Get-Date).ToUniversalTime() -Start $activeGroupRoleObj.ScheduleInfo.StartDateTime).TotalMinutes }
                    }
                }  # Two levels to hide this and save some space
            }
        }

        Write-Progress -Completed -Id 0

        if ($groupStates.Count -eq 0) {
            Write-Host @colorParams ("🚀 No active Entra ID groups found.")
            Write-Host ""
        }

        $userSelections = $groupStates | Out-ConsoleGridView -Title "List of active Entra ID PIM groups (count: $totalCount)"

        # I use this for tidying up some of the output later; find the longest entry in the selections
        $longestRoleLength = ($userSelections.GroupName | Sort-Object -Property { $_.Length } -Descending | Select-Object -First 1).Length

        # An array to capture each of the items we action below
        $requestObjsArray = @()

        foreach ($selection in $userSelections) {
            if ($selection.More.More.ActiveMinutes -le 5) {
                Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} " -f $($selection.GroupName))
                Write-Host "Cannot disable the group as it must be active for at least 5 minutes."
                continue
            }

            Write-Host -NoNewline @colorParams ("👉 {0,-$longestRoleLength} " -f $($selection.GroupName))
            Write-Host "Disabling group"

            $params = @{
                accessId = $selection.More.More.AccessId
                action = "selfDeactivate"
                principalId = $userId
                groupId = $selection.More.More.GroupId
            }

            try {
                $requestObj = New-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -BodyParameter $params -ErrorAction Stop
            
                # And add it to an array so we can loop over in the end
                $requestObjsArray += $requestObj
        
            } catch {
                Write-Error "Error deactivating '$($selection.GroupName)': $($_.Exception.Message)"
            }
        }

        if ($requestObjsArray.Count -ne 0) {
            Write-Host ""

            $counter = 0
            $maxWaitSecs = 20
            while ($counter -lt $maxWaitSecs) {
                Write-Progress "Waiting $maxWaitSecs seconds before showing the final status" -PercentComplete $($counter*100/$maxWaitSecs) -Status " "
                Start-Sleep -Seconds 1
                $counter++
            }

            Write-Progress -Completed
        }

        $counter = 0
        $totalCount = $requestObjsArray.Count

        $finalOutput = foreach ($requestObj in $requestObjsArray) {
            $counter++
            Write-Progress "Fetching status of group '$($groupNamesCache[$($requestObj.GroupId)])'" -PercentComplete $($counter*100/$totalCount) -Status "$counter/$totalCount" 
            
            Get-MgIdentityGovernancePrivilegedAccessGroupAssignmentScheduleRequest -PrivilegedAccessGroupAssignmentScheduleRequestId $requestObj.Id | Select-Object -Property @{
                "Name" = "Group";
                "Expression" = { $groupNamesCache[$($_.GroupId)] }
            }, @{
                "Name" = "Type";
                "Expression" = { if ($_.AccessId -eq "member") { "Member" } else { "Owner" } }
            }, Status 
        }

        $finalOutput | Format-Table
    }
}