April 25, 2025

Retrieving Viva Engage (Yammer) Posts in SPFx – A Complete Guide

Overview

With Microsoft Viva Engage (formerly Yammer) becoming a central hub for company-wide communication and community engagement, integrating its content directly into SharePoint through SPFx (SharePoint Framework) can provide a seamless user experience.

While Viva Engage content can be embedded using the out-of-the-box (OOTB) web part, it may not provide the flexibility and customization required for more complex or tailored use cases. That’s where SPFx steps in. With custom development, you can fetch and display posts from specific communities, apply filters, or even perform additional business logic as needed.

In this blog post, we will walk you through how to retrieve Viva Engage posts using SPFx and Microsoft Graph API. This is especially useful when the OOTB web part doesn't offer sufficient customization for your needs.

Prerequisites

Before diving into code, make sure you have the following ready:

  • SPFx development environment set up
  • Access to Microsoft 365 admin portal (to grant API permissions)
  • Viva Engage license and active community
  • Basic knowledge of React (optional, but helpful)

Step 1: Register Your App in Azure AD

To access Viva Engage data, you need permissions via Microsoft Graph API.

  1. Go to Azure Portal
  2. Navigate to Azure Active Directory > App registrations
  3. Click New registration
  4. Name your app (e.g., SPFxVivaEngageIntegration)
  5. Add redirect URI: https://localhost:5432
  6. Once registered, go to API permissions > Add permission
  7. Choose APIs my organization uses and search for Yammer
  8. Select Yammer and then Delegated permissions
  9. Add: user_impersonation
  10. Click Grant admin consent

Step 2: Get Access Token

Here’s how to get a token for Yammer (Viva Engage) using AAD token provider in SPFx:

 private async getViVaEngageToken(): Promise<void> {  
  try {  
   const tokenProvider = await this.props.spcontext.aadTokenProviderFactory.getTokenProvider();  
   const token = await tokenProvider.getToken("https://api.yammer.com");  
   this.setState(  
    { vivaEngageToken: token },  
    this.getAllPostsfromGroups  
   );  
  } catch (error) {  
   console.error("Error getting token: ", error);  
  }  
 }  

Step 3: Retrieve Group ID Using Community Name

This method fetches all groups and finds the group ID based on the community name.

 private async getGroupIdByName(communityName: string): Promise&lt;{ topLevelMessages: any[] }&gt; {  
  try {  
   const response = await this.props.spcontext.httpClient.get(  
    `https://api.yammer.com/api/v1/groups.json`,  
    HttpClient.configurations.v1,  
    {  
     headers: {  
      Authorization: `Bearer ${this.state.vivaEngageToken}`,  
      "Content-type": "application/json",  
     },  
    }  
   );  
   const data = await response.json();  
   const group = data.find((g: any) =&gt; g.full_name === communityName);  
   if (group) {  
    return await this.getPosts(group.id);  
   } else {  
    console.warn("Community not found.");  
    return { topLevelMessages: [] };  
   }  
  } catch (error) {  
   console.error("Error fetching group ID: ", error);  
   return { topLevelMessages: [] };  
  }  
 }  

Step 4: Retrieve Posts from Viva Engage

This method calls the messages API and returns only the top-level posts (ignores replies).

 private async getPosts(communityId: string): Promise&lt;{ topLevelMessages: any[] }&gt; {  
  try {  
   const apiUrl = `https://api.yammer.com/api/v1/messages/in_group/${communityId}.json?threaded=true`;  
   const response = await this.props.spcontext.httpClient.get(  
    apiUrl,  
    HttpClient.configurations.v1,  
    {  
     headers: {  
      Authorization: `Bearer ${this.state.vivaEngageToken}`,  
      "Content-type": "application/json",  
     },  
    }  
   );  
   const data = await response.json();  
   const messages = data?.messages || [];  
   const topLevelMessages = messages.filter((msg: any) =&gt; !msg.replied_to_id);  
   return { topLevelMessages };  
  } catch (error) {  
   console.error("Error fetching posts: ", error);  
   return { topLevelMessages: [] };  
  }  
 }  

Conclusion

