MaxOffice.TasksByMe.Entra.psm1

# MaxOffice.TasksByMe.Entra.psm1
# PowerShell Module for managing Tasks by Me Entra ID Application

#Requires -Modules Microsoft.Graph.Applications, Microsoft.Graph.Authentication

# Module Constants
$script:AppDisplayName = "Tasks by Me"
$script:LogoUrl = "https://raw.githubusercontent.com/MaxOffice/tasksbyme/refs/heads/main/assets/logo.png"
$script:NotSetMessage = "Not set. Please configure using Set-TasksByMeAppUrl."


# Helper function to show browser UI for login
function Show-LoginUI {
    Write-Host "No active Graph connection found. Connecting to Microsoft Graph..."
    Write-Host "In your browser, please sign in using a Microsoft 365 admin account." -ForegroundColor Yellow
    Connect-MgGraph -Scopes "Application.ReadWrite.All" -NoWelcome
    Write-Host "Successfully connected to Microsoft Graph"
}

# Helper function to ensure Graph connection
function EnsureGraphConnection {
    try {
        Write-Verbose "Trying to connect to Microsoft Graph..."
        $context = Get-MgContext
        if (-not $context) {
            Show-LoginUI
        }
        else {
            Write-Verbose "Using existing Graph connection for tenant: $($context.TenantId)"
        }
        return $true
    }
    catch {
        Write-Error "Failed to connect to Microsoft Graph: $_"
        return $false
    }
}

# Helper function to get app by display name
function GetTasksByMeApp {
    try {
        if (-not (EnsureGraphConnection)) {
            return $null
        }

        $apps = Get-MgApplication -Filter "displayName eq '$script:AppDisplayName'"
        return $apps | Select-Object -First 1
    }
    catch {
        Write-Error "Failed to retrieve application: $_"
        return $null
    }
}

# Helper function to download and validate logo
function TestLogoDownload {
    try {
        Write-Verbose "Testing logo download from: $script:LogoUrl"
        $tempFile = [System.IO.Path]::GetTempFileName() + ".png"

        # Test download
        Invoke-WebRequest -Uri $script:LogoUrl -OutFile $tempFile -TimeoutSec 30

        # Validate file was downloaded and has content
        if (-not (Test-Path $tempFile) -or (Get-Item $tempFile).Length -eq 0) {
            throw "Downloaded file is empty or does not exist"
        }

        Write-Verbose "Logo download test successful. File size: $((Get-Item $tempFile).Length) bytes"
        Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
        return $true
    }
    catch {
        Write-Error "Failed to download logo from $script:LogoUrl : $_"
        if (Test-Path $tempFile) {
            Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
        }
        return $false
    }
}

# Helper function to download and set app logo
function SetAppLogo {
    param(
        [Parameter(Mandatory = $true)]
        [string]$AppId
    )

    try {
        Write-Verbose "Downloading and setting application logo"
        $tempFile = [System.IO.Path]::GetTempFileName() + ".png"
        Invoke-WebRequest -Uri $script:LogoUrl -OutFile $tempFile -TimeoutSec 30

        Set-MgApplicationLogo -ApplicationId $AppId -InFile $tempFile
        Write-Verbose "Application logo set successfully"

        Remove-Item $tempFile -Force
        return $true
    }
    catch {
        Write-Error "Failed to set application logo: $_"
        if (Test-Path $tempFile) {
            Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
        }
        return $false
    }
}

<#
.SYNOPSIS
Installs the Tasks by Me Entra ID application.

.DESCRIPTION
Creates a new Entra ID application with the display name "Tasks by Me" if it doesn't exist.
Sets the application logo from a remote URL and creates a client secret.

.EXAMPLE
Install-TasksByMeApp
#>

