├── .gitignore ├── LICENSE ├── README.md ├── app └── requiredscopes.json ├── assets └── certificate │ └── Create-SelfSignedCertificate.ps1 ├── docs ├── PART1.md ├── PART2.md ├── PART3.md ├── PART4.md ├── PART5.md └── PART6.md ├── functions ├── sp-trigger-function │ ├── .gitignore │ ├── .jshintrc │ ├── .vscode │ │ ├── extensions.json │ │ ├── launch.json │ │ ├── settings.json │ │ └── tasks.json │ ├── README.md │ ├── deploy.ps1 │ ├── images │ │ ├── cors_settings.png │ │ └── func_debug.png │ ├── junit.xml │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── functions │ │ │ └── webhookHandler │ │ │ │ ├── config │ │ │ │ └── samplekey.txt │ │ │ │ ├── function.json │ │ │ │ ├── webhookHandler.ts │ │ │ │ └── webhookHandler │ │ │ │ └── webhookHandler.spec.ts │ │ ├── helpers │ │ │ ├── AuthenticationHelper.ts │ │ │ ├── SharePointWebHookHelper.ts │ │ │ └── StorageHelper.ts │ │ ├── host.json │ │ ├── local.settings.json │ │ ├── models │ │ │ ├── IChangeItem.ts │ │ │ ├── IChangeQuery.ts │ │ │ ├── IFunctionSettings.ts │ │ │ └── IWebhookSubcriptionData.ts │ │ └── proxies.json │ ├── tools │ │ └── build │ │ │ ├── helpers.js │ │ │ ├── webpack.dev.js │ │ │ └── webpack.prod.js │ ├── tsconfig.json │ └── tslint.json └── teams-config-function │ ├── .gitignore │ ├── Activities │ ├── Groups │ │ └── EnsureAndUpdateGroupActivity.cs │ └── Teams │ │ ├── AddSPLibProjectTabActivity.cs │ │ ├── AddSPLibTabActivity.cs │ │ ├── CreateProjectChannelActivity.cs │ │ └── EnableTeamActivity.cs │ ├── CreationResult.cs │ ├── Services │ ├── CertificateService.cs │ ├── ConfigurationService.cs │ ├── GraphServiceClientFactory.cs │ └── TeamsTabService.cs │ ├── StartConfiguration.cs │ ├── TeamsConfig.cs │ ├── TeamsConfiguration.csproj │ ├── TeamsConfiguration.sln │ └── host.json ├── images ├── ad_app_permissions.png ├── add_app.png ├── add_app_site.png ├── architecture.png ├── automation_creds.png ├── automation_variables.png ├── az_func_ext.png ├── azure_ad_clientid.png ├── browse_modules.png ├── certificate_thumbprint.png ├── check_deploy.png ├── configure_refiners.png ├── crawled_property_mapping.png ├── create_app.png ├── create_func.png ├── create_new_workspace.png ├── creation_completed.png ├── demo.gif ├── enable_logs.png ├── file_share.png ├── flow_step1.png ├── flow_step2.png ├── func_logd.png ├── get_func_url.png ├── grant_consent.png ├── group_membership.png ├── item_provisioning_status.png ├── logic_app_url.png ├── logicapp_final.png ├── logicapp_logs.png ├── logicapp_step1.png ├── logicapp_step2.png ├── logicapp_step3.png ├── logicapp_step4.png ├── logicapp_step5.png ├── logicapp_step6.png ├── logicapp_step7.png ├── logicapp_step8.png ├── logicapp_step9.png ├── logicapp_trigger.png ├── new-runbook-script.png ├── new-runbook.png ├── new_function.png ├── new_secret.png ├── ngrok.png ├── pnp-modern-search.png ├── pnp_workspace_requests_nav.png ├── query_template.png ├── runbook_logs.png ├── runbook_status1.png ├── runbook_status2.png ├── search_experience.png ├── start_flow.png ├── storage_account.png ├── storage_connection_string.png ├── teams-function-logic-app.png ├── term_store_config.png ├── term_store_perms.png ├── upload_cert.png ├── upload_cert_automation.png ├── upload_settings.png ├── upload_template.png ├── webhook_settings.png ├── webhook_statetable.png └── webhook_test.png ├── scripts └── New-Workspace.ps1 └── templates ├── resources ├── pnp-workspace-en-us.resx └── pnp-workspace-fr-fr.resx ├── rootsite-template.xml └── workspace-template.xml /.gitignore: -------------------------------------------------------------------------------- 1 |  2 | # Created by https://www.gitignore.io/api/node,dotnetcore,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=node,dotnetcore,visualstudiocode 4 | 5 | ### DotnetCore ### 6 | # .NET Core build folders 7 | /bin 8 | /obj 9 | 10 | # Common node modules locations 11 | /node_modules 12 | /wwwroot/node_modules 13 | 14 | 15 | ### Node ### 16 | # Logs 17 | logs 18 | *.log 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | lerna-debug.log* 23 | 24 | # Diagnostic reports (https://nodejs.org/api/report.html) 25 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 26 | 27 | # Runtime data 28 | pids 29 | *.pid 30 | *.seed 31 | *.pid.lock 32 | 33 | # Directory for instrumented libs generated by jscoverage/JSCover 34 | lib-cov 35 | 36 | # Coverage directory used by tools like istanbul 37 | coverage 38 | *.lcov 39 | 40 | # nyc test coverage 41 | .nyc_output 42 | 43 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 44 | .grunt 45 | 46 | # Bower dependency directory (https://bower.io/) 47 | bower_components 48 | 49 | # node-waf configuration 50 | .lock-wscript 51 | 52 | # Compiled binary addons (https://nodejs.org/api/addons.html) 53 | build/Release 54 | 55 | # Dependency directories 56 | node_modules/ 57 | jspm_packages/ 58 | 59 | # TypeScript v1 declaration files 60 | typings/ 61 | 62 | # TypeScript cache 63 | *.tsbuildinfo 64 | 65 | # Optional npm cache directory 66 | .npm 67 | 68 | # Optional eslint cache 69 | .eslintcache 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variables file 81 | .env 82 | .env.test 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | 87 | # next.js build output 88 | .next 89 | 90 | # nuxt.js build output 91 | .nuxt 92 | 93 | # react / gatsby 94 | public/ 95 | 96 | # vuepress build output 97 | .vuepress/dist 98 | 99 | # Serverless directories 100 | .serverless/ 101 | 102 | # FuseBox cache 103 | .fusebox/ 104 | 105 | # DynamoDB Local files 106 | .dynamodb/ 107 | 108 | ### VisualStudioCode ### 109 | .vscode/* 110 | !.vscode/settings.json 111 | !.vscode/tasks.json 112 | !.vscode/launch.json 113 | !.vscode/extensions.json 114 | 115 | ### VisualStudioCode Patch ### 116 | # Ignore all local history of files 117 | .history 118 | 119 | # End of https://www.gitignore.io/api/node,dotnetcore,visualstudiocode 120 | 121 | *.pfx 122 | *.cer 123 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 PnP 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tutorial - Create an end-to-end Office 365 groups provisioning solution # 2 | 3 | ## Authors 4 | 5 | - Vincent Biret -[@baywet](https://twitter.com/baywet) 6 | - Franck Cornu - [@FranckCornu](https://twitter.com/FranckCornu) 7 | 8 | ## Why a provisiong solution for Office 365 groups? 9 | 10 | This solution was first designed to avoid SharePoint sites sprawling resulting of an uncontrolled Teams deployment inside an organisation. By 'uncontrolled' we mainly refer to Teams containing organisation reference or critical documents without any kind structure or metadata making hard for users to find them outside this specific Teams (data isolation). 11 | 12 | A provision solution can, of course, also serve many other purposes. You can also refer to this article to know more about this topic: https://laurakokkarinen.com/teams-and-sharepoint-provisioning-what-why-and-how/ 13 | 14 | This tutorial only provides the very minimum building blocks to create a working provisioning solution for Office 365 groups. Feel free to adapt it according to your context and requirements. 15 | 16 | ![PnP Worskpaces Demo](./images/demo.gif) 17 | 18 | Here are the covered topics during this tutorial: 19 | 20 | - Use Microsoft Graph and SharePoint APIs to create and configure Office 365 groups. 21 | - Use Azure back end services to run the provisioning logic. 22 | - Create and use SharePoint web hooks. 23 | - Create a SharePoint search experience for created groups. 24 | 25 | ### Technical architecture 26 | 27 | ![Architecture](./images/architecture.png) 28 | 29 | ## Prerequisites 30 | 31 | - A subscription to [Office 365 developer tenant](https://developer.microsoft.com/en-us/office/dev-program) 32 | - An Azure subscription ([Free Trial](https://azure.microsoft.com/en-us/free/)). 33 | - Azure following permissions 34 | - Create AD applications and resource groups 35 | - Office 365 36 | - Create term groups and term sets 37 | - Create site collections 38 | - [Node.js 10](https://nodejs.org/dist/latest-v10.x/) (**not latest**) 39 | - [Visual Studio Code](https://code.visualstudio.com/) 40 | - [Postman](https://www.getpostman.com/) 41 | - A modern browser (pick one) 42 | - [The new Edge](https://www.microsoftedgeinsider.com/en-us/download/) 43 | - [Google Chrome](https://www.google.com/chrome/index.html) 44 | - [FireFox](https://www.mozilla.org/en-US/firefox/new/) 45 | - [Brave](https://brave.com/bra043) 46 | - A modern command line tool (pick one) 47 | - [New Windows Terminal](https://www.microsoft.com/store/productId/9N0DX20HK701) 48 | - [Cmder](https://cmder.net/) 49 | - [SharePoint Online Client Components SDK](https://www.microsoft.com/en-us/download/details.aspx?id=42038) + [PnP PowerShell for SharePoint Online](https://github.com/SharePoint/PnP-PowerShell/releases) 50 | - [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest) 51 | - [Dot Net Core SDK 2.2](https://dotnet.microsoft.com/download/dotnet-core/2.2) (installer version) 52 | - (Optional) Azure Functions Core tools 2.X : `npm i -g azure-functions-core-tools@2.7.1149` 53 | 54 | ## Tutorial steps 55 | 56 | - [Part 1 - Setup Azure AD application and workspace requests SharePoint site](./docs/PART1.md) 57 | - [Part 2 - Setup Azure back-end services](./docs/PART2.md) 58 | - [Part 3 - Create and register SharePoint Webhook](./docs/PART3.md) 59 | - [Part 4 - Test the end-to-end Office 365 group creation](./docs/PART4.md) 60 | - [Part 5 - Setup end-user experience](./docs/PART5.md) 61 | - [Part 6 - Setup Teams Configuration](./docs/PART6.md) 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /app/requiredscopes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "resourceAppId": "00000003-0000-0000-c000-000000000000", 4 | "resourceAccess": [ 5 | { 6 | "id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d", 7 | "type": "Scope" 8 | }, 9 | { 10 | "id": "19dbc75e-c2e2-444c-a770-ec69d8559fc7", 11 | "type": "Role" 12 | }, 13 | { 14 | "id": "62a82d76-70ea-41e2-9197-370581804d09", 15 | "type": "Role" 16 | }, 17 | { 18 | "id": "a82116e5-55eb-4c41-a434-62fe8a61c773", 19 | "type": "Role" 20 | }, 21 | { 22 | "id": "332a536c-c7ef-4017-ab91-336970924f0d", 23 | "type": "Role" 24 | } 25 | ] 26 | }, 27 | { 28 | "resourceAppId": "00000003-0000-0ff1-ce00-000000000000", 29 | "resourceAccess": [ 30 | { 31 | "id": "678536fe-1083-478a-9c59-b99265e6b0d3", 32 | "type": "Role" 33 | } 34 | ] 35 | } 36 | ] -------------------------------------------------------------------------------- /assets/certificate/Create-SelfSignedCertificate.ps1: -------------------------------------------------------------------------------- 1 | #Requires -RunAsAdministrator 2 | <# 3 | .SYNOPSIS 4 | Creates a Self Signed Certificate for use in server to server authentication 5 | .DESCRIPTION 6 | .EXAMPLE 7 | .\Create-SelfSignedCertificate.ps1 -CommonName "MyCert" -StartDate 2015-11-21 -EndDate 2017-11-21 8 | This will create a new self signed certificate with the common name "CN=MyCert". During creation you will be asked to provide a password to protect the private key. 9 | .EXAMPLE 10 | .\Create-SelfSignedCertificate.ps1 -CommonName "MyCert" -StartDate 2015-11-21 -EndDate 2017-11-21 -Password (ConvertTo-SecureString -String "MyPassword" -AsPlainText -Force) 11 | This will create a new self signed certificate with the common name "CN=MyCert". The password as specified in the Password parameter will be used to protect the private key 12 | .EXAMPLE 13 | .\Create-SelfSignedCertificate.ps1 -CommonName "MyCert" -StartDate 2015-11-21 -EndDate 2017-11-21 -Force 14 | This will create a new self signed certificate with the common name "CN=MyCert". During creation you will be asked to provide a password to protect the private key. If there is already a certificate with the common name you specified, it will be removed first. 15 | #> 16 | Param( 17 | 18 | [Parameter(Mandatory=$true)] 19 | [string]$CommonName, 20 | 21 | [Parameter(Mandatory=$true)] 22 | [DateTime]$StartDate, 23 | 24 | [Parameter(Mandatory=$true)] 25 | [DateTime]$EndDate, 26 | 27 | [Parameter(Mandatory=$false, HelpMessage="Will overwrite existing certificates")] 28 | [Switch]$Force, 29 | 30 | [Parameter(Mandatory=$false)] 31 | [SecureString]$Password 32 | ) 33 | 34 | # DO NOT MODIFY BELOW 35 | 36 | function CreateSelfSignedCertificate(){ 37 | 38 | #Remove and existing certificates with the same common name from personal and root stores 39 | #Need to be very wary of this as could break something 40 | if($CommonName.ToLower().StartsWith("cn=")) 41 | { 42 | # Remove CN from common name 43 | $CommonName = $CommonName.Substring(3) 44 | } 45 | $certs = Get-ChildItem -Path Cert:\LocalMachine\my | Where-Object{$_.Subject -eq "CN=$CommonName"} 46 | if($certs -ne $null -and $certs.Length -gt 0) 47 | { 48 | if($Force) 49 | { 50 | 51 | foreach($c in $certs) 52 | { 53 | remove-item $c.PSPath 54 | } 55 | } else { 56 | Write-Host -ForegroundColor Red "One or more certificates with the same common name (CN=$CommonName) are already located in the local certificate store. Use -Force to remove them"; 57 | return $false 58 | } 59 | } 60 | 61 | $name = new-object -com "X509Enrollment.CX500DistinguishedName.1" 62 | $name.Encode("CN=$CommonName", 0) 63 | 64 | $key = new-object -com "X509Enrollment.CX509PrivateKey.1" 65 | $key.ProviderName = "Microsoft RSA SChannel Cryptographic Provider" 66 | $key.KeySpec = 1 67 | $key.Length = 2048 68 | $key.SecurityDescriptor = "D:PAI(A;;0xd01f01ff;;;SY)(A;;0xd01f01ff;;;BA)(A;;0x80120089;;;NS)" 69 | $key.MachineContext = 1 70 | $key.ExportPolicy = 1 # This is required to allow the private key to be exported 71 | $key.Create() 72 | 73 | $serverauthoid = new-object -com "X509Enrollment.CObjectId.1" 74 | $serverauthoid.InitializeFromValue("1.3.6.1.5.5.7.3.1") # Server Authentication 75 | $ekuoids = new-object -com "X509Enrollment.CObjectIds.1" 76 | $ekuoids.add($serverauthoid) 77 | $ekuext = new-object -com "X509Enrollment.CX509ExtensionEnhancedKeyUsage.1" 78 | $ekuext.InitializeEncode($ekuoids) 79 | 80 | $cert = new-object -com "X509Enrollment.CX509CertificateRequestCertificate.1" 81 | $cert.InitializeFromPrivateKey(2, $key, "") 82 | $cert.Subject = $name 83 | $cert.Issuer = $cert.Subject 84 | $cert.NotBefore = $StartDate 85 | $cert.NotAfter = $EndDate 86 | $cert.X509Extensions.Add($ekuext) 87 | $cert.Encode() 88 | 89 | $enrollment = new-object -com "X509Enrollment.CX509Enrollment.1" 90 | $enrollment.InitializeFromRequest($cert) 91 | $certdata = $enrollment.CreateRequest(0) 92 | $enrollment.InstallResponse(2, $certdata, 0, "") 93 | return $true 94 | } 95 | 96 | function ExportPFXFile() 97 | { 98 | if($CommonName.ToLower().StartsWith("cn=")) 99 | { 100 | # Remove CN from common name 101 | $CommonName = $CommonName.Substring(3) 102 | } 103 | if($Password -eq $null) 104 | { 105 | $Password = Read-Host -Prompt "Enter Password to protect private key" -AsSecureString 106 | } 107 | $cert = Get-ChildItem -Path Cert:\LocalMachine\my | where-object{$_.Subject -eq "CN=$CommonName"} 108 | 109 | Export-PfxCertificate -Cert $cert -Password $Password -FilePath "$($CommonName).pfx" 110 | Export-Certificate -Cert $cert -Type CERT -FilePath "$CommonName.cer" 111 | } 112 | 113 | function RemoveCertsFromStore() 114 | { 115 | # Once the certificates have been been exported we can safely remove them from the store 116 | if($CommonName.ToLower().StartsWith("cn=")) 117 | { 118 | # Remove CN from common name 119 | $CommonName = $CommonName.Substring(3) 120 | } 121 | $certs = Get-ChildItem -Path Cert:\LocalMachine\my | Where-Object{$_.Subject -eq "CN=$CommonName"} 122 | foreach($c in $certs) 123 | { 124 | remove-item $c.PSPath 125 | } 126 | } 127 | 128 | if(CreateSelfSignedCertificate) 129 | { 130 | ExportPFXFile 131 | RemoveCertsFromStore 132 | } -------------------------------------------------------------------------------- /docs/PART1.md: -------------------------------------------------------------------------------- 1 | # Part 1 - Setup Azure AD application and workspace requests SharePoint site 2 | 3 | The part 1 focuses on the creation on the first building blocks of the solution. 4 | 5 | ## Setup Azure AD application 6 | 7 | To create an new Office 365 group and apply SharePoint PnP templates, we need first to create a new Azure AD application that will use application permissions. 8 | 9 | 1. In your [Azure tenant](https://aad.portal.azure.com), create a new Azure AD application (in `App Registrations` under `Azure Active Directory`) _Office365GroupsProvisioning_ in the same domain as your Office 365 domain (hosting the SharePoint site new workspace requests will be created). Set correct **application permissions** (not delegated) for _Microsoft Graph_ and _SharePoint Online_ resources as follow: 10 | 11 | ![Create Azure AD app](../images/create_app.png) 12 | 13 | ![Azure AD app permissions](../images/ad_app_permissions.png) 14 | 15 | **SharePoint Online** 16 | 17 | | Permission | Used for | 18 | | ---------- | -------- | 19 | | Have full control of all site collections (**Sites.FullControl.All**) | SharePoint sites configuration 20 | 21 | **Microsoft Graph** 22 | 23 | | Permission | Used for | 24 | | ---------- | -------- | 25 | | Have full control of all site collections (**Sites.FullControl.All**) | Apply PnP Templates for SharePoint sites 26 | | Read and write directory data (**Directory.ReadWrite.All**) | Manage permissions for Office 365 groups 27 | | Read and write all groups (**Group.ReadWrite.All**) | Create new Office 365 groups 28 | 29 | Alternatively you can run the following _Azure CLI_ command. (make sure youre located in the `app` folder) 30 | ```bash 31 | az ad app create --display-name 'Office365GroupsProvisioning' --available-to-other-tenants false --native-app false --oauth2-allow-implicit-flow true --required-resource-accesses @app/requiredscopes.json 32 | ``` 33 | 34 | 1. Grant consent for these permissions: 35 | 36 | ![Grant Consent](../images/grant_consent.png) 37 | 38 | 1. Because SharePoint connection requires the use of a certificate, create a new self signed certificate for the Azure AD application using the PowerShell script provided [here](https://docs.microsoft.com/en-us/sharepoint/dev/solution-guidance/security-apponly-azuread). 39 | 40 | ```PowerShell 41 | .\Create-SelfSignedCertificate.ps1 -CommonName "MyCompanyName" -StartDate 2017-10-01 -EndDate 2021-10-01 42 | ``` 43 | 44 | > Save the password you use to create the certificate, you will need it after. 45 | 46 | 1. Upload the certificate (.cer) to you Azure AD application: 47 | 48 | ![Upload Cert](../images/upload_cert.png) 49 | 50 | Alternatively you can run the following _Azure CLI_ Command. 51 | ```bash 52 | az ad app credential reset --id --append --cert @ 53 | ``` 54 | 55 | 1. Install [PnP PowerShell SharePoint Online Cmdlets](https://docs.microsoft.com/en-us/powershell/sharepoint/sharepoint-pnp/sharepoint-pnp-cmdlets?view=sharepoint-ps) 56 | 57 | ```PowerShell 58 | Install-Module SharePointPnPPowerShellOnline 59 | ``` 60 | 61 | 1. In the SharePoint taxonomy term store, create a new '**pnp**' term group and a '**Category**' and '**Document Type**' term sets with few sample terms: 62 | 63 | ![Term store configuration](../images/term_store_config.png) 64 | 65 | Note: the Term Store management portal is located at `https://-admin.sharepoint.com/_layouts/15/TermStoreManager.aspx` 66 | 67 | Note: you might need to add your account as a term store administator before you can add any term. 68 | 69 | ![Term store permissions](../images/term_store_perms.png) 70 | 71 | 72 | ## Setup a SharePoint root site for new workspace requests 73 | 74 | 1. In the current project, from the `templates` folder, open a PowerShell console as administrator and run the following script: 75 | ```PowerShell 76 | $tenantName = "" # eq 'aequos' in 'aequos.sharepoint.com' 77 | Connect-PnPOnline -Url "https://$tenantName-admin.sharepoint.com" 78 | New-PnPSite -Type CommunicationSite -Title "PnP Workspaces" -Url "https://$tenantName.sharepoint.com/sites/workspaces" 79 | Connect-PnPOnline -Url "https://$tenantName.sharepoint.com/sites/workspaces" 80 | Apply-PnPProvisioningTemplate -Path rootsite-template.xml -ResourceFolder ./resources 81 | ``` 82 | 83 | Note: when applying the template, you might get an error message like `The website has been updated by a different process`. This is normal and because we recently created the site, simply re-run the last command 84 | 85 | 1. Browse your site to see everything is setup correctly. You should see the workspace requests list in the site content navigation. 86 | 87 | ![PnP Worskpaces site](../images/pnp_workspace_requests_nav.png) 88 | 89 | 90 | > Next part: [Part 2 - Setup Azure back-end services](./PART2.md) -------------------------------------------------------------------------------- /docs/PART2.md: -------------------------------------------------------------------------------- 1 | # Part 2 - Setup Azure back-end services 2 | 3 | The part 2 focuses and the Azure back-end services and provisioning assets. This setup can be created in a different Azure tenant than your Office 365 tenant. 4 | 5 | ## Setup Azure backend services 6 | 7 | 1. Create a new Azure resource group '_Office365Provisioning_'. 8 | 9 | ### Storage account 10 | 11 | 1. Create a new storage account. 12 | Note: _Azure CLI_ commands for reference 13 | ```bash 14 | az group create --location "Canada East" --name "pnptutorialPractice" 15 | az storage account create --name pnptutorialpractice --resource-group "pnptutorialPractice" --location "Canada East" --sku Standard_LRS --kind StorageV2 16 | ``` 17 | 1. Create a new File Share named '_templates_'. 18 | 19 | ![Add File Share](../images/file_share.png) 20 | 21 | Note: _Azure CLI_ commands for reference 22 | ```bash 23 | az storage share create --name templates --quota 1 --account-name pnptutorialpractice 24 | ``` 25 | 26 | 1. Upload the PnP template .xml file `workspace-template.xml`. 27 | Note: _Azure CLI_ commands for reference 28 | ```bash 29 | az storage file upload --account-name pnptutorialpractice --share-name "templates" --source ./templates/workspace-template.xml --path workspace-template.xml 30 | ``` 31 | 32 | 1. Create a new directory named `resources` and upload the _.resx_ files from the local resource files. 33 | 34 | ![Upload PnP template](../images/upload_template.png) 35 | Note: _Azure CLI_ commands for reference 36 | ```bash 37 | az storage directory create --account-name pnptutorialpractice --share-name templates --name resources 38 | az storage file upload --account-name pnptutorialpractice --share-name "templates" --source ./templates/resources/pnp-workspace-en-us.resx --path resources/pnp-workspace-en-us.resx 39 | az storage file upload --account-name pnptutorialpractice --share-name "templates" --source ./templates/resources/pnp-workspace-fr-fr.resx --path resources/pnp-workspace-fr-fr.resx 40 | ``` 41 | 42 | ### Automation account 43 | 44 | 1. Create a new Azure Automation account. 45 | 46 | 1. Upload the certificate (.pfx) with the name `pnpCert`. This value is hardcoded in the provisioning script so you need to use the same. **Make sure the certificate is set as 'Exportable'**. 47 | 48 | ![Upload certificate Automation](../images/upload_cert_automation.png) 49 | 50 | 1. Add the '_SharePointPnPPowerShellOnline_' module. 51 | 52 | ![Browse module](../images/browse_modules.png) 53 | 54 | 1. Create the following automation variables with corresponding values: 55 | 56 | ![Automation variables](../images/automation_variables.png) 57 | 58 | | Variable | Description | Encrypted | Value | 59 | | -------- | ----- | ----- | ------ | 60 | | `certificatePassword` | The certificate password you used as an encrypted value | Yes | ex: '`pass@word1`' 61 | | `storageConnectionString` | The storage account connection string previously created | Yes | You can get this value by browsing your storage account.
![Storage Connection String](../images/storage_connection_string.png) 62 | | `fileShareName` | The name of the file share where template file are stored | No | `templates` 63 | | `provisioningTemplateFileName` | The name of the template file for workspace creation | No | `workspace-template.xml` 64 | | `spTenantUrl` | The URL of the SharePoint adminsitartion site | No |Ex: `https://-admin.sharepoint.com` 65 | | `appId` | The Azure AD application client ID | No | ![Azure AD app client ID](../images/azure_ad_clientid.png) 66 | | `appSecret` | The Azure AD application cient secret | Yes | ![Azure AD app secret](../images/new_secret.png) 67 | | `aadDomain` | The Azure AD domain | No | ex: `mydomain.com` or `mycompany.onmicrosoft.com` (**without 'http://'**) 68 | 69 | 1. Create a new runbook called **New-Workspace** and copy/paste the content of the `New-Workspace.ps1` script. Save and **Publish** the file. 70 | 71 | ![New runbook](../images/new-runbook.png) 72 | 73 | ### Logic App for provisioning 74 | 75 | 1. Create a new Logic App ('_When a HTTP request is received_' template) that will be used for calling the automation runbook. Implement the following steps: 76 | 77 | | # | Step | Visual 78 | | -- | ----- | ------ | 79 | | 1 |Create a HTTP request trigger using the following payload JSON sample to generate th schema (see note below).| ![Trigger](../images/logicapp_trigger.png) | 80 | | 2 | Get current SharePoint item properties.| ![Step 1](../images/logicapp_step1.png) | 81 | | 3 | Set the provisioning status in the requests list. | ![Step 2](../images/logicapp_step2.png) | 82 | | 4 | Create an Azure Automation job using the provisioning runbook and select the **'New-Workspace'** runbook. For parameters and because the _'members'_ and _'categories'_ columns in the requests list are multi values, you will have to use `concat(body('Get_item')?['pnpCategory'])` and `concat(body('Get_item')?['pnpWorkspaceMembers'])` expressions in associated parameters. The `body('Get_item')` expression refers to the name of the step #1. If you changed the name of this action, you will have to update the expression accordingly. Also make sure you wait for the job to finish. | ![Step 3](../images/logicapp_step3.png) | 83 | | 5 | Get the job status. | ![Step 4](../images/logicapp_step4.png) | 84 | | 6 | Create a new condition on the job status value. | ![Step 5](../images/logicapp_step5.png) | 85 | | 7 | On the `true` branch, get the job output. | ![Step 6](../images/logicapp_step6.png) 86 | | 8 | On the `true` branch, parse the job output JSON. Use `{ "GroupUrl":""}` as sample payload to generate the schema (see note below). | ![Step 7](../images/logicapp_step7.png) 87 | | 9 | On the `true` branch, update the Office 365 group URL in the request list by updating the SharePoint item. | ![Step 8](../images/logicapp_step8.png) 88 | | 10 | In any case, update the job status in the request list. | ![Step 9](../images/logicapp_step9.png) 89 | 90 | Note: JSON schema for the HTTP trigger 91 | ```JSON 92 | { 93 | "properties":{ 94 | "itemId": { 95 | "type": "integer" 96 | } 97 | }, 98 | "type": "object" 99 | } 100 | ``` 101 | 102 | Note: JSON schema for the parse JSON step 103 | ```JSON 104 | { 105 | "properties": { 106 | "GroupUrl": { 107 | "type": "string" 108 | } 109 | }, 110 | "type": "object" 111 | } 112 | ``` 113 | 114 | Your final logic app shoud look like this: 115 | 116 | ![Complete Logic App](../images/logicapp_final.png) 117 | 118 | > Next part: [Part 3 - Create and register SharePoint Webhook](./PART3.md) 119 | -------------------------------------------------------------------------------- /docs/PART3.md: -------------------------------------------------------------------------------- 1 | # Part 3 - Create and register SharePoint Webhook 2 | 3 | The part 3 demonstrates how to create and register a SharePoint webhook to catch events from the workspace requests list. This allows to avoid using a costly Logic App polling trigger in a case of an heavy workload. 4 | 5 | ## Setup solution 6 | 7 | 1. Install [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli-windows?view=azure-cli-latest) on your machine. 8 | 9 | 1. Install Azure Function Core tools globaly using `npm i -g azure-functions-core-tools@2.7.1149` (version 2). 10 | 11 | 1. Install `npm i -g ngork` and type the following command in a new console: `ngrok http 7071`. This way SharePoint will be able to reply to your local function via the ngrok proxy. Let the console run and go to the next step: 12 | 13 | ![Ngrok](../images/ngrok.png) 14 | 15 | 1. In the Azure storage account you created in the previous step, create a new table named '**WebhookStateTable**'. As its name suppose, this will used to 16 | 17 | ![Webhook state stable](../images/webhook_statetable.png) 18 | 19 | 1. Go to the `functions/sp-trigger-function` folder and install all dependencies using `npm i`. 20 | 21 | 1. Generate the certificate private key 22 | 23 | This solution uses an Azure AD application certificate connection to communicate with SharePoint for the web hook registration process. It means you need to generate a **certificate private key** in _*.pem_ format and copy it to the _'sp-trigger-function/src/functions/webhookHandler/config'_ folder of your function. You can reuse the certificate you created in previous steps. Then use the [OpenSSL](https://wiki.openssl.org/index.php/Binaries) tool and the following command to generate the _.pem_ key from the _.pfx_ certificate (use Cmder for the first command): 24 | 25 | ```bash 26 | openssl pkcs12 -in C:\.pfx -nocerts -out C:\.pem -nodes 27 | ``` 28 | 29 | 1. Update sample the `local.settings.json` file according to the following settings: 30 | 31 | | Setting | Value | 32 | | ------- | ----- | 33 | | `appId` | The Azure AD application client ID used to connect to SharePoint. 34 | | `tenant` | The tenant name. Ex: `mytenant`. 35 | | `resource` | The resource to access. Ex: `https://mytenant.sharepoint.com` 36 | | `certificateThumbPrint` | The certificate thumprint. Ex: `3366e28f1c95cff2680ae799f248e448f8655134`. You can get this value in the Azure AD Application:
![Certificate Thumbprint](../images/certificate_thumbprint.png) 37 | | `certificatePath` | The path to the .pem file. Ex `.\\config\\key.pem`. 38 | | `spListId` | The SharePoint list ID to connect to for webhook. (see PowerShell script below or grab it from the SharePoint URL by browsing the list directly in your site). 39 | | `spWebUrl` | The absolute web URL containing the SharePoint list. Ex: `https://mytenant.sharepoint.com/sites/workspaces`. 40 | | `webhookSubscriptionIdentifier` | The web hook subscription identifier. Ex: `pnp-workspace-provisioning`. This value is just here as an informational purpose. 41 | | `webhookNotificationUrl` | The URL to call when the webhook fires. Typically your function URL. For local scenario, use the **ngrok** generated URL (Ex: `http://68f556ec.ngrok.io/api/webhookhandler`) 42 | | `webhookTargetUrl` | URL to trigger by the function. This will be the URL of the Logic App previously created. ![Logic App URL](../images/logic_app_url.png) 43 | | `azStorageConnectionString` | The Azure Storage connection string. Ex: `DefaultEndpointsProtocol=https;AccountName=;AccountKey=;EndpointSuffix=core.windows.net`. 44 | | `azStorageTableName` | The name of the azure storage table. This table is used to keep track of changes in the list using a timestamp token. Ex: `WebhookStateTable`. 45 | 46 | Note: PowerShell script to get the list id 47 | ```PowerShell 48 | Connect-PnPOnline -Url https://tenant.sharepoint.com/sites/workspaces 49 | Get-PnPList |?{$_.Title -eq "Workspace requests"} 50 | ``` 51 | 52 | Note: Azure CLI script to create the table storage 53 | ```bash 54 | az storage table create --name WebhookStateTable --account-name pnptutorialpractice 55 | ``` 56 | 57 | ## Debug your function locally 58 | 59 | 1. In a Node.js console, build the solution using `npm run build:dev` 60 | 61 | 1. In a Node.js console, from the `./dist` folder, run the following command `func start`. 62 | 63 | 1. From Visual Studio Code, launch the *'Debug Local Azure Function'* debug configuration. 64 | 65 | 1. Open Postman, and send an empty POST query to the URL `http:///api/newworkspace`. It will register the webhook for the first time according to the local settings. The function should return `200` meaning the subscription has been created. If you try to run it again, the subscription process will be skipped. 66 | 67 | > You can also register your webhook using the following script: 68 | 69 | ```PowerShell 70 | Connect-PnPOnline -Url https://.sharepoint.com/sites/workspaces -UseWebLogin 71 | Add-PnPWebhookSubscription -List "Workspace requests" -NotificationUrl http:///api/webhookhandler 72 | ``` 73 | 74 | 1. Put a debug breakpoint in your code and try to add a new item in the workspace requests list to ensure the webhook is triggered correctly. 75 | 76 | ![Webhook test](../images/webhook_test.png) 77 | 78 | ## Deploy to Azure 79 | 80 | Now we have a working function in localhost ,we likely want to deploy to Azure. 81 | 82 | 1. In the Azure resource group _Office365Provisioning_, create a new 'Function App'. Choose 'Node.js' as the runtime stack: 83 | 84 | ![Create Function App](../images/create_func.png) 85 | 86 | Note: Azure CLI script to create the function app 87 | ```bash 88 | az functionapp create --resource-group pnptutorialpractice --consumption-plan-location canadaeast --name pnptutorialpractice --storage-account pnptutorialpractice --runtime node 89 | ``` 90 | 91 | 1. In VSCode, download the [Azure Function](https://code.visualstudio.com/tutorials/functions-extension/getting-started) extension. 92 | 93 | ![Azure Function VSCode extension](../images/az_func_ext.png) 94 | 95 | 1. Sign-in to to Azure account into the extension. 96 | 1. In a Node.js console, build the application using the command `npm run build` (minified version this time). 97 | 1. Use the **"Deploy to Function App"** feature (in the extension top bar) using the *'dist'* folder in the newly created function. Make sure you've run the `npm run build` cmd before. 98 | 1. Upload the application settings file according to your environment (`local.settings.json`). 99 | 100 | ![Upload local settings](../images/upload_settings.png) 101 | 102 | Note: Azure functions SDK command to deploy the app 103 | ```bash 104 | func azure functionapp publish pnptutorialpractice --publish-local-settings -i --overwrite-settings -y 105 | ``` 106 | 107 | 1. Check the deployment by browsing the function in the Azure portal: 108 | 109 | ![Check deployment](../images/check_deploy.png) 110 | 111 | 1. Register the webhook with SharePoint using the `Add-PnPWebhookSubscription` cmdlet. You can can the function URL in the Azure portal: 112 | 113 | ![Get func URL](../images/get_func_url.png) 114 | ```PowerShell 115 | Connect-PnPOnline -Url https://.sharepoint.com/sites/workspaces 116 | Add-PnPWebhookSubscription -List "Workspace requests" -NotificationUrl https://.azurewebsites.net/api/webhookhandler 117 | ``` 118 | 119 | > Next part: [Part 4 - Setup end-user experience](./PART4.md) -------------------------------------------------------------------------------- /docs/PART4.md: -------------------------------------------------------------------------------- 1 | # Part 4 - Test the end-to-end Office 365 group creation 2 | 3 | In the part 4 we test all the pieces together by running the whole creation process from the beginning. 4 | 5 | ## Create a new workspace request 6 | 7 | 1. In the Azure autmation runbook _New-Workspace_, enable logs. This way, you will be able to soo the PowerShell script execution steps. 8 | 9 | ![Enable runbook logs](/images/enable_logs.png) 10 | 11 | 2. In the workspace requests list from the previously created SharePoint site, create a new item. 12 | 13 | ![Create new workspaces](/images/create_new_workspace.png) 14 | 15 | 3. Go to the _New-Workspace_ runbook and inspect. You should see a new instance is running. 16 | 17 | ![Runbook Logs](/images/runbook_status1.png) 18 | 19 | 4. Click on the running instance to see the provisioning status. You should see the status updated as well in the workspace requests list. 20 | 21 | ![Runbook Logs](/images/runbook_status2.png) 22 | 23 | ![Item provisioning status](/images/item_provisioning_status.png) 24 | 25 | 5. When the creation is done, the new URL is available in the request item: 26 | 27 | ![Item provisioning status Completed](/images/creation_completed.png) 28 | 29 | 6. By browsing the site you should see the current members for the group. You will notice the owner set in the request is not set as 'Owner' in the group. This is due to the default behavior of group creation. Actually, in the provisioning script, the Office 365 group is created under the Azure AD Application identity. It means **when an Office 365 group is created using the Graph API, the owner corresponds automatically to the identiy of the one perfoming the call** (in this case, the Azure AD application). To specify an other owner, you need to set it manually in a separate operation and this update is not instantaneous and can take few minutes (due to the fact the permissions are synchronized across different services). 30 | 31 | ![Group membership](/images/group_membership.png) 32 | 33 | ## What happen if case of provisioning issues? 34 | 35 | In the case where the provisioning goes wrong, you have several places to look at to identify the cause: 36 | 37 | 1. The Azure Function used to catch the SharePoint item creation event: 38 | 39 | ![Azure Function logs](/images/func_logd.png) 40 | 41 | 2. The Azure Logic app used to start the automation runbook: 42 | 43 | ![Logic app logs](/images/logicapp_logs.png) 44 | 45 | 3. The Azure automation runbook logs: 46 | 47 | ![Runbook logs](/images/runbook_logs.png) 48 | 49 | ### Create a manual flow 50 | 51 | Sometimes, issues can occur for dummy reasons (timeout, service unavailable at this time, etc.). In this case, you would just have to restart the provisioning process of the same site to get it right. To do this, an administrator can easily create a Microsoft Flow on the requests list, calling the Azure Logic app and therefore, the provisioning job again on the same site. 52 | 53 | 1. In the workspace requests lsit, create a new Flow of type '_SharePoint - For a selected item_' and connect to the list: 54 | 55 | ![Flow step 1](/images/flow_step1.png) 56 | 57 | 2. Add an action _HTTP request_ and use the Logic App URL with the item ID as body parameter: 58 | 59 | ![Flow step 2](/images/flow_step2.png) 60 | 61 | 3. Now you can re-run the provisioning sequence on a existing site manually: 62 | 63 | ![Start Flow](/images/start_flow.png) 64 | 65 | > Next part: [Part 5 - Setup end-user experience](./PART5.md) 66 | -------------------------------------------------------------------------------- /docs/PART5.md: -------------------------------------------------------------------------------- 1 | # Part 5 - Setup end-user experience 2 | 3 | The part 5 demonstrates how to setup a search experience based on workspaces metadata set at creation time using taxonomy values. 4 | 5 | ## PnP Modern Search Configuration - Search for workspaces with taxonomy filters 6 | 7 | 1. Download the **pnp-modern-search.sppkg** SPFx solution from [here](https://github.com/microsoft-search/pnp-modern-search/releases). 8 | 9 | ![PnP Modern Search](/images/pnp-modern-search.png) 10 | 11 | 1. In the workspace requests SharePoint site, add an app catalog by running the following commands: 12 | 13 | Connect-SPOService -Url https://-admin.sharepoint.com 14 | Add-SPOSiteCollectionAppCatalog -Site https://.sharepoint.com/sites/workspaces 15 | 16 | 1. Add the package to the app catalog: 17 | 18 | ![Add application to catalog](/images/add_app.png) 19 | 20 | 1. Add the application 'PnP Search Web Parts' in the site. 21 | 22 | ![Add application to site](/images/add_app_site.png) 23 | 24 | 1. Go to the site settings and the search schema. Create a new **'RefinableString'** (let's say _'RefinableString01'_) managed property and map it to the `PropertyBag_pnpCategory` crawled property. This managed property comes from the Office 365 SharePoint site property bag set by the provisioning script. It can take minutes to hours to see this property appear in your schema. 25 | 26 | ![Crawled property mapping](/images/crawled_property_mapping.png) 27 | 28 | 1. Create a new SharePoint page and add the _'Search Box'_, _'Search results'_ and _'Search Refiners'_ Web Parts. Connect all the three Web Parts together. 29 | 30 | 1. In the _'Search results'_ Web Part, add this query as query template: `{searchTerms} contentclass:STS_Site AND Path:"https://.sharepoint.com/sites/GRP*"`: 31 | 32 | ![Query template](/images/query_template.png) 33 | 34 | 1. In the _'Search Refiners'_ Web Part, configure refiners to use your _'RefinableString01'_ managed property. 35 | 36 | ![Query template](/images/configure_refiners.png) 37 | 38 | 1. Now you have a dedicated search page to browse provisioned Office 365 groups using taxonomy! 39 | 40 | ![Search page](/images/search_experience.png) 41 | 42 | > Next part: [Part 6 - Setup Teams Configuration](./PART6.md) -------------------------------------------------------------------------------- /docs/PART6.md: -------------------------------------------------------------------------------- 1 | # Part 6 - Setup Teams Configuration 2 | 3 | The part 6 demonstrates how you can automate Microsoft Teams Teams configuration and start adding content programmatically so users feel welcome in their new collaboration environments. 4 | 5 | In this part, we will be using Azure Durable Functions with the dotnet framework in combination with the existing solution we built. 6 | 7 | You will notice this function app differs from the previous function app used for the webhook, it is intentionally done to demonstrate multiple technologies at work. 8 | 9 | ## Create a new Function app and deploy the code 10 | Because function apps cannot be collocated if they are built with different languages, we need to create a new one. 11 | 12 | 1. Go to the [Azure Portal](https://portal.azure.com) and create a new function app 13 | 14 | Note: Azure CLI script for reference 15 | ```bash 16 | az functionapp create -g pnptutorialpractice --consumption-plan-location canadaeast -n pnptutorialpracticedotnet --storage-account pnptutorialpractice --runtime node 17 | ``` 18 | 1. Build the function app project 19 | From the `functions/teams-config-function` folder run the following commands 20 | ```bash 21 | dotnet restore 22 | dotnet build --configuration Release --no-restore 23 | dotnet publish --configuration Release --output publish_output --no-restore --no-build 24 | ``` 25 | 1. Package the function app 26 | ```bash 27 | rm -f publish.zip 28 | powershell.exe -nologo -noprofile -command "get-childitem .\publish_output\ | Compress-Archive -DestinationPath ./publish.zip -Update" 29 | ``` 30 | 1. Deploy the function app 31 | ```bash 32 | az functionapp deployment source config-zip -g pnptutorialpractice -n pnptutorialpracticedotnet --src ./publish.zip 33 | ``` 34 | 1. Configuring the function app 35 | ```bash 36 | az functionapp config appsettings set -n pnptutorialpracticedotnet -g pnptutorialpractice --settings "TENANT_NAME=baywet" # the tenant in tenant.onmicrosoft.com 37 | az functionapp config appsettings set -n pnptutorialpracticedotnet -g pnptutorialpractice --settings "LIBRARIES=Shared Documents" #the names of the libraries you want to create tabs for, comma separated 38 | az functionapp config appsettings set -n pnptutorialpracticedotnet -g pnptutorialpractice --settings "LIBRARIES_SOURCE=https://baywet.sharepoint.com" #the url of the site collection you want the function to create teams tab for 39 | az functionapp config appsettings set -n pnptutorialpracticedotnet -g pnptutorialpractice --settings "WEBSITE_LOAD_CERTIFICATES=*" #this instructs the function app to load certificates available in the resource group 40 | az functionapp config appsettings set -n pnptutorialpracticedotnet -g pnptutorialpractice --settings "AUTH_CLIENT_SECRET_CERTIFICATE_THUMBPRINT=11E4A4AD3F71D40602CA7D98FD6F7E4B55E048CB" #certificate thumbprint to use for authentication, you can get this information from the app registration, certificates 41 | az webapp auth update -g pnptutorialpractice -n pnptutorialpracticedotnet --action AllowAnonymous --aad-token-issuer-url "https://sts.windows.net/bd4c6c31-c49c-4ab6-a0aa-742e07c20232/" --aad-client-id "771365a9-d7c2-4731-98fc-bb8a4e11b873" --query "clientSecretCertificateThumbprint" 42 | # aad-token-issuer-url: tenant issuer to validate tokens from, make sure you replace the tenant id which you can get from the app registration ovrview page 43 | # aad-client-id: client id of the application, you can get this from the app registration overview page 44 | ``` 45 | 1. Upload the certificate 46 | ```bash 47 | az webapp config ssl upload -n pnptutorialpracticedotnet -g pnptutorialpractice --certificate-file --certificate-password 48 | ``` 49 | ## Update the logic app 50 | 1. Edit the logic app previously created. 51 | 1. In the _If True_ part of the condition, add a new action (after the update item action) 52 | 1. Search for _Azure Functions_, select the function app we created and then the `StartConfiguration` function. 53 | 1. In the _Body_ pass the _Title_ from the _Get Item_ action. 54 | 1. Add a new parameter, select _Method_ and select _POST_ as the value. 55 | 1. Save the Logic App 56 | 57 | Note: Screenshot for reference. 58 | ![function step](../images/teams-function-logic-app.png) 59 | 60 | ## Update app permissions 61 | Note: you can skip this step if you used the CLI to register the application 62 | 1. Go the app registration using the [Azure Portal](https://aad.portal.azure.com) 63 | 1. Navigate to the application we previously registered 64 | 1. Navigate to _API Permissions_ 65 | 1. Add the _Microsoft Graph_ _Application Permission_ _Sites.Read.All_ 66 | 1. Save 67 | 1. Click on `Grant Admin Constent` 68 | 69 | ## Test 70 | 1. Start a new workspace creation from the requests list 71 | 1. Wait a few minutes for the execution to complete 72 | 1. Log on [Microsoft Teams](https://teams.microsoft.com), notice the new team, with an additional channel and a tab configured for this channel. 73 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # IDEs and editors 10 | .idea/ 11 | 12 | # build tools 13 | coverage/ 14 | test-report.xml 15 | 16 | # misc 17 | npm-debug.log 18 | yarn-error.log 19 | *.pem 20 | *.pfx -------------------------------------------------------------------------------- /functions/sp-trigger-function/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "eqeqeq": true, 4 | "forin": true, 5 | "noarg": true, 6 | "noempty": true, 7 | "nonbsp": true, 8 | "nonew": true, 9 | "undef": true, 10 | "varstmt": true, 11 | "esversion": 6, 12 | "latedef": true, 13 | "unused": true, 14 | "indent": 2, 15 | "quotmark": "single", 16 | "maxcomplexity": 20, 17 | "maxlen": 140, 18 | "maxerr": 50, 19 | "globals": {}, 20 | "strict": true, 21 | "laxbreak": true, 22 | "browser": true, 23 | "module": true, 24 | "node": true, 25 | "trailing": true, 26 | "onevar": true, 27 | "white": true 28 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Local Azure Function", 6 | "type": "node", 7 | "request": "attach", 8 | "port": 5858, 9 | "sourceMaps": true, 10 | "outFiles": [ 11 | "${workspaceRoot}/dist/**/*.js" 12 | ] 13 | }, 14 | { 15 | "type": "node", 16 | "request": "launch", 17 | "name": "Debug Jest all tests", 18 | "program": "${workspaceRoot}/node_modules/jest/bin/jest", 19 | "args": [ 20 | "--runInBand" 21 | ], 22 | "console": "integratedTerminal", 23 | "internalConsoleOptions": "neverOpen" 24 | }, 25 | { 26 | "name": "Attach to Node Functions", 27 | "type": "node", 28 | "request": "attach", 29 | "port": 9229, 30 | "preLaunchTask": "func: host start" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.projectSubpath": "dist", 3 | "azureFunctions.deploySubpath": "dist", 4 | "azureFunctions.projectLanguage": "JavaScript", 5 | "azureFunctions.projectRuntime": "~2", 6 | "debug.internalConsoleOptions": "neverOpen" 7 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "func", 6 | "command": "host start", 7 | "problemMatcher": "$func-watch", 8 | "isBackground": true, 9 | "options": { 10 | "cwd": "${workspaceFolder}/dist" 11 | } 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/README.md: -------------------------------------------------------------------------------- 1 | # TypeScript Azure Function boilerplate 2 | 3 | ## Description 4 | 5 | TypeScript Azure Function boilerplate project that can be used to build SPFx back end services. The original project structure has been taken from this [article](https://medium.com/burak-tasci/backend-development-on-azure-functions-with-typescript-56113b6be4b9) with only few modifications. 6 | 7 | ## How to debug this function locally ? 8 | 9 | - In VSCode, open the root folder `./`. 10 | - Install all dependencies using `npm i`. 11 | - Install [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli-windows?view=azure-cli-latest) on youre machine. 12 | - Install Azure Function Core tools globaly using `npm i -g azure-functions-core-tools@2.7.1149` (version 2). 13 | - Install [DotNet Core SDK](https://dotnet.microsoft.com/download) 14 | - In a Node.js console, build the solution using `npm run build:dev` cmd. For production use, execute `npm run build` (minified version of the JS code). 15 | - In a Node.js console, from the `./dist` folder, run the following command `func start`. 16 | - From VSCode, Launch the *'Debug Local Azure Function'* debug configuration 17 | - Send your requests either using Postman with the localhost address according to your settings (i.e. `http://localhost:7071/api/demoFunction`) 18 | - Enjoy ;) 19 | 20 |

