dev-feature/PIM-Groups.ps1

<#
.SYNOPSIS
    Activates Azure AD PIM group assignments via Microsoft Graph API.
 
.DESCRIPTION
    This script provides PowerShell-based PIM group activation functionality similar to pimcli.
    It authenticates to Microsoft Graph, retrieves eligible group assignments, and activates them.
 
.PARAMETER GroupIds
    Optional array of specific Group IDs to activate. If not provided, shows interactive selection.
 
.PARAMETER Justification
    Required justification text for the activation request.
 
.PARAMETER Duration
    ISO 8601 duration for the activation (e.g., "PT1H" for 1 hour, "PT8H" for 8 hours).
    If not specified, uses the maximum allowed duration from the group's policy.
 
.PARAMETER TicketSystem
    Optional ticket system name (e.g., "ServiceNow", "Jira").
 
.PARAMETER TicketNumber
    Optional ticket number for the activation request.
 
.PARAMETER TenantId
    Azure AD Tenant ID. If not provided, uses common endpoint.
 
.PARAMETER ClientId
    Application (client) ID for authentication. Defaults to Microsoft Graph PowerShell.
 
.EXAMPLE
    # Interactive mode - select groups from a list
    .\Invoke-PIMGroupActivation.ps1 -Justification "Daily work tasks"
 
.EXAMPLE
    # Activate specific groups
    .\Invoke-PIMGroupActivation.ps1 -GroupIds @("group-id-1", "group-id-2") -Justification "Project work" -Duration "PT4H"
 
.EXAMPLE
    # With ticket information
    .\Invoke-PIMGroupActivation.ps1 -Justification "INC123456" -TicketSystem "ServiceNow" -TicketNumber "INC123456"
 
.NOTES
    Author: Generated from pimcli Go implementation
    Requires: Microsoft.Graph PowerShell module or direct Graph API access
#>


[CmdletBinding()]
param(
    [Parameter()]
    [string[]]$GroupIds,

    [Parameter(Mandatory = $true)]
    [string]$Justification,

    [Parameter()]
    [string]$Duration,

    [Parameter()]
    [string]$TicketSystem,

    [Parameter()]
    [string]$TicketNumber,

    [Parameter()]
    [string]$TenantId,

    [Parameter()]
    [string]$ClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" # Microsoft Graph PowerShell
)

#region Configuration
$ErrorActionPreference = "Stop"
$script:GraphBaseUrl = "https://graph.microsoft.com/v1.0"
$script:DefaultDuration = "PT1H"
$script:MaxWaitTime = 300  # 5 minutes in seconds
$script:PollInterval = 5   # seconds
#endregion

#region Authentication Functions

# Global variable to store assembly paths
$script:MSALAssemblyPaths = @{}
$script:MSALHelperCompiled = $false