By following the steps above, you can build a custom SPFx web part to pull Viva Engage posts using a community name and show them dynamically in your SharePoint site. This allows for a much more flexible and personalized experience beyond the OOTB web part.

You now have a streamlined way to:

  • Authenticate with Azure AD
  • Retrieve a Viva Engage community’s group ID using its name
  • Fetch and return only the main posts (excluding replies)

This approach is perfect if you're building SPFx web parts or extensions to display real-time conversations and announcements from Viva Engage.

If you have any questions you can reach out our SharePoint Consulting team here.

Transform Your SharePoint Edit Forms into Structured, User-Friendly Interfaces – No Code Needed!

Why Customize SharePoint Edit Form? 

SharePoint’s default edit form is functional but often lacks visual appeal and logical grouping, especially for lists with many fields. JSON form formatting empowers you to: 

  • Improve usability by organizing fields into collapsible sections. 
  • Enhance aesthetics with icons, headers, and consistent spacing. 
  • Add functionality like help links or dynamic buttons. 

In this guide, you will learn to customize the header, body and footer of the edit form popup using JSON let’s dive in! 

Step 1: Enable JSON Customization for Your List: 

  1. Navigate to your SharePoint list where you want to customize the edit form. 
  2. Click on the “Add new item” button. 
  3. In the form, click on the “Edit Form” option (top-right corner). 
  4. Easily customize your SharePoint list using JSON with a no-code, low-code approach to enhance layout and design.
  5. Select “Configure Layout” from the dropdown. 
  6. A panel will appear with three sections: Header, Body, Footer. 
  7. Choose the section you want to customize and paste your JSON code into the editor. 

Step 2: Structure of JSON form formatting: 

SharePoint Forms are divided into three sections: 

  1. Header: Modify the form header to display a title, icon or additional details 
  2. Body: Organize fields into sections for better readability 
  3. Footer: Customize the footer with buttons and actions. 

Step 3: Customizing the Header: 

The header appears at the top of the form. We can add: 

The header appears at the top of the form, and we can customize it to enhance the appearance by adding an icon, title, or even a custom logo. 

Adding an Icon in the header: 

{
  "elmType": "div",
  "attributes": {
    "class": "ms-borderColor-neutralTertiary"
  },
  "style": {
    "width": "100%",
    "border-top-width": "0px",
    "border-bottom-width": "1px",
    "border-left-width": "0px",
    "border-right-width": "0px",
    "border-style": "solid",
    "margin-bottom": "16px"
  },
  "children": [
    {
      "elmType": "div",
      "style": {
        "display": "flex",
        "box-sizing": "border-box",
        "align-items": "center"
      },
      "children": [
        {
          "elmType": "div",
          "attributes": {
            "iconName": "Onboarding",
            "class": "ms-fontSize-42 ms-fontWeight-regular ms-fontColor-themePrimary",
            "title": "Details"
          },
          "style": {
            "flex": "none",
            "padding": "0px",
            "padding-left": "0px",
            "height": "36px"
          }
        }
      ]
    },
    {
      "elmType": "div",
      "attributes": {
        "class": "ms-fontColor-neutralSecondary ms-fontWeight-bold ms-fontSize-24"
      },
      "style": {
        "box-sizing": "border-box",
        "width": "100%",
        "text-align": "left",
        "padding": "21px 12px",
        "overflow": "hidden"
      },
      "children": [
        {
          "elmType": "div",
          "txtContent": "= 'Employee Information of ' + [$Title]"
        }
      ]
    }
  ]
}

Where to make changes for popup form Appearance:

Change Required 

Modify This Part in JSON 

Change Icon 

"iconName": "Onboarding" (Replace with another Fluent UI icon) 

Change Icon Size 

“ms-fontsize-42" (increase/decrease number) 

Change Icon Color 

“ms-fontColor-themePrimary" (change theme color) 

Change Header Background Color 

Add “background-color” : “#F32F1” inside style 

Change Title Text 

Modify “txtContent”: “= 'Employee Information of  ' + [$Title]" 

Change Title Font Size 

“ms-fontSize-24" (increase/decrease number) 

Change Title Alignment 

“text-align”: “left” ->”center” or “right” 