21 | 22 | ### Debug tests 23 | 24 | - Set breakpoints directly in your **'spec.ts'** test files 25 | - In VSCode, launch the *'Debug Jest all tests'* debug configuration 26 | - In a Node.js console, build the solution using `npm run test` 27 | - Enjoy ;) 28 | 29 | ### Azure Function Proxy configuration ### 30 | 31 | This solution uses an Azure function proxy to get an only single endpoint URL for multiple functions. See the **proxies.json** file to see defined routes. 32 | 33 | ### Certificate generation ### 34 | 35 | This solution uses an Azure AD application certificate connection to communicate with SharePoint to create the webhook subscription. It means you need to generate the certificate private key in _*.pem_ format and copy it to the _'./config'_ folder of your function. You can create a certificate for your Azure AD app using this [procedure](https://docs.microsoft.com/en-us/sharepoint/dev/solution-guidance/security-apponly-azuread). Then use the [OpenSSL](https://wiki.openssl.org/index.php/Binaries) tool and the following command to generate the _.pem_ key from the _.pfx_ certificate: 36 | 37 | ``` 38 | openssl pkcs12 -in C:\.pfx -nocerts -out C:\.pem -nodes 39 | ``` 40 | 41 | You can also use the [`Get-PnPAzureCertificate`](https://docs.microsoft.com/en-us/powershell/module/sharepoint-pnp/get-pnpazurecertificate?view=sharepoint-ps) cmdlet to do the same: 42 | 43 | ``` 44 | Get-PnPAzureCertificate -CertificatePath "C:\.pfx -" -CertificatePassword (ConvertTo-SecureString -String '' -AsPlainText -Force) 45 | ``` 46 | 47 | ## How to deploy the solution to Azure ? ## 48 | 49 | ### Development scenario 50 | 51 | We recommend to use Visual Studio Code to work with this solution. 52 | 53 | - In VSCode, download the [Azure Function](https://code.visualstudio.com/tutorials/functions-extension/getting-started) extension 54 | - Sign-in to to Azure account into the extension 55 | - In a Node.js console, build the application using the command `npm run build` (minified version) 56 | - Use the **"Deploy to Function App"** feature (in the extension top bar) using the *'dist'* folder. Make sure you've run the `npm run build` cmd before. 57 | - Upload the application settings file according to your environment (`local.settings.json`) 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/deploy.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | Param ( 3 | 4 | [Parameter(Mandatory = $False)] 5 | [switch]$OverwriteSettings, 6 | 7 | [Parameter(Mandatory = $False)] 8 | [switch]$Minified 9 | ) 10 | 11 | $0 = $myInvocation.MyCommand.Definition 12 | $CommandDirectory = [System.IO.Path]::GetDirectoryName($0) 13 | 14 | # A good idea here is to use a different local.settings file according to the targeted environment (DEV, PROD, etc.) 15 | $AppSettingsFilePath = Join-Path -Path $CommandDirectory -ChildPath "src\local.settings.json" 16 | $AppSettings = Get-Content -Path $AppSettingsFilePath -Raw | ConvertFrom-Json 17 | 18 | $ErrorActionPreference = 'Continue' 19 | 20 | # Execute tests 21 | npm run test:ci 2>&1 | Write-Host 22 | 23 | if ($LASTEXITCODE -eq 1) { 24 | throw "Error during tests!" 25 | } 26 | 27 | # Build the solution 28 | $ErrorActionPreference = 'Stop' 29 | 30 | if ($Minified.IsPresent) { 31 | npm run build 32 | } else { 33 | npm run build:dev 34 | } 35 | 36 | # Deploy the functions 37 | Push-Location '.\dist' 38 | 39 | # Get the Azure function name according to the settings 40 | $AzureFunctionName = $AppSettings.Values.Azure_Function_Name 41 | 42 | Write-Output "Deploy to function $AzureFunctionName..." 43 | 44 | if ($OverwriteSettings.IsPresent) { 45 | # https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local#publish 46 | func azure functionapp publish $AzureFunctionName --publish-local-settings --overwrite-settings 47 | } else { 48 | func azure functionapp publish $AzureFunctionName 49 | } 50 | 51 | Pop-Location -------------------------------------------------------------------------------- /functions/sp-trigger-function/images/cors_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/functions/sp-trigger-function/images/cors_settings.png -------------------------------------------------------------------------------- /functions/sp-trigger-function/images/func_debug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/functions/sp-trigger-function/images/func_debug.png -------------------------------------------------------------------------------- /functions/sp-trigger-function/junit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-azure-function", 3 | "version": "1.0.0", 4 | "description": "TypeScript Azure Function boilerplate ", 5 | "repository": { 6 | "type": "git", 7 | "url": "" 8 | }, 9 | "keywords": [ 10 | "api", 11 | "rest", 12 | "azure-functions", 13 | "azure" 14 | ], 15 | "author": { 16 | "name": "Franck Cornu", 17 | "email": "franck.cornu@aequos.ca" 18 | }, 19 | "license": "MIT", 20 | "scripts": { 21 | "build": "webpack --config ./tools/build/webpack.prod.js", 22 | "build:dev": "webpack --config ./tools/build/webpack.dev.js", 23 | "lint": "tslint -p ./tsconfig.json --force", 24 | "test": "jest --coverage --colors --verbose", 25 | "test:ci": "jest --ci --coverage --colors", 26 | "release": "standard-version" 27 | }, 28 | "dependencies": { 29 | "@types/form-data": "^2.2.1", 30 | "@types/lodash": "^4.14.118", 31 | "@types/node-fetch": "^1.6.9", 32 | "@types/sinon": "^4.3.3", 33 | "@types/sprintf-js": "^1.1.1", 34 | "adal-node": "^0.1.28", 35 | "azure-functions-ts-essentials": "1.3.1", 36 | "azure-storage": "^2.10.3", 37 | "clean-webpack-plugin": "^0.1.19", 38 | "jest-junit": "^4.0.0", 39 | "node-fetch": "^2.5.0", 40 | "path": "^0.12.7", 41 | "sinon": "^5.1.1" 42 | }, 43 | "devDependencies": { 44 | "@types/jest": "22.1.4", 45 | "@types/node": "^9.4.7", 46 | "awesome-typescript-loader": "~3.4.1", 47 | "copy-webpack-plugin": "~4.3.1", 48 | "cp-cli": "^1.1.2", 49 | "jest": "22.1.4", 50 | "standard-version": "~4.3.0", 51 | "ts-jest": "22.0.4", 52 | "tslint": "~5.9.1", 53 | "typescript": "~2.6.2", 54 | "uglifyjs-webpack-plugin": "^1.1.8", 55 | "webpack": "~3.10.0" 56 | }, 57 | "jest": { 58 | "transform": { 59 | "^.+\\.(ts|tsx)$": "/node_modules/ts-jest/preprocessor.js" 60 | }, 61 | "testMatch": [ 62 | "**/*.spec.ts" 63 | ], 64 | "moduleFileExtensions": [ 65 | "ts", 66 | "js" 67 | ], 68 | "testResultsProcessor": "./node_modules/jest-junit", 69 | "cache": false, 70 | "silent": true, 71 | "testURL": "http://localhost/" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/functions/webhookHandler/config/samplekey.txt: -------------------------------------------------------------------------------- 1 | Bag Attributes 2 | Microsoft Local Key set: 3 | localKeyID: 01 00 00 00 4 | friendlyName: tp-2bbcf4a7-62a1-4d55-963d-34297fa848bb 5 | Microsoft CSP Name: Microsoft RSA SChannel Cryptographic Provider 6 | Key Attributes 7 | X509v3 Key Usage: 10 8 | -----BEGIN PRIVATE KEY----- 9 | 10 | -----END PRIVATE KEY----- 11 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/functions/webhookHandler/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "type": "httpTrigger", 7 | "direction": "in", 8 | "name": "req", 9 | "methods": [ 10 | "POST" 11 | ] 12 | }, 13 | { 14 | "name": "$return", 15 | "type": "http", 16 | "direction": "out" 17 | } 18 | ], 19 | "scriptFile": "webhookHandler.js" 20 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/functions/webhookHandler/webhookHandler.ts: -------------------------------------------------------------------------------- 1 | import { Context, HttpMethod, HttpRequest, HttpResponse, HttpStatusCode } from 'azure-functions-ts-essentials'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import { IFunctionSettings } from '../../models/IFunctionSettings'; 5 | import { AuthenticationHelper } from '../../helpers/AuthenticationHelper'; 6 | import { SharePointWebHookHelper } from '../../helpers/SharePointWebHookHelper'; 7 | import { StorageHelper } from '../../helpers/StorageHelper'; 8 | import IWebhookSubcriptionData from '../../models/IWebhookSubcriptionData'; 9 | import { IChangeQuery, ChangeType } from '../../models/IChangeQuery'; 10 | import fetch from 'node-fetch'; 11 | 12 | /* 13 | * Routes the request to the default controller using the relevant method. 14 | */ 15 | export async function run(context: Context, req: HttpRequest): Promise { 16 | 17 | let res: HttpResponse; 18 | 19 | switch (req.method) { 20 | case HttpMethod.Get: 21 | break; 22 | 23 | case HttpMethod.Post: 24 | 25 | const validationToken = req.query ? req.query.validationtoken : undefined; 26 | 27 | // Get function settings 28 | const settingsData: IFunctionSettings = process.env as any; 29 | 30 | // Authentication settings 31 | const { appId, tenant, resource, certificatePath, certificateThumbPrint } = settingsData; 32 | const certificate = fs.readFileSync(path.resolve(__dirname, `./${certificatePath}`), { encoding: 'utf8' }); 33 | 34 | // SharePoint settings 35 | const { spWebUrl, spListId } = settingsData; 36 | 37 | // Webhook settings 38 | const { webhookNotificationUrl, webhookSubscriptionIdentifier , webhookTargetEndpointUrl } = settingsData; 39 | 40 | // Azure Storage settings 41 | const { azStorageConnectionString, azTableName } = settingsData; 42 | 43 | try { 44 | 45 | let response = {}; 46 | 47 | if (validationToken) { 48 | 49 | // Reply directly to SharePoint to complete the webhook subscription 50 | response = validationToken; 51 | 52 | } else { 53 | 54 | const authenticationHelper = new AuthenticationHelper(appId, tenant, resource, certificateThumbPrint, certificate); 55 | 56 | // Get an acces token 57 | const token = await authenticationHelper.getToken(); 58 | const webhookHelper = new SharePointWebHookHelper(spWebUrl, token.accessToken); 59 | 60 | // Setting to null corresponds to 180 days expiration by default 61 | const subscriptionData: IWebhookSubcriptionData = { 62 | expirationDateTime: null, 63 | key: webhookSubscriptionIdentifier, 64 | notificationUrl: webhookNotificationUrl 65 | }; 66 | 67 | await webhookHelper.ensureWebhookSubscription(spListId, subscriptionData); 68 | 69 | // Get last time token 70 | const storageHelper = new StorageHelper(azStorageConnectionString, azTableName, 'webhook-data'); 71 | let tokenValue = await storageHelper.getPropertyValue('lastChangeToken'); 72 | let changeToken = null; 73 | 74 | if (tokenValue) { 75 | changeToken = { 76 | StringValue: tokenValue 77 | } 78 | } 79 | 80 | // Get changes for 'Add' operations only 81 | const changeRequest: IChangeQuery = { 82 | query: { 83 | Add: true, 84 | Item: true, 85 | RecursiveAll: true, 86 | ChangeTokenStart: changeToken 87 | } 88 | }; 89 | 90 | const changes = await webhookHelper.getListItemChanges(spListId, changeRequest); 91 | 92 | // Changes are already sorted from least recent to most recent according to the change token 93 | const additions = changes.filter(changeItem => { 94 | return changeItem.ChangeType === ChangeType.Add 95 | }); 96 | 97 | // We don't trigger the target endpoint if there was no change token in order to avoid to deal with list full history since creation. 98 | if (changeToken) { 99 | 100 | const allPromises = additions.map(addition => { 101 | const response = fetch(webhookTargetEndpointUrl, { 102 | method: 'POST', 103 | headers: { 104 | 'Content-Type': 'application/json', 105 | 'Accept': 'application/json' 106 | }, 107 | body: JSON.stringify({ 108 | itemId: addition.ItemId 109 | }) 110 | }); 111 | 112 | return response; 113 | }); 114 | 115 | await Promise.all(allPromises); 116 | } 117 | 118 | // Store the last change token for future calls. If no changes to process, set a start token as of 'now' 119 | const lastChangeToken = additions.length > 0 ? 120 | additions[additions.length - 1].ChangeToken.StringValue : 121 | `1;3;${spListId};${((new Date().getTime() * 10000) + 621355968000000000)};-1` 122 | 123 | await storageHelper.setPropertyValue('lastChangeToken', lastChangeToken); 124 | } 125 | 126 | res = { 127 | status: HttpStatusCode.OK, 128 | body: response 129 | }; 130 | 131 | } catch (error) { 132 | res = { 133 | status: HttpStatusCode.InternalServerError, 134 | body: { 135 | error: { 136 | type: 'function_error', 137 | message: error.message 138 | } 139 | } 140 | }; 141 | } 142 | 143 | break; 144 | case HttpMethod.Patch: 145 | break; 146 | case HttpMethod.Delete: 147 | break; 148 | 149 | default: 150 | res = { 151 | status: HttpStatusCode.MethodNotAllowed, 152 | body: { 153 | error: { 154 | type: 'not_supported', 155 | message: `Method ${req.method} not supported.` 156 | } 157 | } 158 | }; 159 | } 160 | 161 | return res; 162 | } 163 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/functions/webhookHandler/webhookHandler/webhookHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import { Context, HttpMethod, HttpRequest, HttpStatusCode } from 'azure-functions-ts-essentials'; 2 | import { run } from '../webhookHandler'; 3 | import * as $ from '../../../../tools/build/helpers'; 4 | import * as fs from 'fs'; 5 | 6 | // Build the process.env object according to Azure Function Application Settings 7 | const localSettingsFile = $.root('./src/local.settings.json'); 8 | const settings = JSON.parse(fs.readFileSync(localSettingsFile, { encoding: 'utf8' }) 9 | .toString()).Values; 10 | 11 | // Get all Azure Function settings so we can use them in tests 12 | Object.keys(settings) 13 | .map(key => { 14 | process.env[key] = settings[key]; 15 | }); 16 | 17 | describe('POST /api/webhookHandler', () => { 18 | 19 | it('should reply with the same validation token', async () => { 20 | 21 | const validationToken = '9f78e435-a29c-44b8-8121-6e4d325daf84'; 22 | 23 | const mockContext: Context = { 24 | done: (err, response) => { 25 | expect(err).toBeUndefined(); 26 | expect(response.status).toEqual(HttpStatusCode.OK); 27 | expect(response.body).toBe(validationToken); 28 | } 29 | }; 30 | 31 | const mockRequest: HttpRequest = { 32 | method: HttpMethod.Post, 33 | headers: { 'content-type': 'application/json' }, 34 | body: { 35 | validationToken: validationToken 36 | } 37 | }; 38 | 39 | try { 40 | await run(mockContext, mockRequest); 41 | } catch (e) { 42 | fail(e); 43 | } 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/helpers/AuthenticationHelper.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationContext, TokenResponse } from 'adal-node'; 2 | 3 | export class AuthenticationHelper { 4 | 5 | private _authorityHostUrl: string = 'https://login.windows.net'; 6 | private _tenant: string; 7 | private _appId: string; 8 | private _resource: string; 9 | private _certificateThumbprint: string; 10 | private _certificate: string; 11 | private _authenticationContext: AuthenticationContext; 12 | 13 | public constructor(appId: string, tenant: string, resource: string, certificateThumbprint: string, certificate: string) { 14 | 15 | this._appId = appId; 16 | this._tenant = tenant; 17 | this._resource = resource; 18 | this._certificate = certificate; 19 | this._certificateThumbprint = certificateThumbprint; 20 | 21 | // Set the authentication context 22 | this._authenticationContext = new AuthenticationContext(`${this._authorityHostUrl}/${this._tenant}`); 23 | } 24 | 25 | /** 26 | * Gets an access token from Azure AD 27 | */ 28 | public getToken(): Promise { 29 | 30 | const p1 = new Promise((resolve, reject) => { 31 | 32 | this._authenticationContext.acquireTokenWithClientCertificate(this._resource, this._appId, this._certificate, this._certificateThumbprint, (err, tokenResponse) => { 33 | if (err) { 34 | reject(err.message); 35 | } else { 36 | resolve(tokenResponse as TokenResponse); 37 | } 38 | }); 39 | }); 40 | 41 | return p1; 42 | } 43 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/helpers/SharePointWebHookHelper.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { StorageHelper } from "./StorageHelper"; 3 | import IWebhookSubcriptionData from "../models/IWebhookSubcriptionData"; 4 | import { IChangeQuery } from '../models/IChangeQuery'; 5 | import IChangeItem from '../models/IChangeItem'; 6 | 7 | export class SharePointWebHookHelper { 8 | 9 | // Default renewal period equals to 180 days 10 | private _expirationRenewalDays: number = 180; 11 | 12 | public get expirationRenewalDays(): number { return this._expirationRenewalDays; } 13 | public set expirationRenewalDays(value: number) { this._expirationRenewalDays = value; } 14 | 15 | private _webUrl: string; 16 | private _token: string; 17 | 18 | public constructor(webUrl: string, token: string) { 19 | this._webUrl = webUrl; 20 | this._token = token; 21 | } 22 | 23 | /** 24 | * Ensures the subscription already exists or not. If does not exist, it will be created. If expired, it will be renewed automatically. 25 | * @param listId the list GUID 26 | * @param subscriptionData the subscription information 27 | */ 28 | public async ensureWebhookSubscription(listId: string, subscriptionData: IWebhookSubcriptionData) { 29 | 30 | // Get the subscription by notification URL 31 | const response = await fetch(`${this._webUrl}/_api/web/lists('${listId}')/subscriptions?$filter=notificationUrl eq '${subscriptionData.notificationUrl}'&$top=1`, { 32 | method: 'GET', 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | 'Accept': 'application/json', 36 | 'Authorization' : `Bearer ${this._token}` 37 | } 38 | }); 39 | 40 | if (response.status === 200) { 41 | 42 | const body = await response.json(); 43 | if (body.value.length === 0) { 44 | 45 | // Create a new subscription if doesn't exist 46 | await this.createWebhookSubscription(listId, subscriptionData); 47 | 48 | } else { 49 | // Update the subscription if expired 50 | const subscription = body.value[0]; 51 | const expirationDateTime = subscription.expirationDateTime; 52 | 53 | if (new Date(expirationDateTime) < new Date()) { 54 | await this.updateWebhookSubscription(listId, subscription.id, subscriptionData); 55 | } 56 | } 57 | 58 | } else { 59 | const error = JSON.stringify(response); 60 | throw new Error(error); 61 | } 62 | } 63 | 64 | /** 65 | * Creates a new webhook subscription on a SharePoint list. 66 | * @param listId the list GUID 67 | * @param subscriptionData the subscription information 68 | */ 69 | private async createWebhookSubscription(listId: string,subscriptionData: IWebhookSubcriptionData) { 70 | 71 | let expirationDateTime: Date = subscriptionData.expirationDateTime; 72 | 73 | if(!expirationDateTime) { 74 | expirationDateTime = new Date(); 75 | expirationDateTime.setDate(expirationDateTime.getDate() + this._expirationRenewalDays); 76 | } 77 | 78 | const response = await fetch(`${this._webUrl}/_api/web/lists('${listId}')/subscriptions`, { 79 | method: 'POST', 80 | headers: { 81 | 'Content-Type': 'application/json', 82 | 'Accept': 'application/json', 83 | 'Authorization' : `Bearer ${this._token}` 84 | }, 85 | body: JSON.stringify({ 86 | resource: `${this._webUrl}/_api/web/lists('${listId}')`, 87 | notificationUrl: subscriptionData.notificationUrl, 88 | expirationDateTime: expirationDateTime.toISOString(), 89 | clientState: subscriptionData.key 90 | }) 91 | }); 92 | 93 | if (response.status !== 201) { 94 | const error = JSON.stringify(response); 95 | throw new Error(error); 96 | } 97 | } 98 | 99 | /** 100 | * Updates a webhook subscription on a SharePoint list. 101 | * @param listId the list GUID 102 | * @param subscriptionData the subscription information 103 | */ 104 | private async updateWebhookSubscription(listId: string, subscriptionId: string, subscriptionData: IWebhookSubcriptionData) { 105 | 106 | let expirationDateTime: Date = new Date(); 107 | expirationDateTime.setDate(expirationDateTime.getDate() + this._expirationRenewalDays); 108 | 109 | const response = await fetch(`${this._webUrl}/_api/web/lists('${listId}')/subscriptions('${subscriptionId}')`, { 110 | method: 'PATCH', 111 | headers: { 112 | 'Content-Type': 'application/json', 113 | 'Accept': 'application/json', 114 | 'Authorization' : `Bearer ${this._token}` 115 | }, 116 | body: JSON.stringify({ 117 | notificationUrl: subscriptionData.notificationUrl, 118 | expirationDateTime: expirationDateTime.toISOString(), 119 | clientState: subscriptionData.key 120 | }) 121 | }); 122 | 123 | if (response.status !== 204) { 124 | const error = JSON.stringify(response); 125 | throw new Error(error); 126 | } 127 | } 128 | 129 | /** 130 | * Gets the list changes since the last change token specified in the change query 131 | * @param listId the list GUID 132 | * @param changeQuery the change query to use 133 | */ 134 | public async getListItemChanges(listId: string, changeQuery: IChangeQuery): Promise { 135 | 136 | const response = await fetch(`${this._webUrl}/_api/web/lists('${listId}')/RootFolder/GetListItemChanges?$Expand=RelativeTime&$top=1000`, { 137 | method: 'POST', 138 | headers: { 139 | 'Content-Type': 'application/json', 140 | 'Accept': 'application/json', 141 | 'Authorization' : `Bearer ${this._token}` 142 | }, 143 | body: JSON.stringify(changeQuery) 144 | }); 145 | 146 | if (response.status === 200) { 147 | 148 | const body = await response.json(); 149 | const value = body.value as IChangeItem[]; 150 | 151 | return value; 152 | 153 | } else { 154 | const error = JSON.stringify(response); 155 | throw new Error(error); 156 | } 157 | } 158 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/helpers/StorageHelper.ts: -------------------------------------------------------------------------------- 1 | import * as azure from 'azure-storage'; 2 | 3 | export class StorageHelper { 4 | 5 | private _tableService: azure.TableService; 6 | private _tableName: string; 7 | private _partitionKey: string; 8 | 9 | public constructor(connectionString: string, tableName: string, partitionKey: string) { 10 | this._tableService = azure.createTableService(connectionString); 11 | this._tableName = tableName; 12 | this._partitionKey = partitionKey; 13 | } 14 | 15 | /** 16 | * Gets a property value from an Azure table 17 | * @param propertyName the property name to retrieve 18 | */ 19 | public async getPropertyValue(propertyName: string): Promise { 20 | 21 | const p1 = new Promise((resolve, reject) => { 22 | 23 | const requestOptions: azure.TableService.TableEntityRequestOptions = { 24 | autoResolveProperties: true, 25 | }; 26 | 27 | this._tableService.retrieveEntity(this._tableName, this._partitionKey, propertyName, requestOptions, (error: Error, result: any, response)=>{ 28 | if (error) { 29 | 30 | if (response.statusCode === 404) { 31 | resolve(null); 32 | } 33 | 34 | reject(error); 35 | } else { 36 | resolve(result.Value._); 37 | } 38 | }); 39 | }); 40 | 41 | return p1; 42 | } 43 | 44 | /** 45 | * Sets a proeprty value in an Azure table 46 | * @param propertyName the property name to insert or replace 47 | * @param propertyValue the property value 48 | */ 49 | public async setPropertyValue(propertyName: string, propertyValue: any): Promise { 50 | 51 | const p1 = new Promise((resolve, reject) => { 52 | 53 | const entity = { 54 | PartitionKey: this._partitionKey, 55 | RowKey: propertyName, 56 | Value: propertyValue 57 | }; 58 | 59 | this._tableService.insertOrMergeEntity(this._tableName, entity, (error: Error, result, response) => { 60 | if (error) { 61 | reject(error); 62 | } else { 63 | resolve(); 64 | } 65 | }); 66 | }); 67 | 68 | return p1; 69 | } 70 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0" 3 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "FUNCTIONS_WORKER_RUNTIME": "node", 5 | "NODE_OPTIONS": "--inspect=5858", 6 | "appId": "3515c8ac-84fe-4ba3-acb7-b3da319ddd64", 7 | "tenant": "aequosdev.onmicrosoft.com", 8 | "resource": "https://aequosdev.sharepoint.com", 9 | "certificateThumbPrint": "81F2732C4C9CE9BFD8029849D7DCDC66AD2F1711", 10 | "certificatePath": ".\\config\\key.pem", 11 | "spListId": "291cd922-e15d-4d20-bd80-2505f496962c", 12 | "spWebUrl": "https://aequosdev.sharepoint.com/sites/workspaces", 13 | "webhookSubscriptionIdentifier": "pnp-workspace-provisioning", 14 | "webhookNotificationUrl": "http://68f556ec.ngrok.io/api/webhookhandler", 15 | "webhookTargetEndpointUrl": "", 16 | "azStorageConnectionString": "", 17 | "azTableName": "WebhookStateTable" 18 | }, 19 | "Host": { 20 | "LocalHttpPort": 7071, 21 | "CORS": "*" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/models/IChangeItem.ts: -------------------------------------------------------------------------------- 1 | import { ChangeType } from "./IChangeQuery"; 2 | 3 | export default interface IChangeItem { 4 | 5 | /** 6 | * The change token 7 | */ 8 | ChangeToken: { 9 | StringValue: string; 10 | } 11 | 12 | /** 13 | * The change type (Add, Delete, etc.) 14 | */ 15 | ChangeType: ChangeType; 16 | 17 | /** 18 | * List item ID 19 | */ 20 | ItemId: number; 21 | 22 | /** 23 | * Item properties 24 | */ 25 | [key: string]: any; 26 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/models/IChangeQuery.ts: -------------------------------------------------------------------------------- 1 | // https://docs.microsoft.com/en-us/previous-versions/office/sharepoint-server/ee537784(v=office.15) 2 | export interface IChangeQuery { 3 | query: { 4 | Add?: boolean; 5 | Update?: boolean; 6 | SystemUpdate?: boolean; 7 | DeleteObject?: boolean; 8 | Move?: boolean; 9 | Rename?: boolean; 10 | Restore?: boolean; 11 | Item?: boolean; 12 | File?: boolean; 13 | RecursiveAll?: boolean; 14 | ChangeTokenStart?: { 15 | StringValue: string; 16 | }; 17 | } 18 | } 19 | 20 | export enum ChangeType { 21 | NoChange, 22 | Add, 23 | Update, 24 | DeleteObject, 25 | Rename, 26 | MoveAway, 27 | MoveInto, 28 | Restore, 29 | RoleAdd, 30 | RoleDelete, 31 | RoleUpdate, 32 | AssignmentAdd, 33 | AssignmentDelete, 34 | MemberAdd, 35 | MemberDelete, 36 | SystemUpdate, 37 | Navigation, 38 | ScopeAdd, 39 | ScopeDelete, 40 | ListContentTypeAdd, 41 | ListContentTypeDelete, 42 | Dirty, 43 | Activity 44 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/models/IFunctionSettings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Add your local configuration schema here so you can use manipulate it easily in your code per env 3 | */ 4 | export interface IFunctionSettings { 5 | appId: string; 6 | tenant: string; 7 | resource: string; 8 | certificateThumbPrint: string; 9 | certificatePath: string; 10 | spListId: string; 11 | spWebUrl: string; 12 | webhookSubscriptionIdentifier: string; 13 | webhookNotificationUrl: string; 14 | webhookTargetEndpointUrl: string; 15 | azStorageConnectionString: string; 16 | azTableName: string; 17 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/models/IWebhookSubcriptionData.ts: -------------------------------------------------------------------------------- 1 | export default interface IWebhookSubcriptionData { 2 | 3 | /** 4 | * The service URL to send notifications to. 5 | */ 6 | notificationUrl: string; 7 | 8 | /** 9 | * The expiration date 10 | */ 11 | expirationDateTime: Date; 12 | 13 | /** 14 | * An unique identifier for this subscription 15 | */ 16 | key?: string; 17 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/src/proxies.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/proxies", 3 | "proxies": { 4 | "WebhookProvisioning": { 5 | "matchCondition": { 6 | "route": "/api/newworkspace", 7 | "methods": [ 8 | "POST" 9 | ] 10 | }, 11 | "backendUri": "http://%WEBSITE_HOSTNAME%/api/webhookHandler" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /functions/sp-trigger-function/tools/build/helpers.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | exports.root = function(args) { 4 | const ROOT = path.resolve(__dirname, '../..'); 5 | args = Array.prototype.slice.call(arguments, 0); 6 | 7 | return path.join.apply(path, [ROOT].concat(args)); 8 | }; 9 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/tools/build/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const $ = require('./helpers'); 2 | const copyWebpackPlugin = require('copy-webpack-plugin'); 3 | const cleanWebpackPlugin = require('clean-webpack-plugin'); 4 | const webpack = require('webpack'); 5 | 6 | module.exports = { 7 | target: 'node', 8 | entry: { 9 | 'webhookHandler': $.root('./src/functions/webhookHandler/webhookHandler.ts') 10 | }, 11 | output: { 12 | path: $.root('dist'), 13 | filename: '[name]/[name].js', 14 | libraryTarget: 'commonjs2', 15 | devtoolModuleFilenameTemplate: '[absolute-resource-path]' 16 | }, 17 | devtool: 'source-map', 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.ts$/, 22 | use: 'awesome-typescript-loader?declaration=false', 23 | exclude: [/\.(spec|e2e)\.ts$/] 24 | } 25 | ] 26 | }, 27 | resolve: { 28 | extensions: ['.ts', '.js', '.json'], 29 | modules: [ 30 | 'node_modules', 31 | 'src' 32 | ] 33 | }, 34 | plugins: [ 35 | new copyWebpackPlugin([ 36 | { 37 | from: 'src/host.json', 38 | to: 'host.json' 39 | }, 40 | { 41 | from: 'src/proxies.json', 42 | to: 'proxies.json' 43 | }, 44 | { 45 | from: 'src/local.settings.json', 46 | to: 'local.settings.json' 47 | }, 48 | { 49 | context: 'src/functions', 50 | from: '**/function.json', 51 | to: '' 52 | }, 53 | { 54 | context: 'src/functions', 55 | from: '**/config/*.json', 56 | to: '' 57 | }, 58 | { 59 | context: 'src/functions', 60 | from: '**/config/*.pem', 61 | to: '' 62 | } 63 | ]), 64 | new cleanWebpackPlugin(['dist/**/*'], { 65 | allowExternal: true, 66 | root: $.root('.'), 67 | verbose: false 68 | }), 69 | new webpack.IgnorePlugin(/^encoding$/, /node-fetch/) 70 | ], 71 | node: { 72 | __filename: false, 73 | __dirname: false, 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/tools/build/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const $ = require('./helpers'); 2 | const uglifyJSPlugin = require('uglifyjs-webpack-plugin'); 3 | const copyWebpackPlugin = require('copy-webpack-plugin'); 4 | const cleanWebpackPlugin = require('clean-webpack-plugin'); 5 | const webpack = require('webpack'); 6 | 7 | module.exports = { 8 | target: 'node', 9 | entry: { 10 | 'webhookHandler': $.root('./src/functions/webhookHandler/webhookHandler.ts') 11 | /* 'anotherFunctionEntryPoint': $.root('./src/functions/anotherFunctionEntryPoint/anotherFunctionEntryPoint.ts'),*/ 12 | }, 13 | output: { 14 | path: $.root('dist'), 15 | filename: '[name]/[name].js', 16 | libraryTarget: 'commonjs2' 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.ts$/, 22 | use: 'awesome-typescript-loader?declaration=false', 23 | exclude: [/\.(spec|e2e)\.ts$/] 24 | } 25 | ] 26 | }, 27 | resolve: { 28 | extensions: ['.ts', '.js', '.json'], 29 | modules: [ 30 | 'node_modules', 31 | 'src' 32 | ] 33 | }, 34 | plugins: [ 35 | new uglifyJSPlugin({ 36 | uglifyOptions: { 37 | ecma: 6 38 | } 39 | }), 40 | new copyWebpackPlugin([ 41 | { 42 | from: 'src/host.json', 43 | to: 'host.json' 44 | }, 45 | { 46 | from: 'src/proxies.json', 47 | to: 'proxies.json' 48 | }, 49 | { 50 | context: 'src/functions', 51 | from: '**/function.json', 52 | to: '' 53 | }, 54 | { 55 | from: 'src/local.settings.json', 56 | to: 'local.settings.json' 57 | }, 58 | { 59 | context: 'src/functions', 60 | from: '**/config/*.json', 61 | to: '' 62 | }, 63 | { 64 | context: 'src/functions', 65 | from: '**/config/*.pem', 66 | to: '' 67 | } 68 | ]), 69 | new cleanWebpackPlugin(['dist/**/*'], { 70 | allowExternal: true, 71 | root: $.root('.'), 72 | verbose: false 73 | }), 74 | new webpack.IgnorePlugin(/^encoding$/, /node-fetch/) 75 | ], 76 | node: { 77 | __filename: false, 78 | __dirname: false, 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "commonjs", 5 | "sourceMap": true, 6 | "moduleResolution": "node", 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "suppressImplicitAnyIndexErrors": true, 10 | "lib": [ "es2015"], 11 | }, 12 | "include": [ 13 | "src/**/*.ts" 14 | ], 15 | "awesomeTypescriptLoaderOptions": { 16 | "usePrecompiledFiles": true, 17 | "useWebpackText": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /functions/sp-trigger-function/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-unnecessary-class": [ 4 | true, 5 | "allow-constructor-only", 6 | "allow-static-only", 7 | "allow-empty-class" 8 | ], 9 | "member-access": [ 10 | true, 11 | "no-public" 12 | ], 13 | "member-ordering": [ 14 | true, 15 | "public-before-private", 16 | "static-before-instance", 17 | "variables-before-functions" 18 | ], 19 | "adjacent-overload-signatures": true, 20 | "unified-signatures": true, 21 | "prefer-function-over-method": [ 22 | true, 23 | "allow-public", 24 | "allow-protected" 25 | ], 26 | "no-invalid-this": [ 27 | true, 28 | "check-function-in-method" 29 | ], 30 | "no-duplicate-super": true, 31 | "new-parens": true, 32 | "no-misused-new": true, 33 | "no-construct": true, 34 | "no-empty-interface": true, 35 | "prefer-method-signature": true, 36 | "interface-over-type-literal": true, 37 | "no-arg": true, 38 | "only-arrow-functions": [ 39 | true, 40 | "allow-declarations", 41 | "allow-named-functions" 42 | ], 43 | "arrow-parens": [ 44 | true, 45 | "ban-single-arg-parens" 46 | ], 47 | "arrow-return-shorthand": true, 48 | "no-return-await": true, 49 | "prefer-const": true, 50 | "no-shadowed-variable": [ 51 | true, 52 | { 53 | "temporalDeadZone": false 54 | } 55 | ], 56 | "one-variable-per-declaration": [ 57 | true, 58 | "ignore-for-loop" 59 | ], 60 | "no-duplicate-variable": [ 61 | true, 62 | "check-parameters" 63 | ], 64 | "no-unnecessary-initializer": true, 65 | "no-implicit-dependencies": true, 66 | "ordered-imports": [ 67 | true, 68 | { 69 | "import-sources-order": "any", 70 | "named-imports-order": "case-insensitive", 71 | "grouped-imports": true 72 | } 73 | ], 74 | "no-duplicate-imports": true, 75 | "import-blacklist": [ 76 | true, 77 | "rxjs" 78 | ], 79 | "no-require-imports": true, 80 | "no-default-export": true, 81 | "no-reference": true, 82 | "typedef": [ 83 | true, 84 | "call-signature", 85 | "property-declaration" 86 | ], 87 | "no-inferrable-types": true, 88 | "no-angle-bracket-type-assertion": true, 89 | "callable-types": true, 90 | "no-null-keyword": true, 91 | "no-non-null-assertion": true, 92 | "array-type": [ 93 | true, 94 | "generic" 95 | ], 96 | "prefer-object-spread": true, 97 | "object-literal-shorthand": true, 98 | "object-literal-key-quotes": [ 99 | true, 100 | "as-needed" 101 | ], 102 | "quotemark": [ 103 | true, 104 | "single", 105 | "avoid-template", 106 | "avoid-escape" 107 | ], 108 | "prefer-template": true, 109 | "no-invalid-template-strings": true, 110 | "triple-equals": [ 111 | true, 112 | "allow-null-check" 113 | ], 114 | "binary-expression-operand-order": true, 115 | "no-dynamic-delete": true, 116 | "no-bitwise": true, 117 | "use-isnan": true, 118 | "no-conditional-assignment": true, 119 | "prefer-conditional-expression": [ 120 | true, 121 | "check-else-if" 122 | ], 123 | "prefer-for-of": true, 124 | "forin": true, 125 | "switch-default": true, 126 | "no-switch-case-fall-through": true, 127 | "no-unsafe-finally": true, 128 | "no-duplicate-switch-case": true, 129 | "encoding": true, 130 | "cyclomatic-complexity": [ 131 | true, 132 | 20 133 | ], 134 | "max-file-line-count": [ 135 | true, 136 | 1000 137 | ], 138 | "max-line-length": [ 139 | true, 140 | 300 141 | ], 142 | "indent": [ 143 | true, 144 | "spaces", 145 | 2 146 | ], 147 | "eofline": true, 148 | "curly": [ 149 | true, 150 | ], 151 | "whitespace": [ 152 | true, 153 | "check-branch", 154 | "check-decl", 155 | "check-operator", 156 | "check-module", 157 | "check-separator", 158 | "check-rest-spread", 159 | "check-type", 160 | "check-typecast", 161 | "check-type-operator", 162 | "check-preblock" 163 | ], 164 | "typedef-whitespace": [ 165 | true, 166 | { 167 | "call-signature": "nospace", 168 | "index-signature": "nospace", 169 | "parameter": "nospace", 170 | "property-declaration": "nospace", 171 | "variable-declaration": "nospace" 172 | }, 173 | { 174 | "call-signature": "onespace", 175 | "index-signature": "onespace", 176 | "parameter": "onespace", 177 | "property-declaration": "onespace", 178 | "variable-declaration": "onespace" 179 | } 180 | ], 181 | "space-before-function-paren": [ 182 | true, 183 | { 184 | "anonymous": "never", 185 | "named": "never", 186 | "asyncArrow": "always", 187 | "method": "never", 188 | "constructor": "never" 189 | } 190 | ], 191 | "space-within-parens": 0, 192 | "import-spacing": true, 193 | "no-trailing-whitespace": true, 194 | "newline-before-return": true, 195 | "newline-per-chained-call": true, 196 | "one-line": [ 197 | true, 198 | "check-open-brace", 199 | "check-whitespace", 200 | "check-else", 201 | "check-catch", 202 | "check-finally" 203 | ], 204 | "no-consecutive-blank-lines": [ 205 | true, 206 | 1 207 | ], 208 | "semicolon": [ 209 | true, 210 | "always", 211 | "strict-bound-class-methods" 212 | ], 213 | "align": [ 214 | true, 215 | "parameters", 216 | "statements" 217 | ], 218 | "trailing-comma": [ 219 | true, 220 | { 221 | "multiline": "never", 222 | "singleline": "never", 223 | "esSpecCompliant": true 224 | } 225 | ], 226 | "class-name": true, 227 | "variable-name": [ 228 | true, 229 | "check-format", 230 | "allow-leading-underscore", 231 | "ban-keywords" 232 | ], 233 | "comment-format": [ 234 | true, 235 | "check-space" 236 | ], 237 | "jsdoc-format": [ 238 | true, 239 | "check-multiline-start" 240 | ], 241 | "no-redundant-jsdoc": true, 242 | "no-console": [ 243 | true, 244 | "log", 245 | "debug", 246 | "info", 247 | "time", 248 | "timeEnd", 249 | "trace" 250 | ], 251 | "no-debugger": true, 252 | "no-eval": true, 253 | "no-string-throw": true, 254 | "no-namespace": true, 255 | "no-internal-module": true, 256 | "radix": true, 257 | "no-unused-expression": [ 258 | true, 259 | "allow-fast-null-checks" 260 | ], 261 | "no-empty": true, 262 | "no-sparse-arrays": true 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /functions/teams-config-function/.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # Azure Functions localsettings file 5 | local.settings.json 6 | 7 | # User-specific files 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | bld/ 24 | [Bb]in/ 25 | [Oo]bj/ 26 | [Ll]og/ 27 | 28 | # Visual Studio 2015 cache/options directory 29 | .vs/ 30 | # Uncomment if you have tasks that create the project's static files in wwwroot 31 | #wwwroot/ 32 | 33 | # MSTest test Results 34 | [Tt]est[Rr]esult*/ 35 | [Bb]uild[Ll]og.* 36 | 37 | # NUNIT 38 | *.VisualState.xml 39 | TestResult.xml 40 | 41 | # Build Results of an ATL Project 42 | [Dd]ebugPS/ 43 | [Rr]eleasePS/ 44 | dlldata.c 45 | 46 | # DNX 47 | project.lock.json 48 | project.fragment.lock.json 49 | artifacts/ 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # NCrunch 117 | _NCrunch_* 118 | .*crunch*.local.xml 119 | nCrunchTemp_* 120 | 121 | # MightyMoose 122 | *.mm.* 123 | AutoTest.Net/ 124 | 125 | # Web workbench (sass) 126 | .sass-cache/ 127 | 128 | # Installshield output folder 129 | [Ee]xpress/ 130 | 131 | # DocProject is a documentation generator add-in 132 | DocProject/buildhelp/ 133 | DocProject/Help/*.HxT 134 | DocProject/Help/*.HxC 135 | DocProject/Help/*.hhc 136 | DocProject/Help/*.hhk 137 | DocProject/Help/*.hhp 138 | DocProject/Help/Html2 139 | DocProject/Help/html 140 | 141 | # Click-Once directory 142 | publish/ 143 | 144 | # Publish Web Output 145 | *.[Pp]ublish.xml 146 | *.azurePubxml 147 | # TODO: Comment the next line if you want to checkin your web deploy settings 148 | # but database connection strings (with potential passwords) will be unencrypted 149 | #*.pubxml 150 | *.publishproj 151 | 152 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 153 | # checkin your Azure Web App publish settings, but sensitive information contained 154 | # in these scripts will be unencrypted 155 | PublishScripts/ 156 | 157 | # NuGet Packages 158 | *.nupkg 159 | # The packages folder can be ignored because of Package Restore 160 | **/packages/* 161 | # except build/, which is used as an MSBuild target. 162 | !**/packages/build/ 163 | # Uncomment if necessary however generally it will be regenerated when needed 164 | #!**/packages/repositories.config 165 | # NuGet v3's project.json files produces more ignoreable files 166 | *.nuget.props 167 | *.nuget.targets 168 | 169 | # Microsoft Azure Build Output 170 | csx/ 171 | *.build.csdef 172 | 173 | # Microsoft Azure Emulator 174 | ecf/ 175 | rcf/ 176 | 177 | # Windows Store app package directories and files 178 | AppPackages/ 179 | BundleArtifacts/ 180 | Package.StoreAssociation.xml 181 | _pkginfo.txt 182 | 183 | # Visual Studio cache files 184 | # files ending in .cache can be ignored 185 | *.[Cc]ache 186 | # but keep track of directories ending in .cache 187 | !*.[Cc]ache/ 188 | 189 | # Others 190 | ClientBin/ 191 | ~$* 192 | *~ 193 | *.dbmdl 194 | *.dbproj.schemaview 195 | *.jfm 196 | *.pfx 197 | *.publishsettings 198 | node_modules/ 199 | orleans.codegen.cs 200 | 201 | # Since there are multiple workflows, uncomment next line to ignore bower_components 202 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 203 | #bower_components/ 204 | 205 | # RIA/Silverlight projects 206 | Generated_Code/ 207 | 208 | # Backup & report files from converting an old project file 209 | # to a newer Visual Studio version. Backup files are not needed, 210 | # because we have git ;-) 211 | _UpgradeReport_Files/ 212 | Backup*/ 213 | UpgradeLog*.XML 214 | UpgradeLog*.htm 215 | 216 | # SQL Server files 217 | *.mdf 218 | *.ldf 219 | 220 | # Business Intelligence projects 221 | *.rdl.data 222 | *.bim.layout 223 | *.bim_*.settings 224 | 225 | # Microsoft Fakes 226 | FakesAssemblies/ 227 | 228 | # GhostDoc plugin setting file 229 | *.GhostDoc.xml 230 | 231 | # Node.js Tools for Visual Studio 232 | .ntvs_analysis.dat 233 | 234 | # Visual Studio 6 build log 235 | *.plg 236 | 237 | # Visual Studio 6 workspace options file 238 | *.opt 239 | 240 | # Visual Studio LightSwitch build output 241 | **/*.HTMLClient/GeneratedArtifacts 242 | **/*.DesktopClient/GeneratedArtifacts 243 | **/*.DesktopClient/ModelManifest.xml 244 | **/*.Server/GeneratedArtifacts 245 | **/*.Server/ModelManifest.xml 246 | _Pvt_Extensions 247 | 248 | # Paket dependency manager 249 | .paket/paket.exe 250 | paket-files/ 251 | 252 | # FAKE - F# Make 253 | .fake/ 254 | 255 | # JetBrains Rider 256 | .idea/ 257 | *.sln.iml 258 | 259 | # CodeRush 260 | .cr/ 261 | 262 | # Python Tools for Visual Studio (PTVS) 263 | __pycache__/ 264 | *.pyc 265 | 266 | 267 | publish_output/ 268 | publish.zip -------------------------------------------------------------------------------- /functions/teams-config-function/Activities/Groups/EnsureAndUpdateGroupActivity.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.WebJobs; 2 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 3 | using Microsoft.Extensions.Logging; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using TeamsConfiguration.Services; 7 | 8 | namespace TeamsConfiguration.Activities.Groups 9 | { 10 | public static class EnsureAndUpdateGroupActivity 11 | { 12 | [FunctionName(nameof(EnsureAndUpdateGroupActivity))] 13 | public static async Task Run([ActivityTrigger] string title, ILogger log, ExecutionContext context) 14 | { 15 | log.LogInformation($"getting group id for {title}"); 16 | var client = GraphServiceClientFactory.GetInstance(context?.FunctionAppDirectory).Client.Value; 17 | 18 | var existingGroups = await client.Groups.Request().Filter($"displayName eq '{title}'").GetAsync(); 19 | 20 | return existingGroups.FirstOrDefault()?.Id; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /functions/teams-config-function/Activities/Teams/AddSPLibProjectTabActivity.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.WebJobs; 2 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Graph; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Text.RegularExpressions; 8 | using System.Threading.Tasks; 9 | using TeamsConfiguration.Services; 10 | 11 | namespace TeamsConfiguration.Activities.Teams 12 | { 13 | public class AddSPLibProjectTabActivity : AddSPLibTabActivity 14 | { 15 | private static readonly Regex webExtraction = new Regex(@"https://(?[^.]+.sharepoint.com)(?/[^/]+/[^/]+/[^/]+)?"); 16 | [FunctionName(nameof(AddSPLibProjectTabActivity))] 17 | public static async Task Run([ActivityTrigger] CreationResult input, ILogger log, ExecutionContext context) 18 | { 19 | log.LogInformation("creating library tabs for client"); 20 | var client = GraphServiceClientFactory.GetInstance(context?.FunctionAppDirectory).Client.Value; 21 | var tabService = new TeamsTabService(client, AppDefId); 22 | await AddConfiguredLibTabsToChannel(input.GroupId, input.ProjectChannelId, input.ProjectSiteUrl, client, tabService, input.ProjectChannelLibTabsLibNames, webExtraction); 23 | log.LogInformation("created library tabs for client"); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /functions/teams-config-function/Activities/Teams/AddSPLibTabActivity.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Graph; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Text.RegularExpressions; 7 | using System.Threading.Tasks; 8 | using TeamsConfiguration.Services; 9 | 10 | namespace TeamsConfiguration.Activities.Teams 11 | { 12 | public abstract class AddSPLibTabActivity 13 | { 14 | protected static readonly string AppDefId = "com.microsoft.teamspace.tab.files.sharepoint"; 15 | protected static async Task AddConfiguredLibTabsToChannel(string groupId, string channelId, string targetSiteOrSiteCollectionUrl, GraphServiceClient client, TeamsTabService tabService, List librariesNames, Regex executer) 16 | { 17 | bool validation(string sectionName, TeamsTab x) => x.DisplayName == sectionName; 18 | var hostNameAndPath = getSiteHostNameAndPath(executer, targetSiteOrSiteCollectionUrl); 19 | var site = await getTargetSite(client, hostNameAndPath); 20 | foreach (var libraryName in librariesNames) 21 | { 22 | var libraryServerRelativeUrl = await getTargetLibrary(client, libraryName, hostNameAndPath.Item1, site); 23 | if (!await tabService.DoesTabExist(groupId, channelId, (x) => validation(libraryName, x)) && !string.IsNullOrEmpty(libraryServerRelativeUrl)) 24 | await tabService.AddTab(groupId, channelId, libraryName, getTabConfiguration($"https://{hostNameAndPath.Item1}{hostNameAndPath.Item2}", $"/{WebUtility.UrlEncode(libraryName)}")); 25 | } 26 | } 27 | private static async Task getTargetLibrary(GraphServiceClient client, string libraryName, string hostName, Site customerSite) 28 | { 29 | var library = (await client.Sites[customerSite.Id].Lists.Request().Filter($"displayName eq '{libraryName}'").GetAsync()).FirstOrDefault(); 30 | if (library == null) 31 | return null; 32 | else 33 | return library.WebUrl.Replace($"https://{hostName}", ""); 34 | } 35 | private static Task getTargetSite(GraphServiceClient client, Tuple hostNameAndPath) 36 | { 37 | var customerSiteId = $"{hostNameAndPath.Item1}{(string.IsNullOrEmpty(hostNameAndPath.Item2) ? string.Empty : ":")}{hostNameAndPath.Item2}"; 38 | return client.Sites[customerSiteId].Request().GetAsync(); 39 | } 40 | private static Tuple getSiteHostNameAndPath(Regex executer, string value) 41 | { 42 | var customerHubMatch = executer.Match(value); 43 | if (customerHubMatch.Success) 44 | return new Tuple(customerHubMatch.Groups["hostname"].Value, customerHubMatch.Groups["path"].Value); 45 | else 46 | return null; 47 | } 48 | private static TeamsTabConfiguration getTabConfiguration(string siteUrl, string libraryServerRelativeUrl) 49 | { 50 | return new TeamsTabConfiguration 51 | { 52 | ContentUrl = $"{siteUrl}{libraryServerRelativeUrl}" 53 | }; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /functions/teams-config-function/Activities/Teams/CreateProjectChannelActivity.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.WebJobs; 2 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Graph; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Threading.Tasks; 9 | using TeamsConfiguration.Services; 10 | 11 | namespace TeamsConfiguration.Activities.Teams 12 | { 13 | public static class CreateProjectChannelActivity 14 | { 15 | [FunctionName(nameof(CreateProjectChannelActivity))] 16 | public static async Task> Run([ActivityTrigger] CreationResult input, ILogger log, ExecutionContext context) 17 | { 18 | var client = GraphServiceClientFactory.GetInstance(context?.FunctionAppDirectory).Client.Value; 19 | var channels = await client.Teams[input.GroupId].Channels.Request().GetAsync(); 20 | var projectChannel = channels.FirstOrDefault(x => x.DisplayName.Equals(input.FullProjectTitle, StringComparison.InvariantCultureIgnoreCase)); 21 | 22 | if (projectChannel == null) 23 | return new Tuple(await CreateChannel(input, client), true); 24 | else 25 | return new Tuple(projectChannel.Id, false); 26 | } 27 | private static async Task CreateChannel(CreationResult input, GraphServiceClient client) 28 | { 29 | var channel = await client.Teams[input.GroupId].Channels.Request().AddAsync(new Channel 30 | { 31 | DisplayName = input.FullProjectTitle, 32 | Description = input.ProjectDescription, 33 | }); 34 | return channel.Id; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /functions/teams-config-function/Activities/Teams/EnableTeamActivity.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.WebJobs; 2 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Graph; 5 | using System; 6 | using System.Net; 7 | using System.Threading.Tasks; 8 | using TeamsConfiguration.Services; 9 | 10 | namespace TeamsConfiguration.Activities.Teams 11 | { 12 | public static class EnableTeamActivity 13 | { 14 | [FunctionName(nameof(EnableTeamActivity))] 15 | public static async Task Run([ActivityTrigger] CreationResult input, ILogger log, ExecutionContext context) 16 | { 17 | var client = GraphServiceClientFactory.GetInstance(context?.FunctionAppDirectory).Client.Value; 18 | if (!await DoesTeamExist(input, client)) 19 | await EnableTeams(input, client); 20 | } 21 | private async static Task DoesTeamExist(CreationResult input, GraphServiceClient client) 22 | { 23 | try 24 | { 25 | var team = await client.Groups[input.GroupId].Team.Request().GetAsync(); 26 | return team != null; 27 | } 28 | catch (ServiceException ex) 29 | { 30 | if (ex.StatusCode == HttpStatusCode.NotFound) 31 | return false; 32 | else 33 | throw; 34 | } 35 | } 36 | private static async Task EnableTeams(CreationResult input, GraphServiceClient client) 37 | { 38 | await client.Groups[input.GroupId].Team.Request().PutAsync(new Team 39 | { 40 | MemberSettings = new TeamMemberSettings 41 | { 42 | AllowCreateUpdateChannels = true, 43 | ODataType = null, // https://github.com/microsoftgraph/msgraph-sdk-dotnet/issues/566#issuecomment-548057688 44 | }, 45 | MessagingSettings = new TeamMessagingSettings 46 | { 47 | AllowUserEditMessages = true, 48 | AllowUserDeleteMessages = true, 49 | ODataType = null, //https://github.com/microsoftgraph/msgraph-sdk-dotnet/issues/566#issuecomment-548057688 50 | }, 51 | FunSettings = new TeamFunSettings 52 | { 53 | AllowGiphy = true, 54 | GiphyContentRating = GiphyRatingType.Strict, 55 | ODataType = null, //https://github.com/microsoftgraph/msgraph-sdk-dotnet/issues/566#issuecomment-548057688 56 | }, 57 | ODataType = null 58 | }); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /functions/teams-config-function/CreationResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace TeamsConfiguration 6 | { 7 | public class CreationResult 8 | { 9 | public string GroupId { get; set; } 10 | public List ProjectChannelLibTabsLibNames { get; set; } 11 | public string ProjectSiteUrl { get; set; } 12 | public string ProjectChannelId { get; set; } 13 | public string FullProjectTitle { get; set; } 14 | public string ProjectDescription { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /functions/teams-config-function/Services/CertificateService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Security.Cryptography.X509Certificates; 4 | using System.Text; 5 | 6 | namespace TeamsConfiguration.Services 7 | { 8 | public class CertificateService 9 | { 10 | private const string certificateThumbprintKey = "AUTH_CLIENT_SECRET_CERTIFICATE_THUMBPRINT"; 11 | public CertificateService(ConfigurationService configurationService) 12 | { 13 | if (configurationService == null) 14 | throw new ArgumentNullException(nameof(configurationService)); 15 | AppCertificate = new Lazy(() => 16 | { 17 | var configuration = configurationService.Configuration.Value; 18 | var storeName = StoreName.My; 19 | var storeLocation = StoreLocation.CurrentUser; 20 | 21 | var certStore = new X509Store(storeName, storeLocation); 22 | certStore.Open(OpenFlags.ReadOnly); 23 | var thumbPrint = configuration[certificateThumbprintKey]; 24 | 25 | if (string.IsNullOrEmpty(thumbPrint)) 26 | return null; 27 | else 28 | { 29 | var certCollection = certStore.Certificates.Find( 30 | X509FindType.FindByThumbprint, 31 | thumbPrint, 32 | false); 33 | 34 | 35 | var certificate = certCollection.Count > 0 ? certCollection[0] : null; 36 | certStore.Close(); 37 | return certificate; 38 | } 39 | }); 40 | } 41 | public Lazy AppCertificate { get; private set; } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /functions/teams-config-function/Services/ConfigurationService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using System; 3 | 4 | namespace TeamsConfiguration.Services 5 | { 6 | public class ConfigurationService 7 | { 8 | private ConfigurationService(string appDirectory) 9 | { 10 | if (string.IsNullOrEmpty(appDirectory)) 11 | throw new ArgumentNullException(nameof(appDirectory)); 12 | Configuration = new Lazy(() => 13 | { 14 | return new ConfigurationBuilder() 15 | .SetBasePath(appDirectory) 16 | .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true) 17 | .AddEnvironmentVariables() 18 | .Build(); 19 | }); 20 | } 21 | public Lazy Configuration { get; private set; } 22 | private static ConfigurationService _instance; 23 | public static ConfigurationService GetInstance(string appDirectory) 24 | { 25 | if(_instance == null) 26 | _instance = new ConfigurationService(appDirectory); 27 | return _instance; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /functions/teams-config-function/Services/GraphServiceClientFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Graph; 2 | using Microsoft.Graph.Auth; 3 | using Microsoft.Identity.Client; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text; 8 | 9 | namespace TeamsConfiguration.Services 10 | { 11 | public class GraphServiceClientFactory 12 | { 13 | private const string clientIdKey = "WEBSITE_AUTH_CLIENT_ID"; 14 | private const string issuerIdKey = "WEBSITE_AUTH_OPENID_ISSUER"; 15 | private GraphServiceClientFactory(ConfigurationService configurationService, CertificateService certificateService) 16 | { 17 | if (configurationService == null) 18 | throw new ArgumentNullException(nameof(configurationService)); 19 | if (certificateService == null) 20 | throw new ArgumentNullException(nameof(certificateService)); 21 | var configuration = configurationService.Configuration.Value; 22 | Client = new Lazy(() => 23 | { 24 | var confidentialClientApplication = ConfidentialClientApplicationBuilder 25 | .Create(configuration[clientIdKey]) 26 | .WithTenantId(GetTenantIdFromIssuer(configuration[issuerIdKey])) 27 | .WithCertificate(certificateService.AppCertificate.Value) 28 | .Build(); 29 | var authProvider = new ClientCredentialProvider(confidentialClientApplication); 30 | var client = new GraphServiceClient(authProvider); 31 | return client; 32 | }); 33 | } 34 | public Lazy Client { get; private set; } 35 | private string GetTenantIdFromIssuer(string issuer) => issuer?.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries)?.LastOrDefault(); 36 | 37 | private static GraphServiceClientFactory _instance; 38 | public static GraphServiceClientFactory GetInstance(string appDirectory) 39 | { 40 | if (_instance == null) 41 | { 42 | var configurationService = ConfigurationService.GetInstance(appDirectory); 43 | _instance = new GraphServiceClientFactory(configurationService, new CertificateService(configurationService)); 44 | } 45 | return _instance; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /functions/teams-config-function/Services/TeamsTabService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Graph; 2 | using Newtonsoft.Json; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Net.Http; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace TeamsConfiguration.Services 11 | { 12 | public class TeamsTabService 13 | { 14 | private readonly string _appDefId; 15 | private readonly GraphServiceClient _client; 16 | public TeamsTabService(GraphServiceClient client, string appDefId) 17 | { 18 | _client = client; 19 | _appDefId = appDefId; 20 | } 21 | public async Task DoesTabExist(string groupId, string channelId, Func validation) 22 | { 23 | var tabsCollection = await _client.Teams[groupId].Channels[channelId].Tabs.Request().Expand(nameof(TeamsTab.TeamsApp)).GetAsync(); 24 | return tabsCollection.Any((x) => x.TeamsApp.Id == _appDefId && validation(x)); 25 | } 26 | public async Task AddTab(string groupId, string channelId, string tabName, TeamsTabConfiguration configuration) 27 | { 28 | var tab = new TeamsTab 29 | { 30 | DisplayName = tabName, 31 | Configuration = configuration, 32 | AdditionalData = new Dictionary 33 | { 34 | { "teamsApp@odata.bind", $"https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/{_appDefId}" } 35 | } 36 | }; 37 | await _client.Teams[groupId].Channels[channelId].Tabs.Request().AddAsync(tab); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /functions/teams-config-function/StartConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Azure.WebJobs; 6 | using Microsoft.Azure.WebJobs.Extensions.Http; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.Extensions.Logging; 9 | using Newtonsoft.Json; 10 | using System.Net.Http; 11 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 12 | 13 | namespace TeamsConfiguration 14 | { 15 | public static class StartConfiguration 16 | { 17 | [FunctionName(nameof(StartConfiguration))] 18 | public static async Task HttpStart( 19 | [HttpTrigger(AuthorizationLevel.Anonymous, "post")]HttpRequestMessage req, 20 | [DurableClient]IDurableOrchestrationClient starter, 21 | ILogger log) 22 | { 23 | var groupName = await req.Content.ReadAsStringAsync(); 24 | var instanceId = await starter.StartNewAsync(nameof(TeamsConfig), groupName); 25 | 26 | log.LogInformation($"Started orchestration with ID = '{instanceId}'."); 27 | 28 | return starter.CreateCheckStatusResponse(req, instanceId); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /functions/teams-config-function/TeamsConfig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.Azure.WebJobs; 6 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 7 | using TeamsConfiguration.Activities.Groups; 8 | using TeamsConfiguration.Activities.Teams; 9 | using TeamsConfiguration.Services; 10 | 11 | namespace TeamsConfiguration 12 | { 13 | public static class TeamsConfig 14 | { 15 | [FunctionName(nameof(TeamsConfig))] 16 | public static async Task RunOrchestrator( 17 | [OrchestrationTrigger] IDurableOrchestrationContext context, ExecutionContext exContext) 18 | { 19 | var payload = context.GetInput(); 20 | var configuration = ConfigurationService.GetInstance(exContext?.FunctionAppDirectory).Configuration.Value; 21 | 22 | var input = new CreationResult 23 | { 24 | FullProjectTitle = "Project leadership", 25 | ProjectDescription = "This channel helps project leaders get organized", 26 | ProjectSiteUrl = (configuration["LIBRARIES_SOURCE"] as string), 27 | ProjectChannelLibTabsLibNames = (configuration["LIBRARIES"] as string).Split(new char[] { ',' }).ToList(), 28 | }; 29 | input.GroupId = await context.CallActivityAsync(nameof(EnsureAndUpdateGroupActivity), payload); 30 | await context.CallActivityAsync(nameof(EnableTeamActivity), input); 31 | var channelCreation = await context.CallActivityWithRetryAsync>(nameof(CreateProjectChannelActivity), new RetryOptions(TimeSpan.FromSeconds(10), 10) 32 | { 33 | BackoffCoefficient = 2 //after creating the team not everything is available right away and some calls fail 34 | }, input); 35 | input.ProjectChannelId = channelCreation.Item1; 36 | await context.CallActivityAsync(nameof(AddSPLibProjectTabActivity), input); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /functions/teams-config-function/TeamsConfiguration.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp2.2 4 | v2 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PreserveNewest 17 | 18 | 19 | PreserveNewest 20 | Never 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /functions/teams-config-function/TeamsConfiguration.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29503.13 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsConfiguration", "TeamsConfiguration.csproj", "{36CA7AF5-EBAA-40AC-9F51-DB6EE923408A}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {36CA7AF5-EBAA-40AC-9F51-DB6EE923408A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {36CA7AF5-EBAA-40AC-9F51-DB6EE923408A}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {36CA7AF5-EBAA-40AC-9F51-DB6EE923408A}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {36CA7AF5-EBAA-40AC-9F51-DB6EE923408A}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {C2D1C663-5CC2-46A7-9396-A73B4AA200CF} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /functions/teams-config-function/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0" 3 | } -------------------------------------------------------------------------------- /images/ad_app_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/ad_app_permissions.png -------------------------------------------------------------------------------- /images/add_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/add_app.png -------------------------------------------------------------------------------- /images/add_app_site.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/add_app_site.png -------------------------------------------------------------------------------- /images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/architecture.png -------------------------------------------------------------------------------- /images/automation_creds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/automation_creds.png -------------------------------------------------------------------------------- /images/automation_variables.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/automation_variables.png -------------------------------------------------------------------------------- /images/az_func_ext.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/az_func_ext.png -------------------------------------------------------------------------------- /images/azure_ad_clientid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/azure_ad_clientid.png -------------------------------------------------------------------------------- /images/browse_modules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/browse_modules.png -------------------------------------------------------------------------------- /images/certificate_thumbprint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/certificate_thumbprint.png -------------------------------------------------------------------------------- /images/check_deploy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/check_deploy.png -------------------------------------------------------------------------------- /images/configure_refiners.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/configure_refiners.png -------------------------------------------------------------------------------- /images/crawled_property_mapping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/crawled_property_mapping.png -------------------------------------------------------------------------------- /images/create_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/create_app.png -------------------------------------------------------------------------------- /images/create_func.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/create_func.png -------------------------------------------------------------------------------- /images/create_new_workspace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/create_new_workspace.png -------------------------------------------------------------------------------- /images/creation_completed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/creation_completed.png -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/demo.gif -------------------------------------------------------------------------------- /images/enable_logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/enable_logs.png -------------------------------------------------------------------------------- /images/file_share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/file_share.png -------------------------------------------------------------------------------- /images/flow_step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/flow_step1.png -------------------------------------------------------------------------------- /images/flow_step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/flow_step2.png -------------------------------------------------------------------------------- /images/func_logd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/func_logd.png -------------------------------------------------------------------------------- /images/get_func_url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/get_func_url.png -------------------------------------------------------------------------------- /images/grant_consent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/grant_consent.png -------------------------------------------------------------------------------- /images/group_membership.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/group_membership.png -------------------------------------------------------------------------------- /images/item_provisioning_status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/item_provisioning_status.png -------------------------------------------------------------------------------- /images/logic_app_url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logic_app_url.png -------------------------------------------------------------------------------- /images/logicapp_final.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_final.png -------------------------------------------------------------------------------- /images/logicapp_logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_logs.png -------------------------------------------------------------------------------- /images/logicapp_step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_step1.png -------------------------------------------------------------------------------- /images/logicapp_step2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_step2.png -------------------------------------------------------------------------------- /images/logicapp_step3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_step3.png -------------------------------------------------------------------------------- /images/logicapp_step4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_step4.png -------------------------------------------------------------------------------- /images/logicapp_step5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_step5.png -------------------------------------------------------------------------------- /images/logicapp_step6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_step6.png -------------------------------------------------------------------------------- /images/logicapp_step7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_step7.png -------------------------------------------------------------------------------- /images/logicapp_step8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_step8.png -------------------------------------------------------------------------------- /images/logicapp_step9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_step9.png -------------------------------------------------------------------------------- /images/logicapp_trigger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/logicapp_trigger.png -------------------------------------------------------------------------------- /images/new-runbook-script.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/new-runbook-script.png -------------------------------------------------------------------------------- /images/new-runbook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/new-runbook.png -------------------------------------------------------------------------------- /images/new_function.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/new_function.png -------------------------------------------------------------------------------- /images/new_secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/new_secret.png -------------------------------------------------------------------------------- /images/ngrok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/ngrok.png -------------------------------------------------------------------------------- /images/pnp-modern-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/pnp-modern-search.png -------------------------------------------------------------------------------- /images/pnp_workspace_requests_nav.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/pnp_workspace_requests_nav.png -------------------------------------------------------------------------------- /images/query_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/query_template.png -------------------------------------------------------------------------------- /images/runbook_logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/runbook_logs.png -------------------------------------------------------------------------------- /images/runbook_status1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/runbook_status1.png -------------------------------------------------------------------------------- /images/runbook_status2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/runbook_status2.png -------------------------------------------------------------------------------- /images/search_experience.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/search_experience.png -------------------------------------------------------------------------------- /images/start_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/start_flow.png -------------------------------------------------------------------------------- /images/storage_account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/storage_account.png -------------------------------------------------------------------------------- /images/storage_connection_string.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/storage_connection_string.png -------------------------------------------------------------------------------- /images/teams-function-logic-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/teams-function-logic-app.png -------------------------------------------------------------------------------- /images/term_store_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/term_store_config.png -------------------------------------------------------------------------------- /images/term_store_perms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/term_store_perms.png -------------------------------------------------------------------------------- /images/upload_cert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/upload_cert.png -------------------------------------------------------------------------------- /images/upload_cert_automation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/upload_cert_automation.png -------------------------------------------------------------------------------- /images/upload_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/upload_settings.png -------------------------------------------------------------------------------- /images/upload_template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/upload_template.png -------------------------------------------------------------------------------- /images/webhook_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/webhook_settings.png -------------------------------------------------------------------------------- /images/webhook_statetable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/webhook_statetable.png -------------------------------------------------------------------------------- /images/webhook_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pnp/tutorial-workspace-provisioning/bbafac44a6b54ff33d74595c7f211d72708b4d1f/images/webhook_test.png -------------------------------------------------------------------------------- /scripts/New-Workspace.ps1: -------------------------------------------------------------------------------- 1 | Param 2 | ( 3 | [Parameter(Mandatory= $true)] 4 | $workspaceName, 5 | [Parameter(Mandatory= $true)] 6 | $workspaceDescription, 7 | [Parameter(Mandatory= $true)] 8 | $workspaceCategory, 9 | [Parameter(Mandatory= $true)] 10 | $workspaceOwner, 11 | [Parameter(Mandatory= $true)] 12 | $workspaceMembers 13 | ) 14 | 15 | # Set Error Action preference 16 | $ErrorActionPreference = 'Stop' 17 | 18 | # Get variables 19 | $certificatePassword = Get-AutomationVariable -Name "certificatePassword" 20 | $storageConnectionString = Get-AutomationVariable -Name "storageConnectionString" 21 | $fileShareName = Get-AutomationVariable -Name "fileShareName" 22 | $provisioningTemplateFileName = Get-AutomationVariable -Name "provisioningTemplateFileName" 23 | $spAdminSiteUrl = Get-AutomationVariable -Name 'spTenantUrl' 24 | $appId = Get-AutomationVariable -Name 'appId' 25 | $appSecret = Get-AutomationVariable -Name 'appSecret' 26 | $aadDomain = Get-AutomationVariable -Name 'aadDomain' 27 | 28 | # Create unique name to avoid concurrent file access. 29 | $tmpWorkingFolderName = "C:\" + [System.IO.Path]::GetRandomFileName() 30 | 31 | Write-Verbose "`t Temporary working directory created: '$tmpWorkingFolderName'" 32 | 33 | # Format certificate to be used with PnP PowerShell module 34 | $cert = Get-AutomationCertificate -Name 'pnpCert' 35 | $pfxCert = $cert.Export(3, $certificatePassword) # 3=Pfx 36 | $certPath = Join-Path $tmpWorkingFolderName "pnpCert.pfx" 37 | Set-Content -Value $pfxCert -Path $certPath -Force -Encoding Byte 38 | 39 | # Get PnP provisioning template file from Azure storage 40 | $storageContext = New-AzureStorageContext -ConnectionString $storageConnectionString 41 | 42 | $templateFile = New-TemporaryFile 43 | Get-AzureStorageFileContent -Context $storageContext -ShareName $fileShareName -Path "/$provisioningTemplateFileName" -Destination $templateFile -Force 44 | 45 | # Get PnP template resources directory from the Azure storage 46 | $azResourcesFolder = Get-AzureStorageFile -Context $storageContext -ShareName $fileShareName -Path "/resources" 47 | 48 | # Copy folder to local since we can't use the remote file path directly 49 | $tempResourcesFolder = New-Item -ItemType Directory -Path $tmpWorkingFolderName -Name "resources" -Force 50 | $tempResourcesFolderPath = $tempResourcesFolder.FullName; 51 | 52 | Write-Verbose "`tTemporary resources folder created: '$tempResourcesFolderPath'" 53 | 54 | Get-AzureStorageFile -Directory $azResourcesFolder | % { 55 | $filePath = "/" + $azResourcesFolder.Name + "/" + $_.Name 56 | 57 | $resourceFile = New-TemporaryFile 58 | Get-AzureStorageFileContent -Context $storageContext -ShareName $fileShareName -Path $filePath -Force -Destination $resourceFile 59 | 60 | $tempResourceFile = Join-Path $tempResourcesFolderPath $_.Name 61 | Set-Content -Value ($resourceFile | Get-Content) -Path $tempResourceFile -Force -Encoding UTF8 62 | } 63 | 64 | # Determine the Office 365 group URL + Remove special characters 65 | $normalizedName = (($workspaceName.ToLower() -replace ' ','-') -replace '[^\p{L}\p{Nd}/_/-]', '') 66 | 67 | # Remove diacritics 68 | $normalizedName = [Text.Encoding]::ASCII.GetString([Text.Encoding]::GetEncoding("Cyrillic").GetBytes($normalizedName)) 69 | $mailNickname = "GRP_$normalizedName" 70 | 71 | if ("GRP_$normalizedName".Length -gt 64) { 72 | # Limit to 64 characters 73 | $mailNickname = $mailNickname.Substring(0,64) 74 | } 75 | 76 | Write-Verbose "Connecting to SharePoint Online using Azure AD application" 77 | # Connect with Azure AD app to get Graph permissions (do not need client certificate in this scenario) 78 | $graphConnection = Connect-PnPOnline -AppId $appId -AppSecret $appSecret -AADDomain $aadDomain 79 | 80 | # Chek if the Office 365 group already exist 81 | $group = Get-PnPUnifiedGroup -Identity $mailNickname 82 | 83 | # Output group information 84 | 85 | # Format parameters received from the Logic App 86 | $ownerUpn = ($workspaceOwner -split "\|")[-1] 87 | $members = @($ownerUpn) 88 | $workspaceMembers | % { 89 | if ($_.Claims) { 90 | $members += ($_.Claims -split "\|")[-1] 91 | } 92 | } 93 | 94 | if ($members.Length -gt 1) { 95 | $allMembers = $members -join "," 96 | } 97 | 98 | Write-Verbose "`tGroup members:`t`t$allMembers" 99 | Write-Verbose "`tGroup owner:`t`t$ownerUpn" 100 | Write-Verbose "`tGroup URL:`t`t$mailNickname" 101 | Write-Verbose "`tGroup name:`t`t$workspaceName" 102 | Write-Verbose "`tGroup description:`t`t$workspaceDescription" 103 | 104 | if ($group.SiteUrl -eq $null) { 105 | 106 | # Create a new Office 365 group 107 | Write-Verbose "`tCreating new Office 365 group '$mailNickname'" 108 | $group = New-PnPUnifiedGroup -DisplayName $workspaceName -Description $workspaceDescription -MailNickname $mailNickname -IsPrivate:$true -Owners $ownerUpn -Members $members 109 | 110 | } else { 111 | Write-Warning "The Office 365 group '$mailNickname' already exists. Skipping creation..." 112 | } 113 | 114 | $groupSiteUrl = $group.SiteUrl 115 | $groupMail = $group.Mail 116 | 117 | # Set group members and owners. Using AAD app only will set this app as owner by default. We need to set the actual user manually 118 | Set-PnPUnifiedGroup -Identity $group -Owners $ownerUpn -Members $members 119 | 120 | # Allow custom scripts temporary for the site collection 121 | $adminConnection = Connect-PnPOnline -CertificatePath $certPath -CertificatePassword (ConvertTo-SecureString $certificatePassword -AsPlainText -Force) -Tenant $aadDomain -ClientId $appId -Url $spAdminSiteUrl -ReturnConnection 122 | Set-PnPTenantSite -Url $groupSiteUrl -NoScriptSite:$false -Connection $adminConnection 123 | 124 | # Format default taxonomy values for workspace 125 | 126 | # "Category" 127 | $termCategoryDefaults = @() 128 | $workspaceCategory | % { 129 | $termCategoryDefaults += ("-1;#" + $_.Label + "|" + $_.TermGuid) 130 | } 131 | 132 | $siteConnection = Connect-PnPOnline -CertificatePath $certPath -CertificatePassword (ConvertTo-SecureString $certificatePassword -AsPlainText -Force) -Tenant $aadDomain -ClientId $appId -Url $groupSiteUrl -ReturnConnection 133 | 134 | # Set group permissions settings. Needed to get property bag indexed properties to be actually crawled...(won't work otherwise) 135 | $ownerGroup = Get-PnPGroup -AssociatedOwnerGroup -Connection $siteConnection 136 | Add-PnPUserToGroup -LoginName $ownerUpn -Identity $ownerGroup -Connection $siteConnection 137 | 138 | # Apply the PnP Provisioning template 139 | Write-Verbose "`tApplying pnp workspace template to '$groupSiteUrl'" 140 | Apply-PnPProvisioningTemplate -ResourceFolder $tempResourcesFolderPath -Path $templateFile.FullName -Parameters @{"defaultCategory"=($termCategoryDefaults -join ";#")} -Connection $siteConnection 141 | 142 | # Add site property bag values for classification to be able to refine through regular search 143 | Write-Verbose "`tAdding property bag values for search" 144 | 145 | $categoryProperties = @() 146 | $workspaceCategory | % { 147 | $categoryProperties += ("L0|#" + $_.TermGuid + "|" + $_.Label) 148 | } 149 | 150 | $propertyBagValues = @{ 151 | # This format will be used by the ModernSearch WebPart to filter sites using taxonomy values 152 | "PropertyBag_pnpCategory"=($categoryProperties -join ";"); 153 | # The group mail is used to generate the invitation link from the search results if needed 154 | "PropertyBag_pnpGroupMail"=$groupMail; 155 | "PropertyBag_pnpSiteType"="WORKSPACE"; 156 | } 157 | 158 | $propertyBagValues.Keys | % { 159 | Set-PnPPropertyBagValue -Key $_ -Value $propertyBagValues[$_] -Indexed -Connection $siteConnection 160 | } 161 | 162 | # Reindex the site 163 | Request-PnPReIndexWeb -Connection $siteConnection 164 | 165 | # Reset custom script setting 166 | Set-PnPTenantSite -Url $groupSiteUrl -NoScriptSite:$true -Connection $adminConnection 167 | 168 | # Output the response for the logic app 169 | $response = [PSCustomObject]@{ 170 | GroupUrl = $groupSiteUrl 171 | } 172 | 173 | Write-Output ( $response | ConvertTo-Json) 174 | 175 | # Remove temp folder 176 | Remove-Item -Path $tmpWorkingFolderName -Force -Confirm:$false -Recurse 177 | Write-Verbose "`t Temporary working directory removed: '$tmpWorkingFolderName'" -------------------------------------------------------------------------------- /templates/resources/pnp-workspace-en-us.resx: -------------------------------------------------------------------------------- 1 |  2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | All documents 122 | 123 | 124 | All Pages 125 | 126 | 127 | Category 128 | 129 | 130 | PnP document 131 | 132 | 133 | PnP document 134 | 135 | 136 | Document type 137 | 138 | 139 | Workspace description 140 | 141 | 142 | If enabled, final documents of this workspace will be visible to all pnp employees and the workspace will be visible in the search as well. 143 | 144 | 145 | Public workspace 146 | 147 | 148 | Contains all workspace requests 149 | 150 | 151 | Workspace requests 152 | 153 | 154 | Members 155 | 156 | 157 | Workspace name 158 | 159 | 160 | Owner 161 | 162 | 163 | Workspace request 164 | 165 | 166 | Creates a new workspace request. 167 | 168 | 169 | Provisioning status 170 | 171 | 172 | Workspace Url 173 | 174 | -------------------------------------------------------------------------------- /templates/resources/pnp-workspace-fr-fr.resx: -------------------------------------------------------------------------------- 1 |  2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | Tous les documents 122 | 123 | 124 | Toutes les pages 125 | 126 | 127 | Catégorie 128 | 129 | 130 | Document PnP 131 | 132 | 133 | Document PnP 134 | 135 | 136 | Type de document 137 | > 138 | 139 | Description de l'espace de travail 140 | 141 | 142 | Si activé, signifie que les documents finaux de l’espace de travail seront visibles à toute l’organisation en lecture seule. De même, cet espace sera visible via la recherche par les autres utilisateurs. 143 | 144 | 145 | Espace public 146 | 147 | 148 | Contient toutes les demandes d'espace de travail. 149 | 150 | 151 | Liste des demandes 152 | 153 | 154 | Membres de l'espace 155 | 156 | 157 | Nom de l'espace 158 | 159 | 160 | Propriétaire de l'espace 161 | 162 | 163 | Demande d'espace de travail 164 | 165 | 166 | Créer une nouvelle demande d'espace de travail. 167 | 168 | 169 | Statut de création 170 | 171 | 172 | Url de l'espace 173 | 174 | -------------------------------------------------------------------------------- /templates/rootsite-template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 92 | 93 | 94 | 95 | 96 | 97 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 30 128 | clienttemplates.js 129 | 130 | 131 | 132 | 133 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /templates/workspace-template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 30 195 | clienttemplates.js 196 | 197 | 198 | 199 | 200 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 30 229 | clienttemplates.js 230 | 231 | 232 | 233 | 234 | 248 | 249 | 250 | 251 | 252 | --------------------------------------------------------------------------------