Pushing a web tab into your Customers’ Teams environment.

HaloPSA is improving at a phenomenal rate – one of the latest enhancements relates to embedding the “customer portal” inside Teams for easier access by customers – this is early days for this enhancement to Halo and at present there isn’t really a way to push this tab into customer environments… Until now!

Using the script below you can push any website as a tab to your customers’ Teams environments. First a couple of configuration pre-requisites.

  1. You’ll need to have setup the Secure App Model (Thanks Gav for the superb write-up!)
  2. You’ll need to know the name of the Team and Channel you want to deploy the tab – currently the script will use the same details for all customers!

So here’s the script:

#################################################################################################################
###                                                                                                           ###
###  	Script by Mikey O'Toole                                                                               ###
###     Blog -                         https://homotechsual.dev                                               ###
###     GitHub Repository -            http://github.com/homotechsual                                         ###
###     Version 1.0 - 24/11/2021                                                                              ###
###                                                                                                           ###
###     This script uses code from various scripts by Kelvin Tegelaar (www.cyberdrain.com). Please consider   ###
###     supporting Kelvin by sponsoring him: https://github.com/sponsors/KelvinTegelaar and check out CIPP    ###
###     at https://kelvintegelaar.github.io/CIPP.                                                             ###
###                                                                                                           ###
#################################################################################################################

