Skip to main content

Pushing a web tab into your Customers' Teams environment.

· 7 min read

About this script

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!

The fix

:::security Securing Credentials in Scripts Do not store credentials directly in scripts. You should utilise services like Azure KeyVault or other PAM solutions to secure credentials used in scripts, including this one. :::

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!