function Initialize-MSALAssemblies {
    <#
    .SYNOPSIS
        Loads MSAL assemblies for browser-based authentication (no WAM).
    #>


    # Get user home directory (cross-platform)
    $userHome = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }

    # Try to find MSAL from nuget cache first
    $nugetPath = Join-Path $userHome ".nuget/packages/microsoft.identity.client"
    $msalDll = $null
    $abstractionsDll = $null

    if (Test-Path $nugetPath) {
        # Get latest version
        $latestVersion = Get-ChildItem $nugetPath -Directory | Sort-Object Name -Descending | Select-Object -First 1
        if ($latestVersion) {
            $msalDll = Join-Path $latestVersion.FullName "lib/net6.0/Microsoft.Identity.Client.dll"
            if (-not (Test-Path $msalDll)) {
                $msalDll = Join-Path $latestVersion.FullName "lib/netstandard2.0/Microsoft.Identity.Client.dll"
            }
        }

        # Find abstractions
        $abstractionsPath = Join-Path $userHome ".nuget/packages/microsoft.identitymodel.abstractions"
        if (Test-Path $abstractionsPath) {
            $latestAbstractions = Get-ChildItem $abstractionsPath -Directory | Sort-Object Name -Descending | Select-Object -First 1
            if ($latestAbstractions) {
                $abstractionsDll = Join-Path $latestAbstractions.FullName "lib/net6.0/Microsoft.IdentityModel.Abstractions.dll"
                if (-not (Test-Path $abstractionsDll)) {
                    $abstractionsDll = Join-Path $latestAbstractions.FullName "lib/netstandard2.0/Microsoft.IdentityModel.Abstractions.dll"
                }
            }
        }
    }

    # Fallback to Az.Accounts if nuget not available
    if (-not $msalDll -or -not (Test-Path $msalDll)) {
        $LoadedAzAccountsModule = Get-Module -Name Az.Accounts
        if ($null -eq $LoadedAzAccountsModule) {
            $AzAccountsModule = Get-Module -Name Az.Accounts -ListAvailable | Select-Object -First 1
            if ($null -eq $AzAccountsModule) {
                Write-Verbose "Neither nuget cache nor Az.Accounts module found for MSAL"
                return $false
            }
            Import-Module Az.Accounts -ErrorAction SilentlyContinue -Verbose:$false
        }

        $LoadedAssemblies = [System.AppDomain]::CurrentDomain.GetAssemblies() | Select-Object -ExpandProperty Location -ErrorAction SilentlyContinue
        # Cross-platform regex - match both forward and back slashes
        $AzureCommon = $LoadedAssemblies | Where-Object { $_ -match "[/\\]Modules[/\\]Az.Accounts[/\\]" -and $_ -match "Microsoft.Azure.Common" }

        if ($AzureCommon) {
            $AzureCommonLocation = Split-Path -Parent $AzureCommon
            $foundMsal = Get-ChildItem -Path $AzureCommonLocation -Filter "Microsoft.Identity.Client.dll" -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1
            $foundAbstractions = Get-ChildItem -Path $AzureCommonLocation -Filter "Microsoft.IdentityModel.Abstractions.dll" -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1
            if ($foundMsal) { $msalDll = $foundMsal.FullName }
            if ($foundAbstractions) { $abstractionsDll = $foundAbstractions.FullName }
        }
    }

    if (-not $msalDll -or -not (Test-Path $msalDll)) {
        Write-Verbose "Could not find Microsoft.Identity.Client.dll"
        return $false
    }

    # Load assemblies
    $loadedAssembliesCheck = [System.AppDomain]::CurrentDomain.GetAssemblies()

    # Load abstractions first if available
    if ($abstractionsDll -and (Test-Path $abstractionsDll)) {
        $alreadyLoaded = $loadedAssembliesCheck | Where-Object { $_.GetName().Name -eq 'Microsoft.IdentityModel.Abstractions' } | Select-Object -First 1
        if (-not $alreadyLoaded) {
            try {
                [void][System.Reflection.Assembly]::LoadFrom($abstractionsDll)
                $script:MSALAssemblyPaths['Microsoft.IdentityModel.Abstractions'] = $abstractionsDll
            } catch { }
        } else {
            $script:MSALAssemblyPaths['Microsoft.IdentityModel.Abstractions'] = $alreadyLoaded.Location
        }
    }

    # Load MSAL
    $alreadyLoaded = $loadedAssembliesCheck | Where-Object { $_.GetName().Name -eq 'Microsoft.Identity.Client' } | Select-Object -First 1
    if (-not $alreadyLoaded) {
        try {
            [void][System.Reflection.Assembly]::LoadFrom($msalDll)
            $script:MSALAssemblyPaths['Microsoft.Identity.Client'] = $msalDll
        } catch {
            Write-Verbose "Failed to load MSAL: $_"
            return $false
        }
    } else {
        $script:MSALAssemblyPaths['Microsoft.Identity.Client'] = $alreadyLoaded.Location
    }

    return $true
}