### Notes for Usage  ############################################################################################
###                                                                                                           ###
### You'll need to populate the section below with the information for a Secure App Model (SAM) azure AD app  ###
### which has the `TeamsTab.ReadWrite.All` or equivalent permissions.                                         ###
###                                                                                                           ###
### For step by step instructions on setting up a SAM app see:                                                ###
### https://www.gavsto.com/secure-application-model-for-the-layman-and-step-by-step/                          ###
###                                                                                                           ###
### Thanks to Luke Whitelock, Kelvin Tegelaar and Gavin Stone for inspiration, input + various bits of code.  ###
###                                                                                                           ###
#################################################################################################################
###                              Edit the variables below with your details                                   ###
#################################################################################################################
$CustomerExclude = @() # An array of customer domain names to exclude.
$CustomerInclude = @() # An array of customer domain names to include.
$TeamName = '' # The Team name to add the tab for - uses wild card matching - leave blank to attempt to add to the default "whole company" Team.
$ChannelName = '' # The channel name to add the tab for - leave blank for 'General'.
$ClientID = '' # This is the Azure AD application ID for your chosen SAM application.
$ClientSecret = '' # This is the application secret for the chosen SAM application.
$TenantID = '' # This is the tenant GUID for your Microsoft 365 tenant.
$TabName = '' # The name of the tab to add.
$TabURL = '' # This is the URL the tab should call.
#################################################################################################################
# Build the request to get an access token.
$AuthRequestBody = @{
    client_id = $ClientID
    client_secret = $ClientSecret
    scope = 'https://graph.microsoft.com/.default'
    grant_type = 'client_credentials'
}
# Get an access token.
$AccessToken = (Invoke-RestMethod -Uri "https://login.microsoftonline.com/$($TenantID)/oauth2/v2.0/token" -Method 'POST' -Body $AuthRequestBody -ErrorAction Stop)
$AuthHeader = @{ Authorization = "Bearer $($AccessToken.access_token)" }
# Get all partner contracts (Customer tenants) from Graph.
$GraphContractsURI = 'https://graph.microsoft.com/v1.0/contracts?$top=999'
$Customers = do {
    try {
        $GraphContracts = Invoke-RestMethod -Uri $GraphContractsURI -Method 'GET' -Headers $AuthHeader -ErrorAction Stop
        $GraphContractsURI = $GraphContracts.'@odata.nextLink'
        if ($GraphContracts.value) {
            $GraphContracts.value
        }
    } catch {
        if ($_.ErrorDetails.Message) {
            $Message = ($_.ErrorDetails.Message | ConvertFrom-Json).error.message
        }
        if ($Null -eq $Message) {
            $Message = $($_.Exception.Message)
        }
        throw $Message
    }
} until ([String]::IsNullOrEmpty($GraphContractsURI))
# Temporarily override the information preference to output our `Write-Information` calls.
$OriginalInformationPreference = $InformationPreference
$InformationPreference = 'Continue'
# Function to retrieve Teams from Microsoft Graph.
function Get-TeamFromGraph ([hashtable]$CustomerAuthHeader, [string]$TeamName) {
    $GraphTeamsURI = "https://graph.microsoft.com/v1.0/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')"
    $Teams = do {
        try {
            $GraphTeams = Invoke-RestMethod -Uri $GraphTeamsURI -Method 'GET' -Headers $CustomerAuthHeader -ErrorAction Stop
            $GraphTeamsURI = $GraphTeams.'@odata.nextLink'
            if ($GraphTeams.value) {
                $GraphTeams.value
            }
        } catch {
            if ($_.ErrorDetails.Message) {
                $Message = ($_.ErrorDetails.Message | ConvertFrom-Json).error.message
            }
            if ($Null -eq $Message) {
                $Message = $($_.Exception.Message)
            }
            throw $Message
        }
    } until ([String]::IsNullOrEmpty($GraphTeamsURI))
    $Team = $Teams | Where-Object { $_.displayName -eq "$($TeamName)" }
    if ($Team) {
        return $Team
    } else {
        throw "Team $($TeamName) not found."
    }
}
# Function to retrieve channels from Microsoft Graph.
function Get-TeamChannelFromGraph ([object]$Team, [hashtable]$CustomerAuthHeader, [string]$ChannelName = 'General') { 
    $Channels = do {
        try {
            $GraphTeamChannelsURI = "https://graph.microsoft.com/v1.0/teams/$($Team.id)/channels"
            $GraphChannels = Invoke-RestMethod -Uri $GraphTeamChannelsURI -Method 'GET' -Headers $CustomerAuthHeader -ErrorAction Stop
            $GraphTeamChannelsURI = $GraphChannels.'@odata.nextLink'
            if ($GraphChannels.value) {
                $GraphChannels.value
            }
        } catch {
            if ($_.ErrorDetails.Message) {
                $Message = ($_.ErrorDetails.Message | ConvertFrom-Json).error.message
            }
            if ($Null -eq $Message) {
                $Message = $($_.Exception.Message)
            }
            throw $Message
        }
    } until ([String]::IsNullOrEmpty($GraphTeamChannelsURI))
    if (-not [String]::IsNullOrEmpty($ChannelName)) {
        $Channel = $Channels | Where-Object { $_.displayName -eq "$($ChannelName)" }
    } else {
        $Channel = $Channels
    }
    if ($Channel) {
        return $Channel
    } else {
        throw "Channel $($ChannelName) not found."
    }
}
# Function to add the tab to  the teams Channel.
function Add-TabToTeamsChannel ([hashtable]$CustomerAuthHeader, [object]$Team, [object]$Channel, [object]$Customer, [object]$Tab) {
    Write-Information "Adding Teams tab to channel $($Channel.displayName) in team $($Team.displayName) in $($Customer.displayName)."
    try {
        # Make sure we don't already have a tab with the same name.
        $Tab = (Invoke-RestMethod -Method Get -Uri ("https://graph.microsoft.com/v1.0/teams/$($team.ID)/channels/$($channel.ID)/tabs?`$filter=DisplayName eq " + "'" + $tabName + "'") -Headers $CustomerAuthHeader).value
        if (-Not $Tab) {
            # Build the tab.
            $TeamsTab = [ordered]@{
                displayName = $TabName
                "teamsApp@odata.bind" = "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/com.microsoft.teamspace.tab.web"
                configuration = @{
                    contentUrl = $TabURL
                    websiteUrl = $TabURL
                }
            } | ConvertTo-Json
            # Add the tab to the channel.
            Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/teams/$($Team.id)/channels/$($Channel.id)/tabs" -Method 'POST' -Body $TeamsTab -Headers $CustomerAuthHeader -ContentType 'application/json' -ErrorAction Stop
        } else {
            Write-Information "Tab $($Tab.displayName) already exists in channel $($Channel.displayName) in team $($Team.displayName) in $($Customer.displayName)."
        }
    } catch {
        if ($_.ErrorDetails.Message) {
            $Message = ($_.ErrorDetails.Message | ConvertFrom-Json).error.message
        }
        if ($Null -eq $Message) {
            $Message = $($_.Exception.Message)
        }
        throw $Message
    }
}

