Creating A PowerShell Driven XAML Self Help Center

Overview

In this post I will demonstrate how to make a self help center using XAML and PowerShell. In case you missed it, I demonstrated how to integrate XAML into PowerShell here. The main objective of that post was to quickly demonstrate how to integrate XAML into PowerShell and it concluded with a simple application that showed basic operating system details. With this post I decided to demonstrate a PowerShell driven XAML solution to an age old problem; how to empower end users to help themselves while limiting that power to specific tasks that the IT organization has chosen to allow end users to perform. This post also introduces some new concepts such as working with grids, XAML form states, and interacting with Active Directory.

The Challenge

The challenge associated with creating an end user self help center is two fold;

  • How do I make the self help center easily discoverable?
  • How do I limit what end users can do within the self help center?

PowerShell, XAML, and Active Directory provide the answers to both challenges. I will show you how to use PowerShell to perform complex tasks on the user's behalf, XAML will be used to make the self help center easily discoverable for the end users, and Active Directory will be used to ensure end users cannot perform tasks that are outside of the self help center's scope.

Prerequisites

The solution discussed in this post requires that the following prerequisites be met;

  • An Active Directory forest with Windows Server 2008 R2 domain controllers
  • Windows 7 or newer domain joined clients

Configure Active Directory

Before we dive into the PowerShell and XAML application, Active Directory must be configured to allow end users to write to certain attributes within their user accounts. By default, administrative credentials are needed to modify most user attributes, since the objective of this post is to demonstrate how to empower end users, Active Directory permissions must be configured to permit this behavior. The steps below were performed on a Windows Server 2012 R2 domain controller.

  1. Log into a domain controller and go to Start > Run > dsa.msc
  2. Click View  and select Advanced Features
  3. Locate a user account that resides in the OU where you wish to provide the user's some self service capability.
  4. Right click > Properties > Security Tab
  5. Highlight the SELF Access Control Entry and ensure "Read personal information" and "Write personal information" is selected as shown below

Create the XAML Application

Now that you have verified that Active Directory will allow the end user to modify some of the properties of the user's object, it is time to create a XAML application that will present those properties to the end user in an intuitive manner. The following XAML code is designed to effectively demonstrate the concepts in this post and to be easily customizable to specific organizational needs.

In the following code I created some textboxes, labels, buttons and grids. Using the grid XAML element I was able to transform the appearance of the XAML interface that is presented to the end user without using something more complex such as tabs or multiple XAML forms. As I mentioned in my previous XAML post, I created all of the elements in Visual Studio Express 2013 first, then made the changes necessary to integrate the XAML form into PowerShell. For this example I also added the variable $ver which stores the version number. Although the version number is not displayed anywhere on the form, it is useful for keeping track of the latest version.