function Initialize-MSALHelper {
    <#
    .SYNOPSIS
        Pre-compiles the MSAL helper C# code for browser-based authentication.
    #>


    if ($script:MSALHelperCompiled) { return $true }

    # Get referenced assemblies for Add-Type
    $referencedAssemblies = @(
        $script:MSALAssemblyPaths['Microsoft.IdentityModel.Abstractions'],
        $script:MSALAssemblyPaths['Microsoft.Identity.Client']
    ) | Where-Object { $_ }

    if ($referencedAssemblies.Count -lt 1) {
        throw "Missing required MSAL assemblies"
    }

    # Add standard assemblies
    $referencedAssemblies += @("netstandard", "System.Linq", "System.Threading.Tasks", "System.Collections")

    # C# code for browser-based authentication (no WAM)
    $code = @"
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client;
 
public class PIMGroupBrowserAuth
{
    public static string GetAccessToken(string clientId, string[] scopes, string tenantId = null)
    {
        try
        {
            var task = Task.Run(async () => await GetAccessTokenAsync(clientId, scopes, tenantId));
            if (task.Wait(TimeSpan.FromSeconds(180)))
            {
                return task.Result;
            }
            throw new TimeoutException("Authentication timed out");
        }
        catch (AggregateException ae)
        {
            if (ae.InnerException != null) throw ae.InnerException;
            throw;
        }
    }
 
    private static async Task<string> GetAccessTokenAsync(string clientId, string[] scopes, string tenantId)
    {
        // Use system browser with localhost redirect - must match app registration
        var builder = PublicClientApplicationBuilder.Create(clientId)
            .WithRedirectUri("http://localhost");
 
        // Add tenant ID if provided
        if (!string.IsNullOrEmpty(tenantId))
        {
            builder = builder.WithAuthority(string.Format("https://login.microsoftonline.com/{0}", tenantId));
        }
 
        IPublicClientApplication publicClientApp = builder.Build();
 
        using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(180)))
        {
            var tokenBuilder = publicClientApp.AcquireTokenInteractive(scopes)
                .WithPrompt(Prompt.SelectAccount)
                .WithUseEmbeddedWebView(false)
                .WithSystemWebViewOptions(new SystemWebViewOptions());
 
            // Add extra query parameters to hint at the tenant
            if (!string.IsNullOrEmpty(tenantId))
            {
                tokenBuilder = tokenBuilder.WithExtraQueryParameters(string.Format("domain_hint={0}", tenantId));
            }
 
            var result = await tokenBuilder
                .ExecuteAsync(cts.Token)
                .ConfigureAwait(false);
 
            return result.AccessToken;
        }
    }
}
"@


    # Check if type already exists
    try {
        $null = [PIMGroupBrowserAuth]
        $script:MSALHelperCompiled = $true
        return $true
    } catch {
        # Type doesn't exist, compile it
    }

    Add-Type -ReferencedAssemblies $referencedAssemblies -TypeDefinition $code -Language CSharp -ErrorAction Stop -IgnoreWarnings 3>$null

    $script:MSALHelperCompiled = $true
    return $true
}

function Get-PIMGroupBrowserAccessToken {
    <#
    .SYNOPSIS
        Gets an access token using browser-based authentication (no WAM).
    #>

    [CmdletBinding()]
    param(
        [string[]]$Scopes
    )

    # Ensure MSAL helper is compiled
    if (-not $script:MSALHelperCompiled) {
        $null = Initialize-MSALHelper
    }

    $clientId = if ($ClientId) { $ClientId } else { "14d82eec-204b-4c2f-b7e8-296a70dab67e" }

    # Build scopes with Graph prefix
    $scopeArray = $Scopes | ForEach-Object {
        if ($_ -notlike "https://*") { "https://graph.microsoft.com/$_" } else { $_ }
    }

    $accessToken = [PIMGroupBrowserAuth]::GetAccessToken($clientId, $scopeArray, $TenantId)
    return $accessToken
}