function Install-TasksByMeApp {
    [CmdletBinding()]
    param()

    try {
        # Ensure Graph connection
        if (-not (EnsureGraphConnection)) {
            return $null
        }

        # Test logo download before proceeding
        Write-Verbose "Validating logo download capability..."
        if (-not (TestLogoDownload)) {
            Write-Error "Logo download failed. Application installation aborted."
            return $null
        }

        # Check if app already exists
        $existingApp = GetTasksByMeApp
        if ($existingApp) {
            Write-Error "Application '$script:AppDisplayName' already exists with ID: $($existingApp.Id)"
            return $null
        }

        # Create new application
        Write-Verbose "Creating new application: $script:AppDisplayName"
        $appParams = @{
            DisplayName    = $script:AppDisplayName
            SignInAudience = "AzureADMyOrg"
        }

        $newApp = New-MgApplication @appParams
        Write-Verbose "Created application with ID: $($newApp.Id)"

        try {
            # Set application logo (this should succeed since we tested it earlier)
            $logoSet = SetAppLogo -AppId $newApp.Id
            if (-not $logoSet) {
                # If logo setting fails after app creation, clean up and fail
                Write-Error "Failed to set application logo after creation. Cleaning up application..."
                Remove-MgApplication -ApplicationId $newApp.Id
                return $null
            }

            # Create client secret
            Write-Verbose "Creating client secret..."
            $secretParams = @{
                ApplicationId      = $newApp.Id
                PasswordCredential = @{
                    DisplayName = "Setup-generated Secret"
                    EndDateTime = (Get-Date).AddYears(2)
                }
            }

            $secret = Add-MgApplicationPassword @secretParams
            Write-Verbose "Client secret created successfully"

            Write-Warning "Please make a note of the ClientSecret. You will not be able to retrieve it again."
            return [PSCustomObject]@{
                ObjectId     = $newApp.Id
                DisplayName  = $newApp.DisplayName
                TenantId     = (Get-MgContext).TenantId
                ClientId     = $newApp.AppId
                ClientSecret = $secret.SecretText
                HomePageUrl  = $script:NotSetMessage
                RedirectUrl  = $script:NotSetMessage
                Status       = "Created"
            }
        }
        catch {
            # Clean up the application if post-creation steps fail
            Write-Error "Post-creation configuration failed: $_"
            Write-Verbose "Attempting to clean up created application..."
            try {
                Remove-MgApplication -ApplicationId $newApp.Id
                Write-Verbose "Successfully cleaned up application"
            }
            catch {
                Write-Warning "Failed to clean up application $($newApp.Id): $_"
            }
            return $null
        }
    }
    catch {
        Write-Error "Failed to install application: $_"
        return $null
    }
}

<#
.SYNOPSIS
Modifies the home page, sign out and redirect URLs of the Tasks by Me application.

.DESCRIPTION
Updates the home page, sign out and redirect URL configurations for the Tasks by Me Entra ID application.

.PARAMETER BaseUri
The base URI where the Tasks by Me web application is located.

.EXAMPLE
Set-TasksByMeAppUrl -BaseUri "https://localhost:8080/"
#>

function Set-TasksByMeAppUrl {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true)]
        [string]$BaseUri
    )


    try {
        $uri = $null
        if (-not [System.Uri]::TryCreate($BaseUri, [System.UriKind]::Absolute, [ref]$uri)) {
            throw "Invalid BaseUrl: '$BaseUri'. Please provide a valid absolute URL (e.g., 'https://example.com')."
        }

        # Ensure the base URL does not end with a slash to prevent double slashes in constructed URLs
        # We use the validated Uri object's AbsoluteUri property to ensure consistent formatting
        $cleanedBaseUrl = $uri.AbsoluteUri.TrimEnd('/')

        # Construct the authentication-related URLs
        $redirectUrl = "$cleanedBaseUrl/auth/callback"
        $signOutUrl = "$cleanedBaseUrl/auth/logout"

        # Ensure Graph connection
        if (-not (EnsureGraphConnection)) {
            return
        }

        $app = GetTasksByMeApp
        if (-not $app) {
            Write-Error "Application '$script:AppDisplayName' not found. Please install it first using Install-TasksByMeApp."
            return
        }

        $currentApp = Get-MgApplication -ApplicationId $app.Id
        $webConfig = $currentApp.Web

        if (-not $webConfig) {
            $webConfig = @{
                RedirectUris = @($RedirectUrl)
            }
        }
        else {
            $webConfig.RedirectUris = @($redirectUrl)
        }

        $webConfig.HomePageUrl = $cleanedBaseUrl
        $webConfig.LogoutUrl = $signOutUrl

        $updateParams = @{
            ApplicationId = $app.Id
            Web           = $webConfig
        }

        if ($PSCmdlet.ShouldProcess($script:AppDisplayName, "Set Entra ID App URLs")) {
            Update-MgApplication @updateParams
            Write-Output "Updated Application URLs."
        }
    }
    catch {
        Write-Error "Failed to update Application URLs: $_"
    }
}