Adjust Header Height & Spacing 

Modify “height”, “margin-bottom”, “padding” 

Make your SharePoint Online list header more attractive by adding icons and matching the design to the list name.

How to select an Icon for the Header: 

You can select any Fluent UI icon for your header. Follow these steps to get the correct icon name:  

  1. Go to https://uifabricicons.azurewebsites.net/ . 
  2. Search for the icon you want. 
  3. Right-click the icon and select “Copy Friendly Name”. 
  4. Paste the copied name into the “iconName” field in the JSON above. 

This will display the selected icon in your SharePoint form header. 

Adding a Custom logo in the header: 

If you want to display a custom logo instead of an icon, modify the JSON as follow: 

{
  "elmType": "img",
  "attributes": {
    "src": "https://yourtenant.sharepoint.com/sites/YourSite/Shared%20Documents
    /Logo.jpg",
    "title": "Company Logo",
    "class": "ms-borderRadius-circle"
  },
  "style": {
    "width": "200px",
    "height": "100px",
    "margin-right": "10px"
  }
}
Customize SharePoint Online list headers by adding an image or logo to create a more branded and visually appealing layout

Explanation of Changes: 

  • ElmType changed from “div” to “img” - this tells SharePoint to display an image instead of an icon. 
  • Src attribute- replace the placeholder URL with the actual URL of your logo stored in a SharePoint document library. 
  • Style changes- Adjust the width, height and margin as needed. 
  • Note:- Ensure that the image URL is publicly accessible or that SharePoint permissions allow users to view the image. 

Step 4: Customizing the Body into Sections:

{
  "sections": [
    {
      "displayname": "Personal Details",
      "fields": [
        "Title",
        "Address",
        "Email Address",
        "Date of Birth",
        "Contact Number",
        "Blood Group"
      ]
    },
    {
      "displayname": "Company Details",
      "fields": [
        "Previous Company Name",
        "Experience of Previous Company",
        "Total Number of Experience",
        "Designation in Previous Company",
        "Income of Previous Company",
        "Expected Income"
      ]
    }
  ]
}

Where to make changes to affect the popup form appearance: 

Change Required 

Modify This Part in JSON 

Change Section Heading 

Modify “displayname” : “Personal Deatils” or “displayname”: “Company Details” 

Add a New Section 

Copy a section and create a new “displayname” with relevant “fields” 

Add a Field to an Existing Section 

Add the field name to the “fields” array 

Remove a Field 

Delete the field name from the “fields” array 

Rearrange Fields in a Section 

Change the order of fields in the “fields” array 

Use JSON formatting to change how SharePoint list items look, so they fit your design and needs.

Step 5: Customizing the Footer with Action Buttons: 

{
  "elmType": "div",
  "style": {
    "width": "100%",
    "text-align": "left",
    "overflow": "hidden",
    "border-top-width": "1px"
  },
  "children": [
    {
      "elmType": "div",
      "style": {
        "width": "100%",
        "padding-top": "10px",
        "height": "24px"
      },
      "children": [
        {
          "elmType": "a",
          "txtContent": "='Contact Details for ' + [$Title]",
          "attributes": {
            "target": "_blank",
            "href": "https://www.google.com/",
            "class": "ms-fontColor-themePrimary
            ms-borderColor-themePrimary
            ms-fontWeight-semibold
            ms-fontSize-m
            ms-fontColor-neutralSecondary–hover
            ms-bgColor-themeLight–hover"
          }
        }
      ]
    }
  ]
}

Where to make changes for popup form Appearance:

Change Required 

Modification This Part in JSON

Change the Link URL 

Replace “href”: https://www.google.com/ with your desired link(e.g. [$ProfileURL] for dynamic links 

Open in Same Tab 

Change “target”: “_blank” to “target”: “_self”. 

Change Link Text 

Modify “txtContent”: “=’Contact Details for ‘ + [$Title]” 

Change Text Color 

Modify “class” and replace ms-fontColor-themePrimary with ms-fontColor-red. 

Make it a Button instead of a link 

Change “elmType” : “a” to “elmtype” : “button”  

Enhance your SharePoint Online site with a custom, visually appealing footer section that improves user experience and boosts branding consistency.