function Connect-PIMGraph {
    <#
    .SYNOPSIS
        Authenticates to Microsoft Graph using browser-based login (no WAM).
    #>

    [CmdletBinding()]
    param(
        [string]$TenantId,
        [string]$ClientId
    )

    Write-Host "🔐 Authenticating to Microsoft Graph..." -ForegroundColor Cyan

    # Load MSAL assemblies
    $msalLoaded = Initialize-MSALAssemblies
    if (-not $msalLoaded) {
        throw "Failed to load MSAL assemblies. Ensure the Microsoft.Identity.Client NuGet package is installed or the Az.Accounts module is available."
    }

    # Compile the C# browser auth helper
    $null = Initialize-MSALHelper

    # Least privileged permissions for PIM group activation
    $scopes = @(
        "PrivilegedEligibilitySchedule.Read.AzureADGroup",      # Read eligible assignments
        "PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup",  # Activate (self-assign)
        "RoleManagementPolicy.Read.AzureADGroup",               # Read policy/duration rules
        "User.Read"                                              # Get current user ID
    )

    if ($ClientId) {
        Write-Host " Using app registration: $ClientId" -ForegroundColor Gray
    } else {
        Write-Host " Using default Microsoft Graph PowerShell client" -ForegroundColor Gray
    }
    if ($TenantId) {
        Write-Host " Tenant: $TenantId" -ForegroundColor Gray
    }

    Write-Host " Opening browser for authentication..." -ForegroundColor Cyan
    Write-Host " Waiting for authentication response..." -ForegroundColor Yellow

    $script:AccessToken = Get-PIMGroupBrowserAccessToken -Scopes $scopes

    if (-not $script:AccessToken) {
        throw "Failed to get access token from browser authentication"
    }

    Write-Host "✅ Authenticated via browser" -ForegroundColor Green
}

function Invoke-PIMGraphRequest {
    <#
    .SYNOPSIS
        Makes a request to Microsoft Graph API using direct REST calls.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Uri,

        [Parameter()]
        [ValidateSet("GET", "POST", "PATCH", "DELETE")]
        [string]$Method = "GET",

        [Parameter()]
        [object]$Body,

        [Parameter()]
        [hashtable]$Headers = @{}
    )

    $fullUri = if ($Uri.StartsWith("http")) { $Uri } else { "$script:GraphBaseUrl$Uri" }
    $requestHeaders = @{
        "Authorization" = "Bearer $script:AccessToken"
        "Content-Type" = "application/json"
        "ConsistencyLevel" = "eventual"
    }
    foreach ($key in $Headers.Keys) {
        $requestHeaders[$key] = $Headers[$key]
    }

    $params = @{
        Method = $Method
        Uri = $fullUri
        Headers = $requestHeaders
    }
    if ($Body) {
        $params.Body = ($Body | ConvertTo-Json -Depth 10)
    }
    return Invoke-RestMethod @params
}
#endregion

#region Helper Functions
function ConvertTo-ISO8601Duration {
    <#
    .SYNOPSIS
        Converts human-friendly duration input to ISO 8601 duration format.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$DurationInput
    )

    $DurationInput = $DurationInput.Trim().ToLower()

    # Already ISO 8601 format (e.g., PT1H, PT30M, PT4H30M)
    if ($DurationInput -match '^pt\d') {
        return $DurationInput.ToUpper()
    }

    $hours = 0
    $minutes = 0

    # Match patterns like "4h30m", "2h", "30m", "1h 30m"
    if ($DurationInput -match '(\d+)\s*h') {
        $hours = [int]$Matches[1]
    }
    if ($DurationInput -match '(\d+)\s*m') {
        $minutes = [int]$Matches[1]
    }

    # If just a number, treat as hours
    if ($hours -eq 0 -and $minutes -eq 0 -and $DurationInput -match '^\d+$') {
        $hours = [int]$DurationInput
    }

    if ($hours -eq 0 -and $minutes -eq 0) {
        Write-Warning "Could not parse duration '$DurationInput', defaulting to 1 hour"
        return "PT1H"
    }

    $result = "PT"
    if ($hours -gt 0) { $result += "${hours}H" }
    if ($minutes -gt 0) { $result += "${minutes}M" }
    return $result
}
#endregion

#region PIM Functions
function Get-CurrentUser {
    <#
    .SYNOPSIS
        Gets the current authenticated user's information.
    #>

    [CmdletBinding()]
    param()

    Write-Verbose "Getting current user information..."
    $user = Invoke-PIMGraphRequest -Uri "/me" -Method GET
    return $user
}