<#
.SYNOPSIS
Shows information about the Tasks by Me application.

.DESCRIPTION
Displays App ID, Display Name, Object ID, redirect URLs, and client secret information
for the Tasks by Me Entra ID application.

.EXAMPLE
Get-TasksByMeApp
#>

function Get-TasksByMeApp {
    [CmdletBinding()]
    param()

    try {
        # Ensure Graph connection
        if (-not (EnsureGraphConnection)) {
            return $null
        }

        $app = GetTasksByMeApp
        if (-not $app) {
            Write-Warning "Application '$script:AppDisplayName' not found."
            return $null
        }

        $status = "Ready"

        # Get detailed application information
        $detailedApp = Get-MgApplication -ApplicationId $app.Id

        # Extract redirect URIs from different platforms
        $redirectUris = @()
        if ($detailedApp.Web -and $detailedApp.Web.RedirectUris) {
            $redirectUris += $detailedApp.Web.RedirectUris | ForEach-Object { "$_ (Web)" }
            $homePageUrl = $detailedApp.Web.HomePageUrl
        }
        else {
            $homePageUrl = $script:NotSetMessage
            $status = "NotReady"
        }

        # Get client secret information (values cannot be retrieved)
        $secrets = $detailedApp.PasswordCredentials
        if ($secrets -and $secrets.Count -gt 0) {
            $secretInfo = "$($secrets.Count) secret(s) configured (values cannot be displayed)"
        }
        else {
            $secretInfo = "No client secrets configured. Please delete and re-create the application."
            $status = "NotReady"
        }

        return [PSCustomObject]@{
            ObjectId     = $detailedApp.Id
            DisplayName  = $detailedApp.DisplayName
            TenantId     = (Get-MgContext).TenantId
            ClientId     = $detailedApp.AppId
            ClientSecret = $secretInfo
            HomePageUrl  = $homePageUrl
            RedirectUrl  = if ($redirectUris) { $redirectUris -join "; " } else { $script:NotSetMessage }
            Status       = $status
        }
    }
    catch {
        Write-Error "Failed to retrieve application information: $_"
        return $null
    }
}

<#
.SYNOPSIS
Removes the Tasks by Me application.

.DESCRIPTION
Deletes the Tasks by Me Entra ID application and all its associated configurations.

.PARAMETER Confirm
Prompts for confirmation before deleting the application.

.EXAMPLE
Remove-TasksByMeApp

.EXAMPLE
Remove-TasksByMeApp -Confirm:$false
#>

function Remove-TasksByMeApp {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param()

    try {
        # Ensure Graph connection
        if (-not (EnsureGraphConnection)) {
            return
        }

        $app = GetTasksByMeApp
        if (-not $app) {
            Write-Warning "Application '$script:AppDisplayName' not found."
            return
        }

        if ($PSCmdlet.ShouldProcess($script:AppDisplayName, "Delete Entra ID Application")) {
            Remove-MgApplication -ApplicationId $app.Id
            Write-Output "Successfully deleted application '$script:AppDisplayName' (ID: $($app.AppId))"
        }
    }
    catch {
        Write-Error "Failed to delete application: $_"
    }
}

# Export module members
Export-ModuleMember -Function @(
    'Install-TasksByMeApp',
    'Set-TasksByMeAppUrl',
    'Get-TasksByMeApp',
    'Remove-TasksByMeApp'
)