#Version
$ver="1.0.03062014"
#XAML
[void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')
[xml]$XAML = @'
<Window
    xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
    Title="LogonBanner" Height="600" Width="800" WindowStartupLocation="CenterScreen" WindowStyle='None' ResizeMode='NoResize' WindowState='Maximized'>
    <Grid HorizontalAlignment="Center" VerticalAlignment="Center">
        <TextBox HorizontalAlignment="Center" HorizontalContentAlignment="Center" Height="60" TextWrapping="NoWrap" Text="Welcome to IT Company" VerticalAlignment="Top" Width="800" FontSize="36" Background="#FF00C6FF"/>
        <TextBox Visibility="Visible" Name="txtMessage" HorizontalAlignment="Center" Height="500" Margin="0,60,0,0" TextWrapping="Wrap" Text="" FontSize="14" VerticalAlignment="Top" Width="800"/>
        <Grid Visibility="Visible" Name="grdSelfHelp" HorizontalAlignment="Center" Height="493" Margin="0,88,0,0" VerticalAlignment="Top" Width="800">
            <TextBox HorizontalContentAlignment="Center" VerticalContentAlignment="Center" HorizontalAlignment="Left" Height="23" Margin="0,-28,0,0" TextWrapping="NoWrap" Text="Self Help Center" VerticalAlignment="Top" Width="800" Background="#FFC6FF00" FontWeight="Bold" FontSize="14"/>
            <Label Content="Logon Name" HorizontalAlignment="Left" Margin="0,33,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtSAMAccountName" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,33,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label  HorizontalContentAlignment="Center" Content="Physical Address" HorizontalAlignment="Left" Margin="450,2,0,0" VerticalAlignment="Top" Width="350" Background="#FF00C6FF"/>
            <TextBox Name="txtStreetAddress" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="575,33,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="Street" HorizontalAlignment="Left" Margin="450,33,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtL" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="575,64,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="City" HorizontalAlignment="Left" Margin="450,64,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtST" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="575,95,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="State/Province" HorizontalAlignment="Left" Margin="450,95,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtPostalCode" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="575,126,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="Zip/Postal Code" HorizontalAlignment="Left" Margin="450,126,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtCO" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="575,157,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="Country/Region" HorizontalAlignment="Left" Margin="450,157,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <Label HorizontalContentAlignment="Center" Content="Account Details" HorizontalAlignment="Left" VerticalAlignment="Top" Width="350" Background="#FF00C6FF" Margin="0,2,0,0"/>
            <Label Content="Profile Path" HorizontalAlignment="Left" Margin="0,64,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtProfilePath" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,64,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="Logon Script" HorizontalAlignment="Left" Margin="0,95,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtLogonScript" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,95,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="Home Folder" HorizontalAlignment="Left" Margin="0,126,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtDirectory" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,126,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="User Principal Name" HorizontalAlignment="Left" Margin="0,157,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtUserPrincipalName" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,157,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="First Name" HorizontalAlignment="Left" Margin="0,222,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtGivenName" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,222,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label HorizontalContentAlignment="Center" Content="Contact Information" HorizontalAlignment="Left" Margin="0,191,0,0" VerticalAlignment="Top" Width="350" Background="#FF00C6FF"/>
            <Label Content="Last Name" HorizontalAlignment="Left" Margin="0,253,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtSN" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,254,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="Display Name" HorizontalAlignment="Left" Margin="0,284,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtDisplayName" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,285,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="Description" HorizontalAlignment="Left" Margin="0,315,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtDescription" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,315,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="Office" HorizontalAlignment="Left" Margin="0,346,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtPhysicalDeliveryOfficeName" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,346,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="Cell Phone Number" HorizontalAlignment="Left" Margin="0,377,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtMobile" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,377,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <Label Content="Email Address" HorizontalAlignment="Left" Margin="0,408,0,0" VerticalAlignment="Top" Width="120" Background="#FF00C6FF"/>
            <TextBox Name="txtMail" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" HorizontalAlignment="Left" Height="26" Margin="125,408,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="225"/>
            <TextBox Name="txtResults" VerticalContentAlignment="Top" HorizontalContentAlignment="Left" HorizontalAlignment="Left" Height="212" Margin="450,222,0,0" TextWrapping="NoWrap" VerticalAlignment="Top" Width="350" BorderThickness="0" IsReadOnly="True"/>
        </Grid>
        <Button Name="btnAccept" Content="Accept" HorizontalAlignment="Left" Margin="0,560,0,0" VerticalAlignment="Top" Width="300" Height="40" Background="#FF00FF27"/>
        <Button Name="btnSelfHelp" Content="Visit Self Help Center" HorizontalAlignment="Left" Margin="305,560,0,0" VerticalAlignment="Top" Width="190" Height="40" Background="#FFBAD3F5"/>
        <Button Name="btnDecline" Content="Decline" HorizontalAlignment="Left" Margin="500,560,0,0" VerticalAlignment="Top" Width="300" Height="40" Background="#FFFF1400"/>
    </Grid>
</Window>
'@

In the next block of code the XAML is stored in the $reader variable and prepared for display.

#Read XAML
$reader=(New-Object System.Xml.XmlNodeReader $xaml)
try{$Form=[Windows.Markup.XamlReader]::Load( $reader )}
catch{Write-Host "Unable to load Windows.Markup.XamlReader. Some possible causes for this problem include: .NET Framework is missing PowerShell must be launched with PowerShell -sta, invalid XAML code was encountered."; exit}

In the following code, the named XAML form objects are stored in PowerShell as variables and the disclaimer message variable is filled with a disclaimer message. I envision this type of XAML application being deployed as a logon script so I created it in a way that it would show a disclaimer message that the user must accept prior to completing the logon process for a domain joined workstation.

#===========================================================================
# Store Form Objects In PowerShell
#===========================================================================
$xaml.SelectNodes("//*[@Name]") | %{Set-Variable -Name ($_.Name) -Value $Form.FindName($_.Name)}

#===========================================================================
# Fills Variable Values
#===========================================================================
$txtMessage.Text = @'
WARNING!  This computer system is the property of the IT Company.  The IT Company may monitor any activity on the system and retrieve any information stored within the system.  By accessing and using this computer, you are consenting to such monitoring and information retrieval for law enforcement and other purposes.  Users should have no expectation of privacy as to any communication on or information stored within the system, including information stored locally on the hard drive or other media in use with this unit (e.g., floppy disks, tapes, CD-ROMs, etc).
'@

The following code adds events to the XAML form objects including function calls based on button presses.

#===========================================================================
# Add events to form objects
#===========================================================================
$btnSelfHelp.Add_Click({
    if($btnSelfHelp.Content -eq "Visit Self Help Center"){fnSelfHelp; fnViewState -State "selfhelp"}
    elseif($btnSelfHelp.Content -eq "Exit Self Help Center"){fnSelfHelp; fnViewState -State "disclaimer"}
    })

#Creates actions based on form state and button click
$btnDecline.Add_Click({
    if($btnDecline.Content -eq "Decline"){fnLogout}
    if($btnDecline.Content -eq "Cancel"){fnViewState -State "disclaimer"}
    })
$btnAccept.Add_Click({
    if($btnAccept.Content -eq "Accept"){$form.Close()}
    if($btnAccept.Content -eq "Update"){

        #Updates All AD Attributes
        fnUpdateAD -DistinguishedName $sDistinguishedName -Attribute "mobile" -NewValue $txtMobile.Text
        }
    })

For a XAML form like this, it is important to keep the form from having too many unnecessary buttons and the goal is to minimize the number of steps the user must take to interact with the form. To accomplish this goal I created multiple view states that transform the XAML form based on user activity. The following function controls that view state.

#===========================================================================
# Sets View State
#===========================================================================
function fnViewState{
param([Parameter(Mandatory=$true)][string]$State)

    #Sets read only Elements
    $txtSAMAccountName.IsEnabled=$false
    $txtStreetAddress.IsEnabled=$false
    $txtSt.IsEnabled=$false
    $txtL.IsEnabled=$false
    $txtST.IsEnabled=$false
    $txtPostalCode.IsEnabled=$false
    $txtCO.IsEnabled=$false
    $txtProfilePath.IsEnabled=$false
    $txtLogonScript.IsEnabled=$false
    $txtDirectory.IsEnabled=$false
    $txtMail.IsEnabled=$false
    $txtGivenName.IsEnabled=$false
    $txtSN.IsEnabled=$false
    $txtDisplayName.IsEnabled=$false
    $txtDescription.IsEnabled=$false
    $txtPhysicalDeliveryOfficeName.IsEnabled=$false
    $txtMobile.IsEnabled=$true
    $txtUserPrincipalName.IsEnabled=$false

    #Changes viewstate based on selection
    switch($State){
        "disclaimer"{
            $txtMessage.Visibility="Visible"
            $grdSelfHelp.Visibility="Hidden"
            $btnAccept.Content="Accept"
            $btnDecline.Content="Decline"
            $btnSelfHelp.Content="Visit Self Help Center"
            $txtResults.Background="#FFFFFFFF"
        }
        "selfhelp"{
            $txtMessage.Visibility="Hidden"
            $grdSelfHelp.Visibility="Visible"
            $btnAccept.Content="Update"
            $btnDecline.Content="Cancel"
            $btnSelfHelp.Content="Exit Self Help Center"
            $txtResults.Background="#FFFFFFFF"
            $txtResults.Text=""
        }
    }
}

In the following code I tie Active Directory attributes to XAML form fields. Get-ADUser would have been much simpler for this task, however it would rely on the AD RSAT tools being installed, so instead I opted to use the .NET framework adsisearcher accelerator.

#===========================================================================
# Displays AD Object Properties
#===========================================================================
function fnSelfHelp{
    #Store AD User Object
    $oUserObject = ([adsisearcher]"(&(objectCategory=User)(objectClass=User)(SAMAccountName=$env:USERNAME))").FindOne()

    #Displays Properties
    try{
        $txtSAMAccountName.Text = $oUserObject.Properties.Item("SAMAccountName")
        $txtStreetAddress.Text = $oUserObject.Properties.Item("streetAddress")
        $txtSt.Text = $oUserObject.Properties.Item("st")
        $txtL.Text = $oUserObject.Properties.Item("l")
        $txtST.text = $oUserObject.Properties.Item("st")
        $txtPostalCode.Text = $oUserObject.Properties.Item("postalCode")
        $txtCO.Text = $oUserObject.Properties.Item("co")
        $txtProfilePath.Text = $oUserObject.Properties.Item("profilePath")
        $txtLogonScript.Text = $oUserObject.Properties.Item("logonscript")
        $txtDirectory.Text = $oUserObject.Properties.Item("homeDirectory")
        $txtMail.Text = $oUserObject.Properties.Item("mail")
        $txtGivenName.Text = $oUserObject.Properties.Item("givenName")
        $txtSN.Text = $oUserObject.Properties.Item("sn")
        $txtDisplayName.Text = $oUserObject.Properties.Item("displayName")
        $txtDescription.Text = $oUserObject.Properties.Item("description")
        $txtPhysicalDeliveryOfficeName.Text = $oUserObject.Properties.Item("PhysicalDeliveryOfficeName")
        $txtMobile.Text = $oUserObject.Properties.Item("mobile")
        $txtUserPrincipalName.Text = $oUserObject.Properties.Item("userPrincipalName")
      }
      catch{}

    #Sets Global DistinguishedName Variable
    Set-Variable -Name sDistinguishedName -Scope Global -Value $oUserObject.Properties.Item("distinguishedName")
}

In the following function, I use the System.DirectoryServices.Protocols assembly to search Active Directory and to update the user's attributes in Active Directory.

#===========================================================================
# Updates Active Directory With Changed Attributes
#===========================================================================
function fnUpdateAD{
param([Parameter(Mandatory=$true)][string]$DistinguishedName,[Parameter(Mandatory=$true)][string]$Attribute,[Parameter(Mandatory=$true)][string]$NewValue)

    #Stores SearchRoot
    $sSearchRoot = "DC=" + $env:USERDNSDOMAIN.replace(".",",DC=")

    #LDAP Search Filter
    $filter = "(&(objectCategory=User)(objectClass=User)(SAMAccountName=$env:USERNAME))"

    #Add Assembly
    Add-Type -AssemblyName System.DirectoryServices.Protocols

    #Create Connection
    $connection=New-Object System.DirectoryServices.Protocols.LDAPConnection($env:USERDNSDOMAIN)

    #Search Request
    $req = New-Object System.DirectoryServices.Protocols.SearchRequest($sSearchRoot,$filter,"Subtree",$null)

    #Search Response
    $rsp = $connection.SendRequest($req)
    
    #Store Current Attribute Value
    $sCurrentValue = $rsp.Entries.Item(0).Attributes.$Attribute[0]

    #Checks for existing value
    if($sCurrentValue.Length -eq 0){$Action = "Add"}else{$Action = "Replace"}

    #Update Attribute Field
    try{
        $req = New-Object System.DirectoryServices.Protocols.ModifyRequest($DistinguishedName,$Action,$Attribute,$NewValue)
        $rsp = $connection.SendRequest($req)
        $txtResults.Text+="$($rsp.ResultCode): Updating $Attribute to $NewValue`r`n"
        if($txtResults.Text.ToLower() -notcontains "error" ){$txtResults.Background="#FFFFFFCC"}
     }
     catch{
        $txtResults.Text+="Error: Failed to update $Attribute to $NewValue`r`n"
        $txtResults.Background="#FF0033"
     }
}

The following function logs off the currently logged on user if the user refuses to accept the terms of use agreement.

#===========================================================================
# Forces Logoff For The Current User
#===========================================================================
function fnLogout{
    (Get-WmiObject -Class Win32_OperatingSystem).Win32Shutdown(4)
}

Last but not least, the following function displays the form.

#===========================================================================
# Shows the form
#===========================================================================
#Set ViewState
fnViewState -State "disclaimer"

#Show Form
$Form.ShowDialog() | out-null

So after all of that code, what does it actually do?

What It Does

When a user logs in they are presented with an acceptable use agreement as shown below. The user can either opt to Accept, Visit Self Help Center or Decline. Accept will log in the user normally, Decline will log the user off, and the main feature is the Visit Self Help Center button.

If the user opts to visit the Self Help Center they are presented with the following screen. For simplicity purposes, I disabled all fields except the Cell Phone Number field since this is meant to only be an example.

Editing the Cell Phone Number then clicking Update displays the following output:

From there the user can then exit the Self Help Center and proceed from the disclaimer page. To verify that the change was indeed successful, you can go to Active Directory and view the attribute as shown below.

Wrap Up

In this post I have shown you how to go beyond the basic XAML application which displays data and demonstrated how to use PowerShell, XAML, and Active Directory to empower end users to help themselves. To keep this demonstration as short as possible I did not include some of the code that should be included in a production deployment such as form validation, additional error checking, reverting back to the previous value if the user clicks cancel, etc. Also, many organizations have proprietary employee databases or in house solutions outside of Active Directory. It would be relatively simple to hook into more data sources than just Active Directory to further empower end users to perform some tasks that are typically performed by the organization's service desk.

To view the complete code it is attached to this post.

SelfHelp.txt