function Get-PIMGroupEligibleAssignments {
    <#
    .SYNOPSIS
        Gets all PIM group eligible assignments for the current user.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$UserId
    )

    Write-Host "📋 Fetching eligible group assignments..." -ForegroundColor Cyan

    $allAssignments = @()
    $filter = "principalId eq '$UserId'"
    $uri = "/identityGovernance/privilegedAccess/group/eligibilitySchedules?`$filter=$filter&`$expand=group,principal"

    do {
        $response = Invoke-PIMGraphRequest -Uri $uri -Method GET
        
        if ($response.value) {
            $allAssignments += $response.value
        }

        $uri = $response.'@odata.nextLink'
    } while ($uri)

    Write-Host "✅ Found $($allAssignments.Count) eligible group(s)" -ForegroundColor Green
    return $allAssignments
}

function Get-PIMGroupActiveAssignments {
    <#
    .SYNOPSIS
        Gets all active PIM group assignments for the current user.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$UserId
    )

    Write-Verbose "Fetching active group assignments..."

    $allAssignments = @()
    $filter = "principalId eq '$UserId'"
    $uri = "/identityGovernance/privilegedAccess/group/assignmentScheduleInstances?`$filter=$filter&`$expand=group,principal"

    do {
        $response = Invoke-PIMGraphRequest -Uri $uri -Method GET
        
        if ($response.value) {
            $allAssignments += $response.value
        }

        $uri = $response.'@odata.nextLink'
    } while ($uri)

    # Build lookup hashtable
    $activeMap = @{}
    foreach ($assignment in $allAssignments) {
        $key = "$($assignment.groupId):$($assignment.accessId)"
        $activeMap[$key] = $true
    }

    return $activeMap
}

function Get-PIMGroupMaxDuration {
    <#
    .SYNOPSIS
        Gets the maximum activation duration for a PIM group.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$GroupId
    )

    Write-Verbose "Getting maximum duration for group $GroupId..."

    try {
        $filter = "scopeId eq '$GroupId' and scopeType eq 'Group' and roleDefinitionId eq 'member'"
        $uri = "/policies/roleManagementPolicyAssignments?`$filter=$filter&`$expand=policy(`$expand=rules)"
        
        $response = Invoke-PIMGraphRequest -Uri $uri -Method GET

        if (-not $response.value -or $response.value.Count -eq 0) {
            Write-Verbose "No policy found for group $GroupId, using default duration"
            return $script:DefaultDuration
        }

        $policy = $response.value[0].policy
        if (-not $policy -or -not $policy.rules) {
            return $script:DefaultDuration
        }

        # Find the expiration rule
        $expirationRule = $policy.rules | Where-Object { $_.id -eq "Expiration_EndUser_Assignment" }
        
        if ($expirationRule -and $expirationRule.maximumDuration) {
            return $expirationRule.maximumDuration
        }

        return $script:DefaultDuration
    }
    catch {
        Write-Warning "Failed to get max duration for group $GroupId`: $_"
        return $script:DefaultDuration
    }
}

function Get-PIMGroupRequiresTicket {
    <#
    .SYNOPSIS
        Checks if a PIM group requires ticket information for activation.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$GroupId
    )

    try {
        $filter = "scopeId eq '$GroupId' and scopeType eq 'Group' and roleDefinitionId eq 'member'"
        $uri = "/policies/roleManagementPolicyAssignments?`$filter=$filter&`$expand=policy(`$expand=rules)"
        
        $response = Invoke-PIMGraphRequest -Uri $uri -Method GET

        if (-not $response.value -or $response.value.Count -eq 0) {
            return $false
        }

        $policy = $response.value[0].policy
        if (-not $policy -or -not $policy.rules) {
            return $false
        }

        # Find the enablement rule
        $enablementRule = $policy.rules | Where-Object { $_.id -eq "Enablement_EndUser_Assignment" }
        
        if ($enablementRule -and $enablementRule.enabledRules) {
            return $enablementRule.enabledRules -contains "Ticketing"
        }

        return $false
    }
    catch {
        Write-Verbose "Failed to check ticket requirement for group $GroupId`: $_"
        return $false
    }
}