if ($CustomerExclude.Count -ge 1) {
    Write-Information "Running in exclusion mode."
} elseif ($CustomerInclude.Count -ge 1) {
    Write-Information "Running in inclusion mode."
} else {
    Write-Information "Running in all customers mode."
}

ForEach ($Customer in $Customers) {
    if ($CustomerExclude.Count -ge 1) {
        if (-Not($CustomerExclude -contains $Customer.DefaultDomainName)) {
            Write-Information "Connecting to $($Customer.DisplayName) and retrieving all Teams."
            $CustomerAccessToken = (Invoke-RestMethod -Uri "https://login.microsoftonline.com/$($Customer.CustomerID)/oauth2/v2.0/token" -Method 'POST' -Body $AuthRequestBody -ErrorAction Stop)
            $CustomerAuthHeader = @{ Authorization = "Bearer $($CustomerAccessToken.access_token)" }
            if (-not [String]::IsNullOrEmpty($TeamName)) {
                $Team = Get-TeamFromGraph -CustomerAuthHeader $CustomerAuthHeader -TeamName $TeamName
            } else {
                $Team = Get-TeamFromGraph -CustomerAuthHeader $CustomerAuthHeader -TeamName $($Customer.DisplayName)
            }
            if (-not [String]::IsNullOrEmpty($ChannelName)) {
                $Channel = Get-TeamChannelFromGraph -Team $Team -CustomerAuthHeader $CustomerAuthHeader -ChannelName $ChannelName
            } else {
                $Channel = Get-TeamChannelFromGraph -Team $Team -CustomerAuthHeader $CustomerAuthHeader
            }
            Add-TabToTeamsChannel -CustomerAuthHeader $CustomerAuthHeader -Team $Team -Channel $Channel -Customer $Customer
        } else {
            Write-Information "Skipping $($Customer.DisplayName) as it is in the exclude list."
        }
    } elseif ($CustomerInclude.Count -ge 1) {
        if ($CustomerInclude -contains $Customer.DefaultDomainName) {
            Write-Information "Connecting to $($Customer.DisplayName) and retrieving all Teams."
            $CustomerAccessToken = (Invoke-RestMethod -Uri "https://login.microsoftonline.com/$($Customer.CustomerID)/oauth2/v2.0/token" -Method 'POST' -Body $AuthRequestBody -ErrorAction Stop)
            $CustomerAuthHeader = @{ Authorization = "Bearer $($CustomerAccessToken.access_token)" }
            if (-not [String]::IsNullOrEmpty($TeamName)) {
                $Team = Get-TeamFromGraph -CustomerAuthHeader $CustomerAuthHeader -TeamName $TeamName
            } else{
                $Team = Get-TeamFromGraph -CustomerAuthHeader $CustomerAuthHeader -TeamName $($Customer.DisplayName)
            }

            if (-not [String]::IsNullOrEmpty($ChannelName)) {
                $Channel = Get-TeamChannelFromGraph -Team $Team -CustomerAuthHeader $CustomerAuthHeader -ChannelName $ChannelName
            } else {
                $Channel = Get-TeamChannelFromGraph -Team $Team -CustomerAuthHeader $CustomerAuthHeader
            }
            Add-TabToTeamsChannel -CustomerAuthHeader $CustomerAuthHeader -Team $Team -Channel $Channel -Customer $Customer
        } else {
            Write-Information "Skipping $($Customer.DisplayName) as it is not in the include list."
        }
    }
}
$InformationPreference = $OriginalInformationPreference

So how do you use this? Well for the simplest option – you just fill in the variables in the marked section noting that:

  • If you leave $CustomerExclude and $CustomerInclude blank the script will run on all customers.
  • If you leave $TeamName blank the script will use the default Team – which should have the same display name as the tenant itself.
  • If you leave $ChannelName blank the script will use “General” as the target channel.
  • Make sure you set $TabName and $TabURL to your desired values – both are required!

Once you’ve filled in the variables – you simply run the script and wait for the tabs to be added!

Leave a comment