Step 6:  Testing and Debugging: 

  1. Copy the complete JSON code (header, body, and footer). 
  2. Paste it into the JSON customization panel in SharePoint. 
  3. Click Save and refresh your list. 
  4. Edit an item to see the customized popup form in action! 

Tips for Advanced Customization: 

  1. Conditional Formatting: Hide/show sections based on field values using “visible”: “=[$status]== ‘Active’” 
  2. Theming: Match your organization’s colors with “style”: { “background”: “#f0f0f0”} 
  3. Integrate Power Automate: Add buttons that trigger workflows using “actionType”: “executeFlow”. 

Conclusion:     

  • Customizing forms with JSON in SharePoint makes it much easier to create clean and user-friendly edit forms. You can organize fields better, add helpful icons, and customize buttons to make the form look good and work smoothly. This helps users save time and makes using the form more enjoyable.

If you have any questions you can reach out our SharePoint Consulting team here.

Precision Scaling in Kubernetes with KEDA: ScaledObject vs. ScaleJob

Introduction

  • Kubernetes scaling is an art that requires precision, especially when working with event-driven workloads. I recently fine-tuned an AWS EKS workload using KEDA (Kubernetes Event-Driven Autoscaling). KEDA provides two primary scaling mechanisms: ScaledObject and ScaleJob.
  • ScaledObject dynamically adjusts pod replicas for Deployments or StatefulSets based on metrics like SQS queue length.
  • ScaleJob is ideal for short-lived tasks, spinning up Jobs to process a single message before terminating.

What situation we encountered?

  • Initially, we faced challenges in achieving precise autoscaling for an event-driven SQS message processing workload on AWS EKS. The key requirements were:
    • A minimum of one pod should always be running.
    • If there were fewer than four messages, they should be processed by a single pod.
    • Scaling should start when messages exceeded five.
  • We explored KEDA’s ScaleJob, but it terminated pods after processing individual messages. Next, we tried KEDA’s built-in SQS trigger, which lacked precise control, specifically:
  • It scaled down too soon, ignoring in-flight messages.
  • It didn’t allow fine-tuned scaling logic based on both visible and in-flight messages.
  • For my use case—an SQS message processing application—ScaledObject was the right choice, keeping pods alive instead of terminating them.

Why we took this approach?

  • To overcome these limitations, we implemented a custom scaler using Flask and Prometheus, allowing us to:
  • Continuously monitor both visible and in-flight SQS messages.
  • Maintain pod count until all messages were processed.
  • Dynamically scale based on real-time queue depth.
  • Prevent premature scale-downs using a stateful tracking mechanism.

Scaling Requirement: Precise Autoscaling with SQL Messages

  • Target Scaling Behavior
    • Scale-up Logic:
      • 1 pod for 0–5 messages
      • 2 pods for 6 messages
      • 3 pods for 7 messages
      • 10 pods for 14+ messages
    • Scale-down Logic:
      • Maintain current pod count until both visible + in-flight messages hit zero.
      • After 120 seconds of inactivity, scale down to 1 pod.
      • To reduce costs, these pods were deployed on AWS Spot Instances, ensuring resilience while taking advantage of lower pricing.

Initial Approach: Using KEDA's Built-in SQS Trigger

  • KEDA provides an aws-sqs-queue trigger to auto-scale based on queue length:
  • queueLength: Defines how many messages in the queue correspond to one pod.
    • Formula: Desired Pods = Total Messages / queueLength
  • targetValue: The threshold metric value at which scaling occurs (e.g., desired messages per pod).
    • Formula: Desired Pods = Total Messages / targetValue
  • activationQueueLength: Minimum number of messages required in the queue before scaling starts.
    • Formula: Scaling Triggers If Total Messages ≥ activationQueueLength
Aspect ScaledObject ScaledJob
Workload Long-running (e.g., Deployments) Short-lived (e.g., Jobs)
Scaling Adjusts pod replicas (0 to N) Creates new Job instances
Use Case Continuous services (e.g., web apps) One-off tasks (e.g., batch processing)
Execution Pods stay active, process multiple events Jobs run once per event, then terminate
Concurrency Multiple pods run in parallel Multiple Jobs run independently

Custom Scaler Solution: Achieving Precision

  • To overcome this, I built a Flask-based custom scaler, exposing Prometheus metrics via /api/v1/query. The logic ensures pods remain active until both visible and in-flight messages reach zero.
  • Python Code:
    last_replicas = 1 def calculate_replicas(visible, not_visible): global last_replicas total_messages = visible + not_visible app.logger.info(f"Visible: {visible}, NotVisible: {not_visible}, Total: {total_messages}") if total_messages == 0: app.logger.info("Queue empty, returning: 1") last_replicas = 1 return 1 else: if total_messages <= 5: desired_replicas = 1 app.logger.info(f"Total <= 5, desired replicas: {desired_replicas}") else: desired_replicas = min(total_messages - 4, 10) app.logger.info(f"Total > 5, calculated desired replicas: {desired_replicas}") if desired_replicas > last_replicas: last_replicas = desired_replicas app.logger.info(f"Scaling up, updating last_replicas to: {last_replicas}") else: app.logger.info(f"Maintaining last_replicas at: {last_replicas} (no scale-down until queue is empty)") return last_replicas
  • Key Features:
    • Maintains pod count until the queue is fully processed.
    • Prevents premature scale-down using last_replicas.
    • Scales dynamically based on visible + in-flight messages.

Deployment: Integrating with KEDA ScaledObject

  • To use this custom scaler in KEDA, we configured a ScaledObject pointing to the Flask service:
  • KEDA Configuration:
    apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: custom-sqs-scaler spec: scaleTargetRef: kind: Deployment name: app pollingInterval: 10 # Every 10 seconds cooldownPeriod: 120 # Wait 120s before scaling down minReplicaCount: 1 maxReplicaCount: 10 triggers: - type: prometheus metadata: serverAddress: "http://custom-sqs-scaler.non-prod.svc.cluster.local:8080" metricName: queue_messages query: "queue_messages" threshold: "1"
  • Why This Works?
    • The Prometheus trigger queries the Flask app for real-time SQS message count.
    • The custom logic ensures pods remain active until all messages are processed.
    • Scaling down is prevented until the queue is empty.

Results: Efficient, COst-effective Scaling

  • Here’s proof that the scaler correctly handled a 12-message workload:
  • Log Output:
    INFO:scaler:Visible: 2, In-flight: 10, Total: 12 INFO:scaler:Total > 5, calculated desired replicas: 8 INFO:scaler:Maintaining last_replicas at: 10 (no scale-down until queue is empty)
  • Key Takeaways:
    • Maintained optimal pod count dynamically.
    • Ensured cost-effective scaling with spot instances.
    • Eliminated premature scale-down issues.

How this has helped?

  • By using a Prometheus-based custom scaler, we achieved:
  • Full control over scaling behavior: Pods scale up accurately based on total messages (visible + in-flight).
  • No premature scale-down: The last known pod count is maintained until the queue is fully processed.
  • Cost-effective scaling: By deploying on AWS Spot Instances, we optimized costs while ensuring workload resilience.
  • Seamless workload management: The system efficiently handled varying message loads without delays or bottlenecks.

Final Thoughts

  • KEDA’s built-in triggers provide a great starting point but can fall short in handling complex scaling scenarios. By implementing a custom scaler, we achieved:
  • Precision scaling based on visible + in-flight messages.
  • No premature scale-down, ensuring messages don’t pile up.
  • Cost-optimized scaling with AWS Spot Instances.
  • For workloads requiring precise autoscaling, implementing a Prometheus-based custom scaler significantly enhances efficiency and cost optimization.

April 17, 2025

Automating Azure Service App Deployment with Azure DevOps Pipelines

Introduction

In modern software development, Continuous Integration and Continuous Deployment (CI/CD) are crucial in ensuring smooth, automated deployments. Azure DevOps provides robust pipeline capabilities that enable developers to automate the deployment of their applications, reducing manual effort and minimizing errors.

In this blog, we’ll walk through setting up an Azure DevOps pipeline for an Azure Service App, ensuring seamless deployment whenever changes are pushed to the repository.

Setting Up the Azure DevOps Pipeline


Before diving into the pipeline configuration, ensure you have:
  • An Azure Service App created in the Azure Portal.
  • service connection in Azure DevOps is linked to your Azure subscription.
  • Your Service App code is stored in a repository like Azure Repos, GitHub, or Bitbucket.

Pipeline Configuration

Below is a YAML-based Azure DevOps pipeline that automates the build and deployment of an Azure Service App.


trigger:
  branches:
    include:
    - main # Change this to your branch if needed
  paths:
    include:
    - ServiceAppCode/*

variables:
  azureSubscription: 'ServiceAppDeployment' # Azure service connection name
  serviceAppName: 'TestingApp' # Azure Service App name
  serviceAppPath: 'ServiceAppCode' # Path to Service App source code
  buildConfiguration: 'Release' # Build service app source code in release
  publishDirectory: '$(Build.ArtifactStagingDirectory)/publish'

pool:
  vmImage: 'ubuntu-22.04' # Also use ubuntu-latest

stages:
- stage: Build
  displayName: 'Build Stage'
  jobs:
  - job: Build
    displayName: 'Build Job'
    steps:
    - task: UseDotNet@2
      displayName: 'Install .NET SDK'
      inputs:
        packageType: 'sdk'
        version: '8.0.x'
        includePreviewVersions: false

    - script: |
        echo "Cleaning up existing publish directory..."
        rm -rf $(publishDirectory)
        mkdir -p $(publishDirectory)
      displayName: 'Ensure Clean Publish Directory'

    - task: DotNetCoreCLI@2
      displayName: 'Restore Dependencies'
      inputs:
        command: 'restore'
        projects: '$(serviceAppPath)/*.csproj'

    - task: DotNetCoreCLI@2
      displayName: 'Build Service App'
      inputs:
        command: 'build'
        projects: '$(serviceAppPath)/*.csproj'
        arguments: '--configuration $(buildConfiguration) /p:WarningLevel=0' # /p:WarningLevel=0 Remove the warning at the time of build service app

    - task: DotNetCoreCLI@2
      displayName: 'Publish Service App'
      inputs:
        command: 'publish'
        projects: '$(serviceAppPath)/*.csproj'
        publishWebProjects: false
        arguments: '--configuration $(buildConfiguration) --output $(publishDirectory)'
        zipAfterPublish: true

    - task: PublishBuildArtifacts@1
      displayName: 'Publish Artifacts'
      inputs:
        pathToPublish: '$(publishDirectory)'
        artifactName: 'drop'

- stage: Deploy
  displayName: 'Deploy Stage'
  dependsOn: Build
  condition: succeeded()
  jobs:
  - job: Deploy
    displayName: 'Deploy to Azure Service App'
    steps:
    - download: current
      displayName: 'Download Build Artifacts'
      artifact: 'drop'

    - task: AzureServiceApp@1
      displayName: 'Deploy to Azure Service App'
      inputs:
        azureSubscription: '$(azureSubscription)'
        appType: 'webApp' # functionApp for function app OR webApp for web app
        appName: '$(serviceAppName)'
        package: '$(Pipeline.Workspace)/drop/*.zip'

Understanding the Pipeline

1. Trigger Configuration

  • The pipeline is triggered when changes are pushed to the main branch.
  • It monitors specific folders where Service App updates are made.

2. Defining Variables

  • azureSubscription: The name of the Azure DevOps service connection.
  • serviceAppName: The name of the Service App in Azure.
  • serviceAppPath: The directory containing the Service App source code.
  • publishDirectory: The folder where the build output is stored.

3. Choosing the Right Agent Pool

  • The pipeline uses the Ubuntu 22.04 VM image for the build process

4. Build Stage

  • Installing .NET SDK: Ensures that the required version is available.
  • Cleaning the Publish Directory: Prevents old files from interfering with new builds.
  • Restoring Dependencies: Ensures that all NuGet dependencies are downloaded.
  • Building the Service App: Compiles the service app with the specified configuration.
  • Publishing the Service App: Packages the compiled app for deployment.
  • Publishing Artifacts: Stores the published files as build artifacts for the deployment stage.

5. Deploy Stage

  • Downloading Build Artifacts: Retrieves the published application files.
  • Deploying to Azure: Uses the AzureServiceApp@1 task to deploy the service app to Azure.

Conclusion

By setting up this Azure DevOps pipeline, you can automate the deployment of your Azure Service App, ensuring quick and error-free releases.

With automation in place, you can focus on developing new features while Azure DevOps handles the heavy lifting of deployments!


If you have any questions you can reach out our SharePoint Consulting team here.

Custom PowerShell Script to Copy Site Groups, Permissions, and Settings Without Migration Tool

Introduction 

Migrating permissions, site groups, and settings from a SharePoint Hub Site to an associated site is a common task, especially in large organizations. While available migration tools can make this easy, they often come at a cost. 

In this blog post, we’ll walk through a custom PowerShell script that automates this process without using any migration tool.  

What This Script Does 

  • Connects to both Hub Site and Associated Site 
  • Copies custom permission levels 
  • Copies site groups with users 
  • Applies appropriate role assignments 
  • Updates sharing capabilities 
  • Streamlines migration process for SharePoint Online environments 

Script Parameters 

Param ( 
    [string] $ClientId = $(Throw "Please provide ClientId"), 
    [string] $HubSiteURL = $(Throw "Please provide HubSiteURL"), 
    [string] $AdminSiteURL = $(Throw "Please provide AdminSiteURL"), 
    [string] $ClientSecret = $(Throw "Please provide ClientSecret"), 
    [string] $AssociatedSiteURL = $(Throw "Please provide Associated Site URL") 
) 

You’ll need to register an Azure AD App with appropriate SharePoint API permissions and provide its ClientId and ClientSecret. This ensures secure authentication without using stored credentials. 

Authentication and Setup 

$HubSiteConnection = Connect-PnPOnline -Url $HubSiteURL -ClientId $ClientId -ClientSecret $ClientSecret -ReturnConnection 
$AssociatedSiteConnection = Connect-PnPOnline -Url $AssociatedSiteURL -ClientId $ClientId -ClientSecret $ClientSecret -ReturnConnection

This part of the script connects to both the Hub Site and the Associated Site using PnP PowerShell, returning secure connections for use in the following functions. 

Updating Sharing Capability 

function Update-ExternalSharing { 
    param ( 
        [string] $AssociatedSiteURL, 
        [string] $AdminSiteURL 
    ) 
 
    Connect-PnPOnline -Url $AdminSiteURL -ClientId $ClientId -ClientSecret $ClientSecret 
    Set-PnPTenantSite -Url $AssociatedSiteURL -SharingCapability ExternalUserSharingOnly 
}
This function sets the sharing capability of the associated site to allow external users to access the site — but only if they are authenticated. This is done by updating the SharingCapability property of the site to ExternalUserSharingOnly. 

Copying Permission Levels 

function Copy-PermissionLevels { 
    param ( 
        $HubSiteConnection, 
        $AssociatedSiteConnection 
    ) 
 
    $AllPermissionLevels = Get-PnPRoleDefinition -Connection $HubSiteConnection 
 
    foreach ($PermissionLevel in $AllPermissionLevels) { 
        if (-not $PermissionLevel.Hidden) { 
            $ExistingPermission = Get-PnPRoleDefinition -Identity $PermissionLevel.Name -Connection $AssociatedSiteConnection -ErrorAction SilentlyContinue 
            if (!$ExistingPermission) { 
                $selectedPermissions = New-Object Microsoft.SharePoint.Client.BasePermissions 
                [Enum]::GetValues([Microsoft.SharePoint.Client.PermissionKind]) | ForEach-Object { 
                    if ($PermissionLevel.BasePermissions.Has($_)) { 
                        $selectedPermissions.Set($_) 
                    } 
                } 
 
                $newRole = Add-PnPRoleDefinition -RoleName $PermissionLevel.Name -Description $PermissionLevel.Description -Connection $AssociatedSiteConnection 
                $newRole.BasePermissions = $selectedPermissions 
                $newRole.Update() 
                Invoke-PnPQuery -Connection $AssociatedSiteConnection 
            } 
        } 
    } 
} 
This function copies non-hidden permission levels from the hub site to the associated site. It checks if the permission already exists to avoid duplication and applies the same Base Permissions.  Copying Groups and Users 
function Copy-HubSiteGroups { 
    param ( 
        $HubSiteName, 
        $AssociatedSiteName, 
        $HubSiteConnection, 
        $AssociatedSiteConnection 
    ) 
 
    $GroupMappings = @( 
        @{ Source = "$HubSiteName Owners"; Destination = "$AssociatedSiteName Owners" }, 
        @{ Source = "$HubSiteName Members"; Destination = "$AssociatedSiteName Members" }, 
        @{ Source = "$HubSiteName Visitors"; Destination = "$AssociatedSiteName Visitors" } 
    ) 
 
    foreach ($Mapping in $GroupMappings) { 
        $SourceGroup = Get-PnPGroup -Identity $Mapping.Source -Connection $HubSiteConnection 
        $DestinationGroup = Get-PnPGroup -Identity $Mapping.Destination -Connection $AssociatedSiteConnection -ErrorAction SilentlyContinue 
 
        if (-not $DestinationGroup) { 
            New-PnPGroup -Title $Mapping.Destination -Connection $AssociatedSiteConnection 
        } 
 
        $SourceGroup.Users | ForEach-Object { 
            $LoginName = if ($_ -like "*#ext#*") { $_.Email } else { $_.LoginName } 
            Add-PnPGroupMember -Group $Mapping.Destination -LoginName $LoginName -Connection $AssociatedSiteConnection 
        } 
    } 
} 

This function replicates SharePoint default groups (Owners, Members, Visitors) from the hub to the associated site. It handles both internal and external users and ensures group membership is preserved. 

Main Function

To simplify script execution, we can wrap all functional calls inside a Main function:

function Main {
    # Connect to the Hub Site and Associated Site using PnP PowerShell
    $HubSiteConnection = Connect-PnPOnline -Url $HubSiteURL -ClientId $ClientId -ClientSecret $ClientSecret -ReturnConnection
    $AssociatedSiteConnection = Connect-PnPOnline -Url $AssociatedSiteURL -ClientId $ClientId -ClientSecret $ClientSecret -ReturnConnection

    # Update external sharing settings before proceeding
    Update-ExternalSharing -AdminSiteURL $AdminSiteURL -AssociatedSiteURL $AssociatedSiteURL

    # Copy custom permission levels from Hub Site to Associated Site
    Copy-PermissionLevels -HubSiteConnection $HubSiteConnection -AssociatedSiteConnection $AssociatedSiteConnection

    # Extract names of sites to match default group names
    $HubSiteName = ($HubSiteURL -split "/")[-1]
    $AssociatedSiteName = ($AssociatedSiteURL -split "/")[-1]

    # Copy site groups and their users
    Copy-HubSiteGroups -HubSiteName $HubSiteName -AssociatedSiteName $AssociatedSiteName -HubSiteConnection $HubSiteConnection -AssociatedSiteConnection $AssociatedSiteConnection
}

Main
Running the Script To run this PowerShell script, first make sure you’ve saved it with a .ps1 extension — for example, name it CopyGroupsAndPermissions.ps1. 

Once saved, navigate to the directory where the script is stored using PowerShell and run the following command (make sure to replace the placeholder values with your actual credentials and URLs): 

./CopyGroupsAndPermissions.ps1 ` 
    -ClientId "xxxx-xxxx-xxxx-xxxx" ` 
    -HubSiteURL "https://yourtenant.sharepoint.com/sites/hubsite" ` 
    -ClientSecret "your-client-secret" ` 
    -AdminSiteURL "https://yourtenant-admin.sharepoint.com" ` 
    -AssociatedSiteURL "https://yourtenant.sharepoint.com/sites/associatedsite" 
  

Final Thoughts 

This script is ideal for scenarios where: 

  • You need to replicate security and structure across multiple sites 
  • You want to avoid third-party tools like ShareGate 

If you're working on managing large SharePoint environments with many associated sites, this can save time and reduce manual effort.

If you have any questions you can reach out our SharePoint Consulting team here.