function Submit-PIMGroupActivation {
    <#
    .SYNOPSIS
        Submits a PIM group activation request.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PrincipalId,
        
        [Parameter(Mandatory)]
        [string]$GroupId,
        
        [Parameter(Mandatory)]
        [string]$Justification,
        
        [Parameter(Mandatory)]
        [string]$Duration,
        
        [Parameter()]
        [string]$TicketSystem,
        
        [Parameter()]
        [string]$TicketNumber
    )

    $body = @{
        accessId = "member"
        principalId = $PrincipalId
        groupId = $GroupId
        action = "selfActivate"
        justification = $Justification
        scheduleInfo = @{
            startDateTime = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
            expiration = @{
                type = "afterDuration"
                duration = $Duration
            }
        }
    }

    if ($TicketSystem -or $TicketNumber) {
        $body.ticketInfo = @{}
        if ($TicketSystem) { $body.ticketInfo.ticketSystem = $TicketSystem }
        if ($TicketNumber) { $body.ticketInfo.ticketNumber = $TicketNumber }
    }

    $uri = "/identityGovernance/privilegedAccess/group/assignmentScheduleRequests"
    $response = Invoke-PIMGraphRequest -Uri $uri -Method POST -Body $body

    return @{
        RequestId = $response.id
        Status = $response.status
    }
}

function Get-PIMGroupRequestStatus {
    <#
    .SYNOPSIS
        Gets the status of a PIM group activation request.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$RequestId
    )

    $uri = "/identityGovernance/privilegedAccess/group/assignmentScheduleRequests/$RequestId"
    $response = Invoke-PIMGraphRequest -Uri $uri -Method GET

    $isComplete = $response.status -in @("Provisioned", "Revoked", "Denied", "Failed", "Canceled")
    $needsApproval = $response.status -eq "PendingApproval"

    return @{
        RequestId = $response.id
        Status = $response.status
        IsComplete = $isComplete
        NeedsApproval = $needsApproval
        GroupId = $response.groupId
    }
}

function Wait-PIMGroupRequestCompletion {
    <#
    .SYNOPSIS
        Waits for a PIM group activation request to complete.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$RequestId,
        
        [Parameter()]
        [int]$MaxWaitSeconds = 300,
        
        [Parameter()]
        [int]$PollIntervalSeconds = 5
    )

    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

    while ($stopwatch.Elapsed.TotalSeconds -lt $MaxWaitSeconds) {
        $status = Get-PIMGroupRequestStatus -RequestId $RequestId

        if ($status.IsComplete -or $status.NeedsApproval) {
            return $status
        }

        Start-Sleep -Seconds $PollIntervalSeconds
    }

    throw "Timeout waiting for request $RequestId to complete"
}

function Invoke-PIMGroupActivation {
    <#
    .SYNOPSIS
        Activates a single PIM group assignment.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Assignment,
        
        [Parameter(Mandatory)]
        [string]$Justification,
        
        [Parameter()]
        [string]$Duration,
        
        [Parameter()]
        [string]$TicketSystem,
        
        [Parameter()]
        [string]$TicketNumber
    )

    $groupName = $Assignment.group.displayName
    $groupId = $Assignment.groupId
    $principalId = $Assignment.principalId

    Write-Host " 🔄 Activating: $groupName" -ForegroundColor White

    try {
        Write-Host " 📤 Submitting activation request (Duration: $Duration)..." -ForegroundColor Gray

        $result = Submit-PIMGroupActivation `
            -PrincipalId $principalId `
            -GroupId $groupId `
            -Justification $Justification `
            -Duration $Duration `
            -TicketSystem $TicketSystem `
            -TicketNumber $TicketNumber

        Write-Host " ⏳ Waiting for completion..." -ForegroundColor Gray

        $finalStatus = Wait-PIMGroupRequestCompletion -RequestId $result.RequestId

        if ($finalStatus.NeedsApproval) {
            Write-Host " ⚠️ $groupName - Pending Approval" -ForegroundColor Yellow
            return @{
                GroupName = $groupName
                GroupId = $groupId
                Status = "PendingApproval"
                Success = $true
                NeedsApproval = $true
            }
        }
        elseif ($finalStatus.Status -eq "Provisioned") {
            Write-Host " ✅ $groupName - Activated Successfully" -ForegroundColor Green
            return @{
                GroupName = $groupName
                GroupId = $groupId
                Status = $finalStatus.Status
                Success = $true
                NeedsApproval = $false
            }
        }
        else {
            Write-Host " ❌ $groupName - $($finalStatus.Status)" -ForegroundColor Red
            return @{
                GroupName = $groupName
                GroupId = $groupId
                Status = $finalStatus.Status
                Success = $false
                NeedsApproval = $false
            }
        }
    }
    catch {
        Write-Host " ❌ $groupName - Error: $_" -ForegroundColor Red
        return @{
            GroupName = $groupName
            GroupId = $groupId
            Status = "Failed"
            Success = $false
            Error = $_.Exception.Message
            NeedsApproval = $false
        }
    }
}
#endregion

