NOTE: Site Provisioning using apps has been better addressed by myself and others in the community on the Office AMS project published on codeplex. You are highly encouraged to reference that material over this. I would also discourage the use of Autohosted apps for this type of customization. Thanks!

 

I’ve always been a big fan of self-service site provisioning in SharePoint.  That process is even better in SharePoint 2013 (including SharePoint Online), with the ability to specify a custom site creation form.  Owning the provisioning process is incredibly powerful for farm/tenant administrators to configure the options and outputs of site provisioning.  By leveraging the new app model, we can customize the provision process in SharePoint Online to activate specific features, capabilities, and branding to our sites.  This post will explore the process for delivering custom site creation using an app for SharePoint.  The video below demonstrates the solution outlined in this post.

Self-Service Sites in SharePoint 2013

Self-service provisioning is improved in SharePoint 2013 by allowing an administrator to specify a custom creation form.  This form can take over the provisioning process and will be the cornerstone of the solution outlined below.  Without implementing a custom form, the default form only allows a user to specify a title.  The default output will be a Team site created as a sub-site at a specified location.  Again, if we want to capture additional details or provide different options, we must introduce a custom creation form.

Default Create a Site form in SharePoint 2013

The Solution

Because the app needs the ability to create sub-sites and site collections anywhere in the tenancy, it will need FullControl permission on the entire tenancy.  The app will also need to make app-only calls to SharePoint, so it can work with tenant objects or sites outside the context.  Both these settings can be configured in the Permissions tab of the AppManifest.xml.

AppManifest Permissions for out App

 

NOTE: You should typically avoid requesting tenancy permissions in your apps…especially with FullControl.  It is a best practice for apps to request the minimum permissions they need to function.  The “tenancy” permission scope is in place specifically for scenarios like provisioning.  Tenancy-scoped apps will typically be developed in-house, as I would be REALLY surprised if the Office Store accepted an app requesting Tenant FullControl permissions.

 