#region Interactive Selection
function Show-GroupSelection {
    <#
    .SYNOPSIS
        Shows an interactive menu for selecting groups to activate.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [array]$EligibleGroups,
        
        [Parameter(Mandatory)]
        [hashtable]$ActiveMap
    )

    Write-Host "`n📋 Available Groups for Activation:" -ForegroundColor Cyan
    Write-Host ("─" * 60)

    $selectableGroups = @()
    $index = 1

    foreach ($group in $EligibleGroups) {
        $key = "$($group.groupId):$($group.accessId)"
        $isActive = $ActiveMap.ContainsKey($key)
        $groupName = $group.group.displayName
        
        if ($isActive) {
            Write-Host (" [{0,2}] {1} (Already Active)" -f "-", $groupName) -ForegroundColor DarkGray
        }
        else {
            Write-Host (" [{0,2}] {1}" -f $index, $groupName) -ForegroundColor White
            $selectableGroups += @{
                Index = $index
                Assignment = $group
            }
            $index++
        }
    }

    if ($selectableGroups.Count -eq 0) {
        Write-Host "`n✅ All groups are already active!" -ForegroundColor Green
        return @()
    }

    Write-Host ("`n" + "─" * 60)
    Write-Host "Enter group numbers to activate (comma-separated, or 'all' for all, 'q' to quit):" -ForegroundColor Cyan
    $selection = Read-Host "Selection"

    if ($selection -eq 'q' -or $selection -eq 'quit') {
        return @()
    }

    if ($selection -eq 'all' -or $selection -eq 'a') {
        return $selectableGroups.Assignment
    }

    $selectedIndices = $selection -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ }
    
    $selectedGroups = @()
    foreach ($idx in $selectedIndices) {
        $match = $selectableGroups | Where-Object { $_.Index -eq $idx }
        if ($match) {
            $selectedGroups += $match.Assignment
        }
    }

    return $selectedGroups
}
#endregion

#region Main Execution
function Main {
    [CmdletBinding()]
    param()

    Write-Host ("`n" + "═" * 60) -ForegroundColor Cyan
    Write-Host " PIM Group Activation - PowerShell Edition" -ForegroundColor Cyan
    Write-Host ("═" * 60) -ForegroundColor Cyan

    # Authenticate
    Connect-PIMGraph -TenantId $TenantId -ClientId $ClientId

    # Get current user
    $currentUser = Get-CurrentUser
    $userId = $currentUser.id
    $userDisplayName = $currentUser.displayName
    Write-Host "👤 Logged in as: $userDisplayName ($($currentUser.userPrincipalName))" -ForegroundColor Green

    # Get eligible assignments
    $eligibleAssignments = Get-PIMGroupEligibleAssignments -UserId $userId

    if ($eligibleAssignments.Count -eq 0) {
        Write-Host "⚠️ No eligible group assignments found." -ForegroundColor Yellow
        return
    }

    # Get active assignments
    Write-Host "🔍 Checking active assignments..." -ForegroundColor Cyan
    $activeMap = Get-PIMGroupActiveAssignments -UserId $userId

    # Determine which groups to activate
    $groupsToActivate = @()

    if ($GroupIds -and $GroupIds.Count -gt 0) {
        # Activate specific groups
        foreach ($groupId in $GroupIds) {
            $assignment = $eligibleAssignments | Where-Object { $_.groupId -eq $groupId }
            if ($assignment) {
                $key = "$($assignment.groupId):$($assignment.accessId)"
                if ($activeMap.ContainsKey($key)) {
                    Write-Host "ℹ️ $($assignment.group.displayName) is already active, skipping." -ForegroundColor Yellow
                }
                else {
                    $groupsToActivate += $assignment
                }
            }
            else {
                Write-Warning "Group ID '$groupId' not found in eligible assignments"
            }
        }
    }
    else {
        # Interactive selection
        $groupsToActivate = Show-GroupSelection -EligibleGroups $eligibleAssignments -ActiveMap $activeMap
    }

    if ($groupsToActivate.Count -eq 0) {
        Write-Host "`n⚠️ No groups selected for activation." -ForegroundColor Yellow
        return
    }

    # Check for ticket requirements
    $ticketRequired = $false
    $groupsRequiringTickets = @()
    
    foreach ($group in $groupsToActivate) {
        if (Get-PIMGroupRequiresTicket -GroupId $group.groupId) {
            $ticketRequired = $true
            $groupsRequiringTickets += $group.group.displayName
        }
    }

    if ($ticketRequired -and (-not $TicketNumber)) {
        Write-Host "`n🎫 The following groups require ticket information:" -ForegroundColor Yellow
        $groupsRequiringTickets | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
        
        if (-not $TicketSystem) {
            $TicketSystem = Read-Host "Enter ticket system (e.g., ServiceNow, Jira)"
        }
        $TicketNumber = Read-Host "Enter ticket number"
    }

    # Prompt for duration if not provided
    if (-not $Duration) {
        Write-Host "`n⏱️ Enter activation duration (e.g., 1h, 2h, 30m, 4h30m):" -ForegroundColor Cyan
        $durationInput = Read-Host "Duration"
        if (-not $durationInput) {
            $durationInput = "1h"
            Write-Host " Using default: 1 hour" -ForegroundColor Gray
        }
        # Parse human-friendly input to ISO 8601 duration
        $Duration = ConvertTo-ISO8601Duration -DurationInput $durationInput
        Write-Host " 📎 Duration: $Duration" -ForegroundColor Gray
    }

    # Activate groups
    Write-Host "`n🚀 Activating $($groupsToActivate.Count) group(s)..." -ForegroundColor Cyan
    Write-Host ("─" * 60)

    $results = @()
    foreach ($assignment in $groupsToActivate) {
        $result = Invoke-PIMGroupActivation `
            -Assignment $assignment `
            -Justification $Justification `
            -Duration $Duration `
            -TicketSystem $TicketSystem `
            -TicketNumber $TicketNumber
        
        $results += $result
    }

    # Summary
    Write-Host ("`n" + "═" * 60) -ForegroundColor Cyan
    Write-Host " Activation Summary" -ForegroundColor Cyan
    Write-Host ("═" * 60) -ForegroundColor Cyan

    $successful = $results | Where-Object { $_.Success -and -not $_.NeedsApproval }
    $pendingApproval = $results | Where-Object { $_.NeedsApproval }
    $failed = $results | Where-Object { -not $_.Success }

    if ($successful.Count -gt 0) {
        Write-Host "✅ Successfully Activated ($($successful.Count)):" -ForegroundColor Green
        $successful | ForEach-Object { Write-Host " - $($_.GroupName)" -ForegroundColor Green }
    }

    if ($pendingApproval.Count -gt 0) {
        Write-Host "⚠️ Pending Approval ($($pendingApproval.Count)):" -ForegroundColor Yellow
        $pendingApproval | ForEach-Object { Write-Host " - $($_.GroupName)" -ForegroundColor Yellow }
    }

    if ($failed.Count -gt 0) {
        Write-Host "❌ Failed ($($failed.Count)):" -ForegroundColor Red
        $failed | ForEach-Object { Write-Host " - $($_.GroupName): $($_.Error)" -ForegroundColor Red }
    }

    Write-Host ("`n" + "═" * 60) -ForegroundColor Cyan

    # Return results for pipeline usage
    return $results
}

# Execute main function
Main
#endregion