Our app will enable administrators to dynamically configure site creation settings using a Library in the app web.  I’m using a library instead of a list, so each option can be displayed in the site creation form with an icon.  Each row in the Library will represent a site provisioning option for an end-user.  The columns to support this include the following:

  • Title – the title of the configuration option (ex: Small Team Site)
  • Site Template – the WebTemplate name to be used in provisioning (ex: STS#0 for Team Site)
  • Base Path – the absolute URL where this option will get provisioned (ex: https://tenant/teams/)
  • Site Type – choice of “Subsite” or “Site Collection”
  • MasterPage URL – url of the masterpage file to apply (leave blank for no branding)
  • Storage Maximum Limit – the storage quota in MB (only applicable for site collections)
  • UserCode Maximum Limit – the user code quota in points (only applicable for site collections)

Library Configuration in App Project

I leveraged a module in the solution to pre-load a few provisioning options, but an administrator could easily edit these options or add new options through the library.

Module Elements.xml to pre-load provisioning options
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
  <Module Name="AppAssets">
    <File Path="AppAssets\Blog.png" Url="SSConfig/Blog.png" ReplaceContent="TRUE">
      <Property Name="Title" Value="Blog" Type="string"></Property>
      <Property Name="SiteTemplate" Value="BLOG#0" Type="string"></Property>
      <Property Name="BasePath" Value="https://richdizzcom.sharepoint.com/sites/Blogs/" Type="string"></Property>
      <Property Name="SiteType" Value="Subsite" Type="string"></Property>
      <Property Name="MasterPageUrl" Value="/sites/Blogs/_catalogs/masterpage/DallasMTC.com.master" Type="string"></Property>
      <Property Name="StorageMaximumLevel" Value="100" Type="string"></Property>
      <Property Name="UserCodeMaximumLevel" Value="300" Type="string"></Property>
    </File>
    <File Path="AppAssets\Community.png" Url="SSConfig/Community.png" ReplaceContent="TRUE">
      <Property Name="Title" Value="Community" Type="string"></Property>
      <Property Name="SiteTemplate" Value="COMMUNITY#0" Type="string"></Property>
      <Property Name="BasePath" Value="https://richdizzcom.sharepoint.com/sites/Communities/" Type="string"></Property>
      <Property Name="SiteType" Value="Subsite" Type="string"></Property>
      <Property Name="MasterPageUrl" Value="/sites/Communities/_catalogs/masterpage/DallasMTC.com.master" Type="string"></Property>
      <Property Name="StorageMaximumLevel" Value="100" Type="string"></Property>
      <Property Name="UserCodeMaximumLevel" Value="300" Type="string"></Property>
    </File>
    <File Path="AppAssets\Project.png" Url="SSConfig/Project.png" ReplaceContent="TRUE">
      <Property Name="Title" Value="Project" Type="string"></Property>
      <Property Name="SiteTemplate" Value="PROJECTSITE#0" Type="string"></Property>
      <Property Name="BasePath" Value="https://richdizzcom.sharepoint.com/teams/" Type="string"></Property>
      <Property Name="SiteType" Value="Site Collection" Type="string"></Property>
      <Property Name="MasterPageUrl" Value="https://richdizzcom.sharepoint.com/_catalogs/masterpage/DallasMTC.com.master" Type="string"></Property>
      <Property Name="StorageMaximumLevel" Value="100" Type="string"></Property>
      <Property Name="UserCodeMaximumLevel" Value="300" Type="string"></Property>
    </File>
    <File Path="AppAssets\Publishing.png" Url="SSConfig/Publishing.png" ReplaceContent="TRUE">
      <Property Name="Title" Value="Publishing" Type="string"></Property>
      <Property Name="SiteTemplate" Value="BLANKINTERNET#0" Type="string"></Property>
      <Property Name="BasePath" Value="https://richdizzcom.sharepoint.com/sites/" Type="string"></Property>
      <Property Name="SiteType" Value="Site Collection" Type="string"></Property>
      <Property Name="MasterPageUrl" Value="https://richdizzcom.sharepoint.com/_catalogs/masterpage/DallasMTC.com.master" Type="string"></Property>
      <Property Name="StorageMaximumLevel" Value="100" Type="string"></Property>
      <Property Name="UserCodeMaximumLevel" Value="300" Type="string"></Property>
    </File>
    <File Path="AppAssets\Team.png" Url="SSConfig/Team.png" ReplaceContent="TRUE">
      <Property Name="Title" Value="Team" Type="string"></Property>
      <Property Name="SiteTemplate" Value="STS#0" Type="string"></Property>
      <Property Name="BasePath" Value="https://richdizzcom.sharepoint.com/teams/" Type="string"></Property>
      <Property Name="SiteType" Value="Site Collection" Type="string"></Property>
      <Property Name="MasterPageUrl" Value="https://richdizzcom.sharepoint.com/_catalogs/masterpage/DallasMTC.com.master" Type="string"></Property>
      <Property Name="StorageMaximumLevel" Value="100" Type="string"></Property>
      <Property Name="UserCodeMaximumLevel" Value="300" Type="string"></Property>
    </File>
  </Module>
</Elements>

 

Our app will deliver a site creation form that will query the library to display site creation options.

PageLoad and btnCreate Events

private List<SSConfig> configList;
private const string SHAREPOINT_PID = "00000003-0000-0ff1-ce00-000000000000";
private const string TENANT_ADMIN_URL = "https://richdizzcom-admin.sharepoint.com";
protected void Page_Load(object sender, EventArgs e)
{
    //get SharePoint context
    var spContext = Util.ContextUtil.Current;
    using (var clientContext = TokenHelper.GetClientContextWithContextToken(spContext.ContextDetails.AppWebUrl, spContext.ContextDetails.ContextTokenString, Request.Url.Authority))
    {
        //populate the badges control
        List list = clientContext.Web.Lists.GetByTitle("SSConfig");
        CamlQuery query = new CamlQuery()
        {
            ViewXml = "<View><ViewFields><FieldRef Name='Title' /><FieldRef Name='SiteTemplate' /><FieldRef Name='BasePath' /><FieldRef Name='SiteType' /><FieldRef Name='MasterPageUrl' /><FieldRef Name='StorageMaximumLevel' /><FieldRef Name='UserCodeMaximumLevel' /></ViewFields></View>"
        };
        var items = list.GetItems(query);
        clientContext.Load(items, i => i.IncludeWithDefaultProperties(j => j.DisplayName));
        clientContext.ExecuteQuery();
        configList = items.ToList(spContext.ContextDetails.AppWebUrl, "SSConfig");
    }

    if (!this.IsPostBack)
    {
        //bind repeater
        repeaterTemplate.DataSource = configList;
        repeaterTemplate.DataBind();

        //configure buttons based on display type
        if (Page.Request["IsDlg"] == "1")
            btnCancel.Attributes.Add("onclick", "javascript:closeDialog();return false;");
        else
            btnCancel.Click += btnCancel_Click;
    }
}

protected void btnCancel_Click(object sender, EventArgs e)
{
    Response.Redirect(Page.Request["SPHostUrl"]);
}

protected void btnCreate_Click(object sender, EventArgs e)
{
    //get the selected config
    SSConfig selectedConfig = configList.FirstOrDefault(i => i.Title.Equals(hdnSelectedTemplate.Value));
    if (selectedConfig != null)
    {
        string webUrl = "";
        if (selectedConfig.SiteType.Equals("Site Collection", StringComparison.CurrentCultureIgnoreCase))
            webUrl = CreateSiteCollection(selectedConfig);
        else
            webUrl = CreateSubsite(selectedConfig);

        //redirect to new site
        ClientScript.RegisterStartupScript(typeof(Default), "RedirectToSite", "navigateParent('" + webUrl + "');", true);
    }
}

 

Once the user submits the site creation form, the app will provision differently based on site type (Subsite or Site Collection).  One thing that is common between the two methods is the need to execute app-only calls, since we will likely be provisioning with a different context from where the form is hosted (ex: site collection will require the context of the tenant administration site).  The TokenHelper contains a GetAppOnlyAccessToken method to get the access token for a specific site that is different from the context of the form.

To provision a sub-site, we need to establish context with the site that will host our new sub-site (ie – the parent site).  To apply a brand to a sub-site, I’m requiring the master page to exist in the Master Page Gallery of the root web in the site collection.  That way, I can just set the MasterUrl and CustomMasterUrl after the new sub-site is provisioned.

Code to create sub-site

private string CreateSubsite(SSConfig selectedConfig)
{
    string webUrl = selectedConfig.BasePath + txtUrl.Text;

    //create subsite
    var parentSite = new Uri(selectedConfig.BasePath);  //static for my tenant
    var token = TokenHelper.GetAppOnlyAccessToken(SHAREPOINT_PID, parentSite.Authority, null).AccessToken;
    using (var clientContext = TokenHelper.GetClientContextWithAccessToken(parentSite.ToString(), token))
    {
        var properties = new WebCreationInformation()
        {
            Url = txtUrl.Text,
            Title = txtTitle.Text,
            Description = txtDescription.Text,
            WebTemplate = selectedConfig.SiteTemplate,
            UseSamePermissionsAsParentSite = false
        };

        //create and load the new web
        Web newWeb = clientContext.Web.Webs.Add(properties);
        clientContext.Load(newWeb, w => w.Title);
        clientContext.ExecuteQuery();

        //TODO: set additional owners

        //apply the masterpage to the site (if applicable)
        if (!String.IsNullOrEmpty(selectedConfig.MasterUrl))
        {
            newWeb.MasterUrl = selectedConfig.MasterUrl;
            newWeb.CustomMasterUrl = selectedConfig.MasterUrl;
        }

        /**************************************************************************************/
        /*   Placeholder area for updating additional settings and features on the new site   */
        /**************************************************************************************/

        //update the web with the new settings
        newWeb.Update();
        clientContext.ExecuteQuery();
    }

    return webUrl;
}

 

Provisioning a site collection is a little more complex.  First we need to establish context with tenant administration site and use a Tenant object for creation.  The Tenant object can be found in the Microsoft.Online.SharePoint.Client.Tenant assembly which comes with the SharePoint Online Management Shell download.  Provisioning the site collection can take a while to complete, so this occurs asynchronously using the SpoOperation class.  We can leverage this to wait until the creation operation is complete before trying to apply the master page.  Applying a master page to a site collection is a little more complex.  The master page needs to live within the site collection, so our solution will download the master page from the location referenced in the configuration and upload it into the Master Page Gallery of the new site collection before setting MasterUrl and CustomMasterUrl on the site.  This requires that scripts and styles in the master page are referenced using absolute paths.  Pulling down and applying an entire design package would likely be a more elegant, but was overkill for this proof of concept.

Code to create site collection

private byte[] GetMasterPageFile(string masterUrl)
{
    byte[] mpBytes = null;

    //get the siteurl of the masterpage
    string siteUrl = masterUrl.Substring(0, masterUrl.IndexOf("/_catalogs"));

    var siteUri = new Uri(siteUrl);  //static for my tenant
    var token = TokenHelper.GetAppOnlyAccessToken(SHAREPOINT_PID, siteUri.Authority, null).AccessToken;
    using (var clientContext = TokenHelper.GetClientContextWithAccessToken(siteUri.ToString(), token))
    {
        string relativeMasterUrl = masterUrl.Substring(8);
        relativeMasterUrl = relativeMasterUrl.Substring(relativeMasterUrl.IndexOf("/"));
        File file = clientContext.Web.GetFileByServerRelativeUrl(relativeMasterUrl);
        var stream = file.OpenBinaryStream();
        clientContext.ExecuteQuery();
        using (stream.Value)
        {
            mpBytes = new Byte[stream.Value.Length];
            stream.Value.Read(mpBytes, 0, mpBytes.Length);
        }
    }

    return mpBytes;
}

private string CreateSiteCollection(SSConfig selectedConfig)
{
    string webUrl = "";

    //create site collection using the Tenant object
    var tenantAdminUri = new Uri(TENANT_ADMIN_URL);  //static for my tenant
    var token = TokenHelper.GetAppOnlyAccessToken(SHAREPOINT_PID, tenantAdminUri.Authority, null).AccessToken;
    using (var clientContext = TokenHelper.GetClientContextWithAccessToken(tenantAdminUri.ToString(), token))
    {
        var tenant = new Tenant(clientContext);
        webUrl = String.Format("{0}{1}", selectedConfig.BasePath, txtUrl.Text);
        var properties = new SiteCreationProperties()
        {
            Url = webUrl,
            Owner = "ridize@richdizzcom.onmicrosoft.com",
            Title = txtTitle.Text,
            Template = selectedConfig.SiteTemplate,
            StorageMaximumLevel = Convert.ToInt32(selectedConfig.StorageMaximumLevel),
            UserCodeMaximumLevel = Convert.ToDouble(selectedConfig.UserCodeMaximumLevel)
        };
        SpoOperation op = tenant.CreateSite(properties);
        clientContext.Load(tenant);
        clientContext.Load(op, i => i.IsComplete);
        clientContext.ExecuteQuery();

        //check if site creation operation is complete
        while (!op.IsComplete)
        {
            //wait 30seconds and try again
            System.Threading.Thread.Sleep(30000);
            op.RefreshLoad();
            clientContext.ExecuteQuery();
        }
    }

    //get the newly created site collection
    var siteUri = new Uri(webUrl);  //static for my tenant
    token = TokenHelper.GetAppOnlyAccessToken(SHAREPOINT_PID, siteUri.Authority, null).AccessToken;
    using (var clientContext = TokenHelper.GetClientContextWithAccessToken(siteUri.ToString(), token))
    {
        var newWeb = clientContext.Web;
        clientContext.Load(newWeb);
        clientContext.ExecuteQuery();

        //update description
        newWeb.Description = txtDescription.Text;

        //TODO: set additional site collection administrators

        //apply the masterpage to the site (if applicable)
        if (!String.IsNullOrEmpty(selectedConfig.MasterUrl))
        {
            //get the the masterpage bytes from it's existing location
            byte[] masterBytes = GetMasterPageFile(selectedConfig.MasterUrl);
            string newMasterUrl = String.Format("{0}{1}/_catalogs/masterpage/ssp.master", selectedConfig.BasePath, txtUrl.Text);
                   
            //upload to masterpage gallery of new web and set
            List list = newWeb.Lists.GetByTitle("Master Page Gallery");
            clientContext.Load(list, i => i.RootFolder);
            clientContext.ExecuteQuery();
            FileCreationInformation fileInfo = new FileCreationInformation();
            fileInfo.Content = masterBytes;
            fileInfo.Url = newMasterUrl;
            Microsoft.SharePoint.Client.File masterPage = list.RootFolder.Files.Add(fileInfo);
            string relativeMasterUrl = newMasterUrl.Substring(8);
            relativeMasterUrl = relativeMasterUrl.Substring(relativeMasterUrl.IndexOf("/"));

            //we can finally set the masterurls on the newWeb
            newWeb.MasterUrl = relativeMasterUrl;
            newWeb.CustomMasterUrl = relativeMasterUrl;
        }

        /**************************************************************************************/
        /*   Placeholder area for updating additional settings and features on the new site   */
        /**************************************************************************************/

 

        //update the web with the new settings
        newWeb.Update();
        clientContext.ExecuteQuery();
    }

    return webUrl;
}

 

 

 

Because our site creation form will be launch in the “Start a Site” dialog, our app needs to be able to communicate back with the SharePoint page that hosts the dialog.  This communication is necessary to make the dialog page visible, resize the dialog, close the dialog, and navigate away from the dialog.  This cross-domain communication is achieved using the HTML5 postMessage API, where SharePoint “listens” for MakePageVisible, Resize, CloseDialog, and NavigateParent messages from the page displayed within the dialog IFRAME.  Below are the scripts to implement this messaging, which I hunted down through thousands of line of javascript.

postMessage scripts for interacting with dialog parent

//Makes the page visible in the dialog
function MakeSSCDialogPageVisible() {
    var dlgMadeVisible = false;
    try {
        var dlg = window.top.g_childDialog;
        if (Boolean(window.frameElement) && Boolean(window.frameElement.makeVisible)) {
            window.frameElement.makeVisible();
            dlgMadeVisible = true;
        }
    }
    catch (ex) {
    }
    if (!dlgMadeVisible && Boolean(top) && Boolean(top.postMessage)) {
        var message = "MakePageVisible";
        top.postMessage(message, "*");
    }
}

//Resizes the dialog for the page size
function UpdateSSCDialogPageSize() {
    var dlgResized = false;
    try {
        var dlg = window.top.g_childDialog;
        if (!fIsNullOrUndefined(dlg)) {
            dlg.autoSize();
            dlgResized = true;
        }
    }
    catch (ex) {
    }
    if (!dlgResized && Boolean(top) && Boolean(top.postMessage)) {
        var message = "PageWidth=450;PageHeight=480";
        top.postMessage(message, "*");
    }
}

//postMessage to SharePoint for closing dialog
function closeDialog() {
    var target = parent.postMessage ? parent : (parent.document.postMessage ? parent.document : undefined);
    target.postMessage('CloseDialog', '*');
}

//postMessage to close the dialog and navigate from the page to a specified url
function navigateParent(url) {
    var target = parent.postMessage ? parent : (parent.document.postMessage ? parent.document : undefined);
    target.postMessage('NavigateParent=' + url, '*');
}

 

In order for our site creation form to launch from the “add site” link on the “sites” page, we need to configure it as the “Start a Site” form in the tenant administration site.  This field is limited to 255 characters, so we will likely need to remove some of the standard tokens that are passed to our app.  At minimum, the URL MUST include SPHostUrl and SPAppWebUrl parameters.  Our app will leverage these to get context and access tokens from SharePoint.

"Start a Site" configuration in SharePoint admin center

Our app should now be ready for primetime!  Here is a screenshot of our custom app launched from the “Start a Site” dialog and in full-screen mode.

Our custom provisioning app launched from the "Create a Site" link

The fullscreen version of the app

Final Thoughts

I hope this post helped illustrate the power of delivering self-service site creation and how the app model can take it to the next level.  I see this and “App Stapling” as the primary two patterns for pushing capability/features into sites and site collections in SharePoint Online.  Please note that the provided code is not production ready and also references on of my tenants.

Solution code: http://sdrv.ms/